Models
The picker list, the compiled default, and the two places that must stay in sync.
Every model runs through the Vercel AI Gateway,
so there are no per-provider keys for chat. A chat turn runs on the key its user
saved in Settings, not on AI_GATEWAY_API_KEY — that one covers
cohere/rerank-v4-fast, the single model call with no user behind it. See
Who pays for a turn.
The picker list
export const DEFAULT_CHAT_MODEL = "xai/grok-4.20-reasoning"
export const CHAT_MODELS: ChatModel[] = [
{
id: "xai/grok-4.20-reasoning",
name: "Grok 4.20",
vendor: "xAI",
hint: "Fast reasoning · default",
},
// …
]Add an entry and it appears in the composer. Two requirements:
- Vision and tool use. Attachments and the research tools both depend on them; a text-only model will fail mid-turn rather than at selection.
- A gateway id of the form
provider/model. Validated loosely byMODEL_ID_RE.
Provider logos are self-hosted under public/providers (from
svgl.app) and registered in the PROVIDERS map. Monochrome
marks need both icon and iconDark; colourful ones set only icon.
Changing the default
DEFAULT_CHAT_MODEL is more load-bearing than it looks. It is:
- the model shown pre-selected in the composer,
fallbackindefineDynamic, the compiled build-time model used for context-window math, and- the model for every turn that carries no pick: subagents and headless lookout runs.
Pick something that survives long multi-tool turns
The default was google/gemini-3.5-flash-lite. Four of its ten sessions died
on the step following a multi-tool-call step, with an AI Gateway 400:
"Corrupted thought signature", and under it "the number of function response
parts is equal to the number of function call parts". Grok and Sonnet went
0-for-67 on the same workload. This is a real failure mode for a research
agent, and it will not show up in a single-turn test.
How a pick reaches the agent
The composer sends clientContext.chatModel; eve serializes it into a user-role
context message; agent/agent.ts reads the last marker in the transcript on
step.started.
const MARKER = /"chatModel"\s*:\s*"([\w.:/-]+)"/gRead message text, not `JSON.stringify(messages)`
eve puts the client context in the body of a user message, as the literal
string Client context:\n{"chatModel":"…"}. Stringifying the message array
escapes those inner quotes to \", so a "chatModel" pattern never matches.
That bug made the resolver return null for every turn. The picker looked
like it worked, and the compiled fallback silently answered everything.
No validation gate
An earlier version validated the pick against the live gateway catalog and
returned null when it couldn't confirm it. A cold cache or a transient fetch
failure then ran the turn on a different model than the composer was showing,
which made the resulting failures nearly impossible to attribute.
A pick is now honoured as sent. A genuinely bad id fails loudly on the model call, which the turn already surfaces as a failed-turn note.
The compaction model
compaction: {
thresholdPercent: 0.85,
model: "google/gemini-3.5-flash",
}Set explicitly so summarization doesn't bill at the active turn model's rate. On an Opus or Grok thread, every compaction pass would otherwise cost flagship prices to produce a summary. Flash has the window and the price for it.
Keep the two in sync
lib/models.ts holds the list and the default; agent/agent.ts chooses the
live model per turn and computes the context window via contextWindowFor().
Changing one without the other desyncs the context-window math, and the agent
compacts at the wrong point: either wasting window or overflowing it.