packages/provider-registry/docs/reasoning-control.md
This document defines the ownership, data flow, and request-encoding contract for reasoning controls. The migration may temporarily contain legacy fields and serializers, but new code must follow this contract.
The design deliberately does not introduce a second model-family taxonomy for request encoding. A provider endpoint owns its protocol default, while a provider-model row carries an endpoint-keyed contract whenever that endpoint narrows the model's controls or uses a different wire shape.
reasoningFormatType in the custom-provider connection drawer.model.reasoning.type or endpoint reasoningFormatType fields.All entry points use the same user selection:
type ReasoningSelection = 'default' | 'none' | 'auto' | ReasoningEffort
ReasoningEffort is the closed registry vocabulary such as minimal, low, medium, high, xhigh, and
max. The selection is an intent, not a provider request field.
default: omit an explicit choice and let the target's declared default behavior apply.none: explicitly disable reasoning when the model and endpoint support it.auto: ask the target to choose a reasoning depth or budget.reasoning.controls is the source of truth for what a model can do:
type ReasoningControl =
| { kind: 'effort'; values: ReasoningEffort[]; default?: ReasoningEffort }
| { kind: 'budget'; min: number; max: number; default?: number }
| { kind: 'toggle'; default?: boolean }
The model may also carry intrinsic metadata such as its default behavior and thinking-token bounds. It must not carry a provider format, target field name, or serialization protocol.
One narrow exception exists: reasoning.wireDialect ('effort' | 'budget'). It names no format, target field, or
protocol. It selects between two generational variants of the same native protocol in the cases where a vendor
shipped a replacement parameter that the older generation rejects outright:
google-generate-content — Gemini 3 thinkingConfig.thinkingLevel vs Gemini 2.x thinkingConfig.thinkingBudget;anthropic-messages — Claude 4.6+ thinking.type=adaptive vs Claude ≤4.5 thinking.type=enabled + budget_tokens.That split is the vendor's own API contract, so it holds identically across every provider proxying the protocol and
therefore belongs to the model rather than the endpoint. It is inert unless the resolved format declares a
budgetWire (only gemini and anthropic do), so models on OpenAI-compatible endpoints are unaffected — their
dialect genuinely does follow the serving provider and stays in reasoningContracts.
It cannot be inferred from controls: claude-opus-4-5 exposes an effort knob (output_config.effort) yet still
speaks the budget thinking dialect. Effort-capability and dialect are independent axes, so the dialect is declared.
The runtime model exposes selectableEfforts, derived during registry enrichment. This is the only effort list the
renderer consumes. UI helpers add default; they do not inspect model IDs, provider IDs, endpoint formats, or
adapter families.
A wire profile is main-only registry data describing how a normalized invocation becomes provider parameters. It
contains a closed set of modes (default, off, auto, effort) and emission operations.
An operation can write only a reviewed target from the schema, for example:
reasoningEffort or reasoning_effort;reasoning.* or thinking.*;enable_thinking, thinking_budget, or incremental_output;disable_reasoning;chat_template_kwargs.* or extra_body.* leaves;thinkingConfig.*, reasoningConfig.*, or think leaves.An operation value can come only from:
Profiles cannot execute expressions or write arbitrary paths.
The main resolver combines the canonical selection, intrinsic model controls, endpoint profile, and request budget
context into one immutable ResolvedReasoningInvocation. It records:
maxTokens clamping;Encoders consume this result. They do not perform model/provider detection themselves.
| Concern | Owner | Persisted where |
|---|---|---|
| Reasoning capability | Creator/model schema | Generated models.json and runtime model |
| Effort vocabulary | reasoning.controls / runtime selectableEfforts | Generated controls plus runtime projection |
| Budget limits and model default | Model reasoning metadata | Generated model data |
| Endpoint protocol and adapter | Provider endpointConfigs | Generated providers.json |
| Wire encoding | Endpoint/format profile | Provider registry and main memory only |
| Native-protocol generational dialect | reasoning.wireDialect (creator reasoningFamilies) | Generated models.json; not persisted on the runtime row |
| Exact provider-model capability/wire exception | ProviderModelOverride.reasoningContracts[endpoint] | Generated provider-models.json |
| User's default selection | Assistant settings | DataApi/SQLite |
| Selection for one send | Request/queue snapshot | In-memory transport payload |
Provider connection rows persist connection facts such as base URL and adapter family. They do not persist a reasoning format selector.
reasoning-families*The files under src/patterns/reasoning-families* enrich model capabilities; they are not request-encoding
profiles. They additionally compile the declared wireDialect onto the model (see the exception above) — still a
model fact, not a wire: it picks a generational variant, never a format or a target field.
src/creators/*.ts reasoningFamilies
│
├─ generate models.json reasoning.controls
│
└─ generate reasoning-families.gen.ts
│
└─ enrich an unmatched/custom model at the model-service boundary
reasoning-families.gen.tsThis generated file is a flattened runtime artifact of creator-declared rules. Do not edit it directly. It exists so runtime custom-model enrichment can use the rule data without importing creator modules and their build-time API fetchers.
reasoning-families.tsThis is a pure matcher. It contains no provider serialization behavior. Given a model ID and a rule list, it can:
wireDialect declaration.Regular expressions are allowed only in catalog generation and custom-model enrichment in ModelService. The
result is materialized as runtime model metadata before reasoning request resolution. The resolver and encoder
must not import the generated rule table or matcher.
The global format catalog contains protocol defaults only: OpenAI Chat, OpenAI Responses, Anthropic, Gemini, Ollama, and an explicitly disabled profile. It contains no provider names, model owners, control-kind matching, or model-family rules.
Request-time resolution first determines the effective endpoint, then applies this fixed precedence:
ProviderModelOverride.reasoningContracts[endpoint].wire;reasoningFormat.wire;reasoningFormat.type, resolved through selectFormatWire(profile, dialect).Only step 3 consults model data, and only to choose between that format's wire and its optional budgetWire;
steps 1 and 2 still win outright, so a provider can always override a dialect it disagrees with. A format without a
budgetWire ignores the dialect entirely, which keeps step 3 a pure protocol default for every other format.
Because the dialect is a catalog fact and is not persisted onto the runtime row, request-time resolution must
recover it from the catalog for rows that carry no presetModelId — a custom row whose apiModelId resolves to a
catalog entry. Dropping that lookup silently returns those rows to the newer wire and produces requests the vendor
rejects.
The matching contract's support.controls replaces the intrinsic model controls for that endpoint; other support
fields override their intrinsic counterparts individually. This lets an endpoint expose a narrower effort vocabulary
without putting provider protocol facts on the creator model.
Unknown/custom models may still receive intrinsic controls from model-ID enrichment, but their requests use only the endpoint's standard protocol. Non-standard fields require a registry contract or explicit custom parameters.
Reasoning changes follow the normal registry source/artifact boundary:
src/creators/* for model capabilities and controls;src/providers/* for endpoint wire behavior;reasoningContracts[endpoint] on a provider override for an exact provider-model exception;pnpm generate in packages/provider-registry;data/*.json or reasoning-families.gen.ts.Legacy endpoint reasoningFormatType and model reasoning.type values are ignored when read and disappear on the
next normalized write. They do not require a SQLite migration or compatibility write path.
ThinkingButton renders from runtimeModel.reasoning.selectableEfforts and adds default.
A selection updates assistant.settings.reasoning_effort through the existing assistant DataApi mutation. This
is the default for future messages.
Composer submission also snapshots the current value into
ComposerQueuedMessagePayload.reasoningEffort.
AiChatRequestBody, AiStreamOpenRequest, and IpcChatTransport explicitly carry that optional snapshot.
Main computes the effective value as:
request.reasoningEffort ?? assistant.settings.reasoning_effort ?? 'default'
The request snapshot affects only that invocation; it is never written back to the assistant.
The snapshot closes the race where a user changes the control and immediately submits before the assistant update finishes. Queued messages and pending steer entries retain the value captured with their user message instead of reading the latest UI state when they eventually execute.
Commands without a new composer submission, such as regeneration or approval continuation, use the assistant's current setting at execution time. They do not add historical reasoning persistence.
request snapshot + assistant fallback
│
▼
reasoning resolver ───── model intrinsic controls
│ endpoint/profile + maxTokens
▼
ResolvedReasoningInvocation
│
▼
closed emission encoder
│
▼
adapter-owned providerOptions namespace
│
▼
Agent → AI SDK adapter → HTTP
buildAgentParams resolves the endpoint, adapter, aiSdkProviderId, and wire profile once and stores them in the
request scope. Later builders consume that scope rather than repeating adapter or profile inference.
The merge order is:
profile-generated parameters < assistant customParameters < request callOverrides
This lets explicit per-call input win without allowing stale assistant persistence to override the current send.
Native profiles emit AI SDK provider option fields. These still live under the adapter's
providerOptions namespace because the common AI SDK Chat call has one provider-options surface:
| Adapter | AI SDK input |
|---|---|
| OpenAI | providerOptions.openai.{ reasoningEffort, reasoningSummary } |
| Anthropic | providerOptions.anthropic.{ thinking, effort, sendReasoning } |
| Gemini | providerOptions.google.{ thinkingConfig / thinkingLevel } |
| xAI | providerOptions.xai.{ reasoningEffort } |
| Bedrock | providerOptions.bedrock.{ reasoningConfig } |
| Ollama | providerOptions.ollama.{ think } |
These are SDK inputs, not final HTTP JSON. The provider adapter performs its own camelCase-to-wire conversion. The emission encoder cannot query a model ID, provider ID, creator regex, or family regex.
For an OpenAI-compatible or otherwise generic endpoint, the same resolved invocation produces parameters close to
the endpoint's HTTP shape. They are placed under the language model's actual AI SDK provider-options namespace.
For createOpenAICompatible({ name }), this is providerOptions[name], not
providerOptions['openai-compatible']: the SDK validates the canonical namespace against its closed option schema,
while it forwards unknown wire fields such as thinking only from the concrete provider namespace.
The closed profile may emit reviewed fields such as reasoning_effort, thinking_budget, or nested extra_body.
The existing custom-parameter merge remains the boundary for user-supplied parameters, including the established
openai-compatible reasoning_effort to reasoningEffort compatibility conversion.
The generic path does not consult model.reasoning.type or branch on provider/model identifiers.
MiniMax-M3 is a toggle-control example: default omits thinking, none emits
thinking.type = 'disabled', and auto emits thinking.type = 'adaptive'. MiniMax M2.x remains fixed-reasoning
because its API does not allow thinking to be disabled. See the
MiniMax OpenAI-compatible API.
OpenAI, Anthropic, and Gemini inbound fields are first normalized into the canonical selection/invocation model. The gateway then calls the same main-only resolver used by Standard Chat.
callOverrides.providerOptions, preserving its highest merge priority.After mapping, the request uses the normal buildAgentOptions → Agent → AI SDK adapter path.
Agent Session uses the same composer snapshot but does not use AI SDK providerOptions:
AgentComposer writes reasoningEffort into the queued message payload.BeginAgentSessionTurnInput.query() call receives native Options.effort and Options.thinking.sendReasoning is an AI SDK Anthropic option and is ignored on this path. The effort type includes Claude's
supported xhigh value.
Reasoning selection is connection/spawn-frozen state and participates in the query rebuild signature:
default;Pending turns store the selection beside the user message. The deprecated token-only
setMaxThinkingTokens mechanism is not used.
When Claude Code runs through the internal gateway, the Claude SDK still sends Anthropic-native fields. The gateway normalizes them and applies the destination provider profile.
reasoningFamilies declaration.selectableEfforts contract exposes only supported choices.budgetWire variant where the native protocol has two
generations. A model's wireDialect selects between them and never selects a format.wireDialect; siblings of the same generation with
identical controls must not disagree.presetModelId) via the apiModelId catalog lookup.