diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 26ecb707983..528b71842c8 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -395,6 +395,11 @@ MiMo model Command Code serves. (`reasoning.summary: "none"`) keeps the provider default. Native Opus 5.5 on `api.anthropic.com` defaults to `display: "updates"`; explicit `reasoning.summary: "concise"` or `"detailed"` selects `"summarized"` without the updates beta. See [Opus 5.5 progress updates](/guides/providers/#claude-opus-55-progress-updates). + The provider or exact-model `thinkingDisplay` setting overrides that default: + `summarized` forces `display: "summarized"`, `updates` keeps progress commentary, and + `omitted` drops the display field so a Chat client receives no reasoning deltas during the + think. An explicit request summary still wins. See + [provider configuration](/reference/configuration/providers/). - **Structured output:** Responses `text.format` and Chat Completions `response_format` requests with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d9cca538c98..facaaaa2011 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -388,6 +388,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | +| `thinkingDisplay?` | `"auto" \| "summarized" \| "updates" \| "omitted"` | Anthropic adaptive `thinking.display` policy for this provider. Unset and `auto` keep the built-in selection: native Claude Opus 5.5 on `api.anthropic.com` sends `updates` (progress commentary), and other adaptive models send `summarized`. `summarized` forces reasoning summaries for both. `updates` is honored only where the adapter already sent it — native Opus 5.5 — and is treated as `auto` for every other model. `omitted` sends adaptive thinking without a display field, matching hidden thinking; the stream then carries signature-only thinking blocks, so a Chat client sees only stream heartbeats during a long think. An explicit request `reasoning.summary` of `concise` or `detailed` still forces `summarized`. The dashboard provider editor and `PATCH /api/providers` accept `null` to clear it. `ocx config set providers..thinkingDisplay summarized` writes it directly. | +| `modelThinkingDisplay?` | `Record` | Per-model override of `thinkingDisplay`, keyed by the exact upstream model id (for example `claude-opus-5-5`). An entry always wins over the provider-wide value, including an explicit `auto` that returns one model to the built-in behavior; a model with no entry uses the provider value. The same value rules apply, including `updates` degrading to `auto` on models that never received that display. `PATCH /api/providers` merges entries and accepts `null` for one entry or the whole map to clear it. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | | xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. Grok 4.7 defaults to Responses on OAuth through its registry wire default and can use Chat through an explicit `modelAdapters["grok-4.7"] = "openai-chat"` override. | | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index aed2989c321..dda24c68537 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -202,7 +202,7 @@ "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", - "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", + "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-thinking-display-policy.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", @@ -777,7 +777,7 @@ "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", - "config-save-boundary.test.ts": "config", + "config-save-boundary.test.ts": "config", "config-thinking-display.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", @@ -1217,7 +1217,7 @@ "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", - "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", + "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-thinking-display.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index f864bd06468..87a4071f22d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1109,6 +1109,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const summary = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning?.summary; // Codex sends "auto" by default, so only an explicit concise/detailed request opts out of updates (#7728). const summaryRequested = summary === "concise" || summary === "detailed"; + // Operator policy for the adaptive `thinking.display` field (#8363). Unset and "auto" keep + // the built-in selection. Any model entry wins over the provider-wide value, including an + // explicit "auto" that opts one model back out. "updates" is only sendable where this + // adapter already sent it (native Opus 5.5), so anywhere else it is treated as auto rather + // than inventing a display value the model never received. + const modelThinkingDisplay = modelRecordValue(provider.modelThinkingDisplay, parsed.modelId); + const configuredThinkingDisplay = modelThinkingDisplay !== undefined + ? modelThinkingDisplay + : provider.thinkingDisplay; + const thinkingDisplayPolicy = configuredThinkingDisplay === "updates" && !nativeOpus55 + ? "auto" + : configuredThinkingDisplay ?? "auto"; const thinkingToggleWire = declaresThinkingToggleWire(provider, parsed.modelId); const reasoningControlsUnsupported = reasoningControlsAreUnsupported(provider, parsed.modelId); const capabilities = resolveReasoningCapabilities(provider, parsed.modelId); @@ -1145,7 +1157,16 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // thinking blocks, so a Chat client sees minutes of heartbeats and no reasoning delta // during a long think (#5824). Native Opus 5.5 selects its display below; // other models request summarized thinking unless the caller hides it. - body.thinking = parsed.options.hideThinkingSummary + // Under the default policy this stays the pre-existing selection, including its local + // hideThinkingSummary behavior; a configured "summarized" overrides that hiding. + // An explicit request summary forces summarized on every adaptive model (#7881), so it + // also outranks a configured "omitted". "updates" already degraded to auto above + // because only native Opus 5.5 can send it. + // Local hideThinkingSummary only speaks for the built-in policy, where it keeps its + // pre-existing meaning; an operator choice replaces that local preference. + const hiddenThinkingDisplay = (thinkingDisplayPolicy === "omitted" && !summaryRequested) + || (thinkingDisplayPolicy === "auto" && parsed.options.hideThinkingSummary); + body.thinking = hiddenThinkingDisplay ? { type: "adaptive" } : { type: "adaptive", display: "summarized" }; const effort = adaptiveEffort(effectiveReasoning); @@ -1193,7 +1214,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } if (nativeOpus55 && !reasoningControlsUnsupported && thinkingEnabled !== false) { - body.thinking = { type: "adaptive", display: summaryRequested ? "summarized" : "updates" }; + // An explicit per-request summary (concise/detailed) still forces summarized (#7881). + body.thinking = summaryRequested || thinkingDisplayPolicy === "summarized" + ? { type: "adaptive", display: "summarized" } + : thinkingDisplayPolicy === "omitted" + ? { type: "adaptive" } + : { type: "adaptive", display: "updates" }; } // One discriminator governs the display beta and response classification, including reuse. progressUpdates = (body.thinking as { display?: string } | undefined)?.display === "updates"; diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index e06104c0709..1ba0b7decbc 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -13,6 +13,7 @@ import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, REASONING_SUMMARY_DELIVERY_VALUES, + THINKING_DISPLAY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, type ModelCapabilities, @@ -29,6 +30,7 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([ "x-amz-security-token", ]); const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); +const THINKING_DISPLAY_SET = new Set(THINKING_DISPLAY_VALUES); const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; const MAX_MODEL_DISPLAY_NAME_LENGTH = 128; @@ -385,6 +387,33 @@ export function reasoningSummaryDeliveryRecordConfigError( return null; } +/** Validate `thinkingDisplay`: one closed vocabulary shared by config and management writes. */ +export function thinkingDisplayConfigError(value: unknown, field = "thinkingDisplay"): string | null { + if (value === undefined) return null; + if (typeof value !== "string" || !THINKING_DISPLAY_SET.has(value)) { + return `${field} must be one of: ${THINKING_DISPLAY_VALUES.join(", ")}`; + } + return null; +} + +/** Validate `modelThinkingDisplay`: a plain map of model id to the same closed vocabulary. */ +export function modelThinkingDisplayConfigError(value: unknown, field = "modelThinkingDisplay"): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; + for (const [key, entry] of Object.entries(value)) { + const safeKey = JSON.stringify(redactSecretString(key)); + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (["__proto__", "prototype", "constructor"].includes(key)) { + return `${field} keys must not be reserved object keys`; + } + const entryError = thinkingDisplayConfigError(entry, `${field}.${safeKey}`); + if (entryError) return entryError; + } + return null; +} + /** Validate a provider's per-model wire override map against runtime routing rules. */ export function modelAdapterRecordConfigError( value: unknown, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b57c3efa537..3838c5a949e 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -60,6 +60,8 @@ import { providerHeadersConfigError, providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, + thinkingDisplayConfigError, + modelThinkingDisplayConfigError, credentialPinnedProviderConfigError, } from "../provider-validation"; import { @@ -767,6 +769,26 @@ export const configSchema = z.object({ message: reasoningSummaryDeliveryError, }); } + const thinkingDisplayError = thinkingDisplayConfigError( + (provider as { thinkingDisplay?: unknown }).thinkingDisplay, + ); + if (thinkingDisplayError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "thinkingDisplay"], + message: thinkingDisplayError, + }); + } + const modelThinkingDisplayError = modelThinkingDisplayConfigError( + (provider as { modelThinkingDisplay?: unknown }).modelThinkingDisplay, + ); + if (modelThinkingDisplayError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelThinkingDisplay"], + message: modelThinkingDisplayError, + }); + } const minMaxOutputError = positiveIntegerConfigError( (provider as { minMaxOutputTokens?: unknown }).minMaxOutputTokens, "minMaxOutputTokens", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 5df3213e410..eefc16d8ee4 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -29,6 +29,7 @@ import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter, PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS, + THINKING_DISPLAY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, type OcxConfig, @@ -360,6 +361,10 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), + // Validated against a closed vocabulary here as well as at the write boundaries, so a + // hand-edited typo cannot load silently and leave the operator with the built-in display (#8363). + thinkingDisplay: z.enum(THINKING_DISPLAY_VALUES).optional(), + modelThinkingDisplay: z.record(z.string().min(1), z.enum(THINKING_DISPLAY_VALUES)).optional(), promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index a97202d8bdc..62dde304cd3 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -103,6 +103,8 @@ export const PROVIDER_MODEL_RENAME_ROLES = { modelSupportsVerbosity: "record", supportsVerbosity: "none", modelReasoningSummaryDelivery: "record", + thinkingDisplay: "none", + modelThinkingDisplay: "record", modelPreferHostedTools: "record", supportsOpenAiWebSearchToolFields: "none", xaiResponsesXSearch: "none", diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index bacdb6dfb97..fa3e6f6b29a 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -43,6 +43,7 @@ export type StaticProviderPolicyField = | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "showThinkingSummary" | "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "modelCapabilities" | "modelAutoCompactTokenLimits" | "modelSuppressSyntheticMax" | "modelReasoningSummaryDelivery" + | "thinkingDisplay" | "modelThinkingDisplay" | "codexToolMode" | "modelAdapters"; type StaticProviderPolicyShape = Pick; export type ResolvedProviderStaticPolicy = Readonly> & Readonly<{ @@ -182,6 +183,8 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "supportsVerbosity", "responsesItemIdRepair", "showThinkingSummary", "escapeBuiltinToolNames", "googleMode", "project", "location", ] as const) putScalar(key, entry?.[key] as StaticProviderPolicyShape[typeof key] | undefined); + // Operator-only display policy; no registry entry declares it (#8363). + put("thinkingDisplay", provider.thinkingDisplay, provider.thinkingDisplay !== undefined ? "operator" : "unknown"); const legacyClinePassLadder = entry !== undefined && input.providerName === "cline-pass" && provider.reasoningWireFormat === "gateway-object" && sameStringArray(provider.reasoningEfforts, ["low"]); @@ -218,6 +221,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode for (const key of [ "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", "modelReasoningSummaryDelivery", "codexToolMode", "modelAdapters", + "modelThinkingDisplay", ] as const) putScalar(key, undefined); put("modelCapabilities", mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), provider.modelCapabilities !== undefined ? "operator-capability" : entry?.modelCapabilities !== undefined ? "registry" : "unknown"); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index da6e6e32f31..40ba18f5c16 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -29,6 +29,8 @@ import { providerHeadersConfigError, providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, + thinkingDisplayConfigError, + modelThinkingDisplayConfigError, upstreamHttpVersionConfigError, credentialPinnedProviderConfigError, } from "../config/provider-validation"; @@ -949,6 +951,10 @@ export function providerManagementConfigError( raw.modelSupportsReasoningSummaries, ); if (reasoningSummaryDeliveryError) return `provider ${name} ${reasoningSummaryDeliveryError}`; + const thinkingDisplayError = thinkingDisplayConfigError(raw.thinkingDisplay); + if (thinkingDisplayError) return `provider ${name} ${thinkingDisplayError}`; + const modelThinkingDisplayError = modelThinkingDisplayConfigError(raw.modelThinkingDisplay); + if (modelThinkingDisplayError) return `provider ${name} ${modelThinkingDisplayError}`; const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed); if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`; const credentialPinError = credentialPinnedProviderConfigError(name, typed); @@ -1175,6 +1181,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelSupportsVerbosity: "editor", supportsVerbosity: "editor", modelReasoningSummaryDelivery: "editor", + thinkingDisplay: "editor", + modelThinkingDisplay: "editor", modelPreferHostedTools: "editor", supportsOpenAiWebSearchToolFields: "editor", xaiResponsesXSearch: "editor", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 6b1a9b0d550..4b205098427 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -60,7 +60,13 @@ export const PROVIDER_COMPAT_CARRY_FIELDS = [ ...RETRY_POLICY_FIELDS, ] as const satisfies readonly (keyof OcxProviderConfig)[]; -export const PROVIDER_DISPLAY_CARRY_FIELDS = ["hideRawReasoning"] as const satisfies readonly (keyof OcxProviderConfig)[]; +export const PROVIDER_DISPLAY_CARRY_FIELDS = [ + "hideRawReasoning", + // The Anthropic thinking display policy (#8363) is operator configuration the dashboard form + // cannot send either, so an unrelated save must not silently return the model to the default. + "thinkingDisplay", + "modelThinkingDisplay", +] as const satisfies readonly (keyof OcxProviderConfig)[]; export type ProviderCompatCarryField = typeof PROVIDER_COMPAT_CARRY_FIELDS[number]; type ProviderDisplayCarryField = typeof PROVIDER_DISPLAY_CARRY_FIELDS[number]; @@ -131,7 +137,8 @@ export function carryProviderCompatFields( for (const field of PROVIDER_DISPLAY_CARRY_FIELDS) { if (sample.submitted.has(field)) continue; const stored = live[field]; - if (stored !== undefined) target[field] = stored; + // Copy maps rather than alias them, like the compatibility settings below. + if (stored !== undefined) target[field] = stored !== null && typeof stored === "object" ? structuredClone(stored) : stored; } if (!providerOverwriteKeepsDestination(candidate, live, sample)) return; for (const field of PROVIDER_COMPAT_CARRY_FIELDS) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 63e4089e46f..26bba95ceac 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -120,6 +120,7 @@ import { } from "../auth-cors"; import { providerCatalogCapabilityConfigError } from "./provider-capability-config"; import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; +import { applyThinkingDisplayPatchFields } from "./provider-thinking-display-patch"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -688,6 +689,11 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "thinkingDisplay") || Object.hasOwn(rawBody, "modelThinkingDisplay")) { + const displayError = applyThinkingDisplayPatchFields(next, rawBody); + if (displayError) return displayError; + touched = true; + } if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { const value = rawBody.noStructuredOutputModels; if (value === null) { diff --git a/src/server/management/provider-thinking-display-patch.ts b/src/server/management/provider-thinking-display-patch.ts new file mode 100644 index 00000000000..313278405d6 --- /dev/null +++ b/src/server/management/provider-thinking-display-patch.ts @@ -0,0 +1,54 @@ +import type { OcxProviderConfig } from "../../types"; +import { thinkingDisplayConfigError } from "../../config/provider-validation"; +import { isPlainRecord } from "./shared"; + +type ThinkingDisplay = NonNullable; + +/** + * Apply the `thinkingDisplay` / `modelThinkingDisplay` PATCH mask (#8363). + * + * Split out of provider-routes.ts, which sits under the 2,000-line file-size threshold; the + * behaviour stays identical to the sibling per-model map patches there. A model entry of + * `null` deletes one key, `null` on the whole map clears it, and provider-level `null` or "" + * clears the scalar. + */ +export function applyThinkingDisplayPatchFields( + next: OcxProviderConfig, + rawBody: Record, +): { error: string } | null { + if (Object.hasOwn(rawBody, "thinkingDisplay")) { + const value = rawBody.thinkingDisplay; + if (value === null || value === "") { + delete next.thinkingDisplay; + } else { + const error = thinkingDisplayConfigError(value); + if (error) return { error }; + next.thinkingDisplay = value as ThinkingDisplay; + } + } + if (Object.hasOwn(rawBody, "modelThinkingDisplay")) { + const value = rawBody.modelThinkingDisplay; + if (value === null) { + delete next.modelThinkingDisplay; + } else { + if (!isPlainRecord(value)) return { error: "modelThinkingDisplay must be a plain object or null" }; + const merged = Object.assign( + Object.create(null) as Record, + next.modelThinkingDisplay ?? {}, + ); + for (const [model, policy] of Object.entries(value as Record)) { + if (!model.trim()) return { error: "modelThinkingDisplay keys must be nonblank model ids" }; + if (policy === null) delete merged[model]; + else { + const entryError = thinkingDisplayConfigError(policy, `modelThinkingDisplay.${JSON.stringify(model)}`); + if (entryError) return { error: entryError }; + merged[model] = policy as ThinkingDisplay; + } + } + if (Object.keys(merged).length > 0) next.modelThinkingDisplay = merged; + else delete next.modelThinkingDisplay; + } + } + return null; +} + diff --git a/src/types.ts b/src/types.ts index 8da67a3db8b..1c7d579f3a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,12 +24,14 @@ export { } from "./types/tools"; export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; +export type { ThinkingDisplayPolicy } from "./types/wire"; export type { AnthropicInstanceId } from "./providers/anthropic-instance-id"; export { ANTHROPIC_INSTANCE_IDS, ANTHROPIC_PRIMARY_INSTANCE, ANTHROPIC_POOL2_INSTANCE, isAnthropicInstanceId } from "./providers/anthropic-instance-id"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute } from "./types/anthropic-account-pool"; export { UPSTREAM_HTTP_VERSION_VALUES, REASONING_SUMMARY_DELIVERY_VALUES, + THINKING_DISPLAY_VALUES, OPENAI_PROVIDER_TIER_VERSION, MODEL_ADAPTER_OVERRIDE_ALLOWED, captureWireAdapterHardPins, diff --git a/src/types/provider.ts b/src/types/provider.ts index 22be33e0882..9bd9c6708fe 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,4 +1,4 @@ -import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./wire"; +import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode, ThinkingDisplayPolicy } from "./wire"; import type { AccountAutoPausePolicy, AccountPauseDetails } from "./account-auto-pause"; import type { AnthropicAccountPoolConfig } from "./anthropic-account-pool"; @@ -841,6 +841,14 @@ export interface OcxProviderConfig { * Presence also advertises reasoning-summary support for that routed model. */ modelReasoningSummaryDelivery?: Record; + /** + * Anthropic adaptive `thinking.display` policy for this provider (#8363). `auto` and omission + * keep the built-in selection: native Opus 5.5 progress updates, summarized thinking elsewhere. + * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for other models. + */ + thinkingDisplay?: ThinkingDisplayPolicy; + /** Per-model override of `thinkingDisplay`, keyed by the exact upstream model id. */ + modelThinkingDisplay?: Record; /** * Exact-model hosted tools that win collisions with Codex client tool declarations. * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side. diff --git a/src/types/wire.ts b/src/types/wire.ts index 2db38cbaa9d..8c3767889ea 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -25,6 +25,16 @@ export const REASONING_SUMMARY_DELIVERY_VALUES = [ export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; +/** + * Anthropic adaptive `thinking.display` policy (#8363). `auto` is the built-in behavior: + * native Opus 5.5 progress updates, summarized thinking elsewhere unless the caller hides it. + * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for every other model. + * `omitted` sends adaptive thinking with no display field, matching hidden thinking. + */ +export const THINKING_DISPLAY_VALUES = ["auto", "summarized", "updates", "omitted"] as const; + +export type ThinkingDisplayPolicy = (typeof THINKING_DISPLAY_VALUES)[number]; + /** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ export type CodexAccountMode = "direct" | "pool"; diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 5ca176a4184..21719d60fed 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -8,6 +8,12 @@ This display choice also applies when effort is absent or `none`; it does not ad Omitted summary, `auto` (the Codex default) and `none` keep the updates default. Local `hideThinkingSummary` controls summary presentation independently; it does not select the upstream display. The selected display controls both the generated beta header and response classification on every request, including adapter reuse. +An operator override sits above that default without changing it when unset (#8363): the provider +`thinkingDisplay` value, or the exact-model `modelThinkingDisplay` entry that wins over it, can force +`summarized`, keep `updates`, or select `omitted` (adaptive thinking with no display field). `updates` +is treated as `auto` on any model this adapter never sent it to, an explicit request summary still +forces `summarized` on every adaptive model, and `hideThinkingSummary` keeps its meaning only under +the `auto` policy. Every branch derives `progressUpdates` from the final body's `display`. `src/types/request.ts` carries the complete signed update on its text-delta event. `src/adapters/anthropic-progress.ts` uses this documented display contract to classify nonempty thinking text as commentary, buffering one block under the translator budget until its final signature. diff --git a/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts b/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts new file mode 100644 index 00000000000..1f78fd48537 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxProviderConfig } from "../../../src/types"; + +const base = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic" } as const; +// Routed requests reach the adapter with the native wire id, which is also the key these maps use. +const opus55 = "claude-opus-5-5"; +const sonnet5 = "claude-sonnet-5"; +const updatesBeta = "thinking-display-updates-2026-08-18"; + +async function requestBody( + provider: OcxProviderConfig, + model: string, + reasoning?: { effort?: string; summary?: string }, +): Promise<{ thinking: unknown; beta: string | null }> { + const adapter = createAnthropicAdapter(provider); + const request = await adapter.buildRequest(parseRequest({ + model, + input: "question", + ...(reasoning ? { reasoning } : {}), + })); + return { + thinking: JSON.parse(request.body as string).thinking, + beta: new Headers(request.headers).get("anthropic-beta"), + }; +} + +test("modelThinkingDisplay summarized sends summarized for native Opus 5.5 with bare effort", async () => { + const { thinking, beta } = await requestBody({ ...base, modelThinkingDisplay: { "claude-opus-5-5": "summarized" } }, opus55, { effort: "high" }); + expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(beta ?? "").not.toContain(updatesBeta); +}); + +test("provider-level summarized applies when no model entry exists", async () => { + const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "summarized" }, opus55); + expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(beta ?? "").not.toContain(updatesBeta); +}); + +test("model-level updates overrides provider summarized for Opus 5.5", async () => { + const { thinking, beta } = await requestBody({ + ...base, + thinkingDisplay: "summarized", + modelThinkingDisplay: { "claude-opus-5-5": "updates" }, + }, opus55, { effort: "high" }); + expect(thinking).toEqual({ type: "adaptive", display: "updates" }); + expect(beta).toContain(updatesBeta); +}); + +test("updates configured for a non-Opus-5.5 adaptive model keeps current behavior", async () => { + const configured = await requestBody({ ...base, thinkingDisplay: "updates" }, sonnet5, { effort: "high" }); + const unset = await requestBody({ ...base }, sonnet5, { effort: "high" }); + expect(configured.thinking).toEqual(unset.thinking); + expect(configured.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(configured.beta ?? "").not.toContain(updatesBeta); +}); + +test("model-level auto overrides a provider-level policy for that model", async () => { + const { thinking, beta } = await requestBody({ + ...base, + thinkingDisplay: "summarized", + modelThinkingDisplay: { "claude-opus-5-5": "auto" }, + }, opus55, { effort: "high" }); + expect(thinking).toEqual({ type: "adaptive", display: "updates" }); + expect(beta).toContain(updatesBeta); + + // The provider value still applies to a model with no entry of its own. + const sonnet = await requestBody({ ...base, thinkingDisplay: "summarized" }, sonnet5, { effort: "high" }); + expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" }); +}); + +test("explicit request summary detailed still wins over configured updates", async () => { + const { thinking, beta } = await requestBody({ + ...base, + thinkingDisplay: "updates", + modelThinkingDisplay: { "claude-opus-5-5": "updates" }, + }, opus55, { effort: "high", summary: "detailed" }); + expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(beta ?? "").not.toContain(updatesBeta); +}); + +test("explicit request summary wins over configured omitted on every adaptive model", async () => { + for (const model of [opus55, sonnet5]) { + for (const summary of ["concise", "detailed"] as const) { + const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high", summary }); + expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(beta ?? "").not.toContain(updatesBeta); + } + } +}); + +test("unset policy keeps Opus 5.5 updates and non-Opus summarized byte-identically", async () => { + const opus = await requestBody({ ...base }, opus55, { effort: "high" }); + expect(opus.thinking).toEqual({ type: "adaptive", display: "updates" }); + expect(opus.beta).toContain(updatesBeta); + const sonnet = await requestBody({ ...base }, sonnet5, { effort: "high" }); + expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" }); +}); + +test("auto policy keeps hideThinkingSummary meaning on adaptive models", async () => { + const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "auto" }); + const parsed = parseRequest({ model: sonnet5, input: "question", reasoning: { effort: "high" } }); + parsed.options.hideThinkingSummary = true; + const request = await adapter.buildRequest(parsed); + expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive" }); +}); + +test("configured summarized overrides local summary hiding on adaptive models", async () => { + for (const model of [opus55, sonnet5]) { + const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "summarized" }); + const parsed = parseRequest({ model, input: "question", reasoning: { effort: "high" } }); + parsed.options.hideThinkingSummary = true; + const request = await adapter.buildRequest(parsed); + expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive", display: "summarized" }); + } +}); + +test("omitted policy sends adaptive thinking without a summarized display", async () => { + for (const model of [opus55, sonnet5]) { + const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high" }); + expect(thinking).toEqual({ type: "adaptive" }); + expect(beta ?? "").not.toContain(updatesBeta); + } +}); diff --git a/tests/config/config-thinking-display.test.ts b/tests/config/config-thinking-display.test.ts new file mode 100644 index 00000000000..dcb3f2e8584 --- /dev/null +++ b/tests/config/config-thinking-display.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import type { OcxConfig } from "../../src/types"; + +function withProvider(provider: Record): OcxConfig { + return { + ...getDefaultConfig(), + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "synthetic", + ...provider, + }, + }, + } as OcxConfig; +} + +describe("anthropic thinking display policy config", () => { + test("accepts a model-keyed summarized entry, mirroring other model maps", () => { + const result = validateConfigCandidate(withProvider({ + modelThinkingDisplay: { "claude-opus-5-5": "summarized" }, + })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.config.providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); + }); + + test("accepts every documented value at provider and model level", () => { + for (const value of ["auto", "summarized", "updates", "omitted"] as const) { + expect(validateConfigCandidate(withProvider({ thinkingDisplay: value })).ok).toBe(true); + expect(validateConfigCandidate(withProvider({ modelThinkingDisplay: { "claude-opus-5-5": value } })).ok).toBe(true); + } + }); + + test("rejects an unknown provider-level value with a clear message", () => { + const result = validateConfigCandidate(withProvider({ thinkingDisplay: "always" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("thinkingDisplay"); + }); + + test("rejects an unknown model-level value with a clear message", () => { + const result = validateConfigCandidate(withProvider({ + modelThinkingDisplay: { "claude-opus-5-5": "verbose" }, + })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("modelThinkingDisplay"); + }); + + test("rejects a non-object model map and non-string values", () => { + for (const value of [[], "summarized", { "claude-opus-5-5": true }]) { + const result = validateConfigCandidate(withProvider({ modelThinkingDisplay: value })); + expect(result.ok).toBe(false); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ea1a8cef4b4..8f95b0f8abd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -350,6 +350,7 @@ "anthropic-cooldown-recovery.test.ts": "adapters/anthropic", "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", + "anthropic-thinking-display-policy.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", "anthropic-image-guard.test.ts": "adapters/anthropic", "anthropic-image-normalize.test.ts": "adapters/anthropic", "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", @@ -768,6 +769,7 @@ "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", + "config-thinking-display.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows", "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", @@ -1040,6 +1042,7 @@ "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", + "management-provider-thinking-display.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", "protocol-settings-route.test.ts": "server", "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", diff --git a/tests/server/management-provider-thinking-display.test.ts b/tests/server/management-provider-thinking-display.test.ts new file mode 100644 index 00000000000..bfd1804ee5c --- /dev/null +++ b/tests/server/management-provider-thinking-display.test.ts @@ -0,0 +1,128 @@ +/** + * Management-boundary coverage for the Anthropic thinking display policy (#8363). + * + * A sibling file rather than an addition to management-provider-validation.test.ts, which + * sits at its file-size ratchet cap (the same reason recorded in d3ca5522db and #4908). + */ +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +setDefaultTimeout(60_000); + +const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-thinking-display-")); +const previousHome = process.env.OPENCODEX_HOME; + +function seedConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "synthetic", + thinkingDisplay: "summarized", + }, + }, + }; +} + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +describe("provider management thinking display policy", () => { + test("shared validator rejects unknown provider and model values", () => { + const provider = { adapter: "anthropic" as const, baseUrl: "https://api.anthropic.com" }; + expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "summarized" })).toBeNull(); + expect(providerManagementConfigError("anthropic", { ...provider, modelThinkingDisplay: { "claude-opus-5-5": "omitted" } })).toBeNull(); + expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "always" })).toContain("thinkingDisplay"); + expect(providerManagementConfigError("anthropic", { + ...provider, + modelThinkingDisplay: { "claude-opus-5-5": "verbose" }, + })).toContain("modelThinkingDisplay"); + }); + + test("PATCH merges model entries, clears one with null, and rejects an unknown value", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(seedConfig()); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + const server = startServer(0); + try { + const patch = (body: unknown) => fetch(new URL("/api/providers?name=anthropic", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + const merged = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "summarized" } }); + expect(merged.status).toBe(200); + expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); + + const rejected = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "verbose" } }); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toEqual(expect.objectContaining({ + error: expect.stringContaining("modelThinkingDisplay"), + })); + expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); + + const cleared = await patch({ modelThinkingDisplay: { "claude-opus-5-5": null } }); + expect(cleared.status).toBe(200); + expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toBeUndefined(); + + const clearedProvider = await patch({ thinkingDisplay: null }); + expect(clearedProvider.status).toBe(200); + expect(loadConfig().providers.anthropic!.thinkingDisplay).toBeUndefined(); + } finally { + await server.stop(true); + resolvedError.mockRestore(); + } + }); + + test("same-destination POST keeps a policy the dashboard form cannot send", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + ...seedConfig(), + providers: { + anthropic: { + ...seedConfig().providers.anthropic!, + modelThinkingDisplay: { "claude-opus-5-5": "updates" }, + }, + }, + }); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "anthropic", + provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic", defaultModel: "claude-opus-5-5" }, + }), + }); + expect(response.status).toBe(200); + const saved = loadConfig().providers.anthropic!; + expect(saved.thinkingDisplay).toBe("summarized"); + expect(saved.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "updates" }); + } finally { + await server.stop(true); + resolvedError.mockRestore(); + } + }); +}); +