Skip to content

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::ServiceClient posts to LITELLM_URL (default http://localhost:4000), auth mode master_key with key sk-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.

bash
# 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:4000no 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

bash
docker rm -f litellm-local && rm -rf /tmp/litellm-local

Testing 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.

bash
cd rails_api && bundle exec rails console
ruby
# 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.current

This 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 the Bearer sk-local-dev-only Rails sends. (A master key would require a Postgres backend for virtual-key lookup — see the litellm skill.)
  • 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


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)

Loading…