Model selection decides, for one agent's next request, the provider route and the provider-owned model id (plus an optional reasoning effort). It is a resolution chain that runs at request time and sits at the meeting point of several packages: the default model provider, the agent loop's agent/request waterfall, the LLM adapter registry, the session's logged request header, and the UI's selection surface.
| Package | Owns |
|---|---|
@deepseek-ai/dsh-agent-default-model | ctx.agentDefaultModel — the global default selection, persisted through settings |
@deepseek-ai/dsh-agent | ModelSelection, installModelSelection, the agent/request waterfall |
@deepseek-ai/dsh-agent-loop | The request-build path that resolves and freezes config |
@deepseek-ai/dsh-llm | The LlmRuntime adapter registry, prepareCall, stream |
@deepseek-ai/dsh-session | The logged request/header and request/context |
@deepseek-ai/dsh-llm-retry | Retry policy on agent/request-error |
@deepseek-ai/dsh-client-ui-model-selection | The browser half; selection surface |
The resolution chain
The concrete wiring is visible in the headless bundle (packages/bundle/headless/src/index.ts), which is the smallest runnable composition:
session-specific selection (ModelSelectionRef, node half)
│ installModelSelection(agentCtx, ref)
▼
agent default ← agentDefaultModel.currentSelection()
│ seeded into AgentOptions { provider, model } at creation
▼
agent/request waterfall (agent loop buildRequest)
│ frozen LlmCallConfig
▼
LlmRuntime.prepareCall(config) → adapter + defaults
│
▼
provider.stream(request)Three tiers feed the resolved request:
- Global default —
ctx.agentDefaultModel, theAgentDefaultModelConfigservice. ItscurrentSelection()returns the{ provider, model, reasoningEffort? }read from the composition entry or, when a settings provider is mounted, from the live settings document. - Agent default — the default is copied into
AgentOptions.provider/AgentOptions.modelat agent creation, soctx.agents.create({ agentOptions })already carries a concrete route. - Session/step selection —
ctx.agentDefaultModelseeds aModelSelectionRefwhosecurrentvalue a session can replace;installModelSelection(agentCtx, ref)couples that mutable selection to prompt assembly and request routing.
The default model provider
The task's "default model provider" is, in the source, the AgentDefaultModelConfig service (packages/core/agent-default-model/src/index.ts), mounted as ctx.agentDefaultModel. It owns a mutable, settings-backed default:
export class AgentDefaultModelConfig extends Service {
private source: () => AgentDefaultModelSettings
constructor(ctx, config: Config) {
super(ctx, 'agentDefaultModel')
const entry: AgentDefaultModelSettings = { provider: config.provider, model: config.model }
this.source = () => entry
installSettingsSection(ctx, AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE,
AGENT_DEFAULT_MODEL_SETTINGS_SCHEMA, entry, { setSource: (current) => { this.source = current }, onChange: () => {} })
}
currentSelection(): ModelSelection {
return selection(this.source())
}
async saveSelection(next: ModelSelection): Promise<void> {
await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, {
provider: next.provider, model: next.model,
...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) },
})
}
}The settings namespace is agent-default-model, schema carrying provider (required), model (required), and optional reasoningEffort. currentSelection() always reads through this.source(), so a settings change is reflected live without any registration-level rebuild.
The injected consumers (agentDefaultModel, agents, sessions) seed a live agent:
const selection = defaultModel.currentSelection()
const { agent } = await agents.create({
sessionId: SessionId(`session-${randomUUID()}`),
meta: { cwd: process.cwd() },
agentOptions: { provider: selection.provider, model: selection.model },
setup: (agentCtx) => {
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
installModelSelection(agentCtx, selected)
},
})How a session's selection overrides
installModelSelection (in packages/core/agent/src/model-selection.ts) installs two scoped waterfall listeners. The first snapshots the selected model before prompt assembly and sets provider/model as prompt variables; the second forces provider/model (and effort) onto the LlmCallConfig at the agent/request boundary. The current/assembled split means a concurrent switch applies to a later step rather than splitting the two surfaces:
const disposeRequest = agentCtx.on('agent/request', async (_payload, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = selection.assembled
if (selected === undefined) return resolved
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
return {
...withoutInheritedEffort,
provider: selected.provider,
model: selected.model,
...selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort },
}
})How the loop freezes a request
buildRequest in the agent loop reads the persisted request header, runs the agent/request waterfall to a final LlmCallConfig, and requires both a provider and a model (or throws "has no provider/model"). It then logs the request/header (reason initial, resume, or change) and the request/context (provider/model/contextWindow) onto the session, and binds prepareCall so adapter defaults and retry policy are captured with the exact route:
const proposedConfig = await this.dispatch.waterfall(
'agent/request', { turn, step, signal }, () => Promise.resolve(seedConfig),
)
...
preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
config = preparedCall.config
...
const header = canonicalHeader({ config, ...adapterDefaults, ...system, ...tools })Provider registration and models
ctx.llm.registerAdapter(providers, adapter) (the LlmRuntime in packages/llm/llm/src/index.ts) registers a provider route; it throws DUPLICATE_ADAPTER all-or-nothing and returns a disposer carrying AdapterRegistrationHandle.replace for atomic route replacement. Each adapter also exposes providerRetryPolicy(provider), listModels(provider), and resolveModel(info) metadata, so model ids are provider-owned opaque strings — the core never interprets them. prepareCall captures PreparedLlmCall (frozen config with materialized defaults, retryPolicy, and context.contextWindow). The request surface is GenerateOptions (provider, model, messages, system, tools, reasoningEffort, maxTokens); the request goes out through LlmAdapter.stream(options).
Retry hooks
Retry policy is registration-bound: llm-retry implements on agent/request-error. Each scheduled retry is durable before its cancellable wait, and the policy comes from the adapter registration's providerRetryPolicy. Model-request failures surface as structured LlmFailure facts through the same waterfall.
Where API keys come from
Model calls delegate credential resolution to the credential seam, not the core. The deepseek provider adapter (packages/llm/llm-deepseek/src/adapter.ts) resolves its API key per stream call through the optional ctx.credentials seam — a CredentialRef — and injects it as authorization: Bearer <apiKey> at its HTTP boundary (one resolution per call so a changed credential is respected). The core LlmRuntime is transport-agnostic; a deployment mounts the credential seam or supplies the key through configuration.
Further reading
- The agent loop — the
agent/requestwaterfall andbuildRequestfreeze step. - System prompt assembly — how the selected model becomes
provider/modelprompt variables. - Session management — the logged
request/headerandrequest/context. - Scope system — the scoped listeners
installModelSelectionregisters. - Repo source:
packages/core/agent-default-model/src/index.ts,packages/core/agent/src/model-selection.ts,packages/bundle/headless/src/index.ts,packages/llm/llm/src/index.ts. - Official scaffold:
docs/config-catalog.md(model keys),docs/subsystems/credentials.md.