Cosmo Docs
LLM & Prompting

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:

VariableDefaultWhat it serves
COSMO_MAIN_MODELclaude-haiku-5-5Every ordinary request
COSMO_FALLBACK_MODELclaude-sonnet-4-6Deliberate 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_MODEL on 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.model stays the per-routine opt-up),
  • the weekly-digest headline (DIGEST_HEADLINE_MODEL overrides just this one).

What reaches the fallback model

Two paths, and only two:

  1. 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 onto MAIN_VITE_COSMO_FALLBACK_MODEL.
  2. 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-6

Desktop 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-6

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