Testing the Coach locally (real AI replies)
The consumer Coach (the /coach page synopsis + chat, dashboard insight cards, milestone generation, check-in prompts) is powered by LiteLLM → Gemini, not the Agent Runner.
- Rails calls
Ai::ServiceClient.chat(...)(rails_api/app/services/ai/coach_service.rb) Ai::ServiceClientposts toLITELLM_URL(defaulthttp://localhost:4000), auth modemaster_keywith keysk-local-dev-only(service_client.rb)- The Agent Runner (
AGENT_RUNNER_URL, :4001) is a separate service for the admin AI Workforce only. Pointing local at prod Agent Runner does nothing for the Coach.
Why the Coach looks "dead" locally
The local LiteLLM config (infra/litellm/config.yaml) maps every alias to fake/mock models (mock_response: "This is a mock response..."). The synopsis JSON parser then can't parse the mock string and falls back to canned copy, so the Coach renders its cold-start state ("Log a check-in or two and your coach will start summarizing your week here.").
To get real Coach output locally you must run LiteLLM against a real provider.
Run LiteLLM locally against real Gemini
The coaching aliases (coaching/default, batch/insights, batch/check-ins, coaching/milestones, moderation/screen, batch/description) resolve to whichever concrete Gemini id is currently set as the runtime Coach model (gemini/gemini-3.8-flash by default — see Testing the runtime model switch locally below). A GOOGLE_API_KEY is already present in rails_api/.env.
Since v4.53, Ai::ServiceClient sends the resolved concrete id (gemini/gemini-3.8-flash), not the alias name (coaching/default), to LiteLLM (Ai::ServiceClient#resolve_model, rails_api/app/services/ai/service_client.rb). The config below needs the gemini/* wildcard entry — mirroring infra/litellm/config.staging.yaml — for that to route; without it, real Coach traffic through the app 404s even though the six alias entries look right, because nothing in this file's model_list literally matches the id Rails actually sends.
# 1. Write a real-Gemini config (mirrors infra/litellm/config.staging.yaml,
# workforce/content left as a mock so no ANTHROPIC_API_KEY is needed)
mkdir -p /tmp/litellm-local
cat > /tmp/litellm-local/config.yaml <<'YAML'
model_list:
- { model_name: coaching/default, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: coaching/milestones, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: moderation/screen, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: batch/insights, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: batch/check-ins, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: batch/description, litellm_params: { model: gemini/gemini-3.8-flash, api_key: "os.environ/GOOGLE_API_KEY" } }
- { model_name: workforce/content, litellm_params: { model: fake/gpt-4o, mock_response: "mock" } }
# gemini/* wildcard (v4.53) — required so a runtime-resolved concrete id (whatever
# Ai::CoachModel.current returns) actually routes; the six named aliases above are
# only hit by a direct curl using the alias name, never by real app traffic.
- { model_name: "gemini/*", litellm_params: { model: "gemini/*", api_key: "os.environ/GOOGLE_API_KEY" } }
litellm_settings: { num_retries: 2, request_timeout: 30 }
YAML
# 2. Launch a standalone LiteLLM container on :4000 with the real key
GKEY=$(grep '^GOOGLE_API_KEY=' rails_api/.env | cut -d= -f2-)
docker run -d --name litellm-local -p 4000:4000 \
-e GOOGLE_API_KEY="$GKEY" \
-v /tmp/litellm-local/config.yaml:/app/config.yaml \
ghcr.io/berriai/litellm:main-v1.82.3-stable \
--config /app/config.yaml --port 4000 --num_workers 1
# 3. Wait for health, then smoke-test a real completion
curl -s http://localhost:4000/health/readiness # -> 200 when ready
curl -s -X POST http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" -H "Authorization: Bearer sk-local-dev-only" \
-d '{"model":"batch/insights","messages":[{"role":"user","content":"Reply with: PONG"}]}'Native Rails (localhost:3000) reaches the container at its default localhost:4000 — no Rails restart needed. (If Rails runs in docker-compose instead, use the compose litellm service so it resolves http://litellm:4000 on the shared network.)
Now open /coach and send a message — you'll get a real Gemini reply in the Coach's persona voice.
Teardown
docker rm -f litellm-local && rm -rf /tmp/litellm-localTesting the runtime model switch locally
As of v4.53, which Gemini model the six coaching aliases resolve to is a runtime AppSetting (Ai::CoachModel::KEY = 'coach_gemini_model', rails_api/app/services/ai/coach_model.rb), not a LiteLLM config value. A local dev flips it the same way an admin would in production, just via the Rails console instead of the admin dashboard — don't hand-edit infra/litellm/config.local.yaml to change which model Coach uses locally; that file's gemini/gemini-3.8-flash entries are the fail-safe floor, not the switch.
cd rails_api && bundle exec rails console# Set it (must be Ai::ModelCatalog.routable?(id) — allowlisted AND carrying a
# rate window covering now — or Coach falls back to Settings.ai.default_coach_model
# instead of using it):
AppSetting.find_or_initialize_by(key: 'coach_gemini_model').update!(value: 'gemini/gemini-3.8-flash')
Ai::CoachModel.bust_cache! # or wait ~60s for the cache TTL to expire
# Read the effective value (same fail-safe path production uses):
Ai::CoachModel.currentThis exercises the same Ai::CoachModel.resolve fail-safe path production uses — it does not go through Ai::SetCoachModel (no AdminAction is written), so it's a faster loop for testing model behavior, not a stand-in for testing the admin mutation or its audit trail. To test the mutation itself, use the admin dashboard pointed at your local API, or call updateCoachModel directly against localhost:3000/graphql with an admin session.
Notes & gotchas
- No master key needed. The standalone config omits
general_settings.master_key, so LiteLLM runs auth-less and ignores theBearer sk-local-dev-onlyRails sends. (A master key would require a Postgres backend for virtual-key lookup — see thelitellmskill.) - The "This week so far" synopsis is job/cache-backed, not generated on page load. The chat (
Mutations::Ai::GetAdvice) is synchronous — sending a message is the reliable way to see live output. To suppress the cold-start synopsis card in a screenshot, hide.coach-synopsis-card. - Don't point local at prod LiteLLM for this — it requires Cloud Run OIDC credentials the local env doesn't have, and spends production tokens. Run your own container as above.
- This is the same backend used to regenerate the Play Store Coach screenshots — see docs/marketing/store-assets/README.md.
See also
.claude/skills/litellm/SKILL.md— full LiteLLM reference (aliases, deploy, rotation)- docs/getting-started/local-setup.md — full local stack
- docs/operations/ai-runbook.md § Switching the Coach Model — the production/staging procedure this section's Rails-console shortcut mirrors
Last updated: 2026-09-05 (v4.53 Runtime Coach Model Control, OBJ-3385 — added the gemini/* wildcard to the local config snippet and a section on testing the runtime model switch via the Rails console; refreshed same day for OBJ-3392's routable? guard)