From a5e07a9ee46b937e97ff34b6894557db55294fef Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 05:58:45 +0900 Subject: [PATCH] feat(anthropic): add opt-in thinking display policy Native Opus 5.5 on api.anthropic.com always sent thinking.display "updates" unless the request asked for a summary, and every other adaptive model sent "summarized". A Chat Completions client on an updates display saw only heartbeats during a long think, and no operator setting could change it. Add provider-level `thinkingDisplay` and per-model `modelThinkingDisplay` with values auto, summarized, updates and omitted. Unset and auto keep the built-in selection byte for byte. A model entry wins over the provider value, updates degrades to auto on models this adapter never sent it to, omitted sends adaptive thinking with no display field, and an explicit request reasoning.summary of concise or detailed still forces summarized. progressUpdates keeps deriving from the final body display. The new keys validate at every write boundary, migrate through model-rename for model-keyed maps, participate in resolved model policy, survive unrelated provider overwrites, and are accepted by the management PATCH mask and `ocx config set`. Closes #8363 --- .../src/content/docs/reference/adapters.md | 5 + .../docs/reference/configuration/providers.md | 2 + scripts/test-layout/layout.json | 6 +- src/adapters/anthropic.ts | 30 +++- src/config/provider-validation.ts | 29 ++++ src/config/schema/config-schema.ts | 22 +++ src/config/schema/leaf-validators.ts | 5 + src/providers/model-rename-fields.ts | 2 + src/providers/resolved-model-policy.ts | 4 + src/server/auth-cors.ts | 8 ++ .../management/provider-overwrite-carry.ts | 11 +- src/server/management/provider-routes.ts | 6 + .../provider-thinking-display-patch.ts | 54 ++++++++ src/types.ts | 2 + src/types/provider.ts | 10 +- src/types/wire.ts | 10 ++ structure/providers/anthropic-progress.md | 6 + .../anthropic-thinking-display-policy.test.ts | 125 +++++++++++++++++ tests/config/config-thinking-display.test.ts | 56 ++++++++ tests/fixtures/test-layout-expected.json | 3 + ...nagement-provider-thinking-display.test.ts | 128 ++++++++++++++++++ 21 files changed, 516 insertions(+), 8 deletions(-) create mode 100644 src/server/management/provider-thinking-display-patch.ts create mode 100644 tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts create mode 100644 tests/config/config-thinking-display.test.ts create mode 100644 tests/server/management-provider-thinking-display.test.ts 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(); + } + }); +}); +