From ffe1ec861ba06c120e28d6eb9fca47b9cd29099b Mon Sep 17 00:00:00 2001 From: norths7ar Date: Thu, 22 Oct 2026 23:43:48 +0800 Subject: [PATCH 1/9] fix(reasoning): separate support, thinking toggles and effort controls --- .../src/content/docs/guides/providers.md | 5 + src/adapters/anthropic.ts | 21 +- src/adapters/openai-chat.ts | 8 +- src/adapters/openai-chat/passthrough.ts | 22 +- src/adapters/openai-chat/reasoning-wire.ts | 81 +++++- src/adapters/reasoning-controls.ts | 16 ++ src/chat/inbound.ts | 5 +- src/claude/inbound.ts | 3 +- src/cli/export-command.ts | 3 + src/cli/opencode.ts | 27 +- src/clients/config-export/contracts.ts | 27 +- .../config-export/reasoning-metadata.ts | 43 +++- src/clients/reasoning-controls.ts | 49 ++++ src/codex/catalog/effort.ts | 28 +++ src/codex/catalog/model-hints.ts | 48 +++- src/codex/catalog/parsing.ts | 4 + src/config/provider-validation.ts | 11 +- src/providers/derive.ts | 11 + src/providers/mimo-thinking.ts | 12 + src/providers/reasoning-capabilities.ts | 64 +++++ src/providers/registry/entries-extended.ts | 11 +- src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 3 +- src/providers/resolved-model-policy.ts | 29 ++- src/responses/parser.ts | 5 +- src/responses/schema.ts | 1 + src/server/effort-policy.ts | 6 +- src/server/index/serve-options.ts | 2 +- .../management/model-row-export-metadata.ts | 31 ++- src/server/management/model-rows.ts | 33 +-- src/server/models-capabilities.ts | 15 +- src/server/responses/core-normalize.ts | 2 + src/types.ts | 1 + src/types/provider.ts | 18 +- src/types/request.ts | 5 + structure/clients/integrations.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/providers-and-adapters.md | 4 + .../anthropic/anthropic-reasoning.test.ts | 83 +++++++ .../openai-chat-reasoning-wire-policy.test.ts | 234 ++++++++++++++++++ .../claude-integration/claude-inbound.test.ts | 13 +- .../client-export-effective-metadata.test.ts | 74 ++++++ .../client-export-reasoning-controls.test.ts | 131 ++++++++++ .../client-export-reasoning-metadata.test.ts | 13 + tests/codex-integration/effort-policy.test.ts | 18 ++ tests/config/config-load-degrade.test.ts | 22 +- .../cursor/cursor-local-models-schema.test.ts | 13 + tests/providers/mimo-v26-catalog.test.ts | 140 +++++++++++ tests/providers/resolved-model-policy.test.ts | 32 +++ .../chat-inbound-reasoning-none.test.ts | 10 + tests/responses/responses-parser.test.ts | 54 ++++ tests/server/api-catalog-route.test.ts | 113 ++++++++- 52 files changed, 1518 insertions(+), 91 deletions(-) create mode 100644 src/adapters/reasoning-controls.ts create mode 100644 src/clients/reasoning-controls.ts create mode 100644 src/providers/mimo-thinking.ts create mode 100644 src/providers/reasoning-capabilities.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 79b628aa787..f775a914eb8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -912,6 +912,11 @@ Most use the `openai-chat` adapter with a bearer key; Anthropic-compatible prese preset, `xiaomi-mimo`, and a token-plan preset, `mimo`. All three default to MiMo V2.6 (`mimo-v2.6-pro`, or `mimo-v2.6-flash` on `xiaomi-mimo`). Xiaomi retires `mimo-v2.5` and `mimo-v2.5-pro` on 2026-10-21 with no redirect, so switch a saved V2.5 default before then; opencodex does not rewrite it for you. + +**Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models. + +Public MiMo V2.6 is one instance: its three models support reasoning and an on/off switch, but no adjustable intensity; thinking defaults to enabled. The wire adapter sends `thinking.type: enabled/disabled`. OCX's Responses ingress also accepts `reasoning.enabled` independently of `reasoning.effort`. For clients that only offer effort enums, Codex catalogs and OpenCode/Kilo exports represent this switch as `none/high`; those values are client compatibility aliases, not MiMo strength levels. Client-owned labels may still say “High.” Fixed reasoning models without a switch get no fabricated control. Xiaomi's [deep-thinking guide](https://mimo.mi.com/docs/en-US/quick-start/usage-guide/other/deep-thinking) describes the public MiMo switch; free, token-plan, third-party routes and older MiMo models keep their existing declarations. + Volcengine Coding Plan and Agent Plan use their native Responses endpoints through `openai-responses`. During validated Ark Coding Plan tool continuations, replaying the returned Responses `reasoning` item answered `400 InvalidParameter`, so the Coding Plan preset drops replayed reasoning items before diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4251de21e29..3d78c608f72 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -35,6 +35,9 @@ import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isLikelyRealAnthropicThinkingSignature } from "../responses/reasoning-envelope"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; +import { resolveThinkingEnabled } from "../clients/reasoning-controls"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "./reasoning-controls"; import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; import { anthropicCacheWrite1hTokens, resolveAnthropicCacheRetention, type AnthropicCacheRetention } from "./anthropic/cache-retention"; import { claudeFamilyVersion, rejectsCombinedSampling, rejectsForcedToolChoice, rejectsSamplingParameters, supportsExplicitThinkingDisable, usesAdaptiveThinking, usesBetweenToolsFloor } from "./anthropic-model-contract"; @@ -1082,16 +1085,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // model both defaults to thinking and accepts being told not to. const family = claudeFamilyVersion(parsed.modelId); progressUpdates = usesNativeAnthropicEndpoint(provider) && family?.family === "opus" && family.major === 5 && family.minor === 5; + const thinkingToggleWire = declaresThinkingToggleWire(provider, parsed.modelId); + const reasoningControlsUnsupported = reasoningControlsAreUnsupported(provider, parsed.modelId); + const capabilities = resolveReasoningCapabilities(provider, parsed.modelId); + const thinkingEnabled = thinkingToggleWire + ? resolveThinkingEnabled(provider, parsed.modelId, parsed.options) + : undefined; + const supportsReasoningEffort = capabilities.supportsReasoningEffort === true; const effectiveReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); - if (effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { + if (!reasoningControlsUnsupported && thinkingEnabled !== undefined) { + body.thinking = { type: thinkingEnabled ? "enabled" : "disabled" }; + } + if (!reasoningControlsUnsupported && thinkingEnabled === undefined && effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { body.thinking = { type: "disabled" }; - } else if (effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { + } else if (!reasoningControlsUnsupported && thinkingEnabled === undefined && effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { // Sonnet 5.5 rejects `disabled`; `between_tools` is its lowest setting and turns off up-front // thinking. No effort is sent: the API default (high) is inside the range it accepts. body.thinking = { type: "between_tools" }; delete body.temperature; delete body.top_p; - } else if (typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { + } else if (!reasoningControlsUnsupported && thinkingEnabled !== false && (thinkingEnabled === undefined || supportsReasoningEffort) && typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { if (usesAdaptiveThinking(parsed.modelId)) { // Adaptive-thinking models replace the token budget with an effort knob and reject // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible @@ -1148,7 +1161,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti delete body.top_p; } - if (progressUpdates) body.thinking = { type: "adaptive", display: "updates" }; + if (progressUpdates && !reasoningControlsUnsupported && thinkingEnabled !== false) body.thinking = { type: "adaptive", display: "updates" }; if (rejectsSamplingParameters(parsed.modelId)) { // Opus 4.7+, Sonnet 5+, Haiku 5.5+ and Fable 400 on non-default sampling, with or without diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 9e794710c57..5edeed37030 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -3,9 +3,9 @@ import { protectGlmSummaryBudget, resolveMaxTokens } from "./openai-chat/summary import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; -import { modelInList } from "../types"; +import { modelInList, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types"; import { mapReasoningEffort } from "../reasoning-effort"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; @@ -153,7 +153,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const explicitReasoning = applyExplicitChatReasoningWirePolicy({ provider, modelId: parsed.modelId, - hasTools: !!tools, + hasTools: !!tools, thinkingEnabled: parsed.options.thinkingEnabled, requestedEffort, wireEffort: reasoningEffort, reasoningDisabled, @@ -173,7 +173,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd wireValue: budget, }; } - } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) { + } else if (modelInList(provider.thinkingToggleModels, parsed.modelId) && resolveReasoningCapabilities(provider, parsed.modelId).supportsReasoningEffort !== true) { if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") { body.thinking = { type: reasoningEffort }; reasoningLog = { diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 73823aaebcd..b71901a3d4d 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -10,10 +10,12 @@ import { applyGithubCopilotContextTier } from "../../providers/github-copilot-co import { canonicalFastTierMarker, decideTier, type ResolvedFastPolicy } from "../../providers/fastwire"; import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; -import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; +import { mapReasoningEffort, modelRecordValue } from "../../reasoning-effort"; +import { resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; import { planNativeChatReasoningReplay } from "./reasoning-replay"; import type { OcxReasoningReplayScopeRef } from "../../types"; import { jsonUtf8Bytes } from "../../lib/json-byte-size"; @@ -87,22 +89,36 @@ export function buildOpenAIChatPassthroughRequest( const replayPlan = planNativeChatReasoningReplay(body.messages, provider, modelId, replayScope, Array.isArray(rawBody.tools) && rawBody.tools.length > 0, maxSerializedBytes); if (protectGlmSummaryBudget(body, provider.baseUrl, body.reasoning_effort)) body.reasoning_effort = "low"; + const toggleWire = declaresThinkingToggleWire(provider, modelId); const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; - const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0; + const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) + || (!toggleWire && rawEfforts?.length === 0) || reasoningControlsAreUnsupported(provider, modelId); if (reasoningDisabled) { delete body.reasoning_effort; } const hasTools = Array.isArray(rawBody.tools) && rawBody.tools.length > 0; const requestedEffort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined; + const explicitThinkingEnabled = rawBody.reasoning !== null && typeof rawBody.reasoning === "object" + && typeof (rawBody.reasoning as { enabled?: unknown }).enabled === "boolean" + ? (rawBody.reasoning as { enabled: boolean }).enabled + : undefined; applyExplicitChatReasoningWirePolicy({ provider, modelId, hasTools, + thinkingEnabled: explicitThinkingEnabled, requestedEffort, - wireEffort: requestedEffort, + wireEffort: toggleWire ? mapReasoningEffort(provider, modelId, requestedEffort) : requestedEffort, reasoningDisabled, body, }); + // A native caller's own thinking object is already in the upstream wire shape. Forward it only + // on a provider-declared toggle wire, where it remains more specific than a catalog default. + if (!reasoningDisabled && toggleWire + && resolveReasoningCapabilities(provider, modelId).supportsThinkingToggle !== false + && rawBody.thinking !== undefined) { + body.thinking = rawBody.thinking; + } const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); diff --git a/src/adapters/openai-chat/reasoning-wire.ts b/src/adapters/openai-chat/reasoning-wire.ts index ac53a242352..06d85aa2b96 100644 --- a/src/adapters/openai-chat/reasoning-wire.ts +++ b/src/adapters/openai-chat/reasoning-wire.ts @@ -1,11 +1,18 @@ +import { resolveThinkingEnabled } from "../../clients/reasoning-controls"; +import { resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; import { modelInList, type OcxProviderConfig } from "../../types"; import type { AdapterRequest } from "../base"; +import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; import { isNativeOpenAIChatTarget } from "./wire"; export type ExplicitChatReasoningWireResult = | { handled: false } | { handled: true; reasoningLog?: AdapterRequest["reasoningLog"] }; +function isThinkingToggleType(value: string | undefined): value is "enabled" | "disabled" | "adaptive" { + return value === "enabled" || value === "disabled" || value === "adaptive"; +} + /** * Apply provider-declared reasoning wire policy after the effective provider is resolved. * Unset declarations are a no-op so native Chat keeps forwarding the caller's raw field. @@ -14,6 +21,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { provider: OcxProviderConfig; modelId: string; hasTools: boolean; + thinkingEnabled: boolean | undefined; requestedEffort: string | undefined; wireEffort: string | undefined; reasoningDisabled: boolean; @@ -23,13 +31,84 @@ export function applyExplicitChatReasoningWirePolicy(options: { provider, modelId, hasTools, + thinkingEnabled, requestedEffort, wireEffort, reasoningDisabled, body, } = options; - if (reasoningDisabled) return { handled: false }; + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (reasoningDisabled || reasoningControlsAreUnsupported(provider, modelId)) { + delete body.reasoning_effort; + delete body.reasoning; + delete body.thinking; + return { handled: true }; + } + const toggleWire = declaresThinkingToggleWire(provider, modelId); + if (capabilities.supportsReasoningEffort === false && !toggleWire) { + delete body.reasoning_effort; + delete body.reasoning; + if (capabilities.supportsThinkingToggle === false) delete body.thinking; + return { handled: true }; + } + if (toggleWire) { + if (capabilities.supportsThinkingToggle === false) { + delete body.thinking; + delete body.reasoning; + if (requestedEffort !== undefined && capabilities.supportsReasoningEffort !== false) { + // A disabled toggle does not invalidate a separately supported effort axis. Preserve the + // caller's strength rather than reusing the legacy toggle discriminator. + body.reasoning_effort = requestedEffort; + } else { + delete body.reasoning_effort; + } + return { handled: true }; + } + const resolvedThinkingEnabled = resolveThinkingEnabled(provider, modelId, { thinkingEnabled, reasoning: requestedEffort }); + // These values are the provider's declared thinking discriminator, never strengths. This + // remains true when the catalog also advertises a separate, real effort axis. + if (isThinkingToggleType(wireEffort)) { + const thinkingType = resolvedThinkingEnabled === undefined + ? wireEffort + : resolvedThinkingEnabled ? "enabled" : "disabled"; + body.thinking = { type: thinkingType }; + delete body.reasoning_effort; + delete body.reasoning; + return { + handled: true, + reasoningLog: { + effectiveEffort: thinkingType, + wireField: "thinking.type", + wireValue: thinkingType, + }, + }; + } + if (resolvedThinkingEnabled !== undefined) { + const thinkingType = resolvedThinkingEnabled ? "enabled" : "disabled"; + body.thinking = { type: thinkingType }; + // Unknown and explicit strength support retain the established effort wire. Only an + // explicit false says the toggle is the model's sole reasoning control. + if (capabilities.supportsReasoningEffort !== false) { + return { handled: false }; + } + delete body.reasoning_effort; + delete body.reasoning; + return { + handled: true, + reasoningLog: { + effectiveEffort: thinkingType, + wireField: "thinking.type", + wireValue: thinkingType, + }, + }; + } + if (capabilities.supportsReasoningEffort === false) { + delete body.reasoning_effort; + delete body.reasoning; + return { handled: true }; + } + } if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId)) { delete body.reasoning_effort; delete body.reasoning; diff --git a/src/adapters/reasoning-controls.ts b/src/adapters/reasoning-controls.ts new file mode 100644 index 00000000000..520795ec7af --- /dev/null +++ b/src/adapters/reasoning-controls.ts @@ -0,0 +1,16 @@ +import { registryEntryForProviderDestination } from "../providers/registry"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; +import { modelInList, type OcxProviderConfig } from "../types"; + +/** A thinking toggle is serialized only on a provider-declared wire. */ +export function declaresThinkingToggleWire(provider: OcxProviderConfig, modelId: string): boolean { + return modelInList(provider.thinkingToggleModels, modelId) + || modelInList(registryEntryForProviderDestination(provider)?.thinkingToggleModels, modelId); +} + +/** Explicitly unavailable controls constrain both translated and native requests. */ +export function reasoningControlsAreUnsupported(provider: OcxProviderConfig, modelId: string): boolean { + const capabilities = resolveReasoningCapabilities(provider, modelId); + return capabilities.supportsReasoning === false + || (capabilities.supportsThinkingToggle === false && capabilities.supportsReasoningEffort === false); +} diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 4e60b362e2b..a602efce8f7 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -672,9 +672,12 @@ function applyChatSettings(raw: ChatCompletionsRoutingBody, body: Rec): void { const effort = resolveReasoningEffort(raw); const summary = resolveReasoningSummary(raw); - if (effort || summary !== undefined) { + const enabled = isRec(raw.reasoning) && typeof raw.reasoning.enabled === "boolean" ? raw.reasoning.enabled + : isRec(raw.thinking) && ["enabled", "disabled"].includes(String(raw.thinking.type)) ? raw.thinking.type === "enabled" : undefined; + if (effort || summary !== undefined || enabled !== undefined) { body.reasoning = { ...(effort ? { effort } : {}), + ...(enabled !== undefined ? { enabled } : {}), summary: summary ?? "auto", }; } diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 8766552b085..55c5dd3aba9 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -631,9 +631,10 @@ function applyAnthropicSettings( // think by default, omission means thinking is ON, sharing the caller's max_tokens (#545). // `none` is the effort disable sentinel. It is not a valid OpenAI summary // value, so do not attach the similarly named internal catalog sentinel. - body.reasoning = { effort: "none" }; + body.reasoning = { effort: "none", enabled: false }; } else if (isRec(thinking) || outputConfigEffort !== undefined) { const reasoning: Rec = { summary: isRec(thinking) && thinking.display === "omitted" ? "none" : "auto" }; + if (isRec(thinking) && thinking.type === "enabled") reasoning.enabled = true; if (outputConfigEffort !== undefined) { // Adaptive wire: /effort arrives as output_config.effort (devlog 080). reasoning.effort = outputConfigEffort; diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 91f0f3e4930..0a5facf61dc 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -94,6 +94,9 @@ export function exportModelsFromProxyRows( if (entry.maxTokens !== undefined) model.maxTokens = entry.maxTokens; if (entry.maxInputTokens !== undefined) model.maxInputTokens = entry.maxInputTokens; if (entry.reasoningEfforts !== undefined) model.reasoningEfforts = [...entry.reasoningEfforts]; + if (entry.supportsThinkingToggle !== undefined) model.supportsThinkingToggle = entry.supportsThinkingToggle; + if (entry.supportsReasoningEffort !== undefined) model.supportsReasoningEffort = entry.supportsReasoningEffort; + if (entry.defaultThinkingEnabled !== undefined) model.defaultThinkingEnabled = entry.defaultThinkingEnabled; if (entry.defaultReasoningEffort) model.defaultReasoningEffort = entry.defaultReasoningEffort; if (entry.inputModalities && entry.inputModalities.length > 0) { model.inputModalities = [...entry.inputModalities]; diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 059e5931d60..ce6e87b2c60 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -91,6 +91,11 @@ export interface OpencodeRoutedModel { displayName?: string; /** Declared effort ladder; exported as opencode model variants when present. */ reasoningEfforts?: readonly string[]; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; + defaultReasoningEffort?: string; } /** Row shape from authenticated GET /api/models on the running proxy. */ @@ -120,6 +125,9 @@ export interface OpencodeProxyModelRow { inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** Declared default effort from `/api/models`. */ defaultReasoningEffort?: string; /** @@ -180,9 +188,14 @@ function opencodeLaunchCatalog( id: model.id, contextWindow: model.contextWindow, displayName: model.displayName, - ...(model.reasoningEfforts && model.reasoningEfforts.length > 0 + ...(model.reasoningEfforts !== undefined ? { reasoningEfforts: [...model.reasoningEfforts] } : {}), + ...(model.supportsReasoning !== undefined ? { supportsReasoning: model.supportsReasoning } : {}), + ...(model.supportsThinkingToggle !== undefined ? { supportsThinkingToggle: model.supportsThinkingToggle } : {}), + ...(model.supportsReasoningEffort !== undefined ? { supportsReasoningEffort: model.supportsReasoningEffort } : {}), + ...(model.defaultThinkingEnabled !== undefined ? { defaultThinkingEnabled: model.defaultThinkingEnabled } : {}), + ...(model.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), })), ]; } @@ -372,12 +385,17 @@ export function opencodeCatalogFromProxyRows( const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const reasoningEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; + const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; + const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; + const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const effortDisabled = supportsReasoningEffort === false || (effective?.supportsReasoning ?? row.supportsReasoning) === false; + const reasoningEfforts = effortDisabled && declaredEfforts !== undefined ? [] : declaredEfforts; // The projection is the authority on the default: when it exists, an absent default means // NO declared default, and falling back to the row would reintroduce the picker's // synthetic medium/low value. Rows without one (older hubs, hand-built rows) keep the // row's own value. - const defaultReasoningEffort = effective !== undefined + const defaultReasoningEffort = effortDisabled ? undefined : effective !== undefined ? effective.defaultReasoningEffort : row.defaultReasoningEffort; const supportsTools = effective?.supportsTools ?? row.supportsTools; @@ -398,6 +416,9 @@ export function opencodeCatalogFromProxyRows( ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), // `[]` is a declaration, not an absence: an explicit no-rungs ladder stays empty here. ...(Array.isArray(reasoningEfforts) ? { reasoningEfforts: [...reasoningEfforts] } : {}), + ...(typeof supportsThinkingToggle === "boolean" ? { supportsThinkingToggle } : {}), + ...(typeof supportsReasoningEffort === "boolean" ? { supportsReasoningEffort } : {}), + ...(typeof defaultThinkingEnabled === "boolean" ? { defaultThinkingEnabled } : {}), ...(typeof defaultReasoningEffort === "string" && defaultReasoningEffort.length > 0 ? { defaultReasoningEffort } : {}), diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 92337f2f194..34e7f71e3a2 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -57,10 +57,13 @@ export interface OpencodeCatalogModel { inputModalities?: readonly string[]; /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ reasoningEfforts?: readonly string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** * Declared default effort. Carried so every client export reads one deduped, visibility- - * filtered ladder per model. The opencode serializer deliberately does NOT turn it into a - * model-level setting — see {@link opencodeEffortVariants} for why. + * filtered ladder per model. Client serializers map it into their own model defaults + * and allow the selected variant to override it. */ defaultReasoningEffort?: string; supportsTools?: boolean; @@ -90,6 +93,9 @@ export interface ExportModel { inputModalities?: string[]; /** Optional effort ladder exported only to clients that support it. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; defaultReasoningEffort?: string; supportsTools?: boolean; supportsReasoning?: boolean; @@ -116,6 +122,9 @@ export interface EffectiveModelExportMetadata { inputModalities?: string[]; /** Effective ladder; `[]` is an explicit "no rungs" declaration and survives as `[]`. */ reasoningEfforts?: string[]; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** * A default some canonical source declared — never one synthesized from the ladder's * preference order, and only when it is a member of the effective ladder. An empty @@ -128,11 +137,14 @@ export interface EffectiveModelExportMetadata { } /** - * The per-row evidence the capability booleans are derived from. Only positive, canonical - * signals — no independent provider fact tables, no name-based inference. + * Canonical explicit capabilities and positive evidence for otherwise unknown booleans. + * No independent provider fact tables or name-based inference. */ export interface ExportCapabilityEvidence { native?: boolean; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; reasoningEfforts?: readonly string[]; supportsReasoningSummaries?: boolean; /** Normalized upstream capability names a catalog row carried (e.g. `"tools"`). */ @@ -156,8 +168,7 @@ export function knownToolsSupport(evidence: ExportCapabilityEvidence): boolean | } /** - * Reasoning support a canonical source positively asserts, or undefined when nothing is - * known. + * Explicit reasoning support, or positive canonical evidence when no boolean is declared. * * Never `false` from a missing or empty effort ladder: an empty ladder says "no ADJUSTABLE * effort", which is not the claim "cannot reason" — a model can reason at a fixed depth with @@ -167,8 +178,10 @@ export function knownToolsSupport(evidence: ExportCapabilityEvidence): boolean | * nothing about whether the model reasons, so it is not positive evidence on its own. */ export function knownReasoningSupport(evidence: ExportCapabilityEvidence): boolean | undefined { + if (typeof evidence.supportsReasoning === "boolean") return evidence.supportsReasoning; + if (evidence.supportsThinkingToggle === true || evidence.supportsReasoningEffort === true) return true; if (evidence.supportsReasoningSummaries === true) return true; - if (evidence.reasoningEfforts !== undefined + if (evidence.supportsReasoningEffort !== false && evidence.reasoningEfforts !== undefined && canonicalizeReasoningEfforts(evidence.reasoningEfforts).some(effort => effort !== "none")) { return true; } diff --git a/src/clients/config-export/reasoning-metadata.ts b/src/clients/config-export/reasoning-metadata.ts index 642cdec29f3..df870052002 100644 --- a/src/clients/config-export/reasoning-metadata.ts +++ b/src/clients/config-export/reasoning-metadata.ts @@ -1,16 +1,35 @@ import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../../reasoning-effort"; import { knownReasoningSupport, type OpencodeCatalogModel } from "./contracts"; -/** Unknown and explicitly empty ladders have different client-side meanings. */ -export function exportReasoningEfforts(model: OpencodeCatalogModel): string[] | undefined { - return model.reasoningEfforts === undefined ? undefined : canonicalizeReasoningEfforts(model.reasoningEfforts); +type ReasoningMetadata = Pick; + +/** Effort-enum client compatibility only; never write these choices back into model facts. */ +export function exportReasoningEfforts(model: ReasoningMetadata): string[] | undefined { + if (model.supportsReasoning === false) return []; + const efforts = model.reasoningEfforts === undefined ? undefined : canonicalizeReasoningEfforts(model.reasoningEfforts); + if (model.supportsReasoningEffort === false) { + return model.supportsThinkingToggle === true ? ["none", "high"] : []; + } + if (model.supportsThinkingToggle === true) { + const strengths = efforts?.filter(effort => effort !== "none") ?? []; + return strengths.length ? ["none", ...strengths] : ["none", "high"]; + } + return model.supportsThinkingToggle === false ? efforts?.filter(effort => effort !== "none") : efforts; } -/** Carry an authoritative default, never invent one or revive a cleared ladder. */ -export function exportDefaultReasoningEffort(model: OpencodeCatalogModel): string | undefined { +/** Encode the declared effort/default-thinking choice without inventing a strength. */ +export function exportDefaultReasoningEffort(model: ReasoningMetadata): string | undefined { + const efforts = exportReasoningEfforts(model); + if (model.supportsThinkingToggle === true && model.supportsReasoning !== false) { + if (model.defaultThinkingEnabled === false) return "none"; + const toggleOnly = model.supportsReasoningEffort === false + || !model.reasoningEfforts?.some(effort => isDeclaredReasoningEffort(effort) && effort !== "none"); + if (toggleOnly) return model.defaultThinkingEnabled === true ? "high" : undefined; + if (model.defaultThinkingEnabled === true && model.defaultReasoningEffort === "none") return undefined; + } const effort = model.defaultReasoningEffort; if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) return undefined; - const efforts = exportReasoningEfforts(model); return efforts === undefined || efforts.includes(effort) ? effort : undefined; } @@ -34,9 +53,17 @@ export function legacyReasoningMetadata(model: OpencodeCatalogModel): { const suppressed: Record = Object.fromEntries( ["none", "minimal", "low", "medium", "high", "xhigh", "max"].map(id => [id, { disabled: true }]), ); - const variants = efforts?.length + let variants = efforts?.length ? Object.fromEntries(efforts.map(effort => [effort, { reasoningEffort: effort }])) - : reasoning === true ? suppressed : undefined; + : reasoning === true || model.supportsReasoning === false || model.supportsReasoningEffort === false ? suppressed : undefined; + // Constrained controls replace generated choices; a negative toggle by itself does not + // declare the still-unknown effort axis unsupported. + if (model.supportsThinkingToggle === true || model.supportsReasoningEffort !== undefined + || model.supportsReasoning === false || (model.supportsThinkingToggle === false && efforts !== undefined)) { + variants = { ...suppressed, ultra: { disabled: true }, ...variants }; + } else if (model.supportsThinkingToggle === false) { + variants = { none: { disabled: true }, ...variants }; + } return { ...(typeof reasoning === "boolean" ? { reasoning } : {}), ...(defaultEffort !== undefined ? { options: { reasoningEffort: defaultEffort } } : {}), diff --git a/src/clients/reasoning-controls.ts b/src/clients/reasoning-controls.ts new file mode 100644 index 00000000000..81c1781516e --- /dev/null +++ b/src/clients/reasoning-controls.ts @@ -0,0 +1,49 @@ +import type { OcxProviderConfig } from "../types"; +import { isDeclaredReasoningEffort, isReasoningEffortOmitted, modelRecordValue, reasoningEffortMapFor } from "../reasoning-effort"; +import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; + +function mappedClientEffort(provider: OcxProviderConfig, modelId: string, requested: string | undefined): string | undefined { + if (requested === undefined || !isDeclaredReasoningEffort(requested)) return undefined; + const key = requested === "ultra" ? "max" : requested; + const map = reasoningEffortMapFor(provider, modelId); + return map && Object.hasOwn(map, key) ? map[key] : requested; +} + +/** Decode an effort-only client's switch without turning it into a model strength. */ +export function resolveThinkingEnabled( + provider: OcxProviderConfig, + modelId: string, + options: { thinkingEnabled?: boolean; reasoning?: string }, +): boolean | undefined { + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (capabilities.supportsReasoning === false || capabilities.supportsThinkingToggle !== true) return undefined; + if (typeof options.thinkingEnabled === "boolean") return options.thinkingEnabled; + // Effort-enum clients select a switch and, when supported, a real positive strength. + // An explicit boolean above remains independent and takes precedence over that shorthand. + const effort = options.reasoning ?? (capabilities.supportsReasoningEffort === false + ? modelRecordValue(provider.modelDefaultReasoningEfforts, modelId) : undefined); + const value = mappedClientEffort(provider, modelId, effort); + if (value !== undefined) { + // Legacy adaptive wire policy cannot be represented by a boolean; leave its serializer in charge. + if (isReasoningEffortOmitted(value) || value === "adaptive") return undefined; + return value !== "none" && value !== "disabled"; + } + return capabilities.defaultThinkingEnabled; +} + +/** Resolve client aliases before strength-only policy can strip or clamp them. */ +export function normalizeThinkingOptions(provider: OcxProviderConfig, modelId: string, options: { thinkingEnabled?: boolean; reasoning?: string }): void { + const capabilities = resolveReasoningCapabilities(provider, modelId); + if (capabilities.supportsReasoning === false) { + delete options.thinkingEnabled; + delete options.reasoning; + return; + } + const enabled = resolveThinkingEnabled(provider, modelId, options); + if (enabled !== undefined) options.thinkingEnabled = enabled; + else if (capabilities.supportsThinkingToggle === false) delete options.thinkingEnabled; + const mapped = mappedClientEffort(provider, modelId, options.reasoning); + const preserveWirePolicy = capabilities.supportsThinkingToggle === true + && (mapped === "adaptive" || isReasoningEffortOmitted(mapped)); + if (capabilities.supportsReasoningEffort === false && !preserveWirePolicy) delete options.reasoning; +} diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index a900292a634..6300321ffb9 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -9,6 +9,7 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { exportDefaultReasoningEffort, exportReasoningEfforts } from "../../clients/config-export/reasoning-metadata"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts, type CodexReasoningLevel } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; @@ -132,6 +133,13 @@ export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS]; export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { if (!model) return; + applyClientReasoningCapabilities(entry, model); + if (model.supportsThinkingToggle === true && model.supportsReasoningEffort === false + && Array.isArray(entry.supported_reasoning_levels)) { + entry.supported_reasoning_levels = entry.supported_reasoning_levels.map(level => ({ + ...level, description: level.effort === "none" ? "Thinking off" : "Thinking on", + })); + } // This marker survives strict catalog normalization and lets sync distinguish a stale // bare combo alias from a genuine native model row. if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE; @@ -241,6 +249,26 @@ function stampCapabilityProvenance(entry: RawEntry, model: CatalogModel): void { }; } +/** Project explicit independent capabilities only at Codex's effort-enum boundary. */ +function applyClientReasoningCapabilities(entry: RawEntry, model: CatalogModel): void { + // Positive effort support can be inferred from a legacy ladder. It does not classify + // independent controls or opt that model out of the existing max/ultra synthesis. + if (model.supportsThinkingToggle === undefined && model.supportsReasoningEffort !== false + && model.supportsReasoning !== false) return; + const efforts = exportReasoningEfforts(model); + if (efforts === undefined && model.supportsReasoningEffort === undefined) { + // A negative toggle declaration says nothing about an unknown effort capability. + const legacy = catalogEntryEfforts(entry).filter(effort => effort !== "none"); + applyReasoningLevels(entry, legacy, typeof entry.default_reasoning_level === "string" ? entry.default_reasoning_level : undefined, true); + return; + } + const declaredDefault = exportDefaultReasoningEffort(model); + applyReasoningLevels(entry, efforts ?? [], declaredDefault, true); + if (model.supportsThinkingToggle === true && declaredDefault === undefined) { + delete entry.default_reasoning_level; + } +} + export function applyReasoningLevels( entry: RawEntry, effortsOverride?: string[], diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 6d9a13d060f..8c9ae6627af 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -34,6 +34,7 @@ import { } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; +import { REASONING_CAPABILITY_FIELDS } from "../../providers/reasoning-capabilities"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; @@ -264,6 +265,9 @@ export function withVisionSidecarInput( } /** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ +// Reapplying hints starts from discovered reasoning metadata, not the previous config projection. +const reasoningHintSources = new WeakMap>(); + export function applyProviderConfigHints( name: string, prov: OcxProviderConfig, @@ -302,10 +306,24 @@ export function applyProviderConfigHints( const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - const reasoningEfforts = configuredReasoningEfforts(prov, model.id); + const reasoningSource = reasoningHintSources.get(model) ?? model; + const reasoningCapabilities: Partial = {}; + for (const field of REASONING_CAPABILITY_FIELDS) { + const value = modelInList(prov.noReasoningModels, model.id) ? staticPolicy.model[field] + : prov.modelCapabilities?.[model.id]?.[field] ?? reasoningSource[field] ?? staticPolicy.model[field]; + if (typeof value === "boolean") reasoningCapabilities[field] = value; + } + if (reasoningCapabilities.supportsReasoning === false) { + reasoningCapabilities.supportsThinkingToggle = false; + reasoningCapabilities.supportsReasoningEffort = false; + reasoningCapabilities.defaultThinkingEnabled = false; + } + const reasoningEfforts = reasoningCapabilities.supportsReasoningEffort === false ? [] + : configuredReasoningEfforts(prov, model.id) ?? reasoningSource.reasoningEfforts; const suppressSyntheticMax = modelRecordValue(prov.modelSuppressSyntheticMax, model.id) ?? (model.antigravityEffortWireModelIds !== undefined); - const defaultReasoningEffort = staticPolicy.model.defaultReasoningEffort ?? model.defaultReasoningEffort; + const defaultReasoningEffort = reasoningCapabilities.supportsReasoningEffort === false ? undefined + : staticPolicy.model.defaultReasoningEffort ?? reasoningSource.defaultReasoningEffort; const supportsReasoningSummaries = staticPolicy.model.supportsReasoningSummaries; const supportsVerbosity = staticPolicy.model.supportsVerbosity; const fastPolicy = fastPolicyForModel(prov, model.id, name); @@ -322,6 +340,12 @@ export function applyProviderConfigHints( fastTierDescription: _staleFastTierDescription, providerAlias: _staleProviderAlias, suppressSyntheticMax: _staleSuppressSyntheticMax, + supportsReasoning: _staleSupportsReasoning, + supportsThinkingToggle: _staleThinkingToggle, + supportsReasoningEffort: _staleReasoningEffort, + defaultThinkingEnabled: _staleDefaultThinking, + reasoningEfforts: _staleReasoningEfforts, + defaultReasoningEffort: _staleDefaultEffort, ...modelWithoutServiceTier } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 @@ -355,6 +379,7 @@ export function applyProviderConfigHints( } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...reasoningCapabilities, ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), @@ -387,8 +412,7 @@ export function applyProviderConfigHints( : withCap; const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] .filter((value): value is number => typeof value === "number" && value > 0); - if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds; - return { + const result = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { ...withHardBounds, autoCompactTokenLimit: clampAutoCompactTokenLimit( contextWindow, @@ -396,6 +420,10 @@ export function applyProviderConfigHints( Math.min(...softCandidates), ), }; + reasoningHintSources.set(result, Object.fromEntries([ + ...REASONING_CAPABILITY_FIELDS, "reasoningEfforts", "defaultReasoningEffort", + ].map(field => [field, reasoningSource[field as keyof CatalogModel]]))); + return result; } /** Project a configured row, optionally deferring sidecar attachment metadata until publication. */ @@ -695,6 +723,15 @@ export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | " export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { const metadata = plainRecord(item.metadata); const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const reasoningCapabilities: Partial = {}; + for (const [wire, field] of [ + ["supports_reasoning", "supportsReasoning"], + ["supports_thinking_toggle", "supportsThinkingToggle"], + ["supports_reasoning_effort", "supportsReasoningEffort"], + ["default_thinking_enabled", "defaultThinkingEnabled"], + ] as const) { + if (typeof capabilityRecord?.[wire] === "boolean") reasoningCapabilities[field] = capabilityRecord[wire]; + } const limits = plainRecord(metadata?.limits); const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits); const contextWindow = @@ -764,7 +801,8 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...reasoningCapabilities, + ...(reasoningCapabilities.supportsReasoningEffort !== false && reasoningEfforts !== undefined ? { reasoningEfforts } : {}), ...(inputModalities ? { inputModalities } : {}), ...(capabilities ? { capabilities } : {}), // Omitted when the classification is "unknown", following this function's existing diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index bdab81c61e3..f3b71d6a54f 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -104,6 +104,10 @@ export interface CatalogModel { description?: string; owned_by?: string; reasoningEfforts?: string[]; + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; /** Exact CCA family evidence, retained through the in-memory discovery cache. */ antigravityEffortWireModelIds?: Partial>; /** Suppress only catalog synthesis of a missing max rung; provider-declared max survives. */ diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index e093eea6e9a..5bfe33d6fd5 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -463,7 +463,7 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f if (allowTombstones && row === null) continue; if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects"; for (const [axis, declaration] of Object.entries(row)) { - if (!["inputModalities", "contextTier", "video", "deepseekImageFiles"].includes(axis)) return "modelCapabilities contains an unknown axis"; + if (!["inputModalities", "contextTier", "video", "deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"].includes(axis)) return "modelCapabilities contains an unknown axis"; if (allowTombstones && declaration === null) continue; if (axis === "inputModalities") { if (!Array.isArray(declaration) || declaration.length === 0 @@ -472,8 +472,8 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f } } else if (axis === "contextTier") { if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context"; - } else if (axis === "deepseekImageFiles") { - if (typeof declaration !== "boolean") return "modelCapabilities deepseekImageFiles must be a boolean"; + } else if (["deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"].includes(axis)) { + if (typeof declaration !== "boolean") return `modelCapabilities ${axis} must be a boolean`; } else { if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) { return "modelCapabilities video must be a plain object containing only processing"; @@ -506,6 +506,9 @@ export function mergeModelCapabilities( } else if (axis === "deepseekImageFiles") { if (value === null) delete row.deepseekImageFiles; else row.deepseekImageFiles = value as boolean; + } else if (axis === "supportsReasoning" || axis === "supportsThinkingToggle" || axis === "supportsReasoningEffort" || axis === "defaultThinkingEnabled") { + if (value === null) delete row[axis]; + else row[axis] = value as boolean; } else if (axis === "video") { if (value === null) delete row.video; else { @@ -533,7 +536,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record = {}; - for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles"] as const) { + for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles", "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"] as const) { if (!Object.hasOwn(raw, axis)) continue; const declaration = raw[axis]; if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 5e01ebb8766..9528acb6dae 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -3,6 +3,8 @@ import { COST_VENDOR_PRIORITY, getModelMetadata, getModelMetadataCaseInsensitive import { cloneFastWire } from "./fastwire"; import { isAzureModelMetadataDestination, publishedAzureModelMetadata, type AzureModelMetadata } from "./azure-model-metadata"; import { resolveModelPolicy } from "./resolved-model-policy"; +import { mapFill } from "./resolved-model-policy-merge"; +import { mergeReasoningModelCapabilities } from "./reasoning-capabilities"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination, @@ -30,6 +32,7 @@ export interface DerivedKeyLoginProvider { contextWindow?: number; modelContextWindows?: Record; modelInputModalities?: Record; + modelCapabilities?: OcxProviderConfig["modelCapabilities"]; modelMaxInputTokens?: Record; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; @@ -261,6 +264,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(staticPolicy.modelContextWindows ? { modelContextWindows: { ...staticPolicy.modelContextWindows } } : {}), ...(staticPolicy.modelDisplayNames ? { modelDisplayNames: { ...staticPolicy.modelDisplayNames } } : {}), ...(staticPolicy.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(staticPolicy.modelInputModalities) } : {}), + ...(staticPolicy.modelCapabilities ? { modelCapabilities: structuredClone(staticPolicy.modelCapabilities) } : {}), ...(staticPolicy.modelMaxInputTokens ? { modelMaxInputTokens: { ...staticPolicy.modelMaxInputTokens } } : {}), ...(entry.defaultMaxOutputTokens !== undefined ? { defaultMaxOutputTokens: entry.defaultMaxOutputTokens } : {}), ...(staticPolicy.modelMaxOutputTokens ? { modelMaxOutputTokens: { ...staticPolicy.modelMaxOutputTokens } } : {}), @@ -334,6 +338,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.modelMaxOutputTokens ? { modelMaxOutputTokens: { ...entry.modelMaxOutputTokens } } : {}), ...(entry.reasoningEfforts ? { reasoningEfforts: [...entry.reasoningEfforts] } : {}), ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: cloneRecordOfArrays(entry.modelReasoningEfforts) } : {}), + ...(entry.modelCapabilities ? { modelCapabilities: structuredClone(entry.modelCapabilities) } : {}), ...(entry.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: { ...entry.modelDefaultReasoningEfforts } } : {}), ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), @@ -476,6 +481,8 @@ function applyVerbosityDefaults(prov: OcxProviderConfig, entry: ProviderRegistry */ function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void { const destination = registryEntryForProviderDestination(prov); + const capabilities = mergeReasoningModelCapabilities(destination?.modelCapabilities, prov.modelCapabilities); + if (capabilities) prov.modelCapabilities = capabilities; applyReasoningSummaryDefaults(prov, destination?.modelSupportsReasoningSummaries); if (prov.dropResponsesReasoningItems === undefined && destination?.dropResponsesReasoningItems !== undefined) { prov.dropResponsesReasoningItems = destination.dropResponsesReasoningItems; @@ -483,6 +490,9 @@ function enrichReasoningMetadataByDestination(prov: OcxProviderConfig): void { if (destination?.modelReasoningEfforts) { prov.modelReasoningEfforts = fillRecordOfArrays(destination.modelReasoningEfforts, prov.modelReasoningEfforts); } + if (destination?.modelDefaultReasoningEfforts) { + [prov.modelDefaultReasoningEfforts] = mapFill(destination.modelDefaultReasoningEfforts, prov.modelDefaultReasoningEfforts); + } if (prov.reasoningEfforts === undefined && destination?.reasoningEfforts !== undefined) { prov.reasoningEfforts = [...destination.reasoningEfforts]; } @@ -551,6 +561,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // (mapFill in src/providers/resolved-model-policy-merge.ts), so the wire honored the effort while /v1/models and // every client export showed no effort control at all. if (resolvedStatic.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(resolvedStatic.modelReasoningEfforts); + if (resolvedStatic.modelCapabilities) prov.modelCapabilities = structuredClone(resolvedStatic.modelCapabilities); if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts }; if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap }; if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap); diff --git a/src/providers/mimo-thinking.ts b/src/providers/mimo-thinking.ts new file mode 100644 index 00000000000..6e3a5095f11 --- /dev/null +++ b/src/providers/mimo-thinking.ts @@ -0,0 +1,12 @@ +import type { ModelCapabilities } from "../types"; + +// Public MiMo V2.6 exposes an on/off switch, not distinct reasoning strengths. +export const MIMO_THINKING_MODELS = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed"] as const; +export const MIMO_THINKING_CAPABILITIES: Record = Object.fromEntries( + MIMO_THINKING_MODELS.map(id => [id, { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + }]), +); diff --git a/src/providers/reasoning-capabilities.ts b/src/providers/reasoning-capabilities.ts new file mode 100644 index 00000000000..e9e2b0b0e94 --- /dev/null +++ b/src/providers/reasoning-capabilities.ts @@ -0,0 +1,64 @@ +import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; +import { modelInList } from "../types"; +import { registryEntryForProviderDestination } from "./registry"; +import { legacyModelValue, mapFill, nestedMapFill } from "./resolved-model-policy-merge"; + +export const REASONING_CAPABILITY_FIELDS = [ + "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled", +] as const; + +/** Exact model declarations merge per axis; unrelated operator axes retain registry defaults. */ +export function mergeReasoningModelCapabilities( + registry: Record | undefined, + explicit: Record | undefined, +): Record | undefined { + if (!registry && !explicit) return undefined; + const merged: Record = structuredClone(registry ?? {}); + for (const [id, row] of Object.entries(explicit ?? {})) merged[id] = { ...merged[id], ...structuredClone(row) }; + return merged; +} + +/** Captured-policy variant: never reads the live registry after admission. */ +export function reasoningCapabilitiesFromConfig(provider: Readonly, modelId: string): ReasoningCapabilities { + if (modelInList(provider.noReasoningModels, modelId)) { + return { supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }; + } + const declared = provider.modelCapabilities?.[modelId]; + const result: ReasoningCapabilities = {}; + for (const field of REASONING_CAPABILITY_FIELDS) { + if (typeof declared?.[field] === "boolean") result[field] = declared[field]; + } + const efforts = legacyModelValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + const effortMap = legacyModelValue(provider.modelReasoningEffortMap, modelId) ?? provider.reasoningEffortMap; + const effectiveEfforts = efforts?.filter(effort => effort !== "none").map(effort => effortMap?.[effort] ?? effort); + const strengths = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + if (result.supportsReasoningEffort === undefined && effectiveEfforts?.some(effort => strengths.includes(effort))) { + result.supportsReasoningEffort = true; + } + if (result.supportsReasoning === undefined && (result.supportsThinkingToggle === true + || result.supportsReasoningEffort === true || effectiveEfforts?.some(effort => strengths.includes(effort) || effort === "enabled" || effort === "adaptive") + || modelInList(provider.thinkingBudgetModels, modelId))) { + result.supportsReasoning = true; + } + if (result.supportsReasoning === false) { + result.supportsThinkingToggle = false; + result.supportsReasoningEffort = false; + result.defaultThinkingEnabled = false; + } + return result; +} + +/** Resolve fixed destination registry facts beneath exact operator capabilities. Unknown stays absent. */ +export function resolveReasoningCapabilities(provider: OcxProviderConfig, modelId: string): ReasoningCapabilities { + const entry = registryEntryForProviderDestination(provider); + return reasoningCapabilitiesFromConfig({ + ...provider, + modelCapabilities: mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), + modelReasoningEfforts: mapFill(entry?.modelReasoningEfforts, provider.modelReasoningEfforts)[0], + reasoningEfforts: provider.reasoningEfforts ?? entry?.reasoningEfforts, + reasoningEffortMap: mapFill(entry?.reasoningEffortMap, provider.reasoningEffortMap)[0], + modelReasoningEffortMap: nestedMapFill(entry?.modelReasoningEffortMap, provider.modelReasoningEffortMap)[0], + thinkingBudgetModels: [...(entry?.thinkingBudgetModels ?? []), ...(provider.thinkingBudgetModels ?? [])], + noReasoningModels: [...(entry?.noReasoningModels ?? []), ...(provider.noReasoningModels ?? [])], + }, modelId); +} diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 1fd4039425d..33fe7cc3b85 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -9,6 +9,7 @@ import { MOONSHOT_INTL_BASE_URL, } from "../base-url-choices"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../command-code-efforts"; +import { MIMO_THINKING_CAPABILITIES, MIMO_THINKING_MODELS } from "../mimo-thinking"; import { CODEBUDDY_CN_MODELS, CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, @@ -1275,11 +1276,11 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // (https://mimo.mi.com/docs/en-US/updates/deprecate), so the first-party presets default to V2.6. // Saved defaults are not rewritten; V2.5 stays listed until it stops answering. // Both first-party presets read the xiaomi metadata bundle for window, output, modalities and price. - { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"], jawcodeBundle: "xiaomi" }, + { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"], jawcodeBundle: "xiaomi", modelCapabilities: MIMO_THINKING_CAPABILITIES, thinkingToggleModels: [...MIMO_THINKING_MODELS] }, // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic // preset above and the paid token-plan host below. Keep a separate fixed-destination contract - // so existing custom providers are never retargeted while the official route receives the - // strict reasoning ladder its validator enforces (#1483). + // so existing custom providers are never retargeted. V2.6 uses the documented thinking + // toggle; older models retain the reasoning ladder validated in #1483. { id: "xiaomi-mimo", label: "Xiaomi MiMo (OpenAI Chat)", @@ -1289,11 +1290,13 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://platform.xiaomimimo.com/console/balance", defaultModel: "mimo-v2.6-flash", models: ["mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5"], + modelCapabilities: MIMO_THINKING_CAPABILITIES, + thinkingToggleModels: [...MIMO_THINKING_MODELS], jawcodeBundle: "xiaomi", reasoningEfforts: ["low", "medium", "high"], reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, preserveCustomDestination: true, - note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. The upstream validator accepts reasoning_effort none/low/medium/high; higher Codex tiers are clamped to high.", + note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. V2.6 thinking is on/off: none disables it; high is the client-compatible on value, not a distinct reasoning strength. Older models retain their existing effort contract.", }, { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" }, { diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 8fb7603d815..514552fb79d 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -98,6 +98,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { modelContextWindows: RECORD_KEYS, modelDisplayNames: RECORD_KEYS, modelInputModalities: RECORD_KEYS, + modelCapabilities: RECORD_KEYS, defaultMaxOutputTokens: NONE, modelMaxOutputTokens: RECORD_KEYS, reasoningEfforts: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index f5939c98601..1886a108ce8 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -308,6 +308,7 @@ export interface ProviderRegistryEntry { */ modelDisplayNames?: Record; modelInputModalities?: Record; + modelCapabilities?: OcxProviderConfig["modelCapabilities"]; defaultMaxOutputTokens?: number; modelMaxOutputTokens?: Record; reasoningEfforts?: string[]; @@ -378,7 +379,7 @@ export type ProviderConfigSeed = Pick< "anthropicOAuthInstance" | "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "chatCompletionsPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" - | "modelDisplayNames" + | "modelDisplayNames" | "modelCapabilities" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noStopModels" | "noPenaltyModels" diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index e2cf956e9d4..0935d276c31 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -1,4 +1,5 @@ -import type { ModelCapabilities, OcxProviderConfig } from "../types"; +import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; +import { mergeReasoningModelCapabilities, reasoningCapabilitiesFromConfig, REASONING_CAPABILITY_FIELDS } from "./reasoning-capabilities"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { resolveProviderAuthTransport } from "./fastwire"; @@ -48,7 +49,7 @@ export type ResolvedProviderStaticPolicy = Readonly; -export interface ResolvedPerModelStaticPolicy { +export interface ResolvedPerModelStaticPolicy extends Readonly { readonly adapter: string; readonly contextWindow?: number; readonly inputModalities?: readonly string[]; @@ -215,9 +216,11 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode putMergedMap("modelSupportsReasoningSummaries", entry?.modelSupportsReasoningSummaries, provider.modelSupportsReasoningSummaries); putMergedMap("modelSupportsVerbosity", entry?.modelSupportsVerbosity, provider.modelSupportsVerbosity); for (const key of [ - "modelCapabilities", "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", + "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", "modelReasoningSummaryDelivery", "codexToolMode", "modelAdapters", ] as const) putScalar(key, undefined); + put("modelCapabilities", mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), + provider.modelCapabilities !== undefined ? "operator-capability" : entry?.modelCapabilities !== undefined ? "registry" : "unknown"); for (const key of ["modelContextWindows", "modelMaxInputTokens"] as const) { const [value, source] = input.providerName === "openai-apikey" ? positiveCapMap(entry?.[key], provider[key]) @@ -325,7 +328,17 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode : exactMaxInputTokens; const exactMaxOutputTokens = modelValue(providerPolicy.modelMaxOutputTokens); const modelMaxOutputTokens = exactMaxOutputTokens ?? providerPolicy.defaultMaxOutputTokens; - const modelReasoningEfforts = modelValue(providerPolicy.modelReasoningEfforts) ?? providerPolicy.reasoningEfforts; + const reasoningCapabilities = reasoningCapabilitiesFromConfig({ + ...providerPolicy, + adapter: providerPolicy.adapter ?? provider.adapter, + baseUrl: providerPolicy.baseUrl ?? provider.baseUrl, + ...(input.modelCapabilities ? { modelCapabilities: { + ...providerPolicy.modelCapabilities, + [input.modelId]: { ...providerPolicy.modelCapabilities?.[input.modelId], ...input.modelCapabilities }, + } } : {}), + }, input.modelId); + const modelReasoningEfforts = reasoningCapabilities.supportsReasoningEffort === false ? [] + : modelValue(providerPolicy.modelReasoningEfforts) ?? providerPolicy.reasoningEfforts; const modelSupportsReasoningSummaries = modelValue(providerPolicy.modelSupportsReasoningSummaries) ?? (modelValue(providerPolicy.modelReasoningSummaryDelivery) !== undefined ? true : undefined); const modelSupportsVerbosity = modelValue(providerPolicy.modelSupportsVerbosity) ?? providerPolicy.supportsVerbosity; @@ -334,6 +347,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode : modelValue(providerPolicy.modelSupportsServiceTier) ?? providerPolicy.supportsServiceTier; const model: ResolvedPerModelStaticPolicy = { adapter, + ...reasoningCapabilities, ...(modelContextWindow !== undefined ? { contextWindow: modelContextWindow } : {}), ...(capabilityModalities ?? modelValue(providerPolicy.modelInputModalities) !== undefined ? { inputModalities: capabilityModalities ?? modelValue(providerPolicy.modelInputModalities) } @@ -341,7 +355,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode ...(modelMaxInputTokens !== undefined ? { maxInputTokens: modelMaxInputTokens } : {}), ...(modelMaxOutputTokens !== undefined ? { maxOutputTokens: modelMaxOutputTokens } : {}), ...(modelReasoningEfforts !== undefined ? { reasoningEfforts: modelReasoningEfforts } : {}), - ...(modelValue(providerPolicy.modelDefaultReasoningEfforts) !== undefined ? { defaultReasoningEffort: modelValue(providerPolicy.modelDefaultReasoningEfforts) } : {}), + ...(reasoningCapabilities.supportsReasoningEffort !== false && modelValue(providerPolicy.modelDefaultReasoningEfforts) !== undefined ? { defaultReasoningEffort: modelValue(providerPolicy.modelDefaultReasoningEfforts) } : {}), ...(modelSupportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: modelSupportsReasoningSummaries } : {}), ...(modelSupportsVerbosity !== undefined ? { supportsVerbosity: modelSupportsVerbosity } : {}), ...(modelSupportsServiceTier !== undefined ? { supportsServiceTier: modelSupportsServiceTier } : {}), @@ -359,6 +373,11 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode const modelProvenance: Partial> = { adapter: adapterSource, }; + for (const field of REASONING_CAPABILITY_FIELDS) { + modelProvenance[field] = reasoningCapabilities[field] === undefined ? "unknown" + : input.modelCapabilities?.[field] !== undefined || provider.modelCapabilities?.[input.modelId]?.[field] !== undefined + ? "operator-capability" : entry?.modelCapabilities?.[input.modelId]?.[field] !== undefined ? "registry" : "operator"; + } modelProvenance.contextWindow = modelOrProviderSource(provider.modelContextWindows, entry?.modelContextWindows, provider.contextWindow, entry?.contextWindow); if (modelProvenance.contextWindow === "unknown" && familyContextWindow !== undefined) { modelProvenance.contextWindow = anthropicFamilyContextWindow(provider.modelContextWindows, input.modelId) !== undefined diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 10bd953eb5e..575c2521cc9 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -185,8 +185,8 @@ function attachPendingReasoningToCallOwner( const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); -export function hasValidatedActiveReasoningEffort(options: Pick): boolean { - return options.reasoning !== undefined && options.reasoning !== "none"; +export function hasValidatedActiveReasoningEffort(options: Pick): boolean { + return options.thinkingEnabled ?? (options.reasoning !== undefined && options.reasoning !== "none"); } @@ -695,6 +695,7 @@ export function parseRequest( // `reasoning_effort_for_request`), so current clients never send it — but a catalog that // advertises ultra plus an older/direct caller can. Degrade it to max like upstream instead of // silently dropping reasoning altogether. + if (data.reasoning?.enabled !== undefined) options.thinkingEnabled = data.reasoning.enabled; const requestedEffort = data.reasoning?.effort === "ultra" ? "max" : data.reasoning?.effort; if (requestedEffort && REASONING_EFFORTS.has(requestedEffort)) { options.reasoning = requestedEffort; diff --git a/src/responses/schema.ts b/src/responses/schema.ts index 864b4951139..d3fac2f8632 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -180,6 +180,7 @@ export const toolChoiceSchema = z.union([ ]); export const reasoningConfigSchema = z.object({ + enabled: z.boolean().optional(), effort: z.string().optional(), summary: z.enum(["auto", "concise", "detailed", "none"]).optional(), }); diff --git a/src/server/effort-policy.ts b/src/server/effort-policy.ts index 16db1cf3471..664fa7a0e4e 100644 --- a/src/server/effort-policy.ts +++ b/src/server/effort-policy.ts @@ -232,6 +232,7 @@ interface EffortSnapshot { modelId: string; reasoningPresent: boolean; reasoning: OcxParsedRequest["options"]["reasoning"]; + thinkingEnabled: boolean | undefined; rawEffortPresent: boolean; rawEffort: unknown; } @@ -239,7 +240,7 @@ interface EffortSnapshot { const effortSnapshots = new WeakMap(); /** Capture effective synthetic/combo defaults before final model namespace rewriting. - * A different destination restores effort alone; intervening summary/options edits survive. + * A different destination restores effort and thinking choice; intervening summary edits survive. * Credential retries do not change the destination and retain their existing decision. */ export function prepareEffortNormalization( @@ -255,6 +256,7 @@ export function prepareEffortNormalization( modelId: route.modelId, reasoningPresent: Object.hasOwn(parsed.options, "reasoning"), reasoning: parsed.options.reasoning, + thinkingEnabled: parsed.options.thinkingEnabled, rawEffortPresent: !!raw?.reasoning && Object.hasOwn(raw.reasoning, "effort"), rawEffort: raw?.reasoning?.effort, }); @@ -265,6 +267,8 @@ export function prepareEffortNormalization( } if (previous.reasoningPresent) parsed.options.reasoning = previous.reasoning; else delete parsed.options.reasoning; + if (previous.thinkingEnabled !== undefined) parsed.options.thinkingEnabled = previous.thinkingEnabled; + else delete parsed.options.thinkingEnabled; if (raw && previous.rawEffortPresent) { if (!raw.reasoning || typeof raw.reasoning !== "object") raw.reasoning = {}; raw.reasoning.effort = previous.rawEffort; diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index c680d02e2cb..38de62aa5a4 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1304,7 +1304,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { ...(effectiveId !== publicId ? { native_id: publicId } : {}), ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), ...modelCapabilityFields({ - reasoningEfforts: m.reasoningEfforts, + ...m, // contextWindow is already the post-cap effective value; contextCap is the raw // operator knob and over-reports models whose real window sits below it. contextWindow: m.contextWindow, diff --git a/src/server/management/model-row-export-metadata.ts b/src/server/management/model-row-export-metadata.ts index f120a173c3d..72c1ebeb335 100644 --- a/src/server/management/model-row-export-metadata.ts +++ b/src/server/management/model-row-export-metadata.ts @@ -16,6 +16,7 @@ * and config already in hand. */ import { enrichProviderFromRegistry } from "../../providers/derive"; +import { reasoningCapabilitiesFromConfig } from "../../providers/reasoning-capabilities"; import { modelRecordValue } from "../../reasoning-effort"; import { configuredReasoningSummarySupport } from "../../codex/catalog/model-hints"; import type { CatalogModel } from "../../codex/catalog"; @@ -87,24 +88,42 @@ export function customRowExportMetadata( // same reader the materializer uses answers from the provider config, so both paths agree. const supportsReasoningSummaries = catalogRow?.supportsReasoningSummaries ?? configuredReasoningSummarySupport(provider, source.id); + // The effective ladder below already includes custom overrides; do not derive new axes + // from a provider-wide legacy ladder that a custom row may explicitly have cleared. + const configured = enriched === undefined ? undefined : reasoningCapabilitiesFromConfig({ + ...enriched, modelReasoningEfforts: undefined, reasoningEfforts: undefined, + }, source.id); + const supportsThinkingToggle = configured?.supportsThinkingToggle === false ? false + : catalogRow?.supportsThinkingToggle ?? configured?.supportsThinkingToggle; + const supportsReasoningEffort = configured?.supportsReasoningEffort === false ? false + : catalogRow?.supportsReasoningEffort ?? configured?.supportsReasoningEffort; + const defaultThinkingEnabled = configured?.defaultThinkingEnabled === false ? false + : catalogRow?.defaultThinkingEnabled ?? configured?.defaultThinkingEnabled; + const supportsReasoning = configured?.supportsReasoning === false ? false : catalogRow?.supportsReasoning + ?? configured?.supportsReasoning + ?? knownReasoningSupport({ reasoningEfforts: effectiveReasoningEfforts, supportsReasoningSummaries, + supportsThinkingToggle, supportsReasoningEffort }); + const reasoningEfforts = supportsReasoningEffort === false || supportsReasoning === false + ? (effectiveReasoningEfforts === undefined ? undefined : []) : effectiveReasoningEfforts; const defaultReasoningEffort = declaredDefaultWithin( - effectiveReasoningEfforts, - defaultEffort(source, catalogRow, provider), + reasoningEfforts, + supportsReasoningEffort === false || supportsReasoning === false ? undefined : defaultEffort(source, catalogRow, provider), ); return { ...(contextWindow !== undefined ? { contextWindow } : {}), ...(catalogRow?.maxInputTokens !== undefined ? { maxInputTokens: catalogRow.maxInputTokens } : {}), ...(catalogRow?.maxOutputTokens !== undefined ? { maxTokens: catalogRow.maxOutputTokens } : {}), ...(inputModalities !== undefined ? { inputModalities: [...inputModalities] } : {}), - ...(effectiveReasoningEfforts !== undefined ? { reasoningEfforts: [...effectiveReasoningEfforts] } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts: [...reasoningEfforts] } : {}), + ...(supportsThinkingToggle !== undefined ? { supportsThinkingToggle } : {}), + ...(supportsReasoningEffort !== undefined ? { supportsReasoningEffort } : {}), + ...(defaultThinkingEnabled !== undefined ? { defaultThinkingEnabled } : {}), ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}), ...(knownToolsSupport({ capabilities: catalogRow?.capabilities, parallelToolCalls: catalogRow?.parallelToolCalls, }) === true ? { supportsTools: true } : {}), - ...(knownReasoningSupport({ reasoningEfforts: effectiveReasoningEfforts, supportsReasoningSummaries }) === true - ? { supportsReasoning: true } - : {}), + ...(typeof supportsReasoning === "boolean" ? { supportsReasoning } : {}), ...(supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries } : {}), }; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 479c0c880cc..e03206ecbca 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -104,9 +104,9 @@ export type ManagementModelRow = Partial & { */ supportsTools?: boolean; /** - * True when a canonical source positively asserts reasoning (a non-empty effective ladder - * or delivered reasoning summaries). Absent means unknown, never "cannot reason": an empty - * ladder only says the effort is not adjustable. + * A canonical source's explicit capability, or positive evidence from reasoning efforts + * or delivered summaries. Absent means unknown; an empty ladder alone says only that + * effort is not adjustable, not that the model cannot reason. */ supportsReasoning?: boolean; /** @@ -460,9 +460,11 @@ export async function listManagementModelRows( const routed = m.provider !== "combo"; // Resolved once each: the spread below used to evaluate the same helpers again for the guard. const reasoningEfforts = routed - ? effectiveModelReasoningEfforts(config, m.provider, m.id, m.reasoningEfforts, catalogLadders) + ? m.supportsReasoningEffort === false || m.supportsReasoning === false ? [] + : effectiveModelReasoningEfforts(config, m.provider, m.id, m.reasoningEfforts, catalogLadders) : undefined; const defaultReasoningEffort = routed + && m.supportsReasoningEffort !== false && m.supportsReasoning !== false ? effectiveModelDefaultReasoningEffort(config, m.provider, m.id, m.defaultReasoningEffort, reasoningEfforts) : undefined; const inputModalitiesDeclared = routed ? declaredModelInputModalities(config, m.provider, m.id) : undefined; @@ -524,10 +526,7 @@ export async function listManagementModelRows( }); const supportsReasoning = row.exportMetadata?.supportsReasoning ?? row.supportsReasoning - ?? knownReasoningSupport({ - reasoningEfforts: row.reasoningEfforts, - supportsReasoningSummaries: row.supportsReasoningSummaries, - }); + ?? knownReasoningSupport(row); return { ...row, // One shared definition with the request path: the GUI must not re-derive consumer @@ -560,12 +559,17 @@ export function toExportModel(row: ManagementModelRow): ExportModel { const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const reasoningEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; + const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; + const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; + const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const effortDisabled = supportsReasoningEffort === false || (effective?.supportsReasoning ?? row.supportsReasoning) === false; + const reasoningEfforts = effortDisabled && declaredEfforts !== undefined ? [] : declaredEfforts; // The projection is the authority on the default. When it exists, an absent default means // NO declared default — falling back to the row would reintroduce the picker's synthetic // medium/low value, which is exactly what this projection exists to keep out of exports. // Rows without a projection (native-era shapes, hand-built rows) keep the row's own value. - const defaultReasoningEffort = effective !== undefined + const defaultReasoningEffort = effortDisabled ? undefined : effective !== undefined ? effective.defaultReasoningEffort : row.defaultReasoningEffort; const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries; @@ -580,10 +584,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { }); const supportsReasoning = effective?.supportsReasoning ?? row.supportsReasoning - ?? knownReasoningSupport({ - reasoningEfforts: row.reasoningEfforts, - supportsReasoningSummaries: row.supportsReasoningSummaries, - }); + ?? knownReasoningSupport({ ...row, supportsThinkingToggle, supportsReasoningEffort, + reasoningEfforts, supportsReasoningSummaries }); return { namespaced: row.namespaced, provider: row.provider, @@ -597,6 +599,9 @@ export function toExportModel(row: ManagementModelRow): ExportModel { ...(inputModalities ? { inputModalities } : {}), // An empty array is a declaration ("no rungs"), and `[]` is truthy: it survives here. ...(reasoningEfforts ? { reasoningEfforts } : {}), + ...(typeof supportsThinkingToggle === "boolean" ? { supportsThinkingToggle } : {}), + ...(typeof supportsReasoningEffort === "boolean" ? { supportsReasoningEffort } : {}), + ...(typeof defaultThinkingEnabled === "boolean" ? { defaultThinkingEnabled } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), // An explicit false is evidence and survives; only derivation is true-or-nothing. ...(typeof supportsTools === "boolean" ? { supportsTools } : {}), diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index 061d3725b82..4a56da24afa 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -110,6 +110,10 @@ export function predictCursorEffort( } export interface ModelCapabilityInput { + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; reasoningEfforts?: readonly string[]; contextWindow?: number; /** @@ -134,6 +138,9 @@ export interface ModelCapabilityFields { supports_tool_use: true; supports_streaming: true; supports_reasoning: boolean; + supports_thinking_toggle?: boolean; + supports_reasoning_effort?: boolean; + default_thinking_enabled?: boolean; supports_vision?: boolean; reasoning_effort?: string[]; }; @@ -166,7 +173,8 @@ function positiveInt(value: unknown): number | undefined { } export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields { - const efforts = (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); + const efforts = input.supportsReasoningEffort === false ? [] + : (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); const contextLength = positiveInt(input.contextWindow); const longContextLength = positiveInt(input.longContextWindow); const maxOutputTokens = positiveInt(input.maxOutputTokens); @@ -187,7 +195,10 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil ...(modalities !== undefined && modalities.length > 0 ? { input_modalities: [...modalities] } : {}), supports_tool_use: true, supports_streaming: true, - supports_reasoning: efforts.length > 0, + supports_reasoning: input.supportsReasoning ?? efforts.length > 0, + ...(input.supportsThinkingToggle !== undefined ? { supports_thinking_toggle: input.supportsThinkingToggle } : {}), + ...(input.supportsReasoningEffort !== undefined ? { supports_reasoning_effort: input.supportsReasoningEffort } : {}), + ...(input.defaultThinkingEnabled !== undefined ? { default_thinking_enabled: input.defaultThinkingEnabled } : {}), ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}), ...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}), }, diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 78d9160d75d..2be798d6de1 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -1,4 +1,5 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; +import { normalizeThinkingOptions } from "../../clients/reasoning-controls"; import { sanitizeLogMetadataString } from "../../lib/redact"; import type { RouteResult } from "../../router"; import type { InboundWire } from "../../providers/registry"; @@ -341,6 +342,7 @@ export async function applyFinalRouteRequestNormalization(args: { } { + normalizeThinkingOptions(route.provider, route.modelId, parsed.options); const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) { diff --git a/src/types.ts b/src/types.ts index 5e97032e752..99e08560f2c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,6 +134,7 @@ export type { TierDecision, OcxProviderConfig, ModelCapabilities, + ReasoningCapabilities, } from "./types/provider"; export { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS } from "./types/provider"; diff --git a/src/types/provider.ts b/src/types/provider.ts index 7647ba41470..7ca3727c860 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -278,12 +278,16 @@ export type TierDecision = | { readonly kind: "drop" } | { readonly kind: "set"; readonly value: string }; -/** - * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 - * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. - */ +/** Independent reasoning axes. Absence means unknown, not an explicit denial. */ +export interface ReasoningCapabilities { + supportsReasoning?: boolean; + supportsThinkingToggle?: boolean; + supportsReasoningEffort?: boolean; + defaultThinkingEnabled?: boolean; +} + /** Explicit per-model operator declarations; absent axes keep legacy behavior. */ -export interface ModelCapabilities { +export interface ModelCapabilities extends ReasoningCapabilities { inputModalities?: Array<"text" | "image" | "audio" | "video">; /** Requested tier only; does not imply an upstream window or activate an unverified wire. */ contextTier?: "default" | "long_context"; @@ -292,6 +296,10 @@ export interface ModelCapabilities { deepseekImageFiles?: boolean; } +/** + * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 + * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. + */ export interface OcxProviderConfig { /** Explicit builtin Pool 2 provenance; endpoints never imply ownership. */ anthropicOAuthInstance?: "anthropic2"; diff --git a/src/types/request.ts b/src/types/request.ts index edc20eaae0d..635ed627074 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -386,6 +386,11 @@ export interface OcxRequestOptions { toolChoice?: OcxToolChoice; parallelToolCalls?: boolean; reasoning?: string; + /** + * Independent reasoning toggle resolved at client-compatibility ingress. This is distinct from + * the strength-like `reasoning` effort: toggle-only models consume this value as `thinking.type`. + */ + thinkingEnabled?: boolean; hideThinkingSummary?: boolean; /** Provider policy: suppress raw content-channel reasoning while summaries stay visible. */ hideRawReasoning?: boolean; diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 87fc8f20a8f..22badc65901 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -16,6 +16,7 @@ parsing and ownership rules below. | Module | Responsibility | | --- | --- | | `src/clients/config-export.ts` | Pure per-client config builders and the exact managed fragments each client receives. It never writes files. | +| `src/clients/reasoning-controls.ts` | Ingress compatibility for effort-enum clients: `resolveThinkingEnabled(provider, modelId, options)` decodes toggle aliases into `thinkingEnabled` before strength policy runs, preserving an explicit thinking choice and canonical provider defaults/maps. `normalizeThinkingOptions` removes the alias when real effort is unsupported. | | `src/integrations/registry.ts` | Canonical config/detection paths, current-provider-store declarations, source-preserving YAML declarations, writer-lock behavior, and client IDs. | | `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. | | `src/integrations/config-io.ts` | Bounded regular-file loading and parsing; production reads reject symlinks and stop after 4 MiB, and writes past the same bound are refused so a mutation cannot strand a file it cannot read back. `src/lib/bounded-file-read.ts` starts its buffer from the opened size and grows only as needed, retaining the limit-plus-one overflow probe when a file grows concurrently and returning only bytes actually read. Values that cannot round-trip through the target serializer are rejected before mutation. | @@ -241,6 +242,7 @@ than reviving a cleared capability. Exporters do not invent a medium default for backfilling the stored override fields the editor reads. Management rows attach `exportMetadata`; its presence makes even an absent default authoritative, so export/launcher projections cannot fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. +Management and CLI export projections preserve independent `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled` fields, including explicit false; absent axes remain unknown. An empty effort declaration does not disable a declared thinking toggle. `supportsReasoningEffort: false` prevents legacy ladders from advertising strengths. Metadata projections carry capability truth without synthesizing effort choices. At the Codex, OpenCode and Kilo effort-enum boundaries only, toggle-only models encode off/on as `none`/`high`, with their default taken from `defaultThinkingEnabled`; fixed reasoning creates no picker, while effort-only and combined controls retain real strengths without synthetic top tiers. Legacy unknown capabilities keep their prior behavior: positive effort support alone, which can be inferred from an existing ladder, does not opt a Codex row out of its previous max/ultra synthesis. Explicit toggle-axis declarations and negative reasoning/effort capabilities still constrain that projection exactly. Legacy variant maps disable client-generated choices that the model does not support; client-owned labels may still display “High”. `tests/clients/client-export-reasoning-controls.test.ts` covers generic capability combinations and Codex projection, `tests/clients/client-export-effective-metadata.test.ts` covers routed/custom projection parity, and `tests/server/api-catalog-route.test.ts` checks independent public API flags without a fake effort list and client exports with upstream fetches stubbed. OpenCode V1 and Kilo use model `options` defaults and variant maps; OpenCode V2 uses model `settings` defaults and native variant arrays with `@opencode/ai/providers/openai-compatible`. Declared variants, including `none`, override those defaults. V2 always writes an array, empty diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 121dd38b79a..1ed6d3f229c 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +Thinking enablement is separate from strength: `src/responses/schema.ts` accepts the OCX `reasoning.enabled` boolean and `src/responses/parser.ts` retains it as `options.thinkingEnabled`, independently of `options.reasoning`. Chat `thinking.type` and Anthropic `thinking.type` preserve this choice through their inbound translators; the existing Anthropic disabled-effort sentinel remains for legacy destinations. `src/clients/reasoning-controls.ts` decodes effort-only client aliases for declared toggle-only models before strength caps run. Route fallback restores the original choice alongside effort in `src/server/effort-policy.ts`; a cleared strength list does not clear a thinking switch. Regression coverage lives in `tests/responses/responses-parser.test.ts`, `tests/responses/chat-inbound-reasoning-none.test.ts`, `tests/claude-integration/claude-inbound.test.ts`, and `tests/codex-integration/effort-policy.test.ts`. + The names used for these paths (native, translated, legacy bridge) and the declared per-feature dispositions are owned by [Protocol Paths](protocol-paths.md). diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 04987b76b41..538f3a95b23 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,4 +1,8 @@ # Providers And Adapters + +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath exact operator declarations; absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. Config validation, patch merging and load repair preserve each boolean independently. + +Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. GCP JSON storage in `src/providers/api-keys.ts` and durable-row snapshot/post-commit cleanup in `src/providers/gcp-credential-cleanup.ts` follow [Vertex credential markers](providers/google.md#vertex-credential-markers); malformed blank marker accounts do not block cleanup of valid accounts. Combo target `metered` metadata and configured-key opt-in follow the [runtime consent contract](runtime.md#request-local-target-compatibility). Subscription-to-Cursor-to-key regression coverage in `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` checks unchanged healthy subscription wire bodies, effort, key authentication and cache controls, and structured runTurn 429 propagation. OpenCode Go's exact Gemini pin and credential destination guard follow the [Go Gemini transport contract](providers/google.md#opencode-go-gemini-transport). Configured spend ceilings bind adapter inference executors to the [seed and physical-start contract](transports/responses-spend.md#historical-pool-continuity-and-rollback). Zed completion and auth-recovery replays claim separately; token acquisition stays outside inference accounting. Claude CLI, CodeBuddy and Qoder each count one invocation toward the frozen request limit, seed before spawn and retain a producer through completion. Child-internal retries/tool turns are not separately counted; reported actual usage settles in full, including amounts above the seed estimate. diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index 8ae1be22a2a..a1b24954f72 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -26,6 +26,89 @@ async function bodyOf(p: OcxParsedRequest, configuredProvider = provider): Promi } describe("anthropic extended-thinking gate", () => { + test.each([[false, "disabled"], [true, "enabled"]] as const)("toggle-only models serialize direct boolean %s as %s", async (thinkingEnabled, type) => { + const modelId = "toggle-only-model"; + const request = parsed(undefined, { temperature: 0.3, topP: 0.9 }, modelId); + request.options.thinkingEnabled = thinkingEnabled; + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); + expect(b.temperature).toBe(0.3); + expect(b.top_p).toBe(0.9); + }); + + test("toggle-only models apply their capability default without adding strength fields", async () => { + const modelId = "toggle-only-default"; + const b = await bodyOf(parsed(undefined, {}, modelId), { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "enabled" }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); + }); + + test("models with genuine effort strengths retain their effort wire", async () => { + const modelId = "ordinary-strength-model"; + const b = await bodyOf(parsed("high", {}, modelId), { + ...provider, + baseUrl: "https://gateway.example.test/anthropic", + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "enabled", budget_tokens: 16384 }); + }); + + test("an explicit toggle off wins over a genuine high effort", async () => { + const modelId = "toggle-dual-axis"; + const request = parsed("high", { temperature: 0.3, topP: 0.9, thinkingEnabled: false }, modelId); + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + }); + expect(b.thinking).toEqual({ type: "disabled" }); + expect(b.temperature).toBe(0.3); + expect(b.top_p).toBe(0.9); + }); + + test("an explicitly unsupported capability suppresses direct Anthropic controls", async () => { + const modelId = "unsupported-reasoning"; + const request = parsed("high", { thinkingEnabled: true }, modelId); + const b = await bodyOf(request, { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { supportsReasoning: false } }, + }); + expect(b.thinking).toBeUndefined(); + expect(b.output_config).toBeUndefined(); + }); + // Sonnet 4.5 takes temperature or top_p alone but 400s on both (live 2026-09-29), so a request // carrying both keeps temperature; each field alone survives. test("reasoning 'none' does NOT enable thinking and preserves temperature", async () => { diff --git a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts index 5814291eef6..06aa7280b6b 100644 --- a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts +++ b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts @@ -4,6 +4,8 @@ import { createOpenAIChatAdapter, } from "../../../src/adapters/openai-chat"; import { chatCompletionsToResponsesBody } from "../../../src/chat/inbound"; +import { providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; import { parseRequest } from "../../../src/responses/parser"; import type { OcxProviderConfig } from "../../../src/types"; @@ -37,6 +39,23 @@ const tool: Record = { }; const inputWithTool: Record = { ...input, tools: [tool] }; +const toggleModel = "toggle-only-model"; +const toggleProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.test/v1", + apiKey: "sk-test", + authMode: "key", + thinkingToggleModels: [toggleModel], + modelCapabilities: { + [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + defaultThinkingEnabled: true, + }, + }, +}; + function finalBodies(rawInput: Record): Record<"translated" | "native", Record> { const parsed = parseRequest(chatCompletionsToResponsesBody(rawInput)); const translated = createOpenAIChatAdapter(provider).buildRequest(parsed); @@ -48,6 +67,23 @@ function finalBodies(rawInput: Record): Record<"translated" | " }; } +function finalToggleBodies( + rawInput: Record, + target: OcxProviderConfig = toggleProvider, + targetModel = toggleModel, + thinkingEnabled?: boolean, +): Record<"translated" | "native", Record> { + const requestForModel = { ...rawInput, model: targetModel }; + const parsed = parseRequest(chatCompletionsToResponsesBody(requestForModel)); + if (thinkingEnabled !== undefined) parsed.options.thinkingEnabled = thinkingEnabled; + const translated = createOpenAIChatAdapter(target).buildRequest(parsed); + const native = buildOpenAIChatPassthroughRequest(target, requestForModel, targetModel, false); + return { + translated: JSON.parse(translated.body) as Record, + native: JSON.parse(native.body) as Record, + }; +} + describe("OpenAI Chat reasoning wire policy parity", () => { test("plain reasoning disable uses the gateway object on both final wires", () => { const bodies = finalBodies(input); @@ -82,3 +118,201 @@ describe("OpenAI Chat reasoning wire policy parity", () => { }); }); }); + +describe("toggle-only Chat reasoning capability", () => { + const request = (reasoning_effort?: string): Record => ({ + model: toggleModel, + messages: [{ role: "user", content: "think" }], + ...(reasoning_effort === undefined ? {} : { reasoning_effort }), + }); + + test.each([[false, "disabled"], [true, "enabled"]] as const)("serializes direct boolean %s as %s", (thinkingEnabled, type) => { + const bodies = finalToggleBodies(request(), toggleProvider, toggleModel, thinkingEnabled); + const translated = bodies.translated; + expect(translated.thinking).toEqual({ type }); + expect(translated.reasoning_effort).toBeUndefined(); + }); + + test("translates legacy Chat effort on both final wires", () => { + for (const body of Object.values(finalToggleBodies(request("none")))) { + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("uses the capability default and retains an explicit native toggle when compatibility input is omitted", () => { + const bodies = finalToggleBodies(request()); + expect(bodies.translated.thinking).toEqual({ type: "enabled" }); + expect(bodies.native.thinking).toEqual({ type: "enabled" }); + + const native = buildOpenAIChatPassthroughRequest(toggleProvider, { + ...request(), + thinking: { type: "disabled" }, + }, toggleModel, false); + expect((JSON.parse(native.body) as Record).thinking).toEqual({ type: "disabled" }); + }); + + test("an explicit empty effort list does not disable the independent toggle", () => { + for (const body of Object.values(finalToggleBodies(request(), { + ...toggleProvider, + modelReasoningEfforts: { [toggleModel]: [] }, + }))) { + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an omitted legacy alias does not revive the toggle default", () => { + for (const body of Object.values(finalToggleBodies(request("high"), { + ...toggleProvider, + modelReasoningEffortMap: { [toggleModel]: { high: "__omit__" } }, + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an effort capability false strips native and translated strength without a toggle declaration", () => { + const modelId = "effort-disabled-model"; + const target: OcxProviderConfig = { + ...toggleProvider, + thinkingToggleModels: undefined, + modelCapabilities: { [modelId]: { supportsReasoning: true, supportsReasoningEffort: false } }, + }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), reasoning: { enabled: true } }, target, modelId))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + } + }); + + test("a toggle capability false retains a separately supported raw effort", () => { + const target: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: false, + supportsReasoningEffort: true, + } }, + modelReasoningEffortMap: { [toggleModel]: { high: "enabled" } }, + }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), thinking: { type: "enabled" } }, target))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe("high"); + } + }); + + test.each([ + ["reasoning disabled", { supportsReasoning: false }], + ["both controls disabled", { supportsThinkingToggle: false, supportsReasoningEffort: false }], + ])("an explicit capability with %s drops both control wires", (_label, overrides) => { + const modelCapabilities = { [toggleModel]: { ...toggleProvider.modelCapabilities![toggleModel]!, ...overrides } }; + for (const body of Object.values(finalToggleBodies({ ...request("high"), thinking: { type: "enabled" } }, { + ...toggleProvider, + modelCapabilities, + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + } + }); + + test("noReasoningModels remains authoritative", () => { + for (const body of Object.values(finalToggleBodies(request("none"), { + ...toggleProvider, + noReasoningModels: [toggleModel], + }))) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("a declared dual-axis wire preserves explicit thinking alongside low effort", () => { + const bothProvider: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + defaultThinkingEnabled: true, + } }, + modelReasoningEfforts: { [toggleModel]: ["low"] }, + }; + const raw = { ...request("low"), thinking: { type: "enabled" } }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + parsed.options.thinkingEnabled = true; + const translated = JSON.parse(createOpenAIChatAdapter(bothProvider).buildRequest(parsed).body) as Record; + const native = JSON.parse(buildOpenAIChatPassthroughRequest(bothProvider, raw, toggleModel, false).body) as Record; + for (const body of [translated, native]) { + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning_effort).toBe("low"); + } + }); +}); + +describe("legacy mapped thinking discriminator wires", () => { + test.each([ + ["zhipu-bigmodel", "glm-4.6", "enabled"], + ["opencode-go", "mimo-v2.5", "enabled"], + ["volcengine", "doubao-seed-2-1-pro-260628", "enabled"], + ["minimax", "MiniMax-M3", "adaptive"], + ] as const)("%s/%s emits %s on the declared wire", (providerId, modelId, type) => { + const entry = getProviderRegistryEntry(providerId); + if (!entry) throw new Error(`Missing registry seed ${providerId}`); + const target = { ...providerConfigSeed(entry), apiKey: "sk-test" }; + const raw = { model: modelId, messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const bodies = [ + JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, + JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, modelId, false).body) as Record, + ]; + for (const body of bodies) { + expect(body.thinking).toEqual({ type }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an explicit boolean disables a mapped legacy alias", () => { + const entry = getProviderRegistryEntry("zhipu-bigmodel"); + if (!entry) throw new Error("Missing registry seed zhipu-bigmodel"); + const target = { + ...providerConfigSeed(entry), + apiKey: "sk-test", + modelCapabilities: { "glm-4.6": { + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + } }, + }; + const raw = { + model: "glm-4.6", + messages: [{ role: "user", content: "think" }], + reasoning_effort: "high", + reasoning: { enabled: false }, + }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + parsed.options.thinkingEnabled = false; + const bodies = [ + JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, + JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, "glm-4.6", false).body) as Record, + ]; + for (const body of bodies) { + expect(body.thinking).toEqual({ type: "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test("an undeclared wire keeps the ordinary reasoning effort", () => { + const raw = { model: "ordinary-model", messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const target = { ...toggleProvider, thinkingToggleModels: undefined, modelCapabilities: undefined }; + const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); + const bodies = [ + JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, + JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, "ordinary-model", false).body) as Record, + ]; + for (const body of bodies) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe("high"); + } + }); +}); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index ef851cb7ca6..a8d8ce7732a 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -96,7 +96,7 @@ describe("claude inbound translation", () => { expect(body.stream).toBe(true); expect(body.parallel_tool_calls).toBe(false); expect(body.tool_choice).toBe("auto"); - expect(body.reasoning).toEqual({ summary: "auto", effort: "medium" }); + expect(body.reasoning).toEqual({ summary: "auto", effort: "medium", enabled: true }); const tools = body.tools as Record[]; expect(tools).toHaveLength(2); @@ -212,10 +212,13 @@ describe("claude inbound translation", () => { test("thinking variants", () => { const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; + const enabled = parseRequest(anthropicToResponsesBody({ ...base, thinking: { type: "enabled" } })); + expect(enabled.options.thinkingEnabled).toBe(true); + expect(enabled.options.reasoning).toBeUndefined(); expect((anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" } }) as any).reasoning).toEqual({ summary: "auto" }); // "disabled" and omitted must NOT collapse to the same state: for a model that thinks by // default, omission means thinking is ON and shares the caller's max_tokens (#545). - expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toEqual({ effort: "none" }); // justified: sibling assertions in this test use the same cast + expect((anthropicToResponsesBody({ ...base, thinking: { type: "disabled" } }) as any).reasoning).toEqual({ effort: "none", enabled: false }); // justified: sibling assertions in this test use the same cast expect((anthropicToResponsesBody(base) as any).reasoning).toBeUndefined(); expect(effortForThinkingBudget(1024)).toBe("low"); expect(effortForThinkingBudget(8192)).toBe("medium"); @@ -246,14 +249,14 @@ describe("claude inbound translation", () => { ...base, thinking: { type: "enabled", budget_tokens: 1024 }, output_config: { effort: "xhigh" }, - }))).toEqual({ summary: "auto", effort: "xhigh" }); + }))).toEqual({ summary: "auto", effort: "xhigh", enabled: true }); // disabled thinking suppresses effort entirely (subagent wire, claude-code#65863). // Still suppressed — "high" never reaches the wire — but now stated explicitly as the // "none" disable sentinel instead of by absence, so a default-on model is told to stop // rather than left to think anyway (#545). expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "disabled" }, output_config: { effort: "high" }, - }))).toEqual({ effort: "none" }); + }))).toEqual({ effort: "none", enabled: false }); // unknown effort strings are dropped so downstream defaults win expect(reasoningOf(anthropicToResponsesBody({ ...base, thinking: { type: "adaptive" }, output_config: { effort: "turbo" }, @@ -428,7 +431,7 @@ describe("claude inbound translation", () => { expect(body.tools).toEqual([{ type: "web_search" }]); expect(body.tool_choice).toEqual({ type: "web_search" }); - expect(body.reasoning).toEqual({ effort: "none" }); + expect(body.reasoning).toEqual({ effort: "none", enabled: false }); expect(() => responsesRequestSchema.parse(body)).not.toThrow(); expect(() => parseRequest(body)).not.toThrow(); }); diff --git a/tests/clients/client-export-effective-metadata.test.ts b/tests/clients/client-export-effective-metadata.test.ts index 076a8a015ff..b7786681f25 100644 --- a/tests/clients/client-export-effective-metadata.test.ts +++ b/tests/clients/client-export-effective-metadata.test.ts @@ -545,6 +545,80 @@ describe("the custom projection defers to the gather's resolved values", () => { }); describe("capability booleans come from positive evidence only", () => { + test("independent capability truth survives management and both CLI projections without a fake ladder", async () => { + for (const custom of [false, true]) { + const cfg = config(custom ? { customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] } : {}); + const rows = await listManagementModelRows(cfg, { + models: [{ id: "writer", provider: "acme", supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true, supportsReasoning: true }], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsThinkingToggle: true, supportsReasoning: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true }); + expect(exported.reasoningEfforts ?? []).toEqual([]); + expect(exported.defaultReasoningEffort).toBeUndefined(); + expect(exported).not.toHaveProperty("reasoningControl"); + } + } + }); + + test("explicit false axes and defaults survive projection and stale efforts cannot override them", async () => { + for (const custom of [false, true]) { + const cfg = config(custom ? { customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] } : {}); + const rows = await listManagementModelRows(cfg, { + models: [{ id: "writer", provider: "acme", reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false }], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(exported.defaultReasoningEffort).toBeUndefined(); + } + } + }); + + test("custom-row capability survives an empty ladder and preserves explicit false", async () => { + for (const supportsReasoning of [true, false]) { + const cfg = config({ customModels: [{ id: "c1", provider: "acme", modelId: "writer", reasoningEfforts: [] }] }); + const rows = await listManagementModelRows(cfg, { + models: [gatheredCustomRow("acme", "writer", { reasoningEfforts: [], supportsReasoning })], + }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + expect(row.exportMetadata?.supportsReasoning).toBe(supportsReasoning); + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported.supportsReasoning).toBe(supportsReasoning); + expect(exported.reasoningEfforts).toEqual([]); + } + } + }); + + test("rosterless custom rows inherit configured independent axes and noReasoningModels remains authoritative", async () => { + for (const suppressed of [false, true]) { + const cfg = config({ providers: { acme: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", + modelCapabilities: { writer: { supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: false } }, + modelReasoningEfforts: { writer: [] }, ...(suppressed ? { noReasoningModels: ["writer"] } : {}), + } }, customModels: [{ id: "c1", provider: "acme", modelId: "writer" }] }); + const rows = await listManagementModelRows(cfg, { models: suppressed ? [gatheredCustomRow("acme", "writer", { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, + defaultThinkingEnabled: true, reasoningEfforts: ["low", "high"], + })] : [] }); + const row = rows.find(candidate => candidate.namespaced === "acme/writer")!; + const wire = JSON.parse(JSON.stringify(row)); + for (const exported of [toExportModel(row), ...opencodeCatalogFromProxyRows([wire], cfg), ...exportModelsFromProxyRows([wire], cfg)]) { + expect(exported).toMatchObject({ supportsReasoning: !suppressed, supportsThinkingToggle: !suppressed, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(exported.defaultReasoningEffort).toBeUndefined(); + } + } + }); + test("an off-sentinel-only ladder is not an assertion that the model can reason", async () => { const cfg = config({ customModels: [{ id: "c1", provider: "acme", modelId: "writer", reasoningEfforts: ["none"] }], diff --git a/tests/clients/client-export-reasoning-controls.test.ts b/tests/clients/client-export-reasoning-controls.test.ts index 82fa4e10ef7..d74c2e185c9 100644 --- a/tests/clients/client-export-reasoning-controls.test.ts +++ b/tests/clients/client-export-reasoning-controls.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildClientConfig, type ExportModel, type KiloGeneratedConfig, type OpencodeGeneratedConfig } from "../../src/clients/config-export"; +import { deriveEntry } from "../../src/codex/catalog/derive-entry"; +import { applyProviderConfigHints } from "../../src/codex/catalog/model-hints"; const model: ExportModel = { namespaced: "mock/reasoner", provider: "mock", id: "reasoner", contextWindow: 100_000, @@ -63,6 +65,135 @@ describe("OpenCode and Kilo export real per-model controls", () => { expect(v2.settings).toEqual({ reasoningEffort: "none" }); }); + test("toggle models offer only off/on and suppress regenerated legacy strength choices", () => { + const { v1, v2, kilo } = configs({ ...model, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: true, + reasoningEfforts: [], defaultReasoningEffort: undefined }); + expect(v2.variants).toEqual([ + { id: "none", settings: { reasoningEffort: "none" } }, + { id: "high", settings: { reasoningEffort: "high" } }, + ]); + expect(v2.settings).toEqual({ reasoningEffort: "high" }); + for (const entry of [v1, kilo]) { + expect(entry.reasoning).toBe(true); + expect(entry.options).toEqual({ reasoningEffort: "high" }); + const generated = Object.fromEntries(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] + .map(effort => [effort, { reasoningEffort: effort }])); + const merged = { ...generated, ...entry.variants }; + expect(Object.keys(merged).filter(id => !("disabled" in merged[id]!)).sort()).toEqual(["high", "none"]); + for (const effort of ["none", "high"]) { + expect({ ...entry.options, ...entry.variants![effort] }).toEqual({ reasoningEffort: effort }); + } + } + }); + + test("toggle off defaults survive and stale strength metadata cannot add choices", () => { + const { v1, v2, kilo } = configs({ ...model, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: false, + reasoningEfforts: ["none", "low", "medium", "high", "max", "ultra"], defaultReasoningEffort: "none" }); + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + expect(v2.settings).toEqual({ reasoningEffort: "none" }); + for (const entry of [v1, kilo]) { + expect(entry.options).toEqual({ reasoningEffort: "none" }); + for (const effort of ["minimal", "low", "medium", "xhigh", "max", "ultra"]) { + expect(entry.variants![effort]).toEqual({ disabled: true }); + } + } + }); + + test("reasoning capability survives without effort or summary evidence", () => { + for (const reasoningEfforts of [undefined, []]) { + const { v1, v2, kilo } = configs({ ...model, reasoningEfforts, + supportsReasoning: true, supportsReasoningSummaries: undefined, defaultReasoningEffort: undefined }); + expect(v2.variants).toEqual([]); + for (const entry of [v1, kilo]) { + expect(entry.reasoning).toBe(true); + expect(entry.variants).toEqual(kiloSuppression); + expect(entry.options).toBeUndefined(); + } + } + }); + + test("fixed, effort-only and combined controls keep independent choices across clients and Codex", () => { + const cases = [ + { supportsThinkingToggle: false, supportsReasoningEffort: false, expected: [] }, + { supportsThinkingToggle: false, supportsReasoningEffort: true, expected: ["low", "high"] }, + { supportsThinkingToggle: true, supportsReasoningEffort: true, expected: ["none", "low", "high"] }, + { supportsThinkingToggle: true, supportsReasoningEffort: false, expected: ["none", "high"] }, + ]; + for (const { expected, ...capabilities } of cases) { + const row = { ...model, ...capabilities, reasoningEfforts: ["none", "low", "high"], + defaultReasoningEffort: "low", defaultThinkingEnabled: false }; + const { v1, v2, kilo } = configs(row); + expect(v2.variants.map(variant => variant.id)).toEqual(expected); + for (const entry of [v1, kilo]) { + expect(Object.entries(entry.variants!).filter(([, value]) => !("disabled" in value)).map(([id]) => id)) + .toEqual(expected); + expect(entry.variants!.ultra).toEqual({ disabled: true }); + } + for (const template of [null, { slug: "template", supported_reasoning_levels: [{ effort: "max" }] }]) { + const entry = deriveEntry(template, row.namespaced, "test", 1, { ...row }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(expected); + if (capabilities.supportsThinkingToggle) expect(entry.default_reasoning_level).toBe("none"); + if (expected.length === 0) expect(entry.default_reasoning_level).toBeUndefined(); + } + } + }); + + test("an empty effort declaration leaves a declared toggle available, including an unknown default", () => { + for (const reasoningEfforts of [undefined, []]) { + const row = { ...model, reasoningEfforts, supportsThinkingToggle: true, + supportsReasoningEffort: false, defaultThinkingEnabled: undefined, defaultReasoningEffort: "low" }; + const { v1, v2, kilo } = configs(row); + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + for (const entry of [v1, kilo]) expect(entry.options).toBeUndefined(); + const codex = deriveEntry(null, row.namespaced, "test", 1, row); + expect(codex.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["none", "high"]); + expect(codex.default_reasoning_level).toBeUndefined(); + } + }); + + test("a negative toggle alone does not turn unknown effort support into false", () => { + const bare: ExportModel = { namespaced: "mock/unknown-effort", provider: "mock", id: "unknown-effort", + supportsThinkingToggle: false }; + const { v1, kilo } = configs(bare); + for (const exported of [v1, kilo]) { + expect(exported.variants).toEqual({ none: { disabled: true } }); + expect(exported.reasoning).toBeUndefined(); + } + const row = { ...model, supportsThinkingToggle: false, + reasoningEfforts: undefined, defaultReasoningEffort: undefined }; + const unknown = deriveEntry(null, model.namespaced, "test", 1, { ...row, supportsThinkingToggle: undefined }); + const entry = deriveEntry(null, model.namespaced, "test", 1, row); + expect(entry.supported_reasoning_levels).toEqual(unknown.supported_reasoning_levels.filter( + (level: { effort: string }) => level.effort !== "none")); + expect(entry.default_reasoning_level).toBe(unknown.default_reasoning_level); + }); + + test("inferred positive effort support preserves legacy Codex max/ultra synthesis", () => { + const row = applyProviderConfigHints("synthetic", { adapter: "openai-chat", baseUrl: "https://example.test/v1", + reasoningEfforts: ["low", "medium", "high"], modelDefaultReasoningEfforts: { legacy: "medium" }, + }, { provider: "synthetic", id: "legacy" }); + expect(row.supportsReasoningEffort).toBe(true); + expect(row.supportsThinkingToggle).toBeUndefined(); + for (const template of [null, { slug: "template", supported_reasoning_levels: [{ effort: "xhigh" }] }]) { + const entry = deriveEntry(template, "synthetic/legacy", "test", 1, row); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(entry.default_reasoning_level).toBe("medium"); + const suppressedMax = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, suppressSyntheticMax: true }); + expect(suppressedMax.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high", "ultra"]); + const exact = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsThinkingToggle: false }); + expect(exact.supported_reasoning_levels.map((level: { effort: string }) => level.effort)) + .toEqual(["low", "medium", "high"]); + const fixed = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsReasoningEffort: false }); + expect(fixed.supported_reasoning_levels).toEqual([]); + const disabled = deriveEntry(template, "synthetic/legacy", "test", 1, { ...row, supportsReasoning: false }); + expect(disabled.supported_reasoning_levels).toEqual([]); + } + }); + test("an empty ladder suppresses inferred choices without falsely declaring inability to reason", () => { const { v1, v2, kilo } = configs({ ...model, reasoningEfforts: [] }); expect(v2.variants).toEqual([]); diff --git a/tests/clients/client-export-reasoning-metadata.test.ts b/tests/clients/client-export-reasoning-metadata.test.ts index 2ef80e1240f..15718559fe9 100644 --- a/tests/clients/client-export-reasoning-metadata.test.ts +++ b/tests/clients/client-export-reasoning-metadata.test.ts @@ -14,6 +14,19 @@ describe("client export reasoning metadata", () => { expect(exportReasoningEfforts({ ...model, reasoningEfforts: ["high", "none", "turbo", "low", "high", "minimal"] })) .toEqual(["none", "minimal", "low", "high"]); }); + test("client effort-enum encoding represents a toggle independently of declared strengths", () => { + const toggle = { ...model, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true }; + expect(exportReasoningEfforts(toggle)).toEqual(["none", "high"]); + expect(exportReasoningEfforts({ ...toggle, reasoningEfforts: [] })).toEqual(["none", "high"]); + expect(exportReasoningEfforts({ ...toggle, reasoningEfforts: ["high", "low", "none", "medium", "max", "ultra"] })) + .toEqual(["none", "high"]); + expect(exportDefaultReasoningEffort({ ...toggle, reasoningEfforts: ["none", "high"], defaultReasoningEffort: "high" })) + .toBe("high"); + expect(exportDefaultReasoningEffort({ ...toggle, reasoningEfforts: ["none", "high", "medium"], defaultReasoningEffort: "medium" })) + .toBe("high"); + expect(exportDefaultReasoningEffort({ ...toggle, defaultThinkingEnabled: false })).toBe("none"); + expect(exportDefaultReasoningEffort({ ...toggle, defaultThinkingEnabled: undefined })).toBeUndefined(); + }); test("does not synthesize a default or revive an explicitly cleared ladder", () => { expect(exportDefaultReasoningEffort({ ...model, reasoningEfforts: ["low", "high"] })).toBeUndefined(); expect(exportDefaultReasoningEffort({ ...model, defaultReasoningEffort: "high", reasoningEfforts: [] })).toBeUndefined(); diff --git a/tests/codex-integration/effort-policy.test.ts b/tests/codex-integration/effort-policy.test.ts index 0d3ac76018d..248cc692055 100644 --- a/tests/codex-integration/effort-policy.test.ts +++ b/tests/codex-integration/effort-policy.test.ts @@ -15,6 +15,24 @@ import { mapReasoningEffort } from "../../src/reasoning-effort"; import { nativeEffortClamp } from "../../src/codex/catalog"; import type { OcxConfig, OcxParsedRequest } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { prepareEffortNormalization } from "../../src/server/effort-policy"; +import { normalizeThinkingOptions } from "../../src/clients/reasoning-controls"; + +test("a toggle-only client's choice survives strength policy and is restored on fallback", () => { + const provider = { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", modelCapabilities: { + fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true }, + } }; + const parsed = makeParsed("none"); + prepareEffortNormalization(parsed, { providerName: "fixture", modelId: "fixed" }); + normalizeThinkingOptions(provider, "fixed", parsed.options); + expect(parsed.options.thinkingEnabled).toBe(false); + expect(parsed.options.reasoning).toBeUndefined(); + applyEffortCap(parsed, new Headers(), makeConfig({ effortCap: "low" }), []); + expect(parsed.options.thinkingEnabled).toBe(false); + prepareEffortNormalization(parsed, { providerName: "fixture", modelId: "adjustable" }); + expect(parsed.options.thinkingEnabled).toBeUndefined(); + expect(parsed.options.reasoning).toBe("none"); +}); const savedHome = process.env.OPENCODEX_HOME; const savedCodexHome = process.env.CODEX_HOME; diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 5e7d4dd2f99..2cad3a10bbb 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -12,6 +12,7 @@ import { validateConfigCandidate, } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { mergeModelCapabilities, modelCapabilitiesConfigError } from "../../src/config/provider-validation"; let home = ""; let previousHome: string | undefined; @@ -222,7 +223,7 @@ test("model capability writes stay strict while load preserves independent restr apiKey: "fixture-key", modelCapabilities: { ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, - broken: { inputModalities: "image", contextTier: "typo" }, + broken: { inputModalities: "image", contextTier: "typo", supportsReasoning: true, supportsThinkingToggle: "true", supportsReasoningEffort: false, defaultThinkingEnabled: false }, }, } } }; expect(validateConfigCandidate(raw).ok).toBe(false); @@ -230,7 +231,7 @@ test("model capability writes stay strict while load preserves independent restr const loaded = loadConfig(); expect(loaded.providers.xai.modelCapabilities).toEqual({ ModelA: { inputModalities: ["text"] }, modela: { contextTier: "long_context" }, - broken: { inputModalities: ["text"] }, + broken: { inputModalities: ["text"], supportsReasoning: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }, }); expect(loaded.providers.xai.apiKey).toBe("fixture-key"); expect(validateConfigCandidate(loaded).ok).toBe(true); @@ -239,7 +240,7 @@ test("model capability writes stay strict while load preserves independent restr test("model capabilities round-trip all explicit axes without expanding inference", () => { const raw = { ...candidate(undefined), providers: { xai: { ...candidate(undefined).providers.xai, - modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" } } }, + modelCapabilities: { ModelA: { inputModalities: ["text", "image"], contextTier: "long_context", video: { processing: "agentic" }, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false } }, } } }; const validated = validateConfigCandidate(raw); expect(validated.ok).toBe(true); @@ -249,6 +250,21 @@ test("model capabilities round-trip all explicit axes without expanding inferenc expect(loadConfig().providers.xai.modelContextWindows).toBeUndefined(); }); +test("reasoning capability patches preserve false and independently delete or validate axes", () => { + const original = { ModelA: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true } }; + const patch = { ModelA: { supportsThinkingToggle: false, defaultThinkingEnabled: null } }; + expect(modelCapabilitiesConfigError(patch, true)).toBeNull(); + expect(mergeModelCapabilities(original, patch)).toEqual({ ModelA: { + supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, + } }); + expect(original.ModelA.defaultThinkingEnabled).toBe(true); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"]) { + expect(modelCapabilitiesConfigError({ ModelA: { [field]: "false" } })).not.toBeNull(); + expect(modelCapabilitiesConfigError({ ModelA: { [field]: false } })).toBeNull(); + expect(modelCapabilitiesConfigError({ ModelA: { [field]: null } })).not.toBeNull(); + } +}); + test.each([ { enabled: "true" }, { enabled: true, port: 70000 }, diff --git a/tests/providers/cursor/cursor-local-models-schema.test.ts b/tests/providers/cursor/cursor-local-models-schema.test.ts index 2d8e0fb8c75..574b7f17c98 100644 --- a/tests/providers/cursor/cursor-local-models-schema.test.ts +++ b/tests/providers/cursor/cursor-local-models-schema.test.ts @@ -18,6 +18,18 @@ import type { OcxConfig } from "../../../src/types"; import { SERVER_BUDGET_MS } from "../../helpers/test-budget"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; +test("public discovery separates reasoning, toggle and real strength controls", () => { + const fixed = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false }); + expect(fixed.capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: false, supports_reasoning_effort: false }); + expect(fixed.capabilities.reasoning_effort).toBeUndefined(); + const toggle = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: ["none", "high"] }); + expect(toggle.capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, default_thinking_enabled: false }); + expect(toggle.capabilities.reasoning_effort).toBeUndefined(); + const both = modelCapabilityFields({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, reasoningEfforts: ["low", "high"] }); + expect(both.capabilities.reasoning_effort).toEqual(["low", "high"]); + expect(modelCapabilityFields({}).capabilities.supports_thinking_toggle).toBeUndefined(); +}); + // Cursor's local-agent runtime ("Private Inference" build) only enables its reasoning-effort // control when a GET /v1/models row carries api_types (+ optional capabilities). These cases // start a real server and read the raw OpenAI-shape list, like the Grok discovery tests. @@ -191,6 +203,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => supports_tool_use: true, supports_streaming: true, supports_reasoning: true, + supports_reasoning_effort: true, supports_vision: true, reasoning_effort: ["low", "high", "max"], }); diff --git a/tests/providers/mimo-v26-catalog.test.ts b/tests/providers/mimo-v26-catalog.test.ts index 65f924eb5f4..a8e9449c530 100644 --- a/tests/providers/mimo-v26-catalog.test.ts +++ b/tests/providers/mimo-v26-catalog.test.ts @@ -13,9 +13,149 @@ import { import { routeModel } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { resolveMatchedPrice } from "../../src/usage/cost"; +import { modelCapabilityFields } from "../../src/server/models-capabilities"; +import { deriveEntry } from "../../src/codex/catalog/derive-entry"; +import { MIMO_THINKING_MODELS } from "../../src/providers/mimo-thinking"; +import { resolveReasoningCapabilities } from "../../src/providers/reasoning-capabilities"; +import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/model-hints"; // Xiaomi's published V2.6 prices (USD per 1M tokens), as carried by models.dev on 2026-09-23. const PRO = { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }; + +describe("independent reasoning capabilities", () => { + const base = { adapter: "openai-chat", baseUrl: "https://synthetic.example.test/v1" }; + const id = "synthetic-model"; + test.each([ + { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }, + { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true }, + {}, + ])("synthetic fixed/toggle/effort/both/unknown contract %j", capabilities => { + const provider = { ...base, modelCapabilities: { [id]: capabilities } }; + expect(resolveReasoningCapabilities(provider, id)).toEqual(capabilities); + const row = applyProviderConfigHints("synthetic", provider, { provider: "synthetic", id }); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled"] as const) { + expect(row[field]).toBe(capabilities[field]); + } + }); + test("toggle and effort declarations remain independent, including empty and none-only lists", () => { + const toggle = { ...base, thinkingToggleModels: [id], modelReasoningEfforts: { [id]: [] }, modelCapabilities: { [id]: { supportsThinkingToggle: true } } }; + expect(resolveReasoningCapabilities(toggle, id)).toEqual({ supportsReasoning: true, supportsThinkingToggle: true }); + expect(resolveReasoningCapabilities({ ...base, modelReasoningEfforts: { [id]: ["none"] } }, id)) + .toEqual({}); + expect(resolveReasoningCapabilities({ ...base, thinkingToggleModels: [id] }, id)).toEqual({}); + expect(resolveReasoningCapabilities({ ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } }, thinkingToggleModels: [id] }, id)) + .toEqual({ supportsThinkingToggle: false }); + const suppressed = resolveReasoningCapabilities({ ...toggle, noReasoningModels: [id] }, id); + expect(suppressed).toEqual({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + }); + test.each(["zhipu-bigmodel", "opencode-go", "volcengine", "minimax"])("%s toggle wire aliases do not assert genuine strengths", preset => { + const entry = PROVIDER_REGISTRY.find(row => row.id === preset)!; + const id = entry.thinkingToggleModels![0]!; + const provider = providerConfigSeed(entry); + const resolved = resolveReasoningCapabilities(provider, id); + expect(resolved.supportsThinkingToggle).toBeUndefined(); + expect(resolved.supportsReasoning).toBe(true); + expect(resolved.supportsReasoningEffort).toBeUndefined(); + const row = applyProviderConfigHints(preset, provider, { provider: preset, id }); + expect(row.supportsReasoningEffort).toBeUndefined(); + expect(row.reasoningEfforts?.length).toBeGreaterThan(0); + const explicit = { ...provider, modelCapabilities: { [id]: { supportsThinkingToggle: true, supportsReasoningEffort: true } } }; + expect(resolveReasoningCapabilities(explicit, id).supportsThinkingToggle).toBe(true); + expect(resolveReasoningCapabilities(explicit, id).supportsReasoningEffort).toBe(true); + expect(applyProviderConfigHints(preset, explicit, { provider: preset, id }).reasoningEfforts).toEqual(row.reasoningEfforts); + }); + test("mapped strength inference follows folded overrides and preserves empty overrides", () => { + const provider = { ...base, thinkingToggleModels: [id], modelReasoningEfforts: { [id]: ["low", "high"] }, + modelReasoningEffortMap: { "SYNTHETIC-MODEL": { low: "disabled", high: "enabled" } } }; + expect(resolveReasoningCapabilities(provider, id).supportsReasoningEffort).toBeUndefined(); + expect(resolveReasoningCapabilities({ ...provider, modelReasoningEffortMap: { [id]: {} } }, id).supportsReasoningEffort).toBe(true); + expect(resolveReasoningCapabilities({ ...base, modelReasoningEfforts: { [id]: ["none"] }, reasoningEffortMap: { none: "high" } }, id)).toEqual({}); + const registered = PROVIDER_REGISTRY.find(row => row.id === "zhipu-bigmodel-responses")!; + expect(resolveReasoningCapabilities({ adapter: registered.adapter, baseUrl: registered.baseUrl, + modelReasoningEfforts: { "GLM-5.3": [] } }, "glm-5.3").supportsReasoningEffort).toBeUndefined(); + }); + test("toggle-only does not inherit an old provider-wide ladder or default", () => { + const provider = { ...base, reasoningEfforts: ["low", "medium", "high"], modelDefaultReasoningEfforts: { [id]: "high" }, + modelCapabilities: { [id]: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false } } }; + const row = applyProviderConfigHints("synthetic", provider, { provider: "synthetic", id, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "high" }); + expect(row.reasoningEfforts).toEqual([]); + expect(row.defaultReasoningEffort).toBeUndefined(); + expect(row.supportsThinkingToggle).toBe(true); + const enabled = applyProviderConfigHints("synthetic", { ...provider, modelCapabilities: { [id]: { supportsReasoningEffort: true } } }, row); + expect(enabled.reasoningEfforts).toEqual(["low", "medium", "high"]); + }); + test("cached config projections rederive flags and retain original discovery metadata", () => { + const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }; + const cached = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, discovered); + expect(cached.supportsThinkingToggle).toBe(false); + const restored = applyProviderConfigHints("synthetic", base, cached); + expect(restored).toMatchObject(discovered); + const configured = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: true } } }, { provider: "synthetic", id }); + expect(applyProviderConfigHints("synthetic", base, configured).supportsThinkingToggle).toBeUndefined(); + }); + test("chained public model flags preserve false and unknown independently of efforts", () => { + const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { + supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, + default_thinking_enabled: false, reasoning_effort: ["low", "high"], + } }); + expect(hints).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(hints.reasoningEfforts).toBeUndefined(); + const chained = applyProviderConfigHints("chain", { ...base, reasoningEfforts: ["low", "medium", "high"] }, { provider: "chain", id, ...hints }); + expect(chained.supportsReasoningEffort).toBe(false); + expect(chained.reasoningEfforts).toEqual([]); + const unknown = catalogHintsFromModelsApiItem("chain", { id, capabilities: {} }); + expect(unknown.supportsReasoning).toBeUndefined(); + expect(unknown.supportsThinkingToggle).toBeUndefined(); + const explicitFalse = catalogHintsFromModelsApiItem("chain", { id, capabilities: { supports_reasoning: false, supports_thinking_toggle: false } }); + expect(explicitFalse).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false }); + expect(explicitFalse.supportsReasoningEffort).toBeUndefined(); + }); +}); + +describe("MiMo V2.6 thinking switch", () => { + test.each(["xiaomi", "xiaomi-mimo", "my-mimo"])("public Chat under %s exposes capabilities without synthetic strengths", name => { + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", authMode: "key" }; + enrichProviderFromRegistry(name, provider); + for (const id of MIMO_THINKING_MODELS) { + const row = applyProviderConfigHints(name, provider, { provider: name, id }); + expect(row).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, reasoningEfforts: [] }); + expect(row.defaultReasoningEffort).toBeUndefined(); + expect(modelCapabilityFields(row).capabilities).toMatchObject({ supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, default_thinking_enabled: true }); + expect(modelCapabilityFields(row).capabilities.reasoning_effort).toBeUndefined(); + const catalog = deriveEntry(null, `${name}/${id}`, "MiMo", 1, row); + expect(catalog.supported_reasoning_levels?.map(level => level.effort)).toEqual(["none", "high"]); + expect(catalog.default_reasoning_level).toBe("high"); + } + expect(provider.modelReasoningEfforts).toBeUndefined(); + expect(provider.modelDefaultReasoningEfforts).toBeUndefined(); + }); + test.each([ + { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1" }, + { adapter: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic" }, + ])("destination capability overrides remain authoritative on $adapter", transport => { + const id = "mimo-v2.6-pro"; + const provider: OcxProviderConfig = { ...transport, modelCapabilities: { [id]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } } }; + enrichProviderFromRegistry("my-mimo", provider); + expect(resolveReasoningCapabilities(provider, id)).toEqual({ supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(resolveReasoningCapabilities({ ...transport, baseUrl: "https://foreign.example.test/v1" }, id)).toEqual({}); + const custom = resolveReasoningCapabilities(transport, `${id}-custom`); + expect(custom.supportsThinkingToggle).toBeUndefined(); + expect(custom.defaultThinkingEnabled).toBeUndefined(); + expect(custom.supportsReasoningEffort).not.toBe(false); + }); + test("explicit real effort opt-in preserves case-folded legacy ladder overrides", () => { + const id = "mimo-v2.6-pro"; + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + modelCapabilities: { [id]: { supportsReasoningEffort: true } }, + modelReasoningEfforts: { "MIMO-V2.6-PRO": ["low", "high"] }, + modelDefaultReasoningEfforts: { "MIMO-V2.6-PRO": "low" } }; + enrichProviderFromRegistry("my-mimo", provider); + expect(applyProviderConfigHints("my-mimo", provider, { provider: "my-mimo", id })) + .toMatchObject({ supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }); + }); +}); const FLASH = { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }; const entry = (id: string) => PROVIDER_REGISTRY.find(row => row.id === id)!; diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index d186f870754..a9bdf4c24d6 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -863,3 +863,35 @@ describe("resolved static model policy parity", () => { expect(entry.modelContextWindows?.[MODEL]).toBe(100_000); }); }); + +describe("independent reasoning policy", () => { + const id = "synthetic-reasoner"; + const entry: ProviderRegistryEntry = { + id: "synthetic-reasoner", label: "Synthetic reasoner", adapter: "openai-chat", + baseUrl: "https://synthetic.example.test/v1", authKind: "key", + reasoningEfforts: ["low", "medium", "high"], + modelCapabilities: { [id]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, + } }, + thinkingToggleModels: [id], + }; + test("registry axes fill beneath exact operator axes without synthetic strength defaults", () => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } }, + modelReasoningEfforts: { [id]: ["low", "high"] }, modelDefaultReasoningEfforts: { [id]: "high" } }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(policy.model.defaultReasoningEffort).toBeUndefined(); + expect(policy.provider.modelReasoningEfforts?.[id]).toEqual(["low", "high"]); + expect(policy.provenance.model.supportsThinkingToggle).toBe("operator-capability"); + expect(policy.provenance.model.supportsReasoningEffort).toBe("registry"); + expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); + }); + test("noReasoningModels remains authoritative over every independent declaration", () => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, noReasoningModels: [id] }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + }); +}); \ No newline at end of file diff --git a/tests/responses/chat-inbound-reasoning-none.test.ts b/tests/responses/chat-inbound-reasoning-none.test.ts index 4a453ccd558..cc627d7784c 100644 --- a/tests/responses/chat-inbound-reasoning-none.test.ts +++ b/tests/responses/chat-inbound-reasoning-none.test.ts @@ -22,6 +22,16 @@ function reasoningOf(body: Record): Record | u return body.reasoning as Record | undefined; } +test("Chat thinking toggles survive translation independently of effort", () => { + for (const enabled of [true, false]) { + const native = chatCompletionsToResponsesBody(chat({ thinking: { type: enabled ? "enabled" : "disabled" } })); + expect(reasoningOf(native)).toMatchObject({ enabled }); + expect(reasoningOf(native)?.effort).toBeUndefined(); + const both = chatCompletionsToResponsesBody(chat({ reasoning: { enabled, effort: "low" } })); + expect(reasoningOf(both)).toMatchObject({ enabled, effort: "low" }); + } +}); + describe("F7 explicit reasoning disable survives the Chat boundary", () => { test("preserves a flat reasoning_effort of none", () => { const body = chatCompletionsToResponsesBody(chat({ reasoning_effort: "none" })); diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 235c5ed91d8..93698c8b5d3 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -7,8 +7,62 @@ import { buildTools } from "../../src/responses/parser-tools"; import { parseTextFormat } from "../../src/responses/parser-text-format"; import { buildToolBridgeMaps } from "../../src/server/responses"; import { repoPath } from "../helpers/repo-root"; +import { normalizeThinkingOptions, resolveThinkingEnabled } from "../../src/clients/reasoning-controls"; +import type { OcxProviderConfig } from "../../src/types"; + +describe("generic effort-only client compatibility", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", + modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true } }, + }; + test("decodes switch aliases without a vendor identity or a real strength ladder", () => { + expect(resolveThinkingEnabled(provider, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled(provider, "fixed", { reasoning: "high" })).toBe(true); + expect(resolveThinkingEnabled(provider, "fixed", {})).toBe(true); + expect(resolveThinkingEnabled(provider, "fixed", { thinkingEnabled: false, reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled(provider, "fixed", { thinkingEnabled: true, reasoning: "none" })).toBe(true); + }); + test("keeps real effort independent from a switch and does not invent missing controls", () => { + const both = { ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true } } }; + expect(resolveThinkingEnabled(both, "fixed", { reasoning: "high" })).toBe(true); + expect(resolveThinkingEnabled(both, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled(both, "fixed", { thinkingEnabled: false, reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: false } } }, "fixed", { reasoning: "none" })).toBeUndefined(); + expect(resolveThinkingEnabled(provider, "unknown", { reasoning: "high" })).toBeUndefined(); + }); + test("honors explicit compatibility mappings and leaves an empty strength list independent", () => { + expect(resolveThinkingEnabled({ ...provider, modelReasoningEfforts: { fixed: [] } }, "fixed", { reasoning: "none" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { high: "__omit__" } }, "fixed", { reasoning: "high" })).toBeUndefined(); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { high: "none" } }, "fixed", { reasoning: "high" })).toBe(false); + expect(resolveThinkingEnabled({ ...provider, reasoningEffortMap: { none: "high" } }, "fixed", { reasoning: "none" })).toBe(true); + }); + test("canonical negative declarations constrain requests while unknown axes remain untouched", () => { + for (const flags of [{ supportsReasoning: false }, { supportsThinkingToggle: false, supportsReasoningEffort: false }]) { + const options = { thinkingEnabled: true, reasoning: "high" }; + normalizeThinkingOptions({ ...provider, modelCapabilities: { fixed: flags } }, "fixed", options); + expect(options).toEqual({}); + } + const both = { ...provider, modelCapabilities: { fixed: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: false } } }; + const options = { reasoning: "low" }; + normalizeThinkingOptions(both, "fixed", options); + expect(options).toEqual({ thinkingEnabled: true, reasoning: "low" }); + const unknown = { thinkingEnabled: true, reasoning: "high" }; + normalizeThinkingOptions(provider, "unknown", unknown); + expect(unknown).toEqual({ thinkingEnabled: true, reasoning: "high" }); + }); +}); describe("Responses parser", () => { + test("keeps thinking enablement separate from effort and summary visibility", () => { + const on = parseRequest({ model: "fixed-depth", input: "test", reasoning: { enabled: true } }); + expect(on.options.thinkingEnabled).toBe(true); + expect(on.options.reasoning).toBeUndefined(); + expect(on.options.hideThinkingSummary).toBeUndefined(); + const off = parseRequest({ model: "adjustable", input: "test", reasoning: { enabled: false, effort: "high" } }); + expect(off.options.thinkingEnabled).toBe(false); + expect(off.options.reasoning).toBe("high"); + expect(off.options.hideThinkingSummary).toBe(true); + }); test("normalizes function tool schemas to an object root without corrupting valid schemas (#745)", () => { const validParameters = { type: "object", diff --git a/tests/server/api-catalog-route.test.ts b/tests/server/api-catalog-route.test.ts index f05dcb1febd..aeb61cc8199 100644 --- a/tests/server/api-catalog-route.test.ts +++ b/tests/server/api-catalog-route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -7,6 +7,8 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { ManagementModelRow } from "../../src/server/management/model-rows"; +import type { KiloGeneratedConfig, OpencodeGeneratedConfig } from "../../src/clients/config-export"; const TEST_DIR = join(import.meta.dir, `.tmp-api-catalog-route-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -108,6 +110,115 @@ describe("GET /api/catalog route (#709)", () => { }); }); +test("static Xiaomi MiMo capability truth, client exports and endpoint thinking controls", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-mimo-catalog-export-"); + process.env.OPENCODEX_HOME = join(isolatedCodexHome.path, "proxy"); + const ids = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed"]; + const realFetch = globalThis.fetch; + const blockedRequests: string[] = []; + const inferenceBodies: Record[] = []; + // Only the captured fetch below can reach our local test server. Every fetch made by + // production code is stubbed, including discovery, background work and paid endpoints. + const upstreamFetch = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + blockedRequests.push(url); + if (new URL(url).hostname === "api.xiaomimimo.com" && new URL(url).pathname.endsWith("/chat/completions")) { + inferenceBodies.push(JSON.parse(String(init?.body ?? (input instanceof Request ? await input.text() : "{}")))); + return Response.json({ id: "mock-chat", object: "chat.completion", created: 1, model: ids[0], + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + } + return Response.json({ data: [] }); + }); + let server: ReturnType | undefined; + try { + saveConfig({ + port: 0, hostname: "127.0.0.1", defaultProvider: "xiaomi-mimo", fastRows: false, + providers: { "xiaomi-mimo": { + adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + apiKey: "test-only-mimo-key", authMode: "key", liveModels: false, models: ids, + } }, + } as OcxConfig); + const { startServer } = await import("../../src/server"); + server = startServer(0); + const publicResponse = await realFetch(new URL("/v1/models", server.url)); + expect(publicResponse.status).toBe(200); + const publicList = await publicResponse.json() as { + data: Array<{ id: string; capabilities?: { supports_reasoning?: boolean; supports_thinking_toggle?: boolean; + supports_reasoning_effort?: boolean; default_thinking_enabled?: boolean; reasoning_effort?: string[] } }>; + }; + for (const id of ids) { + const capabilities = publicList.data.find(row => row.id === `xiaomi-mimo/${id}`)?.capabilities; + expect(capabilities).toMatchObject({ + supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, + default_thinking_enabled: true, + }); + expect(capabilities).not.toHaveProperty("reasoning_effort"); + expect(capabilities).not.toHaveProperty("reasoning_control"); + } + + const rowsResponse = await realFetch(new URL("/api/models", server.url), { headers: managementHeaders() }); + expect(rowsResponse.status).toBe(200); + const rows = await rowsResponse.json() as ManagementModelRow[]; + for (const id of ids) { + const row = rows.find(row => row.namespaced === `xiaomi-mimo/${id}`); + expect(row).toMatchObject({ + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: true, + }); + expect(row?.reasoningEfforts ?? []).toEqual([]); + expect(row?.exportMetadata?.reasoningEfforts ?? []).toEqual([]); + expect(row?.exportMetadata?.defaultReasoningEffort).toBeUndefined(); + } + + for (const client of ["opencode", "kilo"]) { + const response = await realFetch(new URL(`/api/client-config?client=${client}`, server.url), { headers: managementHeaders() }); + expect(response.status).toBe(200); + const { config } = await response.json() as { config: OpencodeGeneratedConfig | KiloGeneratedConfig }; + for (const id of ids) { + const entry = config.provider.opencodex!.models[`xiaomi-mimo/${id}`]!; + expect(entry.reasoning).toBe(true); + expect(entry.options).toEqual({ reasoningEffort: "high" }); + expect(entry.variants!.none).toEqual({ reasoningEffort: "none" }); + expect(entry.variants!.high).toEqual({ reasoningEffort: "high" }); + for (const effort of ["minimal", "low", "medium", "xhigh", "max", "ultra"]) { + expect(entry.variants![effort]).toEqual({ disabled: true }); + } + if (client === "opencode") { + const v2 = (config as OpencodeGeneratedConfig).providers.opencodex!.models[`xiaomi-mimo/${id}`]!; + expect(v2.variants.map(variant => variant.id)).toEqual(["none", "high"]); + expect(v2.settings).toEqual({ reasoningEffort: "high" }); + } + } + } + expect(blockedRequests.filter(url => new URL(url).hostname === "api.xiaomimimo.com")).toEqual([]); + for (const path of ["/v1/responses", "/v1/chat/completions"]) { + for (const [control, enabled] of [ + [{ reasoning: { enabled: false, effort: "high" } }, false], + [{ reasoning: { enabled: true, effort: "none" } }, true], + [path === "/v1/responses" ? { reasoning: { effort: "none" } } : { reasoning_effort: "none" }, false], + [path === "/v1/responses" ? { reasoning: { effort: "high" } } : { reasoning_effort: "high" }, true], + ] as const) { + const before = inferenceBodies.length; + const response = await realFetch(new URL(path, server.url), { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `xiaomi-mimo/${ids[0]}`, stream: false, + ...(path === "/v1/responses" ? { input: "hello" } : { messages: [{ role: "user", content: "hello" }] }), + ...control }), + }); + await response.text(); + expect(response.status).toBe(200); + expect(inferenceBodies.length).toBe(before + 1); + expect({ path, control, thinking: inferenceBodies.at(-1)?.thinking }) + .toEqual({ path, control, thinking: { type: enabled ? "enabled" : "disabled" } }); + expect(inferenceBodies.at(-1)).not.toHaveProperty("reasoning_effort"); + } + } + expect(blockedRequests.filter(url => new URL(url).hostname === "api.xiaomimimo.com")).toHaveLength(8); + } finally { + try { await server?.stop(true); } finally { upstreamFetch.mockRestore(); } + } +}, 30_000); + describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { const DATA_KEY = "ocx_data_catalogreadonly"; From d719717b37cf9c57d37c8b9f49990fb3e835b3d2 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 23 Oct 2026 10:19:08 +0800 Subject: [PATCH 2/9] fix(reasoning): address capability and wire review findings --- src/adapters/anthropic.ts | 16 ++-- src/adapters/openai-chat/passthrough.ts | 12 ++- src/adapters/openai-chat/reasoning-wire.ts | 37 +++++---- src/adapters/openai-responses/passthrough.ts | 7 +- src/adapters/openai-responses/reasoning.ts | 13 ++++ src/cli/opencode.ts | 16 +--- .../config-export/reasoning-metadata.ts | 20 +++++ src/codex/catalog/model-hints.ts | 23 ++++-- src/server/index/serve-options.ts | 7 +- src/server/management/model-rows.ts | 16 +--- src/server/models-capabilities.ts | 8 +- structure/catalog.md | 23 +++--- structure/clients/claude-desktop.md | 2 +- structure/clients/integrations.md | 2 +- structure/config.md | 8 +- structure/dashboard-and-usage.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 7 +- structure/ops/docs-and-release.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers-and-adapters.md | 4 +- structure/providers/chat-compat.md | 9 +-- structure/providers/cursor.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/subagents.md | 2 +- structure/transports/responses-failover.md | 4 +- structure/transports/responses-wire-shapes.md | 2 +- structure/transports/responses.md | 6 +- structure/transports/streaming-health.md | 2 +- .../anthropic/anthropic-reasoning.test.ts | 40 ++++++++++ .../openai-chat-reasoning-wire-policy.test.ts | 63 +++++++++++++-- .../client-export-reasoning-metadata.test.ts | 16 +++- .../cursor/cursor-local-models-schema.test.ts | 21 ++++- tests/providers/mimo-v26-catalog.test.ts | 77 ++++++++++++++++++- .../openai-responses-summary-none.test.ts | 40 +++++++++- 35 files changed, 388 insertions(+), 127 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 3d78c608f72..5bf8f99d4b3 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1091,20 +1091,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const thinkingEnabled = thinkingToggleWire ? resolveThinkingEnabled(provider, parsed.modelId, parsed.options) : undefined; + const nativeThinkingContract = usesAdaptiveThinking(parsed.modelId); const supportsReasoningEffort = capabilities.supportsReasoningEffort === true; - const effectiveReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); - if (!reasoningControlsUnsupported && thinkingEnabled !== undefined) { + const requestedReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); + // Native Claude wire constraints outrank a generic toggle declaration. Decode the boolean + // into native effort intent, then let the family contract choose adaptive/disabled/between_tools. + const effectiveReasoning = nativeThinkingContract && thinkingEnabled !== undefined + ? thinkingEnabled ? (requestedReasoning && requestedReasoning !== "none" ? requestedReasoning : "high") : "none" + : requestedReasoning; + if (!reasoningControlsUnsupported && thinkingEnabled !== undefined && !nativeThinkingContract) { body.thinking = { type: thinkingEnabled ? "enabled" : "disabled" }; } - if (!reasoningControlsUnsupported && thinkingEnabled === undefined && effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { + if (!reasoningControlsUnsupported && (thinkingEnabled === undefined || nativeThinkingContract) && effectiveReasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) { body.thinking = { type: "disabled" }; - } else if (!reasoningControlsUnsupported && thinkingEnabled === undefined && effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { + } else if (!reasoningControlsUnsupported && (thinkingEnabled === undefined || nativeThinkingContract) && effectiveReasoning === "none" && usesBetweenToolsFloor(parsed.modelId)) { // Sonnet 5.5 rejects `disabled`; `between_tools` is its lowest setting and turns off up-front // thinking. No effort is sent: the API default (high) is inside the range it accepts. body.thinking = { type: "between_tools" }; delete body.temperature; delete body.top_p; - } else if (!reasoningControlsUnsupported && thinkingEnabled !== false && (thinkingEnabled === undefined || supportsReasoningEffort) && typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { + } else if (!reasoningControlsUnsupported && thinkingEnabled !== false && (thinkingEnabled === undefined || supportsReasoningEffort || nativeThinkingContract) && typeof effectiveReasoning === "string" && effectiveReasoning !== "none") { if (usesAdaptiveThinking(parsed.modelId)) { // Adaptive-thinking models replace the token budget with an effort knob and reject // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index b71901a3d4d..1ad5018ee10 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -102,7 +102,7 @@ export function buildOpenAIChatPassthroughRequest( && typeof (rawBody.reasoning as { enabled?: unknown }).enabled === "boolean" ? (rawBody.reasoning as { enabled: boolean }).enabled : undefined; - applyExplicitChatReasoningWirePolicy({ + const explicitReasoning = applyExplicitChatReasoningWirePolicy({ provider, modelId, hasTools, @@ -112,12 +112,15 @@ export function buildOpenAIChatPassthroughRequest( reasoningDisabled, body, }); + let reasoningLog = explicitReasoning.handled ? explicitReasoning.reasoningLog : undefined; // A native caller's own thinking object is already in the upstream wire shape. Forward it only - // on a provider-declared toggle wire, where it remains more specific than a catalog default. + // on a provider-declared toggle wire, below an explicit reasoning.enabled but above a default. if (!reasoningDisabled && toggleWire && resolveReasoningCapabilities(provider, modelId).supportsThinkingToggle !== false + && explicitThinkingEnabled === undefined && rawBody.thinking !== undefined) { body.thinking = rawBody.thinking; + reasoningLog = undefined; } const openRouterRouting = resolveOpenRouterRouting(provider, modelId); @@ -226,5 +229,8 @@ export function buildOpenAIChatPassthroughRequest( }); } - return { url, method: "POST", headers, body: bodyJson }; + return { + url, method: "POST", headers, body: bodyJson, + ...(reasoningLog ? { reasoningLog } : {}), + }; } diff --git a/src/adapters/openai-chat/reasoning-wire.ts b/src/adapters/openai-chat/reasoning-wire.ts index 06d85aa2b96..a3ceaef29d1 100644 --- a/src/adapters/openai-chat/reasoning-wire.ts +++ b/src/adapters/openai-chat/reasoning-wire.ts @@ -33,7 +33,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { hasTools, thinkingEnabled, requestedEffort, - wireEffort, + wireEffort: mappedWireEffort, reasoningDisabled, body, } = options; @@ -46,25 +46,22 @@ export function applyExplicitChatReasoningWirePolicy(options: { return { handled: true }; } const toggleWire = declaresThinkingToggleWire(provider, modelId); + const toggleDisabled = toggleWire && capabilities.supportsThinkingToggle === false; + // Ignore legacy toggle discriminators on a disabled toggle axis, but retain real effort + // mappings (including omission) and run them through the shared effort policy below. + const requestedStrength = requestedEffort === "ultra" ? "max" : requestedEffort; + const wireEffort = toggleDisabled && isThinkingToggleType(mappedWireEffort) ? requestedStrength : mappedWireEffort; if (capabilities.supportsReasoningEffort === false && !toggleWire) { delete body.reasoning_effort; delete body.reasoning; if (capabilities.supportsThinkingToggle === false) delete body.thinking; return { handled: true }; } - if (toggleWire) { - if (capabilities.supportsThinkingToggle === false) { - delete body.thinking; - delete body.reasoning; - if (requestedEffort !== undefined && capabilities.supportsReasoningEffort !== false) { - // A disabled toggle does not invalidate a separately supported effort axis. Preserve the - // caller's strength rather than reusing the legacy toggle discriminator. - body.reasoning_effort = requestedEffort; - } else { - delete body.reasoning_effort; - } - return { handled: true }; - } + if (toggleDisabled) { + delete body.thinking; + delete body.reasoning; + delete body.reasoning_effort; + } else if (toggleWire) { const resolvedThinkingEnabled = resolveThinkingEnabled(provider, modelId, { thinkingEnabled, reasoning: requestedEffort }); // These values are the provider's declared thinking discriminator, never strengths. This // remains true when the catalog also advertises a separate, real effort axis. @@ -114,7 +111,15 @@ export function applyExplicitChatReasoningWirePolicy(options: { delete body.reasoning; return { handled: true }; } - if (provider.reasoningWireFormat !== "gateway-object") return { handled: false }; + if (provider.reasoningWireFormat !== "gateway-object") { + if (!toggleDisabled) return { handled: false }; + if (wireEffort === undefined) return { handled: true }; + body.reasoning_effort = wireEffort; + return { + handled: true, + reasoningLog: { effectiveEffort: wireEffort, wireField: "reasoning_effort", wireValue: wireEffort }, + }; + } const nativeOpenAI = isNativeOpenAIChatTarget(provider); if (requestedEffort === "none") { @@ -141,7 +146,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { }, }; } - if (wireEffort === undefined) return { handled: false }; + if (wireEffort === undefined) return { handled: toggleDisabled }; if (nativeOpenAI) { delete body.reasoning; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 57c7ccf0e6b..02c9133eb65 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -39,7 +39,7 @@ import { createAdapterTierMetadata, emittedFastWire, } from "../../providers/fastwire"; -import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; +import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripInternalReasoningEnabled, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripProviderInputItemMetadata, stripRejectedSamplingParams } from "./request-strips"; import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; @@ -515,7 +515,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isCanonicalOpenAiForwardProvider(provider)) { validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools); } - const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); + let wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); + if (isOpenAiOperatedResponsesDestination(provider)) { + wireBody = stripInternalReasoningEnabled(wireBody); + } const body = JSON.stringify(wireBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 2fd591aea06..2b4b0c7b35b 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -325,6 +325,19 @@ export function stripDisabledVerbosity( }; } +/** + * `reasoning.enabled` is an OCX inbound compatibility extension, not an OpenAI Responses + * parameter. Call this at the OpenAI-operated final wire boundary so translated clients retain + * the choice until destination selection while routed Responses adapters can receive it. + */ +export function stripInternalReasoningEnabled(body: unknown): unknown { + if (!isPlainObject(body) || !isPlainObject(body.reasoning) || !Object.hasOwn(body.reasoning, "enabled")) { + return body; + } + const { enabled: _enabled, ...reasoning } = body.reasoning; + return { ...body, ...(Object.keys(reasoning).length > 0 ? { reasoning } : { reasoning: undefined }) }; +} + /** * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index ce6e87b2c60..ab66e8dd253 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -40,6 +40,7 @@ import type { OpencodeV2ProviderBlock, } from "../clients/config-export"; import type { EffectiveModelExportMetadata } from "../clients/config-export/contracts"; +import { resolveExportReasoningAxes } from "../clients/config-export/reasoning-metadata"; import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; import { commandInvocation } from "../lib/win-exec"; import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets"; @@ -385,19 +386,8 @@ export function opencodeCatalogFromProxyRows( const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; - const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; - const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; - const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; - const effortDisabled = supportsReasoningEffort === false || (effective?.supportsReasoning ?? row.supportsReasoning) === false; - const reasoningEfforts = effortDisabled && declaredEfforts !== undefined ? [] : declaredEfforts; - // The projection is the authority on the default: when it exists, an absent default means - // NO declared default, and falling back to the row would reintroduce the picker's - // synthetic medium/low value. Rows without one (older hubs, hand-built rows) keep the - // row's own value. - const defaultReasoningEffort = effortDisabled ? undefined : effective !== undefined - ? effective.defaultReasoningEffort - : row.defaultReasoningEffort; + const { supportsThinkingToggle, supportsReasoningEffort, defaultThinkingEnabled, + reasoningEfforts, defaultReasoningEffort } = resolveExportReasoningAxes(effective, row); const supportsTools = effective?.supportsTools ?? row.supportsTools; const supportsReasoning = effective?.supportsReasoning ?? row.supportsReasoning; const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries; diff --git a/src/clients/config-export/reasoning-metadata.ts b/src/clients/config-export/reasoning-metadata.ts index df870052002..2d85324485d 100644 --- a/src/clients/config-export/reasoning-metadata.ts +++ b/src/clients/config-export/reasoning-metadata.ts @@ -4,6 +4,26 @@ import { knownReasoningSupport, type OpencodeCatalogModel } from "./contracts"; type ReasoningMetadata = Pick; +/** Shared row projection; an effective projection owns the default even when it omits it. */ +export function resolveExportReasoningAxes(effective: ReasoningMetadata | undefined, row: ReasoningMetadata) { + const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; + const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; + const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; + const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; + const effortDisabled = supportsReasoningEffort === false + || (effective?.supportsReasoning ?? row.supportsReasoning) === false; + return { + supportsThinkingToggle, + supportsReasoningEffort, + defaultThinkingEnabled, + reasoningEfforts: declaredEfforts === undefined ? undefined : effortDisabled ? [] : [...declaredEfforts], + // Falling back through an existing projection would revive a synthetic picker default. + // Older rows without a projection retain their own declared default. + defaultReasoningEffort: effortDisabled ? undefined : effective !== undefined + ? effective.defaultReasoningEffort : row.defaultReasoningEffort, + }; +} + /** Effort-enum client compatibility only; never write these choices back into model facts. */ export function exportReasoningEfforts(model: ReasoningMetadata): string[] | undefined { if (model.supportsReasoning === false) return []; diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 8c9ae6627af..ed52f8980a9 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -265,8 +265,9 @@ export function withVisionSidecarInput( } /** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ -// Reapplying hints starts from discovered reasoning metadata, not the previous config projection. -const reasoningHintSources = new WeakMap>(); +// Enumerable symbol provenance survives dated/routed spread clones without entering wire JSON. +const reasoningHintSource = Symbol("reasoningHintSource"); +type ReasoningHintModel = CatalogModel & { [reasoningHintSource]?: Partial }; export function applyProviderConfigHints( name: string, @@ -306,7 +307,7 @@ export function applyProviderConfigHints( const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - const reasoningSource = reasoningHintSources.get(model) ?? model; + const reasoningSource = (model as ReasoningHintModel)[reasoningHintSource] ?? model; const reasoningCapabilities: Partial = {}; for (const field of REASONING_CAPABILITY_FIELDS) { const value = modelInList(prov.noReasoningModels, model.id) ? staticPolicy.model[field] @@ -412,7 +413,7 @@ export function applyProviderConfigHints( : withCap; const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] .filter((value): value is number => typeof value === "number" && value > 0); - const result = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { + const result: ReasoningHintModel = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { ...withHardBounds, autoCompactTokenLimit: clampAutoCompactTokenLimit( contextWindow, @@ -420,9 +421,14 @@ export function applyProviderConfigHints( Math.min(...softCandidates), ), }; - reasoningHintSources.set(result, Object.fromEntries([ + const reasoningFields = [ ...REASONING_CAPABILITY_FIELDS, "reasoningEfforts", "defaultReasoningEffort", - ].map(field => [field, reasoningSource[field as keyof CatalogModel]]))); + ] as const; + if (reasoningFields.some(field => result[field] !== reasoningSource[field])) { + result[reasoningHintSource] = Object.fromEntries(reasoningFields.map(field => [field, reasoningSource[field]])); + } else { + delete result[reasoningHintSource]; + } return result; } @@ -783,7 +789,8 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid ?? item.reasoning_efforts ?? reasoningParameters?.efforts; const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); - const reasoningEfforts = listedReasoningEfforts + const reasoningEfforts = reasoningCapabilities.supportsReasoning === false || reasoningCapabilities.supportsReasoningEffort === false ? [] + : listedReasoningEfforts ? sanitizeCodexReasoningEfforts(listedReasoningEfforts) : typeof rawReasoningEfforts === "boolean" ? (rawReasoningEfforts @@ -802,7 +809,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...reasoningCapabilities, - ...(reasoningCapabilities.supportsReasoningEffort !== false && reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), ...(inputModalities ? { inputModalities } : {}), ...(capabilities ? { capabilities } : {}), // Omitted when the classification is "unknown", following this function's existing diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 38de62aa5a4..781e73c7441 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1304,9 +1304,10 @@ export function createServeOptions(ctx: ServeOptionsContext) { ...(effectiveId !== publicId ? { native_id: publicId } : {}), ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), ...modelCapabilityFields({ - ...m, - // contextWindow is already the post-cap effective value; contextCap is the raw - // operator knob and over-reports models whose real window sits below it. + supportsReasoning: m.supportsReasoning, supportsReasoningEffort: m.supportsReasoningEffort, + supportsThinkingToggle: m.supportsThinkingToggle, defaultThinkingEnabled: m.defaultThinkingEnabled, + reasoningEfforts: m.reasoningEfforts, + // Use the post-cap window; the raw contextCap can exceed a model's real window. contextWindow: m.contextWindow, maxOutputTokens: m.maxOutputTokens, inputModalities: m.inputModalities, diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index e03206ecbca..c4d5154825e 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -39,6 +39,7 @@ import { type ExportModel, } from "../../clients/config-export/contracts"; import { customRowExportMetadata } from "./model-row-export-metadata"; +import { resolveExportReasoningAxes } from "../../clients/config-export/reasoning-metadata"; import { providerContextCap } from "../../providers/context-cap"; import { isVisionReasoningEffort, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; @@ -559,19 +560,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { const maxTokens = effective?.maxTokens ?? row.maxOutputTokens; const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens; const inputModalities = effective?.inputModalities ?? row.inputModalities; - const supportsThinkingToggle = effective?.supportsThinkingToggle ?? row.supportsThinkingToggle; - const supportsReasoningEffort = effective?.supportsReasoningEffort ?? row.supportsReasoningEffort; - const defaultThinkingEnabled = effective?.defaultThinkingEnabled ?? row.defaultThinkingEnabled; - const declaredEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts; - const effortDisabled = supportsReasoningEffort === false || (effective?.supportsReasoning ?? row.supportsReasoning) === false; - const reasoningEfforts = effortDisabled && declaredEfforts !== undefined ? [] : declaredEfforts; - // The projection is the authority on the default. When it exists, an absent default means - // NO declared default — falling back to the row would reintroduce the picker's synthetic - // medium/low value, which is exactly what this projection exists to keep out of exports. - // Rows without a projection (native-era shapes, hand-built rows) keep the row's own value. - const defaultReasoningEffort = effortDisabled ? undefined : effective !== undefined - ? effective.defaultReasoningEffort - : row.defaultReasoningEffort; + const { supportsThinkingToggle, supportsReasoningEffort, defaultThinkingEnabled, + reasoningEfforts, defaultReasoningEffort } = resolveExportReasoningAxes(effective, row); const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries; // Projection first, then the row's own explicit boolean (an external producer may state // false, which is evidence), and only then derivation from positive evidence. diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index 4a56da24afa..9a2a43f4bd5 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -137,7 +137,7 @@ export interface ModelCapabilityFields { input_modalities?: string[]; supports_tool_use: true; supports_streaming: true; - supports_reasoning: boolean; + supports_reasoning?: boolean; supports_thinking_toggle?: boolean; supports_reasoning_effort?: boolean; default_thinking_enabled?: boolean; @@ -173,8 +173,10 @@ function positiveInt(value: unknown): number | undefined { } export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields { - const efforts = input.supportsReasoningEffort === false ? [] + const efforts = input.supportsReasoning === false || input.supportsReasoningEffort === false ? [] : (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); + const supportsReasoning = input.supportsReasoning + ?? (input.supportsThinkingToggle === true || input.supportsReasoningEffort === true || efforts.length > 0 ? true : undefined); const contextLength = positiveInt(input.contextWindow); const longContextLength = positiveInt(input.longContextWindow); const maxOutputTokens = positiveInt(input.maxOutputTokens); @@ -195,7 +197,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil ...(modalities !== undefined && modalities.length > 0 ? { input_modalities: [...modalities] } : {}), supports_tool_use: true, supports_streaming: true, - supports_reasoning: input.supportsReasoning ?? efforts.length > 0, + ...(supportsReasoning !== undefined ? { supports_reasoning: supportsReasoning } : {}), ...(input.supportsThinkingToggle !== undefined ? { supports_thinking_toggle: input.supportsThinkingToggle } : {}), ...(input.supportsReasoningEffort !== undefined ? { supports_reasoning_effort: input.supportsReasoningEffort } : {}), ...(input.defaultThinkingEnabled !== undefined ? { default_thinking_enabled: input.defaultThinkingEnabled } : {}), diff --git a/structure/catalog.md b/structure/catalog.md index 60a4225768a..47a5b1e30a2 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -479,11 +479,13 @@ The shared Responses path follows the [bounded multipart recovery contract](suba ## Ultra reasoning level -Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle -controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function -wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). +Catalog rows retain independent reasoning facts from [per-model declarations](config.md#explicit-per-model-capability-declarations) and discovered optional snake-case capability flags. `src/codex/catalog/model-hints.ts` reapplies config over original discovered reasoning metadata rather than a previous projection; removing an override restores that source at the catalog clone sites that explicitly propagate it. Absent axes remain unknown and explicit false survives publication. -For routed models, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value +`src/codex/catalog/effort.ts` uses the [client export projection](clients/integrations.md#model-output-limits) at Codex's effort-enum boundary: toggle-only models receive `none/high` off/on choices and a declared thinking default, without recording those choices as model strengths. Explicit toggle-axis declarations or negative reasoning/effort declarations constrain this projection and suppress synthetic top tiers. Positive strength support inferred from a legacy ladder alone retains its previous synthesis. + +Legacy catalog ladders advertise ultra regardless of the `multi_agent_v2` toggle, which controls only the multi-agent collab surface. `nativeEffortClamp` wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). + +For routed models retaining legacy ladder synthesis, `modelSuppressSyntheticMax` is a catalog-only per-model setting. A true value prevents `src/codex/catalog/effort.ts` from adding a missing synthetic `max` and prevents `src/codex/catalog/build-entries.ts` from repairing that missing rung during observed-state merge. It never removes a provider-declared `max`, and `ultra` remains advertised. If the configured default @@ -494,14 +496,9 @@ can remove the former. Codex uses this same membership for the picker and explic effort validation; an explicit `max` spawn can therefore fail client-side before proxy wire clamping, while retained `ultra` remains the supported harness path. -`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path -(`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting -the request, and they never raise it. +`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. -Combo dispatch reads the final target ladder through the same `supportedLadderFor` authority. An -explicit empty ladder means that target receives no effort control; an unknown ladder receives no -parent effort controls only when the combo opts into `reasoningEffortMode: "adaptive"`. Known -non-empty ladders continue through the existing per-target resolution. +Combo dispatch reads the final target ladder through the same `supportedLadderFor` authority. An explicit empty ladder means that target receives no effort control; an unknown ladder receives no parent effort controls only when the combo opts into `reasoningEffortMode: "adaptive"`. Known non-empty ladders continue through the existing per-target resolution. Independent `reasoning.enabled` survives this strength-only stripping. The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. Its status output preserves unsupported legacy cap values and reports that those fields are ignored; @@ -512,7 +509,7 @@ Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root `modelPinnedEfforts` resolve before applicable effort caps at the final destination. Provider model pins precede provider-wide pins, then global selector/destination pins. A pin can raise the effective caller effort; the later cap can still lower or omit it. -`none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. +On legacy effort paths, `none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. For a declared thinking toggle, the client compatibility decoder resolves `none` to thinking off before strength policy; an independent `thinkingEnabled` boolean takes precedence. Effort caps do not clear that switch, and a destination change restores the original thinking choice alongside effort before normalization. Compaction maintenance is exempt. Pins are user overlays and do not alter registry seeds, model discovery or advertised ladders. Native Chat applies qualifying caps even without a pin, and normalizes pinned values and values rewritten by a cap through provider wire @@ -577,7 +574,7 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara ## Renamed destination reasoning metadata -`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win, matched case-insensitively. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. +`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win, matched case-insensitively. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Independent reasoning booleans merge per exact model and axis under the [capability declaration contract](config.md#explicit-per-model-capability-declarations). Identity, transport and unrelated capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing. Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 99ddb05241d..a057c9a7978 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -541,7 +541,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 22badc65901..85250a8a6ff 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -240,7 +240,7 @@ than reviving a cleared capability. Exporters do not invent a medium default for `src/server/management/model-row-export-metadata.ts` resolves custom-row inheritance without backfilling the stored override fields the editor reads. Management rows attach `exportMetadata`; -its presence makes even an absent default authoritative, so export/launcher projections cannot +its presence makes even an absent default authoritative; the shared `resolveExportReasoningAxes` helper in `src/clients/config-export/reasoning-metadata.ts` keeps management and CLI exports aligned, so export/launcher projections cannot fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. Management and CLI export projections preserve independent `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled` fields, including explicit false; absent axes remain unknown. An empty effort declaration does not disable a declared thinking toggle. `supportsReasoningEffort: false` prevents legacy ladders from advertising strengths. Metadata projections carry capability truth without synthesizing effort choices. At the Codex, OpenCode and Kilo effort-enum boundaries only, toggle-only models encode off/on as `none`/`high`, with their default taken from `defaultThinkingEnabled`; fixed reasoning creates no picker, while effort-only and combined controls retain real strengths without synthetic top tiers. Legacy unknown capabilities keep their prior behavior: positive effort support alone, which can be inferred from an existing ladder, does not opt a Codex row out of its previous max/ultra synthesis. Explicit toggle-axis declarations and negative reasoning/effort capabilities still constrain that projection exactly. Legacy variant maps disable client-generated choices that the model does not support; client-owned labels may still display “High”. `tests/clients/client-export-reasoning-controls.test.ts` covers generic capability combinations and Codex projection, `tests/clients/client-export-effective-metadata.test.ts` covers routed/custom projection parity, and `tests/server/api-catalog-route.test.ts` checks independent public API flags without a fake effort list and client exports with upstream fetches stubbed. OpenCode V1 and Kilo use model `options` defaults and variant maps; OpenCode V2 uses model diff --git a/structure/config.md b/structure/config.md index 10271963372..a84fe71b596 100644 --- a/structure/config.md +++ b/structure/config.md @@ -422,9 +422,7 @@ destinations keep their operator-owned values. Key-auth service-tier defaults ap captured key authority; exact-model provenance comes from the merged key/registry map, then falls back to the resolved provider capability provenance. A model max-input value is bounded by its resolved context window. -Legacy model maps resolve exact id, then the base before a colon suffix, then case-folded exact id; -the separately captured explicit capability row remains exact-only. Per-model provenance is assigned -from the key that wins that same merged lookup, not from an independent source search. +Legacy model maps resolve exact id, then the base before a colon suffix, then case-folded exact id; the separately captured explicit capability row remains exact-only. Per-model provenance is assigned from the key that wins that same merged lookup, not from an independent source search. Provider seed/enrichment and request routing consume the same field-level resolver. Persisted config still stores operator intent rather than the frozen result; registry-only policy is applied at capture/route time and explicit false or empty declarations retain their field-specific meaning. @@ -561,7 +559,9 @@ The optional `autoReviewOverride` block stores a global approval reviewer select Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing and deepseekImageFiles axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the matching registry row; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). + +`src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid sibling axes when a reasoning boolean is malformed; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. `PUT /api/combos` accepts a request-only `visionSidecarTargets` array of exact combo-member `{ provider, model }` pairs (validated against the submitted combo's normalized targets, deduplicated, rejected while `imageInput` is `"disabled"`). An entry whose provider is unconfigured, whose model already accepts image input through any source, or whose exact declaration, custom row, or provider `modelInputModalities` hint lacks `text` is rejected with 400 instead of being overwritten; an entry the sidecar already covers through any runtime source (exact declaration, custom row, `noVisionModels`, legacy record, registry enrichment) is skipped as a no-op. Accepted entries merge `inputModalities: ["text"]` through the same validation/merge helpers, and the combo write, reference migrations, disabled-selector rewrite and capability patches commit in one provider-patch transaction — a failed save restores all of them. The field never persists under `config.combos`, and removing a member from the combo leaves its provider declaration in place until cleared explicitly. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index e38d47e0faf..8a7f77ef458 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -497,7 +497,7 @@ The existing dashboard file-client maps include Cline CLI and reuse its committe Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. Pool quota producers and account commands follow the [bounded raw-observation contract](providers/openai-accounts.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates. The account history response can include a [low-confidence effective capacity estimate](providers/openai-accounts.md#observed-effective-token-capacity); usage normalization retains local-answer provenance so local responses cannot supply samples. Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. Dashboard overview polling observes authorization failures independently of stalled or rejected peer requests, cancels remaining child requests after a decisive result, and exposes resource-level deadline failures without rewriting them as authentication failures. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 939b2f1220e..4f0961dcb1d 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -193,7 +193,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1ed6d3f229c..dd6d7280734 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -140,12 +140,9 @@ the native passthrough there is no canonical Fast injection and no wire mapping: and `fastMode` injects nothing here. Resolved-Fast-policy injection applies only to routes that take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitted only for providers opted into parallel tools (or pinned false by the existing provider opt-out contract). -The native passthrough still applies the existing model capability authority to reasoning: an -explicit empty ladder removes caller `reasoning_effort`, while an unknown ladder remains -unclassified. The two Chat builders share the explicit wire policy after provider resolution: +The native passthrough applies the [independent reasoning capability contract](../providers-and-adapters.md): explicit false declarations constrain their respective controls, and an empty strength ladder removes caller `reasoning_effort` without disabling a declared thinking switch. Unknown axes remain unclassified. The two Chat builders share the explicit wire policy after provider resolution: `reasoningWireFormat: "gateway-object"` projects the configured object shape, and a listed -tool-bearing model omits reasoning effort on both paths. With neither declaration, native raw -reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. +tool-bearing model omits reasoning effort on both paths. On a declared toggle wire, explicit `reasoning.enabled` wins, then caller raw `thinking`, then aliases/defaults; unknown or adaptive raw thinking shapes remain intact. With no applicable declaration, native raw reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. On the response side, the upstream `service_tier` echo (xAI Priority Processing, OpenAI fast tier) relays to the Chat Completions caller on every delivery shape: the non-streaming body diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 687aef65584..57859bc9ce8 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -567,7 +567,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations. Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached, rejects inputs above the safe decoded-pixel ceiling, caps native decode work process-wide, and stops queued work when the request is cancelled. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 7182595e80a..773b43c9ecb 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -381,7 +381,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](../transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 538f3a95b23..cec9a060351 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath exact operator declarations; absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. Config validation, patch merging and load repair preserve each boolean independently. +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath exact operator declarations; absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. @@ -199,7 +199,7 @@ rewrite rules and the routed-id settlement. | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. That identity is read from the leading JSON object of `kiro-cli whoami --format json`, which must end its line: kiro-cli 2.32 prints a human-readable `Profile:` block after it once an IAM Identity Center profile is selected, and that later text is ignored. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. | | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | -| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | +| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders strip effort and legacy alternative controls while retaining independent `reasoning.enabled` and summary; known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. On the noncanonical forward path, configured `baseUrl` trailing-slash stripping is a linear scan that preserves interior slash runs and leaves the canonical forward base and non-forward auth modes untouched; `tests/responses/responses-forward-transport-baseurl.test.ts` pins it. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 4adb8f434cd..82324bc4ed4 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -111,11 +111,7 @@ Passthrough and routed-compaction raw-body repairs emit it as a user turn (`exte Stateful developer-guidance injection reuses that validator for its raw insertion boundary, so parsed messages and stored raw history retain the same task/guidance order. -Native OpenAI passthrough consults the existing configured capability ladder before forwarding -`reasoning_effort`; an explicitly empty ladder removes that unsupported control while an unknown -ladder remains unclassified. Both Chat builders then apply the same explicit provider declarations: -gateway-object projection and tool-bearing model effort omission. An unset declaration preserves the -native caller field exactly. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send +Native Chat passthrough and translated Chat share `src/adapters/openai-chat/reasoning-wire.ts` after provider resolution. Explicit reasoning/effort false declarations and empty strength ladders remove unsupported effort; toggle false removes the switch without declaring an unknown strength axis unsupported. A declared toggle wire serializes the independent thinking choice without treating enabled/disabled/adaptive discriminators as strengths. Gateway-object projection and tool-bearing effort omission retain their existing contracts; unknown declarations preserve legacy raw forwarding. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — @@ -466,8 +462,7 @@ measurement rather than allocating a serialized copy just to measure it. > Decision record: [ADR-0067](../decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md) -`hideThinkingSummary` is set for explicit summary "none", or omitted summary without a validated -active effort. Accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) +`hideThinkingSummary` is set for explicit summary "none", or omitted summary without active thinking. An explicit `thinkingEnabled` boolean determines activity before effort is considered; otherwise accepted minimal/low/medium/high/xhigh/max (including ultra normalized to max) allow raw visibility when summary is omitted; none and invalid efforts do not. Explicit "auto" still permits raw visibility independently of effort. This flag is honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 76e5b5dce88..b7965c6a7a5 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -271,7 +271,7 @@ Canonical Spark Lite metadata follows the final serialized model and surviving n Shared raw-reasoning events retain content-channel presentation; provider-authored thinking keeps its existing summary path. See [bridge contract](chat-compat.md). -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat removes effort only for an explicit empty declaration or no-reasoning model. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations. ## Textual pseudo tool-call quarantine diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index bc4df0c5c33..460238e5898 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -217,7 +217,7 @@ do not alter xAI discovery, model metadata, or transport behavior. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](../transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. diff --git a/structure/subagents.md b/structure/subagents.md index a1616cfea65..c7da85e0bb1 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -515,7 +515,7 @@ The account history response can include a [low-confidence effective capacity es Account quota surfaces use [safe probe diagnostics](transports/inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](transports/responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 3649a480ad3..dce96862373 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -147,8 +147,8 @@ cooldown is live. A stale-generation refusal cannot borrow a sibling request's s `src/server/responses/core.ts` passes the combo's `reasoningEffortMode` and the final target's `supportedLadderFor` result to `src/combos/request.ts` before adapter parsing. Explicit empty -capability ladders remove effort and thinking controls in every combo mode; adaptive mode also -removes those controls for unknown ladders and preserves `reasoning.summary`. Known non-empty +capability ladders remove effort and legacy alternative thinking controls in every combo mode; adaptive mode also +removes those controls for unknown ladders. Independent `reasoning.enabled` and `reasoning.summary` survive. Known non-empty ladders retain the existing per-target effort resolution. This request normalization does not change target order or attempt accounting; provider-400 decisions follow the [request-local target compatibility](../runtime.md#request-local-target-compatibility) contract. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 3c57d7dd0f5..11ea26c099b 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -569,7 +569,7 @@ An injected combo default supplies `summary: "auto"` only when no summary was sp summary choices remain intact. Raw display and hidden-envelope replay follow [reasoning display parity](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary). Final-route normalization preserves visible raw reasoning when the parsed request has a validated -active effort and omits summary; explicit `summary: "none"` still hides it (passthrough strips that internal marker before the upstream send). +active thinking choice (an explicit `thinkingEnabled` boolean takes precedence over effort) and omits summary; explicit `summary: "none"` still hides it (passthrough strips that internal marker before the upstream send). The provider policy `hideRawReasoning` suppresses the raw `reasoning_raw_delta` channel only — openai-chat `reasoning_content`, kiro tags, and Gemini thought parts on routes that do not return thought summaries (direct and Vertex Gemini; a `cloud-code-assist` Gemini route emits its thought diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c003eac87b0..be7f590cc63 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -408,6 +408,8 @@ The continuation-note and continuation-wire tests establish prompt and wire cont ## Core module ownership +Final-route normalization in `src/server/responses/core-normalize.ts` decodes client effort aliases through `src/clients/reasoning-controls.ts` before strength pins/caps. `options.thinkingEnabled` is independent of `options.reasoning`: explicit booleans outrank aliases and defaults, and an empty strength ladder does not erase a declared switch. `src/server/effort-policy.ts` restores both original choices on a destination change; credential-only retries retain the current normalization. Unsupported controls follow the [per-axis capability contract](../providers-and-adapters.md), while unknown axes retain legacy behavior. At serialization, `src/adapters/openai-responses/passthrough.ts` strips the OCX `reasoning.enabled` extension only for OpenAI-operated Responses destinations, for both HTTP and WebSocket bodies; custom gateways retain this extension. The extension-removal step leaves other reasoning members untouched; existing effort and summary normalization still applies. + `src/server/responses/core.ts` is the public ingress and compatibility-export surface. The parent `src/server/responses.ts` facade retains its existing imports. Per-request execution is composed from the following owners in `src/server/responses/`; none is a generated artifact. | Owner | Responsibility | @@ -431,9 +433,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | -Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, -`core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, -`core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types +Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-402` for HTTP 402 quota recovery, `oauth-account-429` for HTTP 429 rate-limit recovery, and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. For Kiro OAuth with load settings, `request-transport.ts` acquires a lease on the admitted account and transfers it before a reactive replacement send; cancellation permanently fences the request holder so recovery cannot install a late lease after abort cleanup. `core.ts` and `core-lifetime.ts` release the lease on returned-body completion, error, or cancellation, outside the inner admission `finally`. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index c2812203286..9f005af61cb 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -358,7 +358,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co Account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. -Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat capability stripping applies only for an explicit empty declaration or no-reasoning model; an initial JEV null choice separately strips caller effort. +Combo child requests normalize effort and thinking controls against the selected target while retaining reasoning summaries; strict unknown targets preserve caller controls. The [Responses transport owner](responses.md) documents this boundary, and native Chat also honors explicit per-axis capability declarations; an initial JEV null choice separately strips caller effort. Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index a1b24954f72..6de9cbb7b69 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -93,6 +93,8 @@ describe("anthropic extended-thinking gate", () => { } }, }); expect(b.thinking).toEqual({ type: "disabled" }); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(8192); expect(b.temperature).toBe(0.3); expect(b.top_p).toBe(0.9); }); @@ -109,6 +111,44 @@ describe("anthropic extended-thinking gate", () => { expect(b.output_config).toBeUndefined(); }); + test.each([ + "claude-sonnet-5-5", "claude-opus-4-8", "claude-haiku-5-5", "claude-fable-5", + ])("%s keeps its native contract with a declared generic toggle", async (modelId) => { + const target = { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, + } }, + }; + const on = await bodyOf(parsed("high", {}, modelId), target); + expect(on.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(on.output_config).toEqual({ effort: "high" }); + const off = await bodyOf(parsed("none", { maxOutputTokens: 64 }, modelId), target); + expect(off.thinking).toEqual(modelId === "claude-sonnet-5-5" ? { type: "between_tools" } + : modelId === "claude-haiku-5-5" ? { type: "disabled" } : undefined); + expect(off.output_config).toBeUndefined(); + expect(off.max_tokens).toBe(64); + const explicitOff = await bodyOf(parsed("high", { thinkingEnabled: false, maxOutputTokens: 64 }, modelId), target); + expect(explicitOff.thinking).toEqual(off.thinking); + expect(explicitOff.output_config).toBeUndefined(); + expect(explicitOff.max_tokens).toBe(64); + const explicitOn = await bodyOf(parsed(undefined, { thinkingEnabled: true }, modelId), target); + expect(explicitOn.thinking).toEqual(on.thinking); + }); + + test("both unsupported axes suppress native thinking and token headroom", async () => { + const modelId = "claude-sonnet-5-5"; + const b = await bodyOf(parsed("high", { thinkingEnabled: true, maxOutputTokens: 64 }, modelId), { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { supportsThinkingToggle: false, supportsReasoningEffort: false } }, + }); + expect(b.thinking).toBeUndefined(); + expect(b.output_config).toBeUndefined(); + expect(b.max_tokens).toBe(64); + }); + // Sonnet 4.5 takes temperature or top_p alone but 400s on both (live 2026-09-29), so a request // carrying both keeps temperature; each field alone survives. test("reasoning 'none' does NOT enable thinking and preserves temperature", async () => { diff --git a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts index 06aa7280b6b..f8464f86c96 100644 --- a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts +++ b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts @@ -73,9 +73,11 @@ function finalToggleBodies( targetModel = toggleModel, thinkingEnabled?: boolean, ): Record<"translated" | "native", Record> { - const requestForModel = { ...rawInput, model: targetModel }; + const requestForModel = { + ...rawInput, model: targetModel, + ...(thinkingEnabled === undefined ? {} : { reasoning: { enabled: thinkingEnabled } }), + }; const parsed = parseRequest(chatCompletionsToResponsesBody(requestForModel)); - if (thinkingEnabled !== undefined) parsed.options.thinkingEnabled = thinkingEnabled; const translated = createOpenAIChatAdapter(target).buildRequest(parsed); const native = buildOpenAIChatPassthroughRequest(target, requestForModel, targetModel, false); return { @@ -128,11 +130,21 @@ describe("toggle-only Chat reasoning capability", () => { test.each([[false, "disabled"], [true, "enabled"]] as const)("serializes direct boolean %s as %s", (thinkingEnabled, type) => { const bodies = finalToggleBodies(request(), toggleProvider, toggleModel, thinkingEnabled); - const translated = bodies.translated; - expect(translated.thinking).toEqual({ type }); - expect(translated.reasoning_effort).toBeUndefined(); + for (const body of Object.values(bodies)) { + expect(body.thinking).toEqual({ type }); + expect(body.reasoning_effort).toBeUndefined(); + } }); + test.each([[false, "enabled", "disabled"], [true, "disabled", "enabled"]] as const)( + "explicit reasoning.enabled %s wins over conflicting raw thinking %s", + (enabled, conflictingType, type) => { + for (const body of Object.values(finalToggleBodies({ + ...request(), reasoning: { enabled }, thinking: { type: conflictingType }, + }))) expect(body.thinking).toEqual({ type }); + }, + ); + test("translates legacy Chat effort on both final wires", () => { for (const body of Object.values(finalToggleBodies(request("none")))) { expect(body.thinking).toEqual({ type: "disabled" }); @@ -202,6 +214,45 @@ describe("toggle-only Chat reasoning capability", () => { } }); + test.each([ + [undefined, "medium", false, "medium", "high"], + [undefined, "enabled", false, "high", "high"], + [undefined, "__omit__", false, undefined, "high"], + [undefined, "medium", true, undefined, "high"], + [undefined, "enabled", false, "max", "ultra"], + ["gateway-object", "medium", false, "medium", "high"], + ["gateway-object", "enabled", false, "high", "high"], + ["gateway-object", "__omit__", false, undefined, "high"], + ["gateway-object", "medium", true, undefined, "high"], + ["gateway-object", "enabled", false, "max", "ultra"], + ] as const)("toggle-disabled effort keeps %s mapping %s with tools=%s for %s (%s)", (reasoningWireFormat, mapped, hasTools, expected, requested) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + modelCapabilities: { [toggleModel]: { + supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true, + } }, + modelReasoningEffortMap: { [toggleModel]: { [requested === "ultra" ? "max" : requested]: mapped } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + const raw = { ...request(requested), thinking: { type: "enabled" }, ...(hasTools ? { tools: [tool] } : {}) }; + const results = [ + createOpenAIChatAdapter(target).buildRequest(parseRequest(chatCompletionsToResponsesBody(raw))), + buildOpenAIChatPassthroughRequest(target, raw, toggleModel, false), + ]; + for (const result of results) { + const body = JSON.parse(result.body) as Record; + expect(body.thinking).toBeUndefined(); + expect(body.reasoning_effort).toBe(reasoningWireFormat ? undefined : expected); + expect(body.reasoning).toEqual(reasoningWireFormat && expected ? { enabled: true, effort: expected } : undefined); + expect(result.reasoningLog).toEqual(expected ? { + effectiveEffort: expected, + wireField: reasoningWireFormat ? "reasoning.effort" : "reasoning_effort", + wireValue: expected, + } : undefined); + if (hasTools) expect(body.tools).toEqual([tool]); + } + }); + test.each([ ["reasoning disabled", { supportsReasoning: false }], ["both controls disabled", { supportsThinkingToggle: false, supportsReasoningEffort: false }], @@ -240,7 +291,6 @@ describe("toggle-only Chat reasoning capability", () => { }; const raw = { ...request("low"), thinking: { type: "enabled" } }; const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); - parsed.options.thinkingEnabled = true; const translated = JSON.parse(createOpenAIChatAdapter(bothProvider).buildRequest(parsed).body) as Record; const native = JSON.parse(buildOpenAIChatPassthroughRequest(bothProvider, raw, toggleModel, false).body) as Record; for (const body of [translated, native]) { @@ -291,7 +341,6 @@ describe("legacy mapped thinking discriminator wires", () => { reasoning: { enabled: false }, }; const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); - parsed.options.thinkingEnabled = false; const bodies = [ JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, "glm-4.6", false).body) as Record, diff --git a/tests/clients/client-export-reasoning-metadata.test.ts b/tests/clients/client-export-reasoning-metadata.test.ts index 15718559fe9..763249d1155 100644 --- a/tests/clients/client-export-reasoning-metadata.test.ts +++ b/tests/clients/client-export-reasoning-metadata.test.ts @@ -1,11 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { exportDefaultReasoningEffort, exportReasoningEfforts } from "../../src/clients/config-export/reasoning-metadata"; +import { exportDefaultReasoningEffort, exportReasoningEfforts, resolveExportReasoningAxes } from "../../src/clients/config-export/reasoning-metadata"; import { inputBudgetFor } from "../../src/clients/config-export/model-metadata"; import type { OpencodeCatalogModel } from "../../src/clients/config-export"; const model: OpencodeCatalogModel = { namespaced: "mock/model" }; describe("client export reasoning metadata", () => { + test("row projections share effective axes without reviving a picker default", () => { + const row = { reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low", + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true }; + expect(resolveExportReasoningAxes(undefined, row)).toMatchObject(row); + expect(resolveExportReasoningAxes({}, row).defaultReasoningEffort).toBeUndefined(); + for (const disabled of [{ supportsReasoning: false }, { supportsReasoningEffort: false }]) { + expect(resolveExportReasoningAxes(disabled, row)).toMatchObject({ + reasoningEfforts: [], defaultReasoningEffort: undefined, + }); + expect(resolveExportReasoningAxes(disabled, {}).reasoningEfforts).toBeUndefined(); + } + expect(resolveExportReasoningAxes({ supportsThinkingToggle: false, defaultThinkingEnabled: false }, row)) + .toMatchObject({ supportsThinkingToggle: false, defaultThinkingEnabled: false, reasoningEfforts: ["low", "high"] }); + }); test("unknown ladders remain unknown and explicit empty ladders remain empty", () => { expect(exportReasoningEfforts(model)).toBeUndefined(); expect(exportReasoningEfforts({ ...model, reasoningEfforts: [] })).toEqual([]); diff --git a/tests/providers/cursor/cursor-local-models-schema.test.ts b/tests/providers/cursor/cursor-local-models-schema.test.ts index 574b7f17c98..4c199b33d9d 100644 --- a/tests/providers/cursor/cursor-local-models-schema.test.ts +++ b/tests/providers/cursor/cursor-local-models-schema.test.ts @@ -92,12 +92,27 @@ describe("modelCapabilityFields", () => { output_modalities: ["text"], supports_tool_use: true, supports_streaming: true, - supports_reasoning: false, }); expect("context_length" in fields.capabilities).toBe(false); expect("input_modalities" in fields.capabilities).toBe(false); expect("supports_vision" in fields.capabilities).toBe(false); expect("reasoning_effort" in fields.capabilities).toBe(false); + expect("supports_reasoning" in fields.capabilities).toBe(false); + }); + + test.each([{ supportsThinkingToggle: true }, { supportsReasoningEffort: true }])("positive reasoning axis infers support: %j", input => { + const fields = modelCapabilityFields(input); + expect(fields.capabilities.supports_reasoning).toBe(true); + expect(fields.capabilities.reasoning_effort).toBeUndefined(); + }); + + test("negative axes alone leave reasoning unknown and explicit false suppresses efforts", () => { + expect(modelCapabilityFields({ supportsThinkingToggle: false, supportsReasoningEffort: false }) + .capabilities.supports_reasoning).toBeUndefined(); + const fields = modelCapabilityFields({ supportsReasoning: false, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"] }); + expect(fields.capabilities.supports_reasoning).toBe(false); + expect(fields.capabilities.reasoning_effort).toBeUndefined(); }); test("non-positive context and text-only modalities are reported honestly", () => { @@ -133,7 +148,7 @@ describe("modelCapabilityFields", () => { expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: Number.MAX_SAFE_INTEGER + 2 }).capabilities) .toBe(false); expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) - .toBe(false); + .toBeUndefined(); }); test("mirrors top-level context_window and max_output_tokens for external and legacy client discovery", () => { @@ -218,7 +233,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => expect(plain).toBeDefined(); expect(plain!.api_types).toEqual(["chat_completions", "responses", "anthropic_messages"]); const plainCaps = plain!.capabilities as Record; - expect(plainCaps.supports_reasoning).toBe(false); + expect("supports_reasoning" in plainCaps).toBe(false); expect("reasoning_effort" in plainCaps).toBe(false); expect("max_output_tokens" in plainCaps).toBe(false); diff --git a/tests/providers/mimo-v26-catalog.test.ts b/tests/providers/mimo-v26-catalog.test.ts index a8e9449c530..a827aba7f48 100644 --- a/tests/providers/mimo-v26-catalog.test.ts +++ b/tests/providers/mimo-v26-catalog.test.ts @@ -18,6 +18,8 @@ import { deriveEntry } from "../../src/codex/catalog/derive-entry"; import { MIMO_THINKING_MODELS } from "../../src/providers/mimo-thinking"; import { resolveReasoningCapabilities } from "../../src/providers/reasoning-capabilities"; import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/model-hints"; +import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/model-visibility"; +import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; // Xiaomi's published V2.6 prices (USD per 1M tokens), as carried by models.dev on 2026-09-23. const PRO = { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }; @@ -90,18 +92,85 @@ describe("independent reasoning capabilities", () => { const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }; const cached = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, discovered); expect(cached.supportsThinkingToggle).toBe(false); + expect(applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, cached)).toEqual(cached); const restored = applyProviderConfigHints("synthetic", base, cached); expect(restored).toMatchObject(discovered); const configured = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: true } } }, { provider: "synthetic", id }); expect(applyProviderConfigHints("synthetic", base, configured).supportsThinkingToggle).toBeUndefined(); }); + test("dated aliases inherit discovery evidence, not the dated row's config projection", () => { + const datedId = `${id}-20261001`; + const discovered = { provider: "synthetic", id: datedId, supportsReasoning: true, + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true, + reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + const provider = { ...base, modelCapabilities: { [datedId]: { supportsReasoning: false } } }; + const dated = applyProviderConfigHints("synthetic", provider, discovered); + expect(dated.reasoningEfforts).toEqual([]); + const merged = mergeConfiguredModelsIntoLiveCatalog({ name: "synthetic", provider, models: [dated], + configured: [{ provider: "synthetic", id }] }); + const alias = merged.models.find(row => row.id === id)!; + expect(alias).toMatchObject({ ...discovered, id }); + expect(alias).toEqual(applyProviderConfigHints("synthetic", provider, { ...discovered, id })); + expect(applyProviderConfigHints("synthetic", provider, { ...alias })).toEqual(alias); + expect(dated.supportsReasoning).toBe(false); + }); + test("routed spread clones restore discovery and withdraw removed config-only hints", () => { + const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + const disabled = applyProviderConfigHints("synthetic", { ...base, noReasoningModels: [id] }, discovered); + const routed = { ...disabled, providerAlias: "routed", contextWindow: 100_000 }; + const restoredDiscovery = applyProviderConfigHints("synthetic", base, routed); + expect(restoredDiscovery).toMatchObject(discovered); + expect(applyProviderConfigHints("synthetic", base, { ...restoredDiscovery })).toEqual(restoredDiscovery); + const configured = applyProviderConfigHints("synthetic", { ...base, + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, + modelReasoningEfforts: { [id]: ["high"] }, modelDefaultReasoningEfforts: { [id]: "high" }, + }, { provider: "synthetic", id }); + const restored = applyProviderConfigHints("synthetic", base, { ...configured, providerAlias: "routed" }); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "reasoningEfforts", "defaultReasoningEffort"] as const) { + expect(restored[field]).toBeUndefined(); + } + expect(JSON.parse(JSON.stringify(routed))).toEqual({ ...JSON.parse(JSON.stringify(disabled)), + providerAlias: "routed", contextWindow: 100_000 }); + const [source] = Object.getOwnPropertySymbols(routed); + expect(source).toBeDefined(); + expect(Object.getOwnPropertyDescriptor(routed, source!)?.enumerable).toBe(true); + expect(Object.getOwnPropertySymbols(JSON.parse(JSON.stringify(routed)))).toEqual([]); + expect(JSON.stringify(routed)).not.toContain("reasoningHintSource"); + }); + test("cache reads and spread clones retain discovery through repeated config changes", () => { + const provider = "reasoning-provenance-test"; + const discovered = { provider, id, supportsReasoning: true, supportsThinkingToggle: true, + supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; + try { + const disabled = applyProviderConfigHints(provider, { ...base, noReasoningModels: [id] }, discovered); + setCached(provider, [{ ...disabled }]); + const [cached] = getFreshCached(provider, 60_000)!; + expect(cached).toEqual(disabled); + const restored = applyProviderConfigHints(provider, base, { ...cached! }); + expect(restored).toEqual(applyProviderConfigHints(provider, base, discovered)); + expect(applyProviderConfigHints(provider, base, restored)).toEqual(restored); + expect(cached!.supportsReasoning).toBe(false); + expect(discovered.reasoningEfforts).toEqual(["low", "high"]); + } finally { + clearModelCache(provider); + } + }); + test("unknown reasoning stays absent through a public discovery roundtrip", () => { + const fields = modelCapabilityFields({}); + expect(fields.capabilities).not.toHaveProperty("supports_reasoning"); + const hints = catalogHintsFromModelsApiItem("chain", { id, ...JSON.parse(JSON.stringify(fields)) }); + for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled", "reasoningEfforts"] as const) { + expect(hints).not.toHaveProperty(field); + } + }); test("chained public model flags preserve false and unknown independently of efforts", () => { const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, default_thinking_enabled: false, reasoning_effort: ["low", "high"], } }); expect(hints).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false, defaultThinkingEnabled: false }); - expect(hints.reasoningEfforts).toBeUndefined(); + expect(hints.reasoningEfforts).toEqual([]); const chained = applyProviderConfigHints("chain", { ...base, reasoningEfforts: ["low", "medium", "high"] }, { provider: "chain", id, ...hints }); expect(chained.supportsReasoningEffort).toBe(false); expect(chained.reasoningEfforts).toEqual([]); @@ -112,6 +181,12 @@ describe("independent reasoning capabilities", () => { expect(explicitFalse).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false }); expect(explicitFalse.supportsReasoningEffort).toBeUndefined(); }); + test.each(["supports_reasoning", "supports_reasoning_effort"])("chained %s=false clears contradictory efforts", field => { + const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { + [field]: false, reasoning_effort: ["low", "high"], + } }); + expect(hints.reasoningEfforts).toEqual([]); + }); }); describe("MiMo V2.6 thinking switch", () => { diff --git a/tests/responses/openai-responses-summary-none.test.ts b/tests/responses/openai-responses-summary-none.test.ts index f052c8fe07a..e5111537787 100644 --- a/tests/responses/openai-responses-summary-none.test.ts +++ b/tests/responses/openai-responses-summary-none.test.ts @@ -1,15 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; import { parseRequest } from "../../src/responses/parser"; +import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; -const targets = [ +const targets: OcxProviderConfig[] = [ { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" as const, apiKey: "sk-test" }, ]; -function outboundReasoning(target: (typeof targets)[number], body: Record): Record | undefined { +function outboundReasoning(target: OcxProviderConfig, body: Record): Record | undefined { const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(target)); const request = adapter.buildRequest(parseRequest(body), { headers: new Headers() }); return (JSON.parse(request.body) as { reasoning?: Record }).reasoning; @@ -39,4 +41,38 @@ describe("Responses summary:none wire marker", () => { .toEqual({ effort: "medium", summary }); } }); + + test("strips only the internal thinking toggle for OpenAI-operated Responses wires", () => { + const bodies = [ + { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false, effort: "high", summary: "detailed" } }, + chatCompletionsToResponsesBody({ + model: "gpt-6-astra", + messages: [{ role: "user", content: "hi" }], + reasoning: { enabled: true, effort: "low" }, + }), + anthropicToResponsesBody({ + model: "gpt-6-astra", + max_tokens: 10, + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled" }, + output_config: { effort: "medium" }, + }), + ]; + + for (const body of bodies) { + const reasoning = body.reasoning as Record; + for (const target of targets) { + expect(outboundReasoning(target, body)).toEqual( + Object.fromEntries(Object.entries(reasoning).filter(([key]) => key !== "enabled")), + ); + } + } + + const routed = { adapter: "openai-responses", baseUrl: "https://compat.example.test/v1", authMode: "key" as const }; + for (const body of bodies) expect(outboundReasoning(routed, body)).toEqual(body.reasoning); + const toggleOnly = { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false } }; + for (const target of targets) expect(outboundReasoning(target, toggleOnly)).toBeUndefined(); + expect(toggleOnly.reasoning).toEqual({ enabled: false }); + expect(outboundReasoning(routed, toggleOnly)).toEqual({ enabled: false }); + }); }); From de77a2722f9dee6842a91bf1e51e507ca49338e2 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 23 Oct 2026 10:30:04 +0800 Subject: [PATCH 3/9] fix(reasoning): preserve custom overrides and native thinking budgets --- src/adapters/anthropic.ts | 5 +- src/codex/catalog/model-hints.ts | 29 +++---- src/codex/catalog/model-visibility.ts | 5 +- src/codex/catalog/routed-gather.ts | 35 ++++---- structure/providers-and-adapters.md | 8 +- .../anthropic/anthropic-reasoning.test.ts | 29 +++++++ tests/providers/mimo-v26-catalog.test.ts | 79 ++++++++++++++++--- 7 files changed, 135 insertions(+), 55 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 5bf8f99d4b3..0faf4b0574c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1091,11 +1091,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const thinkingEnabled = thinkingToggleWire ? resolveThinkingEnabled(provider, parsed.modelId, parsed.options) : undefined; - const nativeThinkingContract = usesAdaptiveThinking(parsed.modelId); + const nativeThinkingContract = usesAdaptiveThinking(parsed.modelId) + || family?.family === "haiku" || family?.family === "sonnet" || family?.family === "opus"; const supportsReasoningEffort = capabilities.supportsReasoningEffort === true; const requestedReasoning = parsed.options.reasoning ?? defaultReasoningEffort(provider, parsed.modelId); // Native Claude wire constraints outrank a generic toggle declaration. Decode the boolean - // into native effort intent, then let the family contract choose adaptive/disabled/between_tools. + // into native effort intent, then let the family contract choose budget/adaptive/off shapes. const effectiveReasoning = nativeThinkingContract && thinkingEnabled !== undefined ? thinkingEnabled ? (requestedReasoning && requestedReasoning !== "none" ? requestedReasoning : "high") : "none" : requestedReasoning; diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index ed52f8980a9..803b7056d1e 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -261,14 +261,20 @@ export function withVisionSidecarInput( ): CatalogModel { if (!enabled || !provider || !isModelVisionSidecarConsumer(provider, model.id)) return model; const modalities = model.inputModalities ?? ["text"]; - return modalities.includes("image") ? model : { ...model, inputModalities: [...modalities, "image"] }; + return modalities.includes("image") ? model + : copyReasoningHintSource(model, { ...model, inputModalities: [...modalities, "image"] }); } -/** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ -// Enumerable symbol provenance survives dated/routed spread clones without entering wire JSON. -const reasoningHintSource = Symbol("reasoningHintSource"); -type ReasoningHintModel = CatalogModel & { [reasoningHintSource]?: Partial }; +const reasoningHintSources = new WeakMap>(); + +/** Forward discovery provenance only across transparent clones, never custom reasoning overrides. */ +export function copyReasoningHintSource(source: CatalogModel, clone: CatalogModel): CatalogModel { + const reasoningSource = reasoningHintSources.get(source); + if (reasoningSource) reasoningHintSources.set(clone, reasoningSource); + return clone; +} +/** Reapply configured limits and captured wire capabilities without trusting stale discovery hints. */ export function applyProviderConfigHints( name: string, prov: OcxProviderConfig, @@ -307,7 +313,7 @@ export function applyProviderConfigHints( const resolvedModalities = staticPolicy.model.inputModalities ?? model.inputModalities ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; - const reasoningSource = (model as ReasoningHintModel)[reasoningHintSource] ?? model; + const reasoningSource = reasoningHintSources.get(model) ?? model; const reasoningCapabilities: Partial = {}; for (const field of REASONING_CAPABILITY_FIELDS) { const value = modelInList(prov.noReasoningModels, model.id) ? staticPolicy.model[field] @@ -413,7 +419,7 @@ export function applyProviderConfigHints( : withCap; const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] .filter((value): value is number => typeof value === "number" && value > 0); - const result: ReasoningHintModel = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { + const result = contextWindow === undefined || softCandidates.length === 0 ? withHardBounds : { ...withHardBounds, autoCompactTokenLimit: clampAutoCompactTokenLimit( contextWindow, @@ -421,14 +427,9 @@ export function applyProviderConfigHints( Math.min(...softCandidates), ), }; - const reasoningFields = [ + reasoningHintSources.set(result, Object.fromEntries([ ...REASONING_CAPABILITY_FIELDS, "reasoningEfforts", "defaultReasoningEffort", - ] as const; - if (reasoningFields.some(field => result[field] !== reasoningSource[field])) { - result[reasoningHintSource] = Object.fromEntries(reasoningFields.map(field => [field, reasoningSource[field]])); - } else { - delete result[reasoningHintSource]; - } + ].map(field => [field, reasoningSource[field as keyof CatalogModel]]))); return result; } diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index c95b8f2d550..51059fca1cb 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -102,7 +102,7 @@ import type { CatalogSourceEvidence, CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; -import { CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, applyProviderConfigHints } from "./model-hints"; +import { CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, applyProviderConfigHints, copyReasoningHintSource } from "./model-hints"; const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/; const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/; @@ -250,7 +250,8 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); + const alias = copyReasoningHintSource(dated, { ...dated, id: candidate.id }); + out.push(applyProviderConfigHints(name, prov, alias, contextCap, metadataModelIdCaseFold, undefined, metadataConfigDir, includeVisionSidecar)); present.add(candidate.id); continue; } diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 13bf24a7064..c822d6cd4b0 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -102,7 +102,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, copyReasoningHintSource, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; import { resolveComboCatalogMember } from "./combo-member"; import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; @@ -818,8 +818,8 @@ async function gatherRoutedModelsUncached( ? config.combos?.[model.id]?.targets ?? [] : [{ provider: model.provider }]; const inactive = quotaInactiveReason(config, targets); - const named = displayName && !model.displayName ? { ...model, displayName } : model; - return inactive ? { ...named, quotaInactiveReason: inactive } : named; + const named = displayName && !model.displayName ? copyReasoningHintSource(model, { ...model, displayName }) : model; + return inactive ? copyReasoningHintSource(named, { ...named, quotaInactiveReason: inactive }) : named; }), comboOmissions: localOmissions, providerAuthOutcomes: localProviderAuthOutcomes, @@ -966,7 +966,7 @@ export function augmentRoutedModelsWithMetadata( : undefined; const liveWindow = typeof existing.contextWindow === "number" && existing.contextWindow > 0; if (liveWindow || publishedWindow === undefined) continue; - const seeded: CatalogModel = { ...existing, contextWindow: publishedWindow }; + const seeded = copyReasoningHintSource(existing, { ...existing, contextWindow: publishedWindow }); out[existingIndex] = providers?.[provider] ? applyProviderConfigHints( provider, @@ -990,21 +990,18 @@ export function augmentRoutedModelsWithMetadata( ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}), }; - out.push({ - ...model, - ...(providers?.[provider] - ? applyProviderConfigHints( - provider, - providers[provider], - model, - contextCap, - metadataModelIdCaseFoldByProvider?.get(provider), - undefined, - metadataConfigDirByProvider?.get(provider), - includeVisionSidecar, - ) - : {}), - }); + out.push(providers?.[provider] + ? applyProviderConfigHints( + provider, + providers[provider], + model, + contextCap, + metadataModelIdCaseFoldByProvider?.get(provider), + undefined, + metadataConfigDirByProvider?.get(provider), + includeVisionSidecar, + ) + : model); } } return out; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ac424792598..f110a04cfc5 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -595,10 +595,4 @@ Presence of both `detail` and `error` across the original root and its own non-a For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted dedicated `code` fields from root and response before display truncation; diagnostic `type` is never promoted, including when code is absent or malformed. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON. Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback. -Token Plan dated-model policy (2026-10-20): both curated presets include exact -`deepseek-v4.1-flash-1017` metadata without changing live discovery or Responses pins. -The registry may seed `noStructuredOutputModels` at route resolution, unioned with -operator opt-outs, just as it seeds `noJsonSchemaModels`. The full opt-out wins on -both translated and native Chat paths. For 1017 this deliberately omits JSON requests: -strict schemas are rejected and a `json_object` fallback has not been verified. -The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window and effort overrides remain untouched. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. +Token Plan dated-model policy (2026-10-20): both curated presets include exact `deepseek-v4.1-flash-1017` metadata without changing live discovery or Responses pins. The registry may seed `noStructuredOutputModels` at route resolution, unioned with operator opt-outs, just as it seeds `noJsonSchemaModels`. The full opt-out wins on both translated and native Chat paths. For 1017 this deliberately omits JSON requests: strict schemas are rejected and a `json_object` fallback has not been verified. The bare model's independently verified downgrade remains separate. Token Plan startup refreshes only exact previous registry rosters for both presets, adding dated models and removing retired International IDs. International rows refresh only on the Token Plan host or the registry default; pay-as-you-go and Custom destinations keep their lists. Edited or reordered lists, saved model defaults and existing per-model window and effort overrides remain untouched. Destination-based enrichment of custom-named rows fills per-model effort defaults, effort maps and structured-output restrictions from the matching registry destination or alias, and operator values win. Both Token Plan presets keep the hosts their official quick starts document; International custom-name enrichment remains excluded by its existing override guard. diff --git a/tests/adapters/anthropic/anthropic-reasoning.test.ts b/tests/adapters/anthropic/anthropic-reasoning.test.ts index 6de9cbb7b69..23f5724ef63 100644 --- a/tests/adapters/anthropic/anthropic-reasoning.test.ts +++ b/tests/adapters/anthropic/anthropic-reasoning.test.ts @@ -137,6 +137,35 @@ describe("anthropic extended-thinking gate", () => { expect(explicitOn.thinking).toEqual(on.thinking); }); + test.each([ + ["claude-haiku-4-5", true, undefined], + ["claude-sonnet-4-6", true, undefined], + ["claude-opus-4-6", true, undefined], + ["claude-haiku-4-5", false, "high"], + ["claude-sonnet-4-6", false, "high"], + ["claude-opus-4-6", false, "high"], + ] as const)("%s preserves native budget thinking (effort support=%s, effort=%s)", async (modelId, supportsReasoningEffort, reasoning) => { + const target = { + ...provider, + thinkingToggleModels: [modelId], + modelCapabilities: { [modelId]: { + supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort, + } }, + }; + const options = { temperature: 0.3, topP: 0.9, ...(supportsReasoningEffort ? { thinkingEnabled: true } : {}) }; + const on = await bodyOf(parsed(reasoning, options, modelId), target); + expect(on.thinking).toEqual({ type: "enabled", budget_tokens: 16384 }); + expect(on.max_tokens).toBe(24576); + expect(on.output_config).toBeUndefined(); + expect(on.temperature).toBeUndefined(); + expect(on.top_p).toBeUndefined(); + // Explicit off must still win without allocating a thinking budget or increasing the limit. + const off = await bodyOf(parsed("high", { thinkingEnabled: false, maxOutputTokens: 64 }, modelId), target); + expect(off.thinking).toBeUndefined(); + expect(off.output_config).toBeUndefined(); + expect(off.max_tokens).toBe(64); + }); + test("both unsupported axes suppress native thinking and token headroom", async () => { const modelId = "claude-sonnet-5-5"; const b = await bodyOf(parsed("high", { thinkingEnabled: true, maxOutputTokens: 64 }, modelId), { diff --git a/tests/providers/mimo-v26-catalog.test.ts b/tests/providers/mimo-v26-catalog.test.ts index a827aba7f48..9e200d8335b 100644 --- a/tests/providers/mimo-v26-catalog.test.ts +++ b/tests/providers/mimo-v26-catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { applyProviderConfigHints } from "../../src/codex/catalog"; +import { applyProviderConfigHints, gatherRoutedModels } from "../../src/codex/catalog"; import { generatedModelMetadata } from "../../src/codex/catalog/parsing"; import { getModelMetadata, resolveMetadataProvider } from "../../src/generated/model-metadata"; import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; @@ -17,7 +17,8 @@ import { modelCapabilityFields } from "../../src/server/models-capabilities"; import { deriveEntry } from "../../src/codex/catalog/derive-entry"; import { MIMO_THINKING_MODELS } from "../../src/providers/mimo-thinking"; import { resolveReasoningCapabilities } from "../../src/providers/reasoning-capabilities"; -import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/model-hints"; +import { catalogHintsFromModelsApiItem, copyReasoningHintSource, withVisionSidecarInput } from "../../src/codex/catalog/model-hints"; +import { augmentRoutedModelsWithMetadata } from "../../src/codex/catalog/routed-gather"; import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/model-visibility"; import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; @@ -111,30 +112,29 @@ describe("independent reasoning capabilities", () => { const alias = merged.models.find(row => row.id === id)!; expect(alias).toMatchObject({ ...discovered, id }); expect(alias).toEqual(applyProviderConfigHints("synthetic", provider, { ...discovered, id })); - expect(applyProviderConfigHints("synthetic", provider, { ...alias })).toEqual(alias); + expect(applyProviderConfigHints("synthetic", provider, copyReasoningHintSource(alias, { ...alias }))).toEqual(alias); expect(dated.supportsReasoning).toBe(false); }); test("routed spread clones restore discovery and withdraw removed config-only hints", () => { const discovered = { provider: "synthetic", id, supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; const disabled = applyProviderConfigHints("synthetic", { ...base, noReasoningModels: [id] }, discovered); - const routed = { ...disabled, providerAlias: "routed", contextWindow: 100_000 }; + const routed = copyReasoningHintSource(disabled, { ...disabled, providerAlias: "routed", contextWindow: 100_000 }); const restoredDiscovery = applyProviderConfigHints("synthetic", base, routed); expect(restoredDiscovery).toMatchObject(discovered); - expect(applyProviderConfigHints("synthetic", base, { ...restoredDiscovery })).toEqual(restoredDiscovery); + expect(applyProviderConfigHints("synthetic", base, copyReasoningHintSource(restoredDiscovery, { ...restoredDiscovery }))).toEqual(restoredDiscovery); const configured = applyProviderConfigHints("synthetic", { ...base, modelCapabilities: { [id]: { supportsThinkingToggle: true } }, modelReasoningEfforts: { [id]: ["high"] }, modelDefaultReasoningEfforts: { [id]: "high" }, }, { provider: "synthetic", id }); - const restored = applyProviderConfigHints("synthetic", base, { ...configured, providerAlias: "routed" }); + const restored = applyProviderConfigHints("synthetic", base, copyReasoningHintSource(configured, { ...configured, providerAlias: "routed" })); for (const field of ["supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "reasoningEfforts", "defaultReasoningEffort"] as const) { expect(restored[field]).toBeUndefined(); } expect(JSON.parse(JSON.stringify(routed))).toEqual({ ...JSON.parse(JSON.stringify(disabled)), providerAlias: "routed", contextWindow: 100_000 }); - const [source] = Object.getOwnPropertySymbols(routed); - expect(source).toBeDefined(); - expect(Object.getOwnPropertyDescriptor(routed, source!)?.enumerable).toBe(true); + expect(Object.getOwnPropertySymbols(routed)).toEqual([]); + expect(routed).toEqual(JSON.parse(JSON.stringify(routed))); expect(Object.getOwnPropertySymbols(JSON.parse(JSON.stringify(routed)))).toEqual([]); expect(JSON.stringify(routed)).not.toContain("reasoningHintSource"); }); @@ -144,10 +144,10 @@ describe("independent reasoning capabilities", () => { supportsReasoningEffort: true, reasoningEfforts: ["low", "high"], defaultReasoningEffort: "low" }; try { const disabled = applyProviderConfigHints(provider, { ...base, noReasoningModels: [id] }, discovered); - setCached(provider, [{ ...disabled }]); + setCached(provider, [disabled]); const [cached] = getFreshCached(provider, 60_000)!; expect(cached).toEqual(disabled); - const restored = applyProviderConfigHints(provider, base, { ...cached! }); + const restored = applyProviderConfigHints(provider, base, copyReasoningHintSource(cached!, { ...cached! })); expect(restored).toEqual(applyProviderConfigHints(provider, base, discovered)); expect(applyProviderConfigHints(provider, base, restored)).toEqual(restored); expect(cached!.supportsReasoning).toBe(false); @@ -156,6 +156,30 @@ describe("independent reasoning capabilities", () => { clearModelCache(provider); } }); + test("metadata window fills retain discovery through repeated config changes", () => { + const provider = "opencode-go"; + const modelId = "glm-5.2"; + const discovered = { provider, id: modelId, supportsThinkingToggle: true, reasoningEfforts: ["low", "high"] }; + const config = { ...base, modelCapabilities: { [modelId]: { supportsThinkingToggle: false } } }; + const projected = applyProviderConfigHints(provider, config, discovered); + expect(projected.contextWindow).toBeUndefined(); + const augmented = augmentRoutedModelsWithMetadata([projected], [provider], { [provider]: config }); + const seeded = augmented.find(row => row.provider === provider && row.id === modelId)!; + expect(seeded.contextWindow).toBeGreaterThan(0); + expect(seeded.supportsThinkingToggle).toBe(false); + expect(applyProviderConfigHints(provider, base, seeded)).toMatchObject(discovered); + expect(applyProviderConfigHints(provider, config, seeded)).toEqual(seeded); + }); + test("sidecar attachment clones preserve discovery without adding object metadata", () => { + const discovered = { provider: "synthetic", id, supportsThinkingToggle: true }; + const projected = applyProviderConfigHints("synthetic", { ...base, + modelCapabilities: { [id]: { supportsThinkingToggle: false } } }, discovered); + const attached = withVisionSidecarInput(projected, { ...base, noVisionModels: [id] }); + expect(attached.inputModalities).toEqual(["text", "image"]); + expect(Object.getOwnPropertySymbols(attached)).toEqual([]); + expect(attached).toEqual(JSON.parse(JSON.stringify(attached))); + expect(applyProviderConfigHints("synthetic", base, attached).supportsThinkingToggle).toBe(true); + }); test("unknown reasoning stays absent through a public discovery roundtrip", () => { const fields = modelCapabilityFields({}); expect(fields.capabilities).not.toHaveProperty("supports_reasoning"); @@ -164,6 +188,39 @@ describe("independent reasoning capabilities", () => { expect(hints).not.toHaveProperty(field); } }); + test.each([ + { efforts: [], cap: undefined, sidecar: false }, + { efforts: ["high"], cap: undefined, sidecar: false }, + { efforts: [], cap: 350_000, sidecar: true }, + { efforts: ["high"], cap: 350_000, sidecar: true }, + ])("custom reasoning overrides remain authoritative for combo members: %j", async ({ efforts, cap, sidecar }) => { + const provider = "custom-reasoning-provenance"; + const config: OcxConfig = { + port: 10100, defaultProvider: provider, + providers: { [provider]: { ...base, authMode: "key", apiKey: "test-key", liveModels: true, models: [id], + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, ...(sidecar ? { noVisionModels: [id] } : {}), + fetch: (() => { throw new Error("cached discovery must not fetch"); }) as typeof fetch, + } }, + ...(cap !== undefined ? { providerContextCaps: { [provider]: cap } } : {}), + customModels: [{ id: "custom", provider, modelId: id, reasoningEfforts: efforts, + addedAt: "2026-10-23T00:00:00.000Z" }], + combos: { solo: { targets: [{ provider, model: id }] } }, + }; + try { + setCached(provider, [{ provider, id, reasoningEfforts: ["low", "high"] }]); + const discovered = await gatherRoutedModels({ ...config, customModels: [], combos: undefined }); + expect(discovered.find(row => row.provider === provider && row.id === id)?.reasoningEfforts).toEqual(["low", "high"]); + const models = await gatherRoutedModels(config); + const custom = models.find(row => row.provider === provider && row.id === id); + const combo = models.find(row => row.provider === "combo" && row.id === "solo"); + expect(custom).toBeDefined(); + expect(combo).toBeDefined(); + expect(custom!.reasoningEfforts).toEqual(efforts); + expect(combo!.reasoningEfforts).toEqual(efforts); + } finally { + clearModelCache(provider); + } + }); test("chained public model flags preserve false and unknown independently of efforts", () => { const hints = catalogHintsFromModelsApiItem("chain", { id, capabilities: { supports_reasoning: true, supports_thinking_toggle: true, supports_reasoning_effort: false, From f45786287ad3b34fe240016a460ad157c783c81f Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 23 Oct 2026 10:33:21 +0800 Subject: [PATCH 4/9] fix(catalog): preserve retained model reasoning provenance --- src/codex/catalog/provider-models.ts | 9 ++++---- tests/providers/mimo-v26-catalog.test.ts | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index a2d680ce29b..2d06869c3cc 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -208,11 +208,10 @@ export async function fetchProviderModelsWithAuth( ...(prov.models ?? []), ...(prov.retainModels ?? []), ])]; - const configured: CatalogModel[] = configuredIds.map(id => ({ - id, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias, metadataConfigDir, false), - })); + const configured: CatalogModel[] = configuredIds.map(id => applyProviderConfigHints( + name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, + captured.effectiveAlias, metadataConfigDir, false, + )); const withConfiguredRetention = ( models: CatalogModel[], options?: { retainComboTargets?: boolean; warnDrops?: boolean }, diff --git a/tests/providers/mimo-v26-catalog.test.ts b/tests/providers/mimo-v26-catalog.test.ts index 9e200d8335b..6da113987bc 100644 --- a/tests/providers/mimo-v26-catalog.test.ts +++ b/tests/providers/mimo-v26-catalog.test.ts @@ -21,11 +21,38 @@ import { catalogHintsFromModelsApiItem, copyReasoningHintSource, withVisionSidec import { augmentRoutedModelsWithMetadata } from "../../src/codex/catalog/routed-gather"; import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/model-visibility"; import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; +import { fetchProviderModels } from "../../src/codex/catalog/provider-models"; // Xiaomi's published V2.6 prices (USD per 1M tokens), as carried by models.dev on 2026-09-23. const PRO = { input: 0.435, output: 0.87, cacheRead: 0.0036, cacheWrite: 0 }; describe("independent reasoning capabilities", () => { + test("retained warm-cache rows withdraw removed configuration-only reasoning axes", async () => { + const name = "retained-reasoning-provenance"; + const modelId = "retained-model"; + let requests = 0; + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://synthetic.example.test/v1", authMode: "key", + apiKey: "test-key", models: [modelId], retainModels: [modelId], + fetch: (async () => { requests++; return Response.json({ data: [] }); }) as typeof fetch, + }; + try { + const first = await fetchProviderModels(name, { ...provider, modelCapabilities: { + [modelId]: { supportsThinkingToggle: true, supportsReasoningEffort: false }, + } }, 60_000); + expect(first.find(row => row.id === modelId)).toMatchObject({ + supportsThinkingToggle: true, supportsReasoningEffort: false, + }); + const warm = await fetchProviderModels(name, provider, 60_000); + const row = warm.find(row => row.id === modelId)!; + expect(row).toBeDefined(); + expect(row.supportsThinkingToggle).toBeUndefined(); + expect(row.supportsReasoningEffort).toBeUndefined(); + expect(requests).toBe(1); + } finally { + clearModelCache(name); + } + }); const base = { adapter: "openai-chat", baseUrl: "https://synthetic.example.test/v1" }; const id = "synthetic-model"; test.each([ From 97e8db651282e4c5f92b6fde8b6c646bd4a6e567 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 23 Oct 2026 12:17:25 +0800 Subject: [PATCH 5/9] fix(providers): preserve case-varied capability overrides --- .../src/content/docs/guides/providers.md | 2 +- src/providers/reasoning-capabilities.ts | 20 +++++++++++++++---- src/providers/resolved-model-policy.ts | 13 ++++++------ structure/config.md | 2 +- structure/providers-and-adapters.md | 2 +- tests/providers/resolved-model-policy.test.ts | 18 ++++++++++++++++- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2eb97c377fb..db043817627 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -950,7 +950,7 @@ preset, `xiaomi-mimo`, and a token-plan preset, `mimo`. All three default to MiM or `mimo-v2.6-flash` on `xiaomi-mimo`). Xiaomi retires `mimo-v2.5` and `mimo-v2.5-pro` on 2026-10-21 with no redirect, so switch a saved V2.5 default before then; opencodex does not rewrite it for you. -**Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models. +**Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map; reasoning axes merge over registry defaults using case-insensitive model IDs, with operator values taking precedence. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models. Public MiMo V2.6 is one instance: its three models support reasoning and an on/off switch, but no adjustable intensity; thinking defaults to enabled. The wire adapter sends `thinking.type: enabled/disabled`. OCX's Responses ingress also accepts `reasoning.enabled` independently of `reasoning.effort`. For clients that only offer effort enums, Codex catalogs and OpenCode/Kilo exports represent this switch as `none/high`; those values are client compatibility aliases, not MiMo strength levels. Client-owned labels may still say “High.” Fixed reasoning models without a switch get no fabricated control. Xiaomi's [deep-thinking guide](https://mimo.mi.com/docs/en-US/quick-start/usage-guide/other/deep-thinking) describes the public MiMo switch; free, token-plan, third-party routes and older MiMo models keep their existing declarations. diff --git a/src/providers/reasoning-capabilities.ts b/src/providers/reasoning-capabilities.ts index e9e2b0b0e94..54e5cc18d7f 100644 --- a/src/providers/reasoning-capabilities.ts +++ b/src/providers/reasoning-capabilities.ts @@ -7,23 +7,35 @@ export const REASONING_CAPABILITY_FIELDS = [ "supportsReasoning", "supportsThinkingToggle", "supportsReasoningEffort", "defaultThinkingEnabled", ] as const; -/** Exact model declarations merge per axis; unrelated operator axes retain registry defaults. */ +/** Case-equal model declarations merge per axis; unrelated axes retain registry defaults. */ export function mergeReasoningModelCapabilities( registry: Record | undefined, explicit: Record | undefined, ): Record | undefined { if (!registry && !explicit) return undefined; - const merged: Record = structuredClone(registry ?? {}); - for (const [id, row] of Object.entries(explicit ?? {})) merged[id] = { ...merged[id], ...structuredClone(row) }; + const merged: Record = {}; + const keys = new Map(); + for (const rows of [registry, explicit]) { + for (const [id, row] of Object.entries(rows ?? {})) { + const key = keys.get(id.toLowerCase()) ?? id; + keys.set(id.toLowerCase(), key); + merged[key] = { ...merged[key], ...structuredClone(row) }; + } + } return merged; } +/** Match a declared model case-insensitively, without inheriting suffix or family aliases. */ +export function declaredModelCapabilities(rows: Readonly> | undefined, modelId: string): ModelCapabilities | undefined { + return rows?.[modelId] ?? Object.entries(rows ?? {}).find(([id]) => id.toLowerCase() === modelId.toLowerCase())?.[1]; +} + /** Captured-policy variant: never reads the live registry after admission. */ export function reasoningCapabilitiesFromConfig(provider: Readonly, modelId: string): ReasoningCapabilities { if (modelInList(provider.noReasoningModels, modelId)) { return { supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false }; } - const declared = provider.modelCapabilities?.[modelId]; + const declared = declaredModelCapabilities(provider.modelCapabilities, modelId); const result: ReasoningCapabilities = {}; for (const field of REASONING_CAPABILITY_FIELDS) { if (typeof declared?.[field] === "boolean") result[field] = declared[field]; diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 6616ef63a92..52ba1b783eb 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -1,5 +1,5 @@ import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; -import { mergeReasoningModelCapabilities, reasoningCapabilitiesFromConfig, REASONING_CAPABILITY_FIELDS } from "./reasoning-capabilities"; +import { declaredModelCapabilities, mergeReasoningModelCapabilities, reasoningCapabilitiesFromConfig, REASONING_CAPABILITY_FIELDS } from "./reasoning-capabilities"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { resolveProviderAuthTransport } from "./fastwire"; @@ -332,10 +332,9 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode ...providerPolicy, adapter: providerPolicy.adapter ?? provider.adapter, baseUrl: providerPolicy.baseUrl ?? provider.baseUrl, - ...(input.modelCapabilities ? { modelCapabilities: { - ...providerPolicy.modelCapabilities, - [input.modelId]: { ...providerPolicy.modelCapabilities?.[input.modelId], ...input.modelCapabilities }, - } } : {}), + ...(input.modelCapabilities ? { modelCapabilities: mergeReasoningModelCapabilities( + providerPolicy.modelCapabilities, { [input.modelId]: input.modelCapabilities }, + ) } : {}), }, input.modelId); const modelReasoningEfforts = reasoningCapabilities.supportsReasoningEffort === false ? [] : modelValue(providerPolicy.modelReasoningEfforts) ?? providerPolicy.reasoningEfforts; @@ -375,8 +374,8 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode }; for (const field of REASONING_CAPABILITY_FIELDS) { modelProvenance[field] = reasoningCapabilities[field] === undefined ? "unknown" - : input.modelCapabilities?.[field] !== undefined || provider.modelCapabilities?.[input.modelId]?.[field] !== undefined - ? "operator-capability" : entry?.modelCapabilities?.[input.modelId]?.[field] !== undefined ? "registry" : "operator"; + : input.modelCapabilities?.[field] !== undefined || declaredModelCapabilities(provider.modelCapabilities, input.modelId)?.[field] !== undefined + ? "operator-capability" : declaredModelCapabilities(entry?.modelCapabilities, input.modelId)?.[field] !== undefined ? "registry" : "operator"; } modelProvenance.contextWindow = modelOrProviderSource(provider.modelContextWindows, entry?.modelContextWindows, provider.contextWindow, entry?.contextWindow); if (modelProvenance.contextWindow === "unknown" && familyContextWindow !== undefined) { diff --git a/structure/config.md b/structure/config.md index b0221736b2d..a94dbdf371d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -559,7 +559,7 @@ The optional `autoReviewOverride` block stores a global approval reviewer select Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the matching registry row; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing, deepseekImageFiles and reasoning axes. The reasoning axes are strict optional booleans: `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled`. Each merges independently over the registry row matched by case-insensitive model ID; an unrelated operator axis does not erase registry reasoning facts. Omitted axes may inherit registry or supported legacy evidence; absent evidence stays unknown. Explicit false is retained, and `supportsReasoning: false` suppresses both controls and the thinking default. These facts are separate from the `thinkingToggleModels` wire declaration and from client-only effort aliases; see [reasoning capability resolution](providers-and-adapters.md). `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid sibling axes when a reasoning boolean is malformed; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 73b2658b11a..8d79d8112c3 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath exact operator declarations; absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index a9bdf4c24d6..f9672b96553 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -888,10 +888,26 @@ describe("independent reasoning policy", () => { expect(policy.provenance.model.supportsReasoningEffort).toBe("registry"); expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); }); + test.each(["synthetic-reasoner", "SYNTHETIC-REASONER"])("case-varied capability overrides remain authoritative for %s", modelId => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id.toUpperCase()]: { supportsThinkingToggle: false, defaultThinkingEnabled: false } } }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId, provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: false }); + expect(policy.provenance.model.supportsThinkingToggle).toBe("operator-capability"); + expect(policy.provenance.model.supportsReasoningEffort).toBe("registry"); + expect(Object.keys(policy.provider.modelCapabilities ?? {})).toHaveLength(1); + const overlay = resolveModelPolicy({ providerName: "synthetic", modelId, provider, registryEntry: entry, + transportMatchedRegistry: true, modelCapabilities: { defaultThinkingEnabled: true } }); + expect(overlay.model).toMatchObject({ supportsReasoning: true, supportsThinkingToggle: false, + supportsReasoningEffort: false, defaultThinkingEnabled: true }); + expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); + expect(provider.modelCapabilities?.[id.toUpperCase()]).toEqual({ supportsThinkingToggle: false, defaultThinkingEnabled: false }); + }); test("noReasoningModels remains authoritative over every independent declaration", () => { const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, noReasoningModels: [id] }; const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry: entry, transportMatchedRegistry: true }); expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); }); -}); \ No newline at end of file +}); From de92d9ccfdbae2b5de02ebfcb49778e9b8abdba7 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 23 Oct 2026 12:51:20 +0800 Subject: [PATCH 6/9] fix(providers): attribute reasoning exclusions correctly --- src/providers/resolved-model-policy.ts | 4 +++- tests/providers/resolved-model-policy.test.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 52ba1b783eb..08c1f4a1099 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -1,6 +1,6 @@ import type { ModelCapabilities, OcxProviderConfig, ReasoningCapabilities } from "../types"; import { declaredModelCapabilities, mergeReasoningModelCapabilities, reasoningCapabilitiesFromConfig, REASONING_CAPABILITY_FIELDS } from "./reasoning-capabilities"; -import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, modelInList, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { resolveProviderAuthTransport } from "./fastwire"; import { fastSwitchOff } from "./fast-opt-in"; @@ -374,6 +374,8 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode }; for (const field of REASONING_CAPABILITY_FIELDS) { modelProvenance[field] = reasoningCapabilities[field] === undefined ? "unknown" + : modelInList(provider.noReasoningModels, input.modelId) ? "operator" + : modelInList(entry?.noReasoningModels, input.modelId) ? "registry" : input.modelCapabilities?.[field] !== undefined || declaredModelCapabilities(provider.modelCapabilities, input.modelId)?.[field] !== undefined ? "operator-capability" : declaredModelCapabilities(entry?.modelCapabilities, input.modelId)?.[field] !== undefined ? "registry" : "operator"; } diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index f9672b96553..0b71881e3cd 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -904,10 +904,15 @@ describe("independent reasoning policy", () => { expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); expect(provider.modelCapabilities?.[id.toUpperCase()]).toEqual({ supportsThinkingToggle: false, defaultThinkingEnabled: false }); }); - test("noReasoningModels remains authoritative over every independent declaration", () => { - const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, noReasoningModels: [id] }; - const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry: entry, transportMatchedRegistry: true }); + test.each(["operator", "registry"])("%s noReasoningModels remains authoritative over every independent declaration", source => { + const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, + modelCapabilities: { [id]: { supportsThinkingToggle: true } }, + ...(source === "operator" ? { noReasoningModels: [id] } : {}) }; + const registryEntry = source === "registry" ? { ...entry, noReasoningModels: [id] } : entry; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: id, provider, registryEntry, transportMatchedRegistry: true }); expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false, defaultThinkingEnabled: false, reasoningEfforts: [] }); + expect(policy.provenance.model).toMatchObject({ supportsReasoning: source, supportsThinkingToggle: source, + supportsReasoningEffort: source, defaultThinkingEnabled: source }); }); }); From 5b57a6030fbd17de08cf0d6391bd3e92de81bb1d Mon Sep 17 00:00:00 2001 From: norths7ar Date: Mon, 26 Oct 2026 15:39:58 +0800 Subject: [PATCH 7/9] fix(reasoning): preserve legacy wires and independent capability projections Address six maintainer findings and preserve legacy Chat behavior across native and translated requests. Cover native effort recovery, Responses extension boundaries, tool omission, case-folded overrides, and Claude discovery. --- .../docs/reference/configuration/providers.md | 2 +- src/adapters/openai-chat.ts | 5 +- src/adapters/openai-chat/passthrough.ts | 7 +- src/adapters/openai-chat/reasoning-wire.ts | 65 +++++++---- src/adapters/openai-responses/passthrough.ts | 2 +- src/adapters/openai-responses/reasoning.ts | 4 +- src/claude/model-info.ts | 39 +++++-- src/codex/catalog/model-hints.ts | 5 +- src/server/responses/core-normalize.ts | 12 +- src/types/provider.ts | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/chat-compat.md | 2 +- structure/transports/responses.md | 2 +- .../openai-chat-reasoning-wire-policy.test.ts | 108 ++++++++++++++++-- .../claude-model-info.test.ts | 73 ++++++++++++ tests/providers/resolved-model-policy.test.ts | 24 ++++ .../openai-responses-summary-none.test.ts | 15 ++- ...responses-native-ultra-effort-wire.test.ts | 51 +++++++++ 20 files changed, 366 insertions(+), 58 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d4d626e0c7a..9ec4cb46ad5 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -362,7 +362,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | -| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts `"gateway-object"` or `null` to clear it. | +| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. For custom Responses destinations, this also explicitly permits the OCX `reasoning.enabled` extension; other Responses destinations strip that internal field. Model thinking capability alone does not opt into this wire format. The ClinePass preset sets this automatically. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts `"gateway-object"` or `null` to clear it. | | `noReasoningModels?` | `string[]` | Models that reject reasoning/thinking parameters. | | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7e63e48d9d6..13fd3d2b63b 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,11 +1,10 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; import { protectGlmSummaryBudget, resolveMaxTokens } from "./openai-chat/summary-budget"; import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; -import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire"; +import { applyExplicitChatReasoningWirePolicy, declaresIndependentChatReasoningEffort } from "./openai-chat/reasoning-wire"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import { modelInList, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types"; import { mapReasoningEffort } from "../reasoning-effort"; -import { resolveReasoningCapabilities } from "../providers/reasoning-capabilities"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; @@ -173,7 +172,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd wireValue: budget, }; } - } else if (modelInList(provider.thinkingToggleModels, parsed.modelId) && resolveReasoningCapabilities(provider, parsed.modelId).supportsReasoningEffort !== true) { + } else if (modelInList(provider.thinkingToggleModels, parsed.modelId) && !declaresIndependentChatReasoningEffort(provider, parsed.modelId)) { if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") { body.thinking = { type: reasoningEffort }; reasoningLog = { diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 1ad5018ee10..00ed1ddad21 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -14,7 +14,7 @@ import { modelInList, type OcxProviderConfig } from "../../types"; import { mapReasoningEffort, modelRecordValue } from "../../reasoning-effort"; import { resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; -import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire"; +import { applyExplicitChatReasoningWirePolicy, declaresIndependentChatReasoningControls } from "./reasoning-wire"; import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; import { planNativeChatReasoningReplay } from "./reasoning-replay"; import type { OcxReasoningReplayScopeRef } from "../../types"; @@ -90,6 +90,7 @@ export function buildOpenAIChatPassthroughRequest( Array.isArray(rawBody.tools) && rawBody.tools.length > 0, maxSerializedBytes); if (protectGlmSummaryBudget(body, provider.baseUrl, body.reasoning_effort)) body.reasoning_effort = "low"; const toggleWire = declaresThinkingToggleWire(provider, modelId); + const independentControls = declaresIndependentChatReasoningControls(provider, modelId); const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || (!toggleWire && rawEfforts?.length === 0) || reasoningControlsAreUnsupported(provider, modelId); @@ -108,14 +109,14 @@ export function buildOpenAIChatPassthroughRequest( hasTools, thinkingEnabled: explicitThinkingEnabled, requestedEffort, - wireEffort: toggleWire ? mapReasoningEffort(provider, modelId, requestedEffort) : requestedEffort, + wireEffort: toggleWire && independentControls ? mapReasoningEffort(provider, modelId, requestedEffort) : requestedEffort, reasoningDisabled, body, }); let reasoningLog = explicitReasoning.handled ? explicitReasoning.reasoningLog : undefined; // A native caller's own thinking object is already in the upstream wire shape. Forward it only // on a provider-declared toggle wire, below an explicit reasoning.enabled but above a default. - if (!reasoningDisabled && toggleWire + if (!reasoningDisabled && toggleWire && independentControls && resolveReasoningCapabilities(provider, modelId).supportsThinkingToggle !== false && explicitThinkingEnabled === undefined && rawBody.thinking !== undefined) { diff --git a/src/adapters/openai-chat/reasoning-wire.ts b/src/adapters/openai-chat/reasoning-wire.ts index a3ceaef29d1..c63c48a3491 100644 --- a/src/adapters/openai-chat/reasoning-wire.ts +++ b/src/adapters/openai-chat/reasoning-wire.ts @@ -1,5 +1,6 @@ import { resolveThinkingEnabled } from "../../clients/reasoning-controls"; -import { resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; +import { declaredModelCapabilities, resolveReasoningCapabilities } from "../../providers/reasoning-capabilities"; +import { registryEntryForProviderDestination } from "../../providers/registry"; import { modelInList, type OcxProviderConfig } from "../../types"; import type { AdapterRequest } from "../base"; import { declaresThinkingToggleWire, reasoningControlsAreUnsupported } from "../reasoning-controls"; @@ -13,6 +14,21 @@ function isThinkingToggleType(value: string | undefined): value is "enabled" | " return value === "enabled" || value === "disabled" || value === "adaptive"; } +/** A legacy ladder alone must not opt a translated toggle wire into a second control. */ +export function declaresIndependentChatReasoningEffort(provider: OcxProviderConfig, modelId: string): boolean { + const explicit = declaredModelCapabilities(provider.modelCapabilities, modelId)?.supportsReasoningEffort; + const registry = declaredModelCapabilities(registryEntryForProviderDestination(provider)?.modelCapabilities, modelId)?.supportsReasoningEffort; + return (explicit ?? registry) === true; +} + +/** Only explicit control axes opt legacy Chat destinations into independent projection. */ +export function declaresIndependentChatReasoningControls(provider: OcxProviderConfig, modelId: string): boolean { + const explicit = declaredModelCapabilities(provider.modelCapabilities, modelId); + const registry = declaredModelCapabilities(registryEntryForProviderDestination(provider)?.modelCapabilities, modelId); + return (["supportsThinkingToggle", "supportsReasoningEffort"] as const) + .some(axis => typeof (explicit?.[axis] ?? registry?.[axis]) === "boolean"); +} + /** * Apply provider-declared reasoning wire policy after the effective provider is resolved. * Unset declarations are a no-op so native Chat keeps forwarding the caller's raw field. @@ -47,6 +63,14 @@ export function applyExplicitChatReasoningWirePolicy(options: { } const toggleWire = declaresThinkingToggleWire(provider, modelId); const toggleDisabled = toggleWire && capabilities.supportsThinkingToggle === false; + // Legacy tool omission precedes discriminator mapping; independent switches are separate + // from strength omission and retain their declared behavior below. + if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId) + && !declaresIndependentChatReasoningControls(provider, modelId)) { + delete body.reasoning_effort; + delete body.reasoning; + return { handled: true }; + } // Ignore legacy toggle discriminators on a disabled toggle axis, but retain real effort // mappings (including omission) and run them through the shared effort policy below. const requestedStrength = requestedEffort === "ultra" ? "max" : requestedEffort; @@ -57,12 +81,14 @@ export function applyExplicitChatReasoningWirePolicy(options: { if (capabilities.supportsThinkingToggle === false) delete body.thinking; return { handled: true }; } + const resolvedThinkingEnabled = toggleWire && !toggleDisabled + ? resolveThinkingEnabled(provider, modelId, { thinkingEnabled, reasoning: requestedEffort }) + : undefined; if (toggleDisabled) { delete body.thinking; delete body.reasoning; delete body.reasoning_effort; } else if (toggleWire) { - const resolvedThinkingEnabled = resolveThinkingEnabled(provider, modelId, { thinkingEnabled, reasoning: requestedEffort }); // These values are the provider's declared thinking discriminator, never strengths. This // remains true when the catalog also advertises a separate, real effort axis. if (isThinkingToggleType(wireEffort)) { @@ -84,21 +110,19 @@ export function applyExplicitChatReasoningWirePolicy(options: { if (resolvedThinkingEnabled !== undefined) { const thinkingType = resolvedThinkingEnabled ? "enabled" : "disabled"; body.thinking = { type: thinkingType }; - // Unknown and explicit strength support retain the established effort wire. Only an - // explicit false says the toggle is the model's sole reasoning control. - if (capabilities.supportsReasoningEffort !== false) { - return { handled: false }; + // A second control still goes through tool omission and gateway projection below. + if (capabilities.supportsReasoningEffort === false) { + delete body.reasoning_effort; + delete body.reasoning; + return { + handled: true, + reasoningLog: { + effectiveEffort: thinkingType, + wireField: "thinking.type", + wireValue: thinkingType, + }, + }; } - delete body.reasoning_effort; - delete body.reasoning; - return { - handled: true, - reasoningLog: { - effectiveEffort: thinkingType, - wireField: "thinking.type", - wireValue: thinkingType, - }, - }; } if (capabilities.supportsReasoningEffort === false) { delete body.reasoning_effort; @@ -136,13 +160,14 @@ export function applyExplicitChatReasoningWirePolicy(options: { }; } delete body.reasoning_effort; - body.reasoning = { enabled: false }; + const enabled = resolvedThinkingEnabled ?? false; + body.reasoning = { enabled }; return { handled: true, reasoningLog: { - effectiveEffort: "none", + effectiveEffort: enabled ? "enabled" : "none", wireField: "reasoning.enabled", - wireValue: false, + wireValue: enabled, }, }; } @@ -161,7 +186,7 @@ export function applyExplicitChatReasoningWirePolicy(options: { }; } delete body.reasoning_effort; - body.reasoning = { enabled: true, effort: wireEffort }; + body.reasoning = { enabled: resolvedThinkingEnabled ?? true, effort: wireEffort }; return { handled: true, reasoningLog: { diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 9bee1375cf4..86ae5e5340b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -516,7 +516,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools); } let wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); - if (isOpenAiOperatedResponsesDestination(provider)) { + if (isOpenAiOperatedResponsesDestination(provider) || provider.reasoningWireFormat !== "gateway-object") { wireBody = stripInternalReasoningEnabled(wireBody); } const body = JSON.stringify(wireBody); diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 2b4b0c7b35b..4ba2f26503c 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -327,8 +327,8 @@ export function stripDisabledVerbosity( /** * `reasoning.enabled` is an OCX inbound compatibility extension, not an OpenAI Responses - * parameter. Call this at the OpenAI-operated final wire boundary so translated clients retain - * the choice until destination selection while routed Responses adapters can receive it. + * parameter. Strip it at Responses egress unless a custom destination explicitly declares + * the gateway-object wire; thinking capability alone does not declare a wire format. */ export function stripInternalReasoningEnabled(body: unknown): unknown { if (!isPlainObject(body) || !isPlainObject(body.reasoning) || !Object.hasOwn(body.reasoning, "enabled")) { diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 649a81e17e8..37b75011161 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -45,8 +45,23 @@ function effortCapability(ladder: readonly string[]) { }; } -function modelCapabilities(ladder: readonly string[], imageInput: boolean) { - const reasons = ladder.length > 0; +function modelCapabilities( + ladder: readonly string[], + imageInput: boolean, + thinkingCapabilities?: Pick, +) { + const reasoningSupported = thinkingCapabilities?.supportsReasoning !== false; + const discoveredEffort = effortCapability(ladder); + const effort = reasoningSupported && thinkingCapabilities?.supportsReasoningEffort !== false + ? discoveredEffort + : effortCapability([]); + // Keep the old ladder projection only while the relevant axis is unknown. An explicit + // toggle proves `enabled`; a usable effort rung proves `adaptive`; neither implies the other. + // This lets clients select MiMo's enabled-only switch without inventing effort. + const adaptive = effort.supported; + const enabled = reasoningSupported && (thinkingCapabilities?.supportsThinkingToggle === true + || (thinkingCapabilities?.supportsThinkingToggle === undefined && effort.supported)); + const thinkingSupported = thinkingCapabilities?.supportsReasoning ?? (adaptive || enabled); return { batch: cap(false), citations: cap(false), @@ -57,12 +72,12 @@ function modelCapabilities(ladder: readonly string[], imageInput: boolean) { clear_tool_uses_20250919: null, compact_20260112: null, }, - effort: effortCapability(ladder), + effort, image_input: cap(imageInput), pdf_input: cap(false), structured_outputs: cap(false), - thinking: reasons - ? { supported: true, types: { adaptive: cap(true), enabled: cap(true) } } + thinking: thinkingSupported + ? { supported: true, types: { adaptive: cap(adaptive), enabled: cap(enabled) } } : { supported: false, types: { adaptive: cap(false), enabled: cap(false) } }, }; } @@ -88,13 +103,21 @@ export interface AnthropicModelInfo { description?: string; } -function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number, description?: string): AnthropicModelInfo { +function modelInfo( + id: string, + displayName: string, + ladder: readonly string[], + imageInput: boolean, + contextWindow?: number, + description?: string, + thinkingCapabilities?: Pick, +): AnthropicModelInfo { return { id, display_name: displayName, type: "model", created_at: MODEL_INFO_CREATED_AT, - capabilities: modelCapabilities(ladder, imageInput), + capabilities: modelCapabilities(ladder, imageInput, thinkingCapabilities), max_input_tokens: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null, max_tokens: null, ...(description === undefined ? {} : { description }), @@ -242,7 +265,7 @@ export function buildAnthropicModelInfos( : m.maxInputTokens) : undefined; const description = idStyle === "readable" ? `Routed by OpenCodex to ${m.provider}/${listedModelId}` : undefined; - const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description); + const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description, m); out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 803b7056d1e..260558d3fa0 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -34,7 +34,7 @@ import { } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; -import { REASONING_CAPABILITY_FIELDS } from "../../providers/reasoning-capabilities"; +import { declaredModelCapabilities, REASONING_CAPABILITY_FIELDS } from "../../providers/reasoning-capabilities"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; @@ -314,10 +314,11 @@ export function applyProviderConfigHints( ?? (azureMetadata?.input ? modelInputModalities({ id: model.id, input_modalities: azureMetadata.input }, undefined) : undefined); const inputModalities = resolvedModalities ? [...resolvedModalities] : undefined; const reasoningSource = reasoningHintSources.get(model) ?? model; + const declaredCapabilities = declaredModelCapabilities(prov.modelCapabilities, model.id); const reasoningCapabilities: Partial = {}; for (const field of REASONING_CAPABILITY_FIELDS) { const value = modelInList(prov.noReasoningModels, model.id) ? staticPolicy.model[field] - : prov.modelCapabilities?.[model.id]?.[field] ?? reasoningSource[field] ?? staticPolicy.model[field]; + : declaredCapabilities?.[field] ?? reasoningSource[field] ?? staticPolicy.model[field]; if (typeof value === "boolean") reasoningCapabilities[field] = value; } if (reasoningCapabilities.supportsReasoning === false) { diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 143eccd8ed6..880f3a4d099 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -1,5 +1,6 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; import { normalizeThinkingOptions } from "../../clients/reasoning-controls"; +import { nativeEffortClamp, shouldApplyNativeEffortClamp } from "../../codex/catalog"; import { sanitizeLogMetadataString } from "../../lib/redact"; import type { RouteResult } from "../../router"; import type { InboundWire } from "../../providers/registry"; @@ -342,7 +343,15 @@ export async function applyFinalRouteRequestNormalization(args: { } { - normalizeThinkingOptions(route.provider, route.modelId, parsed.options); + // Native passthrough keeps its snapshot-owned effort contract, including saved exclusions. + if (!shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)) { + const previousEffort = parsed.options.reasoning; + normalizeThinkingOptions(route.provider, route.modelId, parsed.options); + if (previousEffort !== undefined && parsed.options.reasoning === undefined) { + const raw = parsed._rawBody as { reasoning?: unknown } | undefined; + if (raw) raw.reasoning = stripEmptyLadderEffort(raw.reasoning, []); + } + } const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) { @@ -359,7 +368,6 @@ export async function applyFinalRouteRequestNormalization(args: { } { - const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog"); const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined; // The parser folds `ultra` to `max` only in options.reasoning; the passthrough adapter // serializes the raw body, so a raw `ultra` must reach the clamp itself (#7590). diff --git a/src/types/provider.ts b/src/types/provider.ts index 62cc9006b3e..d14f989d5f4 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -908,7 +908,7 @@ export interface OcxProviderConfig { * `reasoningEffortMap`, resolved per model first. */ modelReasoningEffortMap?: Record>; - /** OpenAI-compatible gateway reasoning wire shape. Default sends `reasoning_effort`. */ + /** Chat gateway reasoning object; also opts custom Responses wires into `reasoning.enabled`. */ reasoningWireFormat?: "gateway-object"; /** * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index a057c9a7978..620c266f250 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -575,7 +575,7 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ ## Claude Code picker descriptions -`src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` · Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. +Routed discovery projects reasoning support independently of effort rungs: fixed reasoners retain thinking support without controls, toggle-only models expose enabled thinking without adaptive effort, and explicit false capability declarations constrain the projection. Unknown legacy axes retain their ladder-based defaults. `src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` · Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. ## Claude Code routed aliases and the context window diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1d55745ea55..1c2ba77749d 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -146,7 +146,7 @@ ineligible for native passthrough so its signature or redacted ciphertext cannot another provider; the bridge keeps only the plaintext projection. Other provider formats retain their native wire shape. The native passthrough applies the [independent reasoning capability contract](../providers-and-adapters.md): explicit false declarations constrain their respective controls, and an empty strength ladder removes caller `reasoning_effort` without disabling a declared thinking switch. Unknown axes remain unclassified. The two Chat builders share the explicit wire policy after provider resolution: `reasoningWireFormat: "gateway-object"` projects the configured object shape, and a listed -tool-bearing model omits reasoning effort on both paths. On a declared toggle wire, explicit `reasoning.enabled` wins, then caller raw `thinking`, then aliases/defaults; unknown or adaptive raw thinking shapes remain intact. With no applicable declaration, native raw reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. +tool-bearing model omits reasoning effort on both paths. For explicitly declared independent controls on a toggle wire, `reasoning.enabled` wins, then caller raw `thinking`, then aliases/defaults; unknown or adaptive raw thinking shapes remain intact. With no applicable declaration, native raw reasoning forwarding stays unchanged. This guard does not alter the separate raw service-tier contract. On the response side, the upstream `service_tier` echo (xAI Priority Processing, OpenAI fast tier) relays to the Chat Completions caller on every delivery shape: the non-streaming body diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 35fe4817709..4e13b1385b1 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses all reasoning controls. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. +Reasoning support, thinking on/off control, real reasoning strengths and the thinking default are independent optional boolean axes in `ReasoningCapabilities`, flattened into `ModelCapabilities` and `CatalogModel`. `src/providers/reasoning-capabilities.ts` resolves fixed destination registry facts beneath operator declarations matched by case-insensitive model ID (without suffix or family matching); absent axes stay unknown and explicit false wins. `thinkingToggleModels` declares wire syntax, including adaptive-only protocols, and never implies an independent on/off capability. Legacy effort declarations infer real strength support only when their effective mapped values include genuine strength levels; enabled/disabled/adaptive aliases retain their existing controls without establishing strength support. Inferred strength support does not opt a legacy toggle serializer into sending an additional effort field; that requires an explicit model capability declaration. Legacy declarations still apply unless `supportsReasoningEffort: false`; an empty effort list does not disable the thinking toggle. `noReasoningModels` suppresses routed reasoning controls; native Codex forwarding keeps its snapshot-owned effort clamp. Captured model policy resolves the same axes without rereading the live registry. [Config validation and merge](config.md#explicit-per-model-capability-declarations) preserve each boolean independently. Native Claude family rules in `src/adapters/anthropic-model-contract.ts` outrank generic toggle serialization: adaptive thinking, the Sonnet 5.5 `between_tools` floor, supported explicit disable and budget/output constraints remain authoritative. Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 82324bc4ed4..bcb1780161c 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -111,7 +111,7 @@ Passthrough and routed-compaction raw-body repairs emit it as a user turn (`exte Stateful developer-guidance injection reuses that validator for its raw insertion boundary, so parsed messages and stored raw history retain the same task/guidance order. -Native Chat passthrough and translated Chat share `src/adapters/openai-chat/reasoning-wire.ts` after provider resolution. Explicit reasoning/effort false declarations and empty strength ladders remove unsupported effort; toggle false removes the switch without declaring an unknown strength axis unsupported. A declared toggle wire serializes the independent thinking choice without treating enabled/disabled/adaptive discriminators as strengths. Gateway-object projection and tool-bearing effort omission retain their existing contracts; unknown declarations preserve legacy raw forwarding. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send +Native Chat passthrough and translated Chat share `src/adapters/openai-chat/reasoning-wire.ts` after provider resolution. Explicit reasoning/effort false declarations and empty strength ladders remove unsupported effort; toggle false removes the switch without declaring an unknown strength axis unsupported. A declared toggle wire serializes the independent thinking choice without treating enabled/disabled/adaptive discriminators as strengths. Dual-control requests still pass through gateway-object projection and tool-bearing effort omission; a legacy toggle configuration gains a second serialized control only with explicit effort support; unknown declarations preserve legacy raw forwarding. Native passthrough also sanitizes routed reasoning history so `reasoning` input items do not send non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — diff --git a/structure/transports/responses.md b/structure/transports/responses.md index deff21d2166..0cb6e213372 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -408,7 +408,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont ## Core module ownership -Final-route normalization in `src/server/responses/core-normalize.ts` decodes client effort aliases through `src/clients/reasoning-controls.ts` before strength pins/caps. `options.thinkingEnabled` is independent of `options.reasoning`: explicit booleans outrank aliases and defaults, and an empty strength ladder does not erase a declared switch. `src/server/effort-policy.ts` restores both original choices on a destination change; credential-only retries retain the current normalization. Unsupported controls follow the [per-axis capability contract](../providers-and-adapters.md), while unknown axes retain legacy behavior. At serialization, `src/adapters/openai-responses/passthrough.ts` strips the OCX `reasoning.enabled` extension only for OpenAI-operated Responses destinations, for both HTTP and WebSocket bodies; custom gateways retain this extension. The extension-removal step leaves other reasoning members untouched; existing effort and summary normalization still applies. +Final-route normalization in `src/server/responses/core-normalize.ts` decodes client effort aliases through `src/clients/reasoning-controls.ts` before strength pins/caps. `options.thinkingEnabled` is independent of `options.reasoning`: explicit booleans outrank aliases and defaults, and an empty strength ladder does not erase a declared switch. `src/server/effort-policy.ts` restores both original choices on a destination change; credential-only retries retain the current normalization. Routed capability normalization removes unsupported effort from both parsed options and the raw request. Native Codex requests retain snapshot-owned effort clamping even with saved capability exclusions. Unsupported routed controls follow the [per-axis capability contract](../providers-and-adapters.md), while unknown axes retain legacy behavior. At serialization, `src/adapters/openai-responses/passthrough.ts` strips the OCX `reasoning.enabled` extension for both HTTP and WebSocket bodies unless a custom destination declares `reasoningWireFormat: "gateway-object"`; OpenAI-operated destinations always strip it. The extension-removal step leaves other reasoning members untouched; existing effort and summary normalization still applies. `src/server/responses/core.ts` is the public ingress and compatibility-export surface. The parent `src/server/responses.ts` facade retains its existing imports. Per-request execution is composed from the following owners in `src/server/responses/`; none is a generated artifact. diff --git a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts index f8464f86c96..186b98ff394 100644 --- a/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts +++ b/tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts @@ -298,27 +298,121 @@ describe("toggle-only Chat reasoning capability", () => { expect(body.reasoning_effort).toBe("low"); } }); + + test.each([ + [undefined, false], [undefined, true], + ["gateway-object", false], ["gateway-object", true], + ] as const)("dual controls retain %s effort policy with tools=%s", (reasoningWireFormat, hasTools) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + reasoningEfforts: ["low", "high"], + modelCapabilities: { [toggleModel]: { + supportsThinkingToggle: true, supportsReasoningEffort: true, defaultThinkingEnabled: true, + } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + for (const thinkingEnabled of [undefined, false, true]) { + const bodies = finalToggleBodies({ + ...request("high"), ...(hasTools ? { tools: [tool] } : {}), + }, target, toggleModel, thinkingEnabled); + for (const body of Object.values(bodies)) { + expect(body.thinking).toEqual({ type: thinkingEnabled === false ? "disabled" : "enabled" }); + expect(body.reasoning_effort).toBe(!hasTools && !reasoningWireFormat ? "high" : undefined); + expect(body.reasoning).toEqual(!hasTools && reasoningWireFormat ? { enabled: thinkingEnabled !== false, effort: "high" } : undefined); + if (hasTools) expect(body.tools).toEqual([tool]); + } + } + }); + + test.each([ + [false, "high"], [true, "high"], [false, "none"], [true, "none"], + ] as const)("dual gateway enablement preserves explicit %s with effort %s", (thinkingEnabled, effort) => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat: "gateway-object", + modelCapabilities: { [toggleModel]: { supportsThinkingToggle: true, supportsReasoningEffort: true } }, + reasoningEfforts: ["low", "high"], + }; + for (const body of Object.values(finalToggleBodies(request(effort), target, toggleModel, thinkingEnabled))) { + expect(body.thinking).toEqual({ type: thinkingEnabled ? "enabled" : "disabled" }); + expect(body.reasoning).toEqual({ enabled: thinkingEnabled, ...(effort === "high" ? { effort } : {}) }); + expect(body.reasoning_effort).toBeUndefined(); + } + }); + + test.each([false, true])("mapped independent toggle survives tool omission with effort support=%s", supportsReasoningEffort => { + const target: OcxProviderConfig = { + ...toggleProvider, + modelCapabilities: { [toggleModel]: { supportsThinkingToggle: true, supportsReasoningEffort } }, + modelReasoningEffortMap: { [toggleModel]: { high: "enabled" } }, + omitReasoningEffortWithToolsModels: [toggleModel], + }; + for (const thinkingEnabled of [false, true]) { + for (const body of Object.values(finalToggleBodies({ + ...request("high"), tools: [tool], + }, target, toggleModel, thinkingEnabled))) { + expect(body.thinking).toEqual({ type: thinkingEnabled ? "enabled" : "disabled" }); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); + expect(body.tools).toEqual([tool]); + } + } + }); }); describe("legacy mapped thinking discriminator wires", () => { + test.each([undefined, "gateway-object"] as const)("legacy toggle selection stays unchanged with %s wire", reasoningWireFormat => { + const target: OcxProviderConfig = { + ...toggleProvider, reasoningWireFormat, + modelCapabilities: undefined, + reasoningEfforts: ["low", "high"], + }; + const raw = { messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; + const bodies = finalToggleBodies(raw, target); + expect(bodies.translated.reasoning_effort).toBeUndefined(); + expect(bodies.native.reasoning_effort).toBe(reasoningWireFormat ? undefined : "high"); + for (const body of Object.values(bodies)) { + expect(body.thinking).toBeUndefined(); + expect(body.reasoning).toEqual(reasoningWireFormat ? { enabled: true, effort: "high" } : undefined); + } + }); + test.each([ ["zhipu-bigmodel", "glm-4.6", "enabled"], ["opencode-go", "mimo-v2.5", "enabled"], ["volcengine", "doubao-seed-2-1-pro-260628", "enabled"], ["minimax", "MiniMax-M3", "adaptive"], - ] as const)("%s/%s emits %s on the declared wire", (providerId, modelId, type) => { + ] as const)("%s/%s translates %s without remapping the native wire", (providerId, modelId, type) => { const entry = getProviderRegistryEntry(providerId); if (!entry) throw new Error(`Missing registry seed ${providerId}`); const target = { ...providerConfigSeed(entry), apiKey: "sk-test" }; const raw = { model: modelId, messages: [{ role: "user", content: "think" }], reasoning_effort: "high" }; const parsed = parseRequest(chatCompletionsToResponsesBody(raw)); - const bodies = [ - JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record, - JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, modelId, false).body) as Record, - ]; - for (const body of bodies) { - expect(body.thinking).toEqual({ type }); + const translated = JSON.parse(createOpenAIChatAdapter(target).buildRequest(parsed).body) as Record; + const native = JSON.parse(buildOpenAIChatPassthroughRequest(target, raw, modelId, false).body) as Record; + expect(translated.thinking).toEqual({ type }); + expect(translated.reasoning_effort).toBeUndefined(); + expect(native.thinking).toBeUndefined(); + expect(native.reasoning_effort).toBe("high"); + }); + + test.each([ + ["zhipu-bigmodel", "glm-4.6"], + ["opencode-go", "mimo-v2.5"], + ] as const)("%s/%s retains legacy tool omission before toggle mapping", (providerId, modelId) => { + const entry = getProviderRegistryEntry(providerId); + if (!entry) throw new Error(`Missing registry seed ${providerId}`); + const target = { + ...providerConfigSeed(entry), apiKey: "sk-test", + omitReasoningEffortWithToolsModels: [modelId], + }; + const bodies = finalToggleBodies({ + messages: [{ role: "user", content: "think" }], reasoning_effort: "high", tools: [tool], + }, target, modelId); + for (const body of Object.values(bodies)) { + expect(body.tools).toEqual([tool]); + expect(body.thinking).toBeUndefined(); expect(body.reasoning_effort).toBeUndefined(); + expect(body.reasoning).toBeUndefined(); } }); diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 70dbd71f0ac..991ba716c9c 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { buildAnthropicModelInfos, nativeEffectiveLadder } from "../../src/claude/model-info"; import { gatherRoutedModels, nativeEffortClamp } from "../../src/codex/catalog"; +import { MIMO_THINKING_CAPABILITIES } from "../../src/providers/mimo-thinking"; describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => { test.each(["anthropic", "anthropic-apikey"])("%s registry image inputs reach Claude discovery aliases", async (provider) => { @@ -58,6 +59,78 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(info!.max_input_tokens).toBeNull(); }); + test("generic toggle-only models publish enabled thinking without claiming adaptive", () => { + const [info] = buildAnthropicModelInfos([], [{ + provider: "p", + id: "toggle-only", + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: false, + }]); + expect(info!.capabilities.thinking).toEqual({ + supported: true, + types: { adaptive: { supported: false }, enabled: { supported: true } }, + }); + }); + + test.each([ + { + name: "fixed reasoning", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: false }, + ladder: ["high"], + effort: false, + thinking: { supported: true, adaptive: false, enabled: false }, + }, + { + name: "effort-only reasoning", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + ladder: ["high"], + effort: true, + thinking: { supported: true, adaptive: true, enabled: false }, + }, + { + name: "combined controls", + capabilities: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: true }, + ladder: ["high"], + effort: true, + thinking: { supported: true, adaptive: true, enabled: true }, + }, + { + name: "explicitly unsupported reasoning", + capabilities: { supportsReasoning: false, supportsThinkingToggle: true, supportsReasoningEffort: true }, + ladder: ["high"], + effort: false, + thinking: { supported: false, adaptive: false, enabled: false }, + }, + { + name: "effort declaration without a usable Claude rung", + capabilities: { supportsReasoning: true, supportsThinkingToggle: false, supportsReasoningEffort: true }, + ladder: [], + effort: false, + thinking: { supported: true, adaptive: false, enabled: false }, + }, + ])("$name preserves independent reasoning axes", ({ capabilities, ladder, effort, thinking }) => { + const [info] = buildAnthropicModelInfos([], [{ provider: "p", id: "model", reasoningEfforts: ladder, ...capabilities }]); + expect(info!.capabilities.effort.supported).toBe(effort); + expect(info!.capabilities.thinking).toEqual({ + supported: thinking.supported, + types: { adaptive: { supported: thinking.adaptive }, enabled: { supported: thinking.enabled } }, + }); + }); + + test("MiMo V2.6 publishes its documented enabled-only thinking switch", () => { + const [info] = buildAnthropicModelInfos([], [{ + provider: "xiaomi", + id: "mimo-v2.6-pro", + ...MIMO_THINKING_CAPABILITIES["mimo-v2.6-pro"], + }]); + expect(info!.capabilities.effort.supported).toBe(false); + expect(info!.capabilities.thinking).toEqual({ + supported: true, + types: { adaptive: { supported: false }, enabled: { supported: true } }, + }); + }); + test("non-anthropic rungs (ultra) are filtered out of the capability set", () => { const [info] = buildAnthropicModelInfos([], [{ provider: "p", id: "m", reasoningEfforts: ["ultra"] }]); expect(info!.capabilities.effort.supported).toBe(false); diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index 0b71881e3cd..9d9884be65a 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -904,6 +904,30 @@ describe("independent reasoning policy", () => { expect(entry.modelCapabilities?.[id]?.supportsThinkingToggle).toBe(true); expect(provider.modelCapabilities?.[id.toUpperCase()]).toEqual({ supportsThinkingToggle: false, defaultThinkingEnabled: false }); }); + test("case-folded capability declarations govern discovered rows without changing unknown legacy evidence", () => { + const provider: OcxProviderConfig = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + modelCapabilities: { M: { supportsReasoning: false } }, + }; + const discovered = { + provider: "synthetic", + id: "m", + supportsReasoning: true, + supportsThinkingToggle: true, + supportsReasoningEffort: true, + reasoningEfforts: ["low", "high"], + }; + const policy = resolveModelPolicy({ providerName: "synthetic", modelId: "m", provider, registryEntry: entry, transportMatchedRegistry: true }); + expect(policy.model).toMatchObject({ supportsReasoning: false, supportsThinkingToggle: false, supportsReasoningEffort: false }); + expect(applyProviderConfigHints("synthetic", provider, discovered)).toMatchObject({ + supportsReasoning: false, + supportsThinkingToggle: false, + supportsReasoningEffort: false, + reasoningEfforts: [], + }); + expect(applyProviderConfigHints("synthetic", { adapter: entry.adapter, baseUrl: entry.baseUrl }, discovered)).toMatchObject(discovered); + }); test.each(["operator", "registry"])("%s noReasoningModels remains authoritative over every independent declaration", source => { const provider: OcxProviderConfig = { adapter: entry.adapter, baseUrl: entry.baseUrl, modelCapabilities: { [id]: { supportsThinkingToggle: true } }, diff --git a/tests/responses/openai-responses-summary-none.test.ts b/tests/responses/openai-responses-summary-none.test.ts index e5111537787..2b94f747c40 100644 --- a/tests/responses/openai-responses-summary-none.test.ts +++ b/tests/responses/openai-responses-summary-none.test.ts @@ -42,7 +42,7 @@ describe("Responses summary:none wire marker", () => { } }); - test("strips only the internal thinking toggle for OpenAI-operated Responses wires", () => { + test("strips the internal thinking toggle unless a custom Responses wire declares it", () => { const bodies = [ { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false, effort: "high", summary: "detailed" } }, chatCompletionsToResponsesBody({ @@ -69,10 +69,19 @@ describe("Responses summary:none wire marker", () => { } const routed = { adapter: "openai-responses", baseUrl: "https://compat.example.test/v1", authMode: "key" as const }; - for (const body of bodies) expect(outboundReasoning(routed, body)).toEqual(body.reasoning); + for (const body of bodies) { + expect(outboundReasoning(routed, body)).toEqual( + Object.fromEntries(Object.entries(body.reasoning as Record).filter(([key]) => key !== "enabled")), + ); + expect(outboundReasoning({ ...routed, reasoningWireFormat: "gateway-object" }, body)).toEqual(body.reasoning); + } const toggleOnly = { model: "gpt-6-astra", input: "hi", reasoning: { enabled: false } }; for (const target of targets) expect(outboundReasoning(target, toggleOnly)).toBeUndefined(); expect(toggleOnly.reasoning).toEqual({ enabled: false }); - expect(outboundReasoning(routed, toggleOnly)).toEqual({ enabled: false }); + expect(outboundReasoning(routed, toggleOnly)).toBeUndefined(); + expect(outboundReasoning({ ...routed, reasoningWireFormat: "gateway-object" }, toggleOnly)).toEqual({ enabled: false }); + for (const target of targets) { + expect(outboundReasoning({ ...target, reasoningWireFormat: "gateway-object" }, toggleOnly)).toBeUndefined(); + } }); }); diff --git a/tests/responses/responses-native-ultra-effort-wire.test.ts b/tests/responses/responses-native-ultra-effort-wire.test.ts index dd1eb9fdd55..25c57c4485b 100644 --- a/tests/responses/responses-native-ultra-effort-wire.test.ts +++ b/tests/responses/responses-native-ultra-effort-wire.test.ts @@ -13,6 +13,10 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { parseRequest } from "../../src/responses/parser"; +import { routeModel } from "../../src/router"; +import { applyFinalRouteRequestNormalization } from "../../src/server/responses/core-normalize"; +import { applyCompactionRoutingOverride } from "../../src/server/responses/compaction-routing"; /** * #7590: the catalog offers `ultra` on native rows as a client-facing choice, but the ChatGPT @@ -77,6 +81,7 @@ describe("native ultra effort reaches the ChatGPT backend as max", () => { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", + noReasoningModels: ["gpt-6.1-sol"], }, }, } as OcxConfig); @@ -137,4 +142,50 @@ describe("native ultra effort reaches the ChatGPT backend as max", () => { await response.text(); expect(efforts).toEqual(["max"]); }, SERVER_BUDGET_MS); + + test("saved noReasoningModels cannot bypass the native none-to-low wire clamp", async () => { + const response = await post("/v1/responses", { + model: "gpt-6.1-sol", stream: false, input: "hi", reasoning: { effort: "none" }, + }); + expect(response.status).toBe(200); + await response.text(); + expect(efforts).toEqual(["low"]); + }, SERVER_BUDGET_MS); +}); + +describe("native capability normalization keeps parsed and raw effort aligned", () => { + test.each(["direct", "compaction-switch", "failover"])("%s retains the native clamp with saved exclusions", async path => { + const config: OcxConfig = { + port: 0, defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", noReasoningModels: ["gpt-6.1-sol"] }, + gateway: { adapter: "openai-responses", authMode: "key", baseUrl: "https://example.test/v1", reasoningEfforts: [], + modelCapabilities: { model: { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false } } }, + }, + compactionRouting: { model: "gpt-6.1-sol", reasoningEffort: "none" }, + }; + const body: Record = { + model: path === "direct" ? "gpt-6.1-sol" : "gateway/model", + input: [{ role: "user", content: "hi" }, ...(path === "compaction-switch" ? [{ type: "compaction_trigger" }] : [])], + reasoning: { effort: "none", summary: "auto" }, + }; + const req = new Request("http://localhost/v1/responses", { headers: { + "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction", compaction: { trigger: "manual" } }), + } }); + if (path === "compaction-switch") expect(applyCompactionRoutingOverride(body, req.headers, config)).not.toBeNull(); + const parsed = parseRequest(body); + const normalize = async (model: string) => { + const route = routeModel(config, model); + await applyFinalRouteRequestNormalization({ parsed, route, config, req, inboundWire: "responses", logCtx: { model, provider: route.providerName } }); + }; + if (path === "failover") { + await normalize("gateway/model"); + expect(parsed.options.reasoning).toBeUndefined(); + expect(parsed.options.thinkingEnabled).toBe(false); + expect((parsed._rawBody as { reasoning: unknown }).reasoning).toEqual({ summary: "auto" }); + } + await normalize("gpt-6.1-sol"); + expect(parsed.options.reasoning).toBe("low"); + expect((parsed._rawBody as { reasoning: unknown }).reasoning).toEqual({ effort: "low", summary: "auto" }); + }); }); From adbe0e87368a4a16064d17fcaadac67ee37284c7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:30:56 +0900 Subject: [PATCH 8/9] docs(structure): keep integrations.md within its 600-line budget after merging dev The merge with dev put structure/clients/integrations.md at 601 lines; join the reasoning-projection sentence onto the preceding export-metadata line. --- structure/clients/integrations.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 9052b698b25..f9e531969da 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -243,8 +243,7 @@ than reviving a cleared capability. Exporters do not invent a medium default for `src/server/management/model-row-export-metadata.ts` resolves custom-row inheritance without backfilling the stored override fields the editor reads. Management rows attach `exportMetadata`; -its presence makes even an absent default authoritative; the shared `resolveExportReasoningAxes` helper in `src/clients/config-export/reasoning-metadata.ts` keeps management and CLI exports aligned, so export/launcher projections cannot fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. -Management and CLI export projections preserve independent `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled` fields, including explicit false; absent axes remain unknown. An empty effort declaration does not disable a declared thinking toggle. `supportsReasoningEffort: false` prevents legacy ladders from advertising strengths. Metadata projections carry capability truth without synthesizing effort choices. At the Codex, OpenCode and Kilo effort-enum boundaries only, toggle-only models encode off/on as `none`/`high`, with their default taken from `defaultThinkingEnabled`; fixed reasoning creates no picker, while effort-only and combined controls retain real strengths without synthetic top tiers. Legacy unknown capabilities keep their prior behavior: positive effort support alone, which can be inferred from an existing ladder, does not opt a Codex row out of its previous max/ultra synthesis. Explicit toggle-axis declarations and negative reasoning/effort capabilities still constrain that projection exactly. Legacy variant maps disable client-generated choices that the model does not support; client-owned labels may still display “High”. `tests/clients/client-export-reasoning-controls.test.ts` covers generic capability combinations and Codex projection, `tests/clients/client-export-effective-metadata.test.ts` covers routed/custom projection parity, and `tests/server/api-catalog-route.test.ts` checks independent public API flags without a fake effort list and client exports with upstream fetches stubbed. +its presence makes even an absent default authoritative; the shared `resolveExportReasoningAxes` helper in `src/clients/config-export/reasoning-metadata.ts` keeps management and CLI exports aligned, so export/launcher projections cannot fall back to the picker's synthesized medium/low preference. Declared empty ladders survive. Management and CLI export projections preserve independent `supportsReasoning`, `supportsThinkingToggle`, `supportsReasoningEffort` and `defaultThinkingEnabled` fields, including explicit false; absent axes remain unknown. An empty effort declaration does not disable a declared thinking toggle. `supportsReasoningEffort: false` prevents legacy ladders from advertising strengths. Metadata projections carry capability truth without synthesizing effort choices. At the Codex, OpenCode and Kilo effort-enum boundaries only, toggle-only models encode off/on as `none`/`high`, with their default taken from `defaultThinkingEnabled`; fixed reasoning creates no picker, while effort-only and combined controls retain real strengths without synthetic top tiers. Legacy unknown capabilities keep their prior behavior: positive effort support alone, which can be inferred from an existing ladder, does not opt a Codex row out of its previous max/ultra synthesis. Explicit toggle-axis declarations and negative reasoning/effort capabilities still constrain that projection exactly. Legacy variant maps disable client-generated choices that the model does not support; client-owned labels may still display “High”. `tests/clients/client-export-reasoning-controls.test.ts` covers generic capability combinations and Codex projection, `tests/clients/client-export-effective-metadata.test.ts` covers routed/custom projection parity, and `tests/server/api-catalog-route.test.ts` checks independent public API flags without a fake effort list and client exports with upstream fetches stubbed. OpenCode V1 and Kilo use model `options` defaults and variant maps; OpenCode V2 uses model `settings` defaults and native variant arrays with `@opencode/ai/providers/openai-compatible`. Declared variants, including `none`, override those defaults. V2 always writes an array, empty From f19687de1a7cbd39d3b1b8b59b7e241707406319 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:39:45 +0900 Subject: [PATCH 9/9] fix(reasoning): preserve legacy empty-ladder discovery signal --- .../src/content/docs/guides/providers.md | 2 ++ src/server/models-capabilities.ts | 7 ++++++- structure/catalog.md | 1 + .../cursor/cursor-local-models-schema.test.ts | 21 ++++++++++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5221f2684d0..17b60809537 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -971,6 +971,8 @@ with no redirect, so switch a saved V2.5 default before then; opencodex does not **Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map; reasoning axes merge over registry defaults using case-insensitive model IDs, with operator values taking precedence. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models. +Legacy configurations with an explicit empty effort list and no reasoning capability declarations retain `capabilities.supports_reasoning: false` in `/v1/models` discovery. An explicit `supportsReasoning: true` or `supportsThinkingToggle: true` still advertises thinking when the effort list is empty. + Public MiMo V2.6 is one instance: its three models support reasoning and an on/off switch, but no adjustable intensity; thinking defaults to enabled. The wire adapter sends `thinking.type: enabled/disabled`. OCX's Responses ingress also accepts `reasoning.enabled` independently of `reasoning.effort`. For clients that only offer effort enums, Codex catalogs and OpenCode/Kilo exports represent this switch as `none/high`; those values are client compatibility aliases, not MiMo strength levels. Client-owned labels may still say “High.” Fixed reasoning models without a switch get no fabricated control. Xiaomi's [deep-thinking guide](https://mimo.mi.com/docs/en-US/quick-start/usage-guide/other/deep-thinking) describes the public MiMo switch; free, token-plan, third-party routes and older MiMo models keep their existing declarations. Volcengine Coding Plan and Agent Plan use their native Responses endpoints through `openai-responses`. diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index 9a2a43f4bd5..2eedefff06c 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -175,8 +175,13 @@ function positiveInt(value: unknown): number | undefined { export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields { const efforts = input.supportsReasoning === false || input.supportsReasoningEffort === false ? [] : (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); + // Legacy discovery used an explicit empty ladder to withhold Cursor reasoning controls. + // Independent capability declarations replace that compatibility inference per axis. + const legacyEmptyLadder = input.reasoningEfforts?.length === 0 + && input.supportsThinkingToggle === undefined && input.supportsReasoningEffort === undefined; const supportsReasoning = input.supportsReasoning - ?? (input.supportsThinkingToggle === true || input.supportsReasoningEffort === true || efforts.length > 0 ? true : undefined); + ?? (input.supportsThinkingToggle === true || input.supportsReasoningEffort === true || efforts.length > 0 + ? true : legacyEmptyLadder ? false : undefined); const contextLength = positiveInt(input.contextWindow); const longContextLength = positiveInt(input.longContextWindow); const maxOutputTokens = positiveInt(input.maxOutputTokens); diff --git a/structure/catalog.md b/structure/catalog.md index 137881ec48b..b48f4cb7b56 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -22,6 +22,7 @@ Accounts added through the [Orca import](codex-home.md#orca-source-owned-account validation-pending. Import alone supplies no entitlement evidence for the model catalog. ## Shared catalog +`src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). diff --git a/tests/providers/cursor/cursor-local-models-schema.test.ts b/tests/providers/cursor/cursor-local-models-schema.test.ts index 4c199b33d9d..06a397face1 100644 --- a/tests/providers/cursor/cursor-local-models-schema.test.ts +++ b/tests/providers/cursor/cursor-local-models-schema.test.ts @@ -115,6 +115,25 @@ describe("modelCapabilityFields", () => { expect(fields.capabilities.reasoning_effort).toBeUndefined(); }); + test("legacy empty ladders retain the negative discovery signal without overriding declared axes", () => { + const legacy = modelCapabilityFields({ reasoningEfforts: [] }).capabilities; + expect(legacy.supports_reasoning).toBe(false); + expect(legacy).not.toHaveProperty("reasoning_effort"); + expect(legacy).not.toHaveProperty("supports_thinking_toggle"); + expect(legacy).not.toHaveProperty("supports_reasoning_effort"); + for (const axes of [{ supportsReasoning: true }, { supportsThinkingToggle: true }, + { supportsReasoning: true, supportsThinkingToggle: true, supportsReasoningEffort: false }]) { + const explicit = modelCapabilityFields({ reasoningEfforts: [], ...axes }).capabilities; + expect(explicit.supports_reasoning).toBe(true); + expect(explicit).not.toHaveProperty("reasoning_effort"); + } + for (const axes of [{ supportsThinkingToggle: false }, { supportsReasoningEffort: false }]) { + expect(modelCapabilityFields({ reasoningEfforts: [], ...axes }).capabilities.supports_reasoning) + .toBeUndefined(); + } + expect(modelCapabilityFields({}).capabilities).not.toHaveProperty("supports_reasoning"); + }); + test("non-positive context and text-only modalities are reported honestly", () => { const fields = modelCapabilityFields({ contextWindow: 0, inputModalities: ["text"], reasoningEfforts: ["", "low"] }); expect("context_length" in fields.capabilities).toBe(false); @@ -233,7 +252,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => expect(plain).toBeDefined(); expect(plain!.api_types).toEqual(["chat_completions", "responses", "anthropic_messages"]); const plainCaps = plain!.capabilities as Record; - expect("supports_reasoning" in plainCaps).toBe(false); + expect(plainCaps.supports_reasoning).toBe(false); expect("reasoning_effort" in plainCaps).toBe(false); expect("max_output_tokens" in plainCaps).toBe(false);