Model routing
COSMO_MAIN_MODEL and COSMO_FALLBACK_MODEL — which model serves a request, and the one path that escalates
Cosmo runs on one model almost all of the time. Two environment variables name it and its exception:
| Variable | Default | What it serves |
|---|---|---|
COSMO_MAIN_MODEL | claude-haiku-5-5 | Every ordinary request |
COSMO_FALLBACK_MODEL | claude-sonnet-4-6 | Deliberate escalation, and the 404 retry — nothing else |
Both defaults live in one place, packages/core/src/llm/model-defaults.ts, and every shell and
service resolves against it. Leave the variables unset and you get the table above.
What runs on the main model
Everything, unless the caller names a model of its own:
- every desktop chat turn (the renderer puts
VITE_COSMO_MAIN_MODELon the wire; the server relays it), - chat-title generation and onboarding memory extraction,
- context-import extraction (one structured-output pass per chunk — an import can be dozens),
- every routine run (
Routine.modelstays the per-routine opt-up), - the weekly-digest headline (
DIGEST_HEADLINE_MODELoverrides just this one).
What reaches the fallback model
Two paths, and only two:
- A deliberate escalation. The assistant calls
cosmo_use_model({ purpose })when the next step genuinely needs a stronger model — today that means non-trivial artifact generation. The desktop maps the purpose ontoMAIN_VITE_COSMO_FALLBACK_MODEL. - The 404 retry. If a requested model id does not exist and nothing has streamed yet, the server retries once on the fallback rather than failing the turn.
The escalation lasts for the turn that asked for it, not the conversation. This is the part
worth remembering: it used to be sticky — the choice was written to ChatSession.autoModel,
synced, and preferred over the client's model on every later turn, so one artifact put every
subsequent "thanks" and "what's next?" on the expensive model for the life of the chat. Nothing
could undo it, because the only reset was the user picking a different model and production hides
the model selector. Per-turn costs nothing in capability: the artifact rules live in the system
prompt, so a later turn doing real artifact work simply escalates again.
Why the split is worth protecting
Sonnet 4.6 is 30x Haiku 5.5's price per token in both directions ($3/$15 vs $0.10/$0.50 per 1M, for prompts under 100K tokens). A path that reaches the fallback model by accident therefore costs a great deal more than the intended one, and the answer looks no different.
That is not hypothetical: before these variables existed, 'claude-sonnet-4-6' was hardcoded as
the ?? default on four separate paths, so titles, imports, routines and the digest all ran on
the escalation model without anyone choosing it.
COS-1050 correction: this section used to also say Sonnet "carries 8x the default output ceiling — 64,000 against 8,192." That 8,192 was a wrong registry value for the since-retired Haiku 4.5. Sonnet has no larger output ceiling than the main model; the price-per-token gap above is the one that applies.
Retired models
Haiku 4.5 (claude-haiku-4-5-20251001) is no longer in the model registry, so it is not offered
in the model selector. An id that still names it — an installed desktop build that has not
updated, a routine saved with it, an old env file — is answered by Haiku 5.5 instead of being
refused (RETIRED_MODEL_ALIASES in model-registry.ts). An env override that names it is
logged at boot as retired.
Setting them
Server and self-host, in infra/.env:
COSMO_MAIN_MODEL=claude-haiku-5-5
COSMO_FALLBACK_MODEL=claude-sonnet-4-6Desktop builds bake theirs in. Set both copies of the main model — the main process builds the engine with it, the renderer puts it on the wire:
MAIN_VITE_COSMO_MAIN_MODEL=claude-haiku-5-5
VITE_COSMO_MAIN_MODEL=claude-haiku-5-5
MAIN_VITE_COSMO_FALLBACK_MODEL=claude-sonnet-4-6An id that is unknown or deprecated is rejected, not obeyed: the built-in default is used and
the rejection is named in the server's boot log (Models — main=… fallback=…). A typo would
otherwise leave the deployment running a model nobody chose, with the bill as the only symptom.
Checking what production actually used
SELECT model, count(*) calls, sum("inputTokens") input, sum("outputTokens") output
FROM "UsageRecord" WHERE "createdAt" > now() - interval '30 days'
GROUP BY model ORDER BY input DESC;PostHog carries the same breakdown as $ai_model. Note the digest headline runs through
streamSystemChat with meter: false — it reaches the provider bill and never lands in
UsageRecord, so the two totals legitimately differ.