From f2603c873d1b2d160ec61220ef0291e8863913c3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 17:51:53 +0000 Subject: [PATCH 01/35] =?UTF-8?q?=F0=9F=A4=96=20promote=20gpt-5.6-sol=20as?= =?UTF-8?q?=20GPT=20default;=20add=20gpt-5.6=20terra/luna;=20retire=20bare?= =?UTF-8?q?=20gpt-5.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate OpenAI's GPT-5.6 family (released July 9, 2026) as first-class known models: - knownModels: GPT entry now points at gpt-5.6-sol (aliases gpt/sol); add GPT_56_TERRA (terra) and GPT_56_LUNA (luna). Bare gpt-5.5 alias retires per gpt-5.5 precedent; openai:gpt-5.5 keeps working via models-extra stats. GPT-5.5 Pro unchanged (no 5.6 Pro). - models-extra: flat-rate entries for the three tiers (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per M; cache writes 1.25x input, reads 90% off; 1M/1M/400K context, 128K max output assumed per GPT-5.5). - thinking: Sol-only native "max" reasoning effort — new openaiSupportsNativeMaxEffort predicate + model-aware getOpenAIReasoningEffort used at both providerOptions call sites; Sol policy is 6 levels (off..max), Terra/Luna 5 (off..xhigh); Sol displays MAX/XHIGH as distinct levels, other OpenAI models keep max->XHIGH. - codexOAuth: three ids allowed (not required; no context overrides). - docs/config/models.mdx + builtInSkillContent regenerated. Dogfooded in a dev-server sandbox: picker lists Sol/Terra/Luna, aliases resolve, Sol slider tops at MAX with distinct XHIGH, Terra clamps at XHIGH, mobile 375px renders without overflow, and a live Sol@max request via Mux Gateway carried reasoningEffort "max" with 1M context + $5/M input pricing reflected in stats. --- docs/config/models.mdx | 4 +- src/common/constants/codexOAuth.test.ts | 9 +++ src/common/constants/codexOAuth.ts | 4 ++ src/common/constants/knownModels.test.ts | 10 ++++ src/common/constants/knownModels.ts | 28 ++++++++- src/common/types/thinking.test.ts | 48 +++++++++++++++ src/common/types/thinking.ts | 39 ++++++++++++- src/common/utils/ai/providerOptions.test.ts | 48 +++++++++++++++ src/common/utils/ai/providerOptions.ts | 8 ++- src/common/utils/thinking/policy.test.ts | 52 +++++++++++++++++ src/common/utils/thinking/policy.ts | 12 ++++ src/common/utils/tokens/modelStats.test.ts | 21 +++++++ src/common/utils/tokens/models-extra.ts | 58 +++++++++++++++++++ .../builtInSkillContent.generated.ts | 4 +- 14 files changed, 335 insertions(+), 10 deletions(-) diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 98f9c99733..7b91af0a1c 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -18,7 +18,9 @@ Mux ships with curated models kept up to date with the frontier. Use any custom | Opus 4.8 | anthropic:claude-opus-4-8 | `opus` | ✓ | | Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | | | Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | | -| GPT-5.5 | openai:gpt-5.5 | `gpt`, `gpt-5.5` | | +| GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | | +| GPT-5.6 Terra | openai:gpt-5.6-terra | `terra` | | +| GPT-5.6 Luna | openai:gpt-5.6-luna | `luna` | | | GPT-5.5 Pro | openai:gpt-5.5-pro | `gpt-pro`, `gpt-5.5-pro` | | | GPT-5.4 Mini | openai:gpt-5.4-mini | `gpt-mini` | | | GPT-5.4 Nano | openai:gpt-5.4-nano | `gpt-nano` | | diff --git a/src/common/constants/codexOAuth.test.ts b/src/common/constants/codexOAuth.test.ts index 8d7b56080d..c64f21379a 100644 --- a/src/common/constants/codexOAuth.test.ts +++ b/src/common/constants/codexOAuth.test.ts @@ -19,6 +19,15 @@ describe("codexOAuth model gating", () => { expect(isCodexOauthRequiredModelId("openai:gpt-5.5")).toBe(false); }); + it("allows the GPT-5.6 family through Codex OAuth without requiring it", () => { + for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + expect(isCodexOauthAllowedModelId(model)).toBe(true); + expect(isCodexOauthAllowedModelId(`openai:${model}`)).toBe(true); + expect(isCodexOauthRequiredModelId(model)).toBe(false); + expect(isCodexOauthRequiredModelId(`openai:${model}`)).toBe(false); + } + }); + it("does not allow GPT-5.5 Pro through the Codex OAuth route", () => { expect(isCodexOauthAllowedModelId("gpt-5.5-pro")).toBe(false); expect(isCodexOauthAllowedModelId("openai:gpt-5.5-pro")).toBe(false); diff --git a/src/common/constants/codexOAuth.ts b/src/common/constants/codexOAuth.ts index a699965514..d08a34f2ff 100644 --- a/src/common/constants/codexOAuth.ts +++ b/src/common/constants/codexOAuth.ts @@ -99,6 +99,10 @@ export const CODEX_OAUTH_ALLOWED_MODELS = new Set([ "gpt-5.2", "gpt-5.4-mini", "gpt-5.5", + // GPT-5.6 family (July 9, 2026): available via both the public API and Codex. + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark", diff --git a/src/common/constants/knownModels.test.ts b/src/common/constants/knownModels.test.ts index 6cb043afdd..a753e45077 100644 --- a/src/common/constants/knownModels.test.ts +++ b/src/common/constants/knownModels.test.ts @@ -33,6 +33,16 @@ describe("Known Models Integration", () => { expect(MODEL_ABBREVIATIONS["gemini-flash"]).toBe("google:gemini-3.5-flash"); }); + test("gpt alias tracks the GPT-5.6 flagship tier alongside the tier aliases", () => { + expect(MODEL_ABBREVIATIONS.gpt).toBe("openai:gpt-5.6-sol"); + expect(MODEL_ABBREVIATIONS.sol).toBe("openai:gpt-5.6-sol"); + expect(MODEL_ABBREVIATIONS.terra).toBe("openai:gpt-5.6-terra"); + expect(MODEL_ABBREVIATIONS.luna).toBe("openai:gpt-5.6-luna"); + // The bare gpt-5.5 alias retired with the entry; openai:gpt-5.5 still + // resolves as a custom model string via models-extra stats. + expect(MODEL_ABBREVIATIONS["gpt-5.5"]).toBeUndefined(); + }); + test("known model ids and aliases stay unique across the curated registry", () => { const seenIds = new Set(); const seenAliases = new Set(); diff --git a/src/common/constants/knownModels.ts b/src/common/constants/knownModels.ts index 8789161631..8f50111c88 100644 --- a/src/common/constants/knownModels.ts +++ b/src/common/constants/knownModels.ts @@ -85,12 +85,34 @@ const MODEL_DEFINITIONS = { aliases: ["haiku"], tokenizerOverride: "anthropic/claude-3.5-haiku", }, - // GPT alias tracks the latest stable GPT-5 tier. + // GPT-5.6 Sol - flagship tier of the GPT-5.6 family, released July 9, 2026. + // Sol/Terra/Luna are durable capability tiers; the bare `gpt` alias tracks the + // latest flagship GPT tier (previously gpt-5.5, which stays usable as the + // custom model string `openai:gpt-5.5`). $5/M input, $30/M output; 1M context + // (launch value). Sol is the only tier with the native "max" reasoning effort. GPT: { provider: "openai", - providerModelId: "gpt-5.5", - aliases: ["gpt", "gpt-5.5"], + providerModelId: "gpt-5.6-sol", + aliases: ["gpt", "sol"], warm: true, + // GPT-5.6 tokenizer not published upstream; reuse gpt-5 for approximate + // counting (same approach as gpt-5.5). + tokenizerOverride: "openai/gpt-5", + }, + // GPT-5.6 Terra - balanced everyday tier, released July 9, 2026. + // GPT-5.5-class quality at half the cost: $2.50/M input, $15/M output; 1M context. + GPT_56_TERRA: { + provider: "openai", + providerModelId: "gpt-5.6-terra", + aliases: ["terra"], + tokenizerOverride: "openai/gpt-5", + }, + // GPT-5.6 Luna - fastest, most cost-efficient tier, released July 9, 2026. + // $1/M input, $6/M output; 400K context (launch value). + GPT_56_LUNA: { + provider: "openai", + providerModelId: "gpt-5.6-luna", + aliases: ["luna"], tokenizerOverride: "openai/gpt-5", }, // GPT Pro alias tracks the latest GPT-5 Pro tier. diff --git a/src/common/types/thinking.test.ts b/src/common/types/thinking.test.ts index a80b9a32b4..51cec31d3d 100644 --- a/src/common/types/thinking.test.ts +++ b/src/common/types/thinking.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { coerceThinkingLevel, + getOpenAIReasoningEffort, getThinkingDisplayLabel, getThinkingOptionLabel, MAX_THINKING_INDEX, + openaiSupportsNativeMaxEffort, parseThinkingInput, } from "./thinking"; @@ -22,6 +24,16 @@ describe("getThinkingDisplayLabel", () => { expect(getThinkingDisplayLabel("max", "mux-gateway:openai/gpt-5.2")).toBe("XHIGH"); }); + test("returns MAX for max on GPT-5.6 Sol (native max effort), XHIGH for xhigh", () => { + expect(getThinkingDisplayLabel("max", "openai:gpt-5.6-sol")).toBe("MAX"); + expect(getThinkingDisplayLabel("xhigh", "openai:gpt-5.6-sol")).toBe("XHIGH"); + expect(getThinkingDisplayLabel("max", "mux-gateway:openai/gpt-5.6-sol")).toBe("MAX"); + // Terra/Luna and gpt-5.5-pro keep the max -> XHIGH display. + expect(getThinkingDisplayLabel("max", "openai:gpt-5.6-terra")).toBe("XHIGH"); + expect(getThinkingDisplayLabel("max", "openai:gpt-5.6-luna")).toBe("XHIGH"); + expect(getThinkingDisplayLabel("max", "openai:gpt-5.5-pro")).toBe("XHIGH"); + }); + test("returns MAX for xhigh/max when no model specified (default)", () => { expect(getThinkingDisplayLabel("xhigh")).toBe("MAX"); expect(getThinkingDisplayLabel("max")).toBe("MAX"); @@ -45,11 +57,47 @@ describe("getThinkingOptionLabel", () => { expect(getThinkingOptionLabel("max", "openai:gpt-5.2")).toBe("xhigh"); }); + test("renders distinct max/xhigh options on GPT-5.6 Sol", () => { + expect(getThinkingOptionLabel("max", "openai:gpt-5.6-sol")).toBe("max"); + expect(getThinkingOptionLabel("xhigh", "openai:gpt-5.6-sol")).toBe("xhigh"); + }); + test("preserves non-xhigh labels", () => { expect(getThinkingOptionLabel("medium", "anthropic:claude-opus-4-6")).toBe("medium"); }); }); +describe("openaiSupportsNativeMaxEffort", () => { + test("matches gpt-5.6-sol including prefixed and dated variants", () => { + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-sol")).toBe(true); + expect(openaiSupportsNativeMaxEffort("gpt-5.6-sol")).toBe(true); + expect(openaiSupportsNativeMaxEffort("mux-gateway:openai/gpt-5.6-sol")).toBe(true); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-sol-2026-07-09")).toBe(true); + }); + + test("rejects other tiers and named variants", () => { + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-terra")).toBe(false); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-luna")).toBe(false); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-sol-mini")).toBe(false); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.5")).toBe(false); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.5-pro")).toBe(false); + }); +}); + +describe("getOpenAIReasoningEffort", () => { + test("maps max to the native max effort on GPT-5.6 Sol only", () => { + expect(getOpenAIReasoningEffort("max", "openai:gpt-5.6-sol")).toBe("max"); + expect(getOpenAIReasoningEffort("xhigh", "openai:gpt-5.6-sol")).toBe("xhigh"); + expect(getOpenAIReasoningEffort("max", "openai:gpt-5.6-terra")).toBe("xhigh"); + expect(getOpenAIReasoningEffort("max", "openai:gpt-5.5-pro")).toBe("xhigh"); + }); + + test("keeps the standard mapping for lower levels", () => { + expect(getOpenAIReasoningEffort("off", "openai:gpt-5.6-sol")).toBeUndefined(); + expect(getOpenAIReasoningEffort("high", "openai:gpt-5.6-sol")).toBe("high"); + }); +}); + describe("coerceThinkingLevel", () => { test("normalizes shorthand aliases", () => { expect(coerceThinkingLevel("med")).toBe("medium"); diff --git a/src/common/types/thinking.ts b/src/common/types/thinking.ts index 0ceb2b1d86..6047496888 100644 --- a/src/common/types/thinking.ts +++ b/src/common/types/thinking.ts @@ -38,8 +38,12 @@ export function getThinkingDisplayLabel(level: ThinkingLevel, modelString?: stri const normalized = modelString.trim().toLowerCase(); const withoutPrefix = normalized.replace(/^[a-z0-9_-]+:\s*/, ""); - // OpenAI: both xhigh and max resolve to "xhigh" reasoning effort - if (normalized.startsWith("openai:") || withoutPrefix.startsWith("openai/")) return "XHIGH"; + // OpenAI: both xhigh and max resolve to "xhigh" reasoning effort — except + // GPT-5.6 Sol, where "max" is a distinct native effort above xhigh. + if (normalized.startsWith("openai:") || withoutPrefix.startsWith("openai/")) { + if (level === "max" && openaiSupportsNativeMaxEffort(modelString)) return "MAX"; + return "XHIGH"; + } // Anthropic Opus 4.7+: xhigh is a distinct effort level from max if (level === "xhigh" && anthropicSupportsNativeXhigh(modelString)) return "XHIGH"; @@ -237,6 +241,19 @@ export function anthropicSupportsNativeXhigh(modelString: string): boolean { ); } +/** + * Whether the given OpenAI model supports the native "max" reasoning effort. + * + * GPT-5.6 Sol (released July 9, 2026) introduced a new top reasoning effort above + * xhigh — Sol only; Terra/Luna top out at xhigh. The `(?!-[a-z])` lookahead + * tolerates version-date suffixes (e.g. gpt-5.6-sol-2026-07-09) while rejecting + * hypothetical named variants (e.g. gpt-5.6-sol-mini). + */ +export function openaiSupportsNativeMaxEffort(modelString: string): boolean { + const withoutPrefix = stripModelProviderPrefixes(modelString); + return /^gpt-5\.6-sol(?!-[a-z])/.test(withoutPrefix); +} + /** * Whether the given Anthropic model rejects `thinking: { type: "disabled" }`. * @@ -279,6 +296,24 @@ export const OPENAI_REASONING_EFFORT: Record max: "xhigh", }; +/** + * Model-aware OpenAI reasoning effort resolution. + * + * Most OpenAI models top out at "xhigh", so the ThinkingLevel "max" downgrades to + * "xhigh" (see OPENAI_REASONING_EFFORT). GPT-5.6 Sol ships a distinct native effort + * above xhigh; assumption: its wire value is the string "max" on the Responses API + * (launch docs name the effort "max"; not yet in the SDK's typed union). + */ +export function getOpenAIReasoningEffort( + level: ThinkingLevel, + modelString: string +): string | undefined { + if (level === "max" && openaiSupportsNativeMaxEffort(modelString)) { + return "max"; + } + return OPENAI_REASONING_EFFORT[level]; +} + /** * OpenRouter reasoning effort mapping * diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 192f52e9a9..6497122839 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -672,6 +672,54 @@ describe("buildProviderOptions - OpenAI", () => { }); }); + describe("GPT-5.6 Sol native max reasoning effort", () => { + test("maps ThinkingLevel max to the native max effort on Sol", () => { + const result = buildProviderOptions("openai:gpt-5.6-sol", "max"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("max"); + }); + + test("keeps xhigh distinct from max on Sol", () => { + const result = buildProviderOptions("openai:gpt-5.6-sol", "xhigh"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("xhigh"); + }); + + test("keeps max -> xhigh for non-Sol OpenAI models", () => { + for (const model of ["openai:gpt-5.6-terra", "openai:gpt-5.6-luna", "openai:gpt-5.5-pro"]) { + const result = buildProviderOptions(model, "max"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("xhigh"); + } + }); + + test("carries the native max effort through the Copilot-routed gateway call site", () => { + const result = buildProviderOptions( + "openai:gpt-5.6-sol", + "max", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "github-copilot" + ); + + expect(result).toEqual({ + "github-copilot": { + reasoningEffort: "max", + }, + }); + }); + }); + describe("OpenAI conversation state management", () => { test("does not reuse previousResponseId when Mux already sends explicit GPT-5.5 history", () => { const messages = [ diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 842cc0bb5d..8ecb3e790e 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -21,7 +21,7 @@ import { anthropicSupportsNativeXhigh, ANTHROPIC_THINKING_BUDGETS, GEMINI_THINKING_BUDGETS, - OPENAI_REASONING_EFFORT, + getOpenAIReasoningEffort, OPENROUTER_REASONING_EFFORT, } from "@/common/types/thinking"; import { isGeminiFlashThinkingLevelModelName } from "@/common/utils/thinking/policy"; @@ -353,7 +353,9 @@ export function buildProviderOptions( // Build OpenAI-specific options if (formatProvider === "openai") { - const reasoningEffort = OPENAI_REASONING_EFFORT[effectiveThinking]; + // Model-aware: GPT-5.6 Sol maps ThinkingLevel "max" to the native "max" effort; + // other OpenAI models keep the max -> "xhigh" downgrade. + const reasoningEffort = getOpenAIReasoningEffort(effectiveThinking, modelString); // Mux always sends the latest conversation history explicitly. OpenAI's // previous_response_id is an alternative state-management path, not an additive one. @@ -507,7 +509,7 @@ export function buildProviderOptions( } if (origin === "openai" && formatProvider !== origin) { - const reasoningEffort = OPENAI_REASONING_EFFORT[effectiveThinking]; + const reasoningEffort = getOpenAIReasoningEffort(effectiveThinking, modelString); if (!reasoningEffort) { log.debug( "buildProviderOptions: OpenAI-compatible gateway (thinking off, no provider options)", diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index 4a6fee5eb7..8b60c37947 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -196,6 +196,58 @@ describe("getThinkingPolicyForModel", () => { ]); }); + test("returns 6 levels including max for gpt-5.6-sol", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.6-sol")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getThinkingPolicyForModel("mux-gateway:openai/gpt-5.6-sol-2026-07-09")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); + + test("returns 5 levels including xhigh for gpt-5.6-terra and gpt-5.6-luna (no max)", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.6-terra")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + expect(getThinkingPolicyForModel("openai:gpt-5.6-luna")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + expect(getThinkingPolicyForModel("mux-gateway:openai/gpt-5.6-terra-2026-07-09")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + ]); + }); + + test("gpt-5.6-sol named variants fall through to the default policy", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.6-sol-mini")).toEqual([ + "off", + "low", + "medium", + "high", + ]); + }); + test("returns 5 levels including xhigh for gpt-5.4-mini", () => { expect(getThinkingPolicyForModel("openai:gpt-5.4-mini")).toEqual([ "off", diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index 7d47db9ea5..f8a12655ed 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -54,6 +54,8 @@ export function isGeminiFlashThinkingLevelModelName(modelName: string): boolean * - openai:gpt-5.3-codex / Spark variants → * ["off", "low", "medium", "high", "xhigh"] (5 levels including xhigh) * - openai:gpt-5.2 / openai:gpt-5.5 → ["off", "low", "medium", "high", "xhigh"] + * - openai:gpt-5.6-sol → ["off", "low", "medium", "high", "xhigh", "max"] (6 levels; Sol-only native max) + * - openai:gpt-5.6-terra / openai:gpt-5.6-luna → ["off", "low", "medium", "high", "xhigh"] * - openai:gpt-5.2-pro / openai:gpt-5.5-pro → ["medium", "high", "xhigh"] (3 levels) * - openai:gpt-5-pro → ["high"] (only supported level, legacy) * - Gemini Flash chat variants → ["off", "low", "medium", "high"] @@ -120,6 +122,16 @@ function getExplicitThinkingPolicy(modelString: string): ThinkingPolicy | null { return ["off", "low", "medium", "high", "xhigh"]; } + // gpt-5.6-sol supports the new native "max" reasoning effort (Sol only). + if (/^gpt-5\.6-sol(?!-[a-z])/.test(withoutProviderNamespace)) { + return ["off", "low", "medium", "high", "xhigh", "max"]; + } + + // gpt-5.6-terra / gpt-5.6-luna support the same 5 levels as gpt-5.5. + if (/^gpt-5\.6-(?:terra|luna)(?!-[a-z])/.test(withoutProviderNamespace)) { + return ["off", "low", "medium", "high", "xhigh"]; + } + // gpt-5.2-pro and gpt-5.5-pro support medium, high, xhigh reasoning levels if (/^gpt-5\.(?:2|5)-pro(?!-[a-z])/.test(withoutProviderNamespace)) { return ["medium", "high", "xhigh"]; diff --git a/src/common/utils/tokens/modelStats.test.ts b/src/common/utils/tokens/modelStats.test.ts index 70a975afb7..37de14694e 100644 --- a/src/common/utils/tokens/modelStats.test.ts +++ b/src/common/utils/tokens/modelStats.test.ts @@ -28,10 +28,31 @@ describe("getModelStats", () => { ["mux-gateway:openai/gpt-5.5-pro-2026-04-23", "openai:gpt-5.5-pro"], ["mux-gateway:openai/gpt-5.4-mini-2026-03-11", "openai:gpt-5.4-mini"], ["mux-gateway:openai/gpt-5.4-nano-2026-03-17", "openai:gpt-5.4-nano"], + ["openai:gpt-5.6-sol-2026-07-09", "openai:gpt-5.6-sol"], ])("falls back from %s to the published %s family entry", (datedModel, canonicalModel) => { expect(expectStats(datedModel)).toEqual(expectStats(canonicalModel)); }); + test.each([ + // [model, input, output, cacheRead, cacheCreation, maxInput] + ["openai:gpt-5.6-sol", 0.000005, 0.00003, 0.0000005, 0.00000625, 1000000], + ["openai:gpt-5.6-terra", 0.0000025, 0.000015, 0.00000025, 0.000003125, 1000000], + ["openai:gpt-5.6-luna", 0.000001, 0.000006, 0.0000001, 0.00000125, 400000], + ] as const)( + "resolves %s with the launch pricing and limits", + (model, input, output, cacheRead, cacheCreation, maxInput) => { + const stats = expectStats(model); + expect(stats.input_cost_per_token).toBe(input); + expect(stats.output_cost_per_token).toBe(output); + expect(stats.cache_read_input_token_cost).toBe(cacheRead); + expect(stats.cache_creation_input_token_cost).toBe(cacheCreation); + expect(stats.max_input_tokens).toBe(maxInput); + expect(stats.max_output_tokens).toBe(128000); + // GPT-5.6 pricing is flat: no long-context tier (unlike gpt-5.5). + expect(stats.tiered_pricing_threshold_tokens).toBeUndefined(); + } + ); + test("resolves GPT-5.4 nano with the published limits and pricing", () => { const stats = expectStats(KNOWN_MODELS.GPT_54_NANO.id); expect(stats.max_input_tokens).toBe(400000); diff --git a/src/common/utils/tokens/models-extra.ts b/src/common/utils/tokens/models-extra.ts index 09867e3842..d268847d5f 100644 --- a/src/common/utils/tokens/models-extra.ts +++ b/src/common/utils/tokens/models-extra.ts @@ -239,6 +239,64 @@ export const modelsExtra: Record = { knowledge_cutoff: "2025-08-31", }, + // GPT-5.6 Sol - Released July 9, 2026 (flagship tier of the GPT-5.6 family). + // Launch-time docs: 1M context, flat pricing (no long-context tier, unlike GPT-5.5). + // Max output not published; assume 128K matching the GPT-5.5 family. + // $5/M input, $30/M output; cache writes billed at 1.25x input ($6.25/M), + // cache reads at the 90% discount ($0.50/M). Knowledge cutoff not published. + "gpt-5.6-sol": { + max_input_tokens: 1000000, + max_output_tokens: 128000, + input_cost_per_token: 0.000005, // $5 per million input tokens + output_cost_per_token: 0.00003, // $30 per million output tokens + cache_read_input_token_cost: 0.0000005, // $0.50 per million cached input tokens + cache_creation_input_token_cost: 0.00000625, // $6.25 per million tokens (1.25x input) + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + }, + + // GPT-5.6 Terra - Released July 9, 2026 (balanced everyday tier). + // Launch-time docs: 1M context, flat pricing; max output assumed 128K (see Sol). + // $2.50/M input, $15/M output; cache writes 1.25x input ($3.125/M), cache reads + // 90% off ($0.25/M). Knowledge cutoff not published. + "gpt-5.6-terra": { + max_input_tokens: 1000000, + max_output_tokens: 128000, + input_cost_per_token: 0.0000025, // $2.50 per million input tokens + output_cost_per_token: 0.000015, // $15 per million output tokens + cache_read_input_token_cost: 0.00000025, // $0.25 per million cached input tokens + cache_creation_input_token_cost: 0.000003125, // $3.125 per million tokens (1.25x input) + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + }, + + // GPT-5.6 Luna - Released July 9, 2026 (fastest, most cost-efficient tier). + // Launch-time docs: 400K context, flat pricing; max output assumed 128K (see Sol). + // $1/M input, $6/M output; cache writes 1.25x input ($1.25/M), cache reads + // 90% off ($0.10/M). Knowledge cutoff not published. + "gpt-5.6-luna": { + max_input_tokens: 400000, + max_output_tokens: 128000, + input_cost_per_token: 0.000001, // $1 per million input tokens + output_cost_per_token: 0.000006, // $6 per million output tokens + cache_read_input_token_cost: 0.0000001, // $0.10 per million cached input tokens + cache_creation_input_token_cost: 0.00000125, // $1.25 per million tokens (1.25x input) + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + }, + // GPT-5.5 Pro - Released April 23, 2026 // Native 1.05M context, 128K max output; Responses API only. // Base pricing: $30/M input, $180/M output; OpenAI has not published cached-input pricing. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 412815d093..aa431bba02 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3097,7 +3097,9 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Opus 4.8 | anthropic:claude-opus-4-8 | `opus` | ✓ |", "| Sonnet 5 | anthropic:claude-sonnet-5 | `sonnet` | |", "| Haiku 4.5 | anthropic:claude-haiku-4-5 | `haiku` | |", - "| GPT-5.5 | openai:gpt-5.5 | `gpt`, `gpt-5.5` | |", + "| GPT-5.6 Sol | openai:gpt-5.6-sol | `gpt`, `sol` | |", + "| GPT-5.6 Terra | openai:gpt-5.6-terra | `terra` | |", + "| GPT-5.6 Luna | openai:gpt-5.6-luna | `luna` | |", "| GPT-5.5 Pro | openai:gpt-5.5-pro | `gpt-pro`, `gpt-5.5-pro` | |", "| GPT-5.4 Mini | openai:gpt-5.4-mini | `gpt-mini` | |", "| GPT-5.4 Nano | openai:gpt-5.4-nano | `gpt-nano` | |", From 594cd52f5e97b11e2e4c86a0441087fd0aa50318 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 18:10:32 +0000 Subject: [PATCH 02/35] =?UTF-8?q?=F0=9F=A4=96=20tests:=20pin=20gpt-5.5=20c?= =?UTF-8?q?ontext-limit=20expectations=20to=20literal=20model=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KNOWN_MODELS.GPT.id now resolves to openai:gpt-5.6-sol, but these tests exercise gpt-5.5-specific behavior (1.05M native window, 272K Codex OAuth context cap) that remains in production for the literal openai:gpt-5.5. Also add a behavioral test asserting gpt-5.6-sol keeps its 1M window under Codex OAuth (no context-window override published at launch). Signed-off-by: Thomas Kosiewski --- .../utils/compaction/contextLimit.test.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/common/utils/compaction/contextLimit.test.ts b/src/common/utils/compaction/contextLimit.test.ts index d3b55b37dc..ee4e80a212 100644 --- a/src/common/utils/compaction/contextLimit.test.ts +++ b/src/common/utils/compaction/contextLimit.test.ts @@ -54,8 +54,8 @@ describe("getEffectiveContextLimit", () => { }); test("uses GPT-5.5's native 1.05M context without the 1M toggle", () => { - const baseLimit = getEffectiveContextLimit(KNOWN_MODELS.GPT.id, false, null); - const toggledLimit = getEffectiveContextLimit(KNOWN_MODELS.GPT.id, true, null); + const baseLimit = getEffectiveContextLimit("openai:gpt-5.5", false, null); + const toggledLimit = getEffectiveContextLimit("openai:gpt-5.5", true, null); expect(baseLimit).toBe(1_050_000); expect(toggledLimit).toBe(1_050_000); @@ -63,33 +63,45 @@ describe("getEffectiveContextLimit", () => { test("caps GPT-5.5 at the Codex OAuth context window when OAuth is the active auth route", () => { const oauthOnlyLimit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ codexOauthSet: true }) ); expect(oauthOnlyLimit).toBe(272_000); const defaultOauthLimit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ apiKeySet: true, codexOauthSet: true }) ); expect(defaultOauthLimit).toBe(272_000); }); + // Pinned to the literal "gpt-5.5" (not KNOWN_MODELS.GPT.id): the GPT alias now + // tracks gpt-5.6-sol, which has no Codex OAuth context override, while the cap + // under test belongs to the retired-but-still-priced gpt-5.5 model string. test("inherits the Codex OAuth context cap from a mapped OpenAI model", () => { const limit = getEffectiveContextLimit( "openai:team-gpt", false, providersWithOpenAI({ codexOauthSet: true, - models: [{ id: "team-gpt", mappedToModel: KNOWN_MODELS.GPT.id }], + models: [{ id: "team-gpt", mappedToModel: "gpt-5.5" }], }) ); expect(limit).toBe(272_000); }); + test("does not cap GPT-5.6 Sol under Codex OAuth (no context-window override published)", () => { + const oauthOnlyLimit = getEffectiveContextLimit( + "openai:gpt-5.6-sol", + false, + providersWithOpenAI({ codexOauthSet: true }) + ); + expect(oauthOnlyLimit).toBe(1_000_000); + }); + test("does not apply the GPT-5.5 OAuth cap to gateway-routed models", () => { const limit = getEffectiveContextLimit( "openrouter:openai/gpt-5.5", @@ -102,7 +114,7 @@ describe("getEffectiveContextLimit", () => { test("keeps GPT-5.5's API context window when API key auth is selected", () => { const limit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ apiKeySet: true, @@ -116,7 +128,7 @@ describe("getEffectiveContextLimit", () => { test("does not treat unresolved API-key files as active API-key auth", () => { const limit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ apiKeyFile: "/missing/openai-key", @@ -130,7 +142,7 @@ describe("getEffectiveContextLimit", () => { test("uses GPT-5.5's API context window for resolved API-key files", () => { const limit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ apiKeyFile: "/readable/openai-key", @@ -145,7 +157,7 @@ describe("getEffectiveContextLimit", () => { test("detects env-sourced API keys when deciding GPT-5.5 Codex OAuth routing", () => { const limit = getEffectiveContextLimit( - KNOWN_MODELS.GPT.id, + "openai:gpt-5.5", false, providersWithOpenAI({ apiKeySource: "env", From e9d865c9f7911f3b728d689b3c5a17567b57b1e3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 18:29:55 +0000 Subject: [PATCH 03/35] =?UTF-8?q?=F0=9F=A4=96=20tests:=20pin=20remaining?= =?UTF-8?q?=20gpt-5.5=20expectations=20to=20literal=20model=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the last two tests still using KNOWN_MODELS.GPT.id for gpt-5.5-specific behavior (272K Codex OAuth token-meter cap, 1.05M native auto-compaction window) after GPT was repointed to openai:gpt-5.6-sol. Same pattern as 911b1287c; also add a companion token-meter assertion that gpt-5.6-sol stays uncapped (1M) under Codex OAuth. --- .../compaction/autoCompactionCheck.test.ts | 6 ++++-- .../utils/tokens/tokenMeterUtils.test.ts | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/common/utils/compaction/autoCompactionCheck.test.ts b/src/common/utils/compaction/autoCompactionCheck.test.ts index 1d8562bc6a..a8460ae4e6 100644 --- a/src/common/utils/compaction/autoCompactionCheck.test.ts +++ b/src/common/utils/compaction/autoCompactionCheck.test.ts @@ -228,8 +228,10 @@ describe("checkAutoCompaction", () => { }); test("uses GPT-5.5's native 1.05M limit without relying on the 1M toggle", () => { - const usage = createMockUsage(600_000, undefined, KNOWN_MODELS.GPT.id); - const result = checkAutoCompaction(usage, KNOWN_MODELS.GPT.id, false); + // Pin to the literal model string: gpt-5.5's 1.05M native window remains in + // production even though KNOWN_MODELS.GPT now points at gpt-5.6-sol (1M). + const usage = createMockUsage(600_000, undefined, "openai:gpt-5.5"); + const result = checkAutoCompaction(usage, "openai:gpt-5.5", false); expect(result.usagePercentage).toBeCloseTo(57.14, 2); expect(result.shouldShowWarning).toBe(false); diff --git a/src/common/utils/tokens/tokenMeterUtils.test.ts b/src/common/utils/tokens/tokenMeterUtils.test.ts index 668e39d6c0..98abc67ef2 100644 --- a/src/common/utils/tokens/tokenMeterUtils.test.ts +++ b/src/common/utils/tokens/tokenMeterUtils.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { KNOWN_MODELS } from "@/common/constants/knownModels"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { calculateTokenMeterData, formatTokens } from "./tokenMeterUtils"; @@ -78,7 +77,9 @@ describe("calculateTokenMeterData", () => { }); test("uses the Codex OAuth cap for GPT-5.5 token meter percentages", () => { - const result = calculateTokenMeterData(SAMPLE_USAGE, KNOWN_MODELS.GPT.id, false, false, { + // Pin to the literal model string: gpt-5.5's 272K Codex OAuth cap remains in + // production even though KNOWN_MODELS.GPT now points at gpt-5.6-sol. + const result = calculateTokenMeterData(SAMPLE_USAGE, "openai:gpt-5.5", false, false, { openai: { apiKeySet: false, isEnabled: true, @@ -91,6 +92,20 @@ describe("calculateTokenMeterData", () => { expect(result.totalPercentage).toBeCloseTo((11_000 / 272_000) * 100); }); + test("does not cap GPT-5.6 Sol under Codex OAuth (no context-window override published)", () => { + const result = calculateTokenMeterData(SAMPLE_USAGE, "openai:gpt-5.6-sol", false, false, { + openai: { + apiKeySet: false, + isEnabled: true, + isConfigured: true, + codexOauthSet: true, + }, + }); + + expect(result.maxTokens).toBe(1_000_000); + expect(result.totalPercentage).toBeCloseTo(1.1); + }); + test("uses Claude Sonnet 4.6's native 1M context even when the beta toggle is off", () => { const result = calculateTokenMeterData(SAMPLE_USAGE, "anthropic:claude-sonnet-4-6", false); From 98bfda8f27ecd790fcdd3363d37185174f143056 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 20:13:34 +0000 Subject: [PATCH 04/35] =?UTF-8?q?=F0=9F=A4=96=20feat:=20GPT-5.6=20pro-mode?= =?UTF-8?q?=20toggle=20(reasoning.mode=20wire=20injection=20+=20setting=20?= =?UTF-8?q?plumbing=20+=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Kosiewski --- src/browser/App.tsx | 103 +++++++++++++++- .../ThinkingSlider/ProModeToggle.tsx | 48 ++++++++ src/browser/contexts/ThinkingContext.tsx | 108 +++++++++++++--- src/browser/contexts/WorkspaceContext.tsx | 23 +++- src/browser/features/ChatInput/index.tsx | 23 +++- .../ChatInput/useCreationWorkspace.test.tsx | 1 + .../ChatInput/useCreationWorkspace.ts | 31 ++++- .../hooks/useDraftWorkspaceSettings.ts | 6 +- src/browser/hooks/useReasoningMode.ts | 12 ++ src/browser/hooks/useSendMessageOptions.ts | 3 + src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 2 + src/browser/utils/commands/sources.ts | 24 +++- .../utils/messages/buildSendMessageOptions.ts | 4 +- src/browser/utils/messages/sendOptions.ts | 9 +- src/browser/utils/workspaceAiSettingsSync.ts | 16 ++- src/common/constants/storage.ts | 8 ++ src/common/orpc/schemas/stream.ts | 4 +- .../orpc/schemas/workspaceAiSettings.ts | 7 +- src/common/types/message.ts | 4 + src/common/types/thinking.ts | 31 +++++ src/common/utils/ai/providerOptions.ts | 32 ++++- src/node/services/agentSession.ts | 27 +++- src/node/services/aiService.ts | 17 ++- src/node/services/providerModelFactory.ts | 115 +++++++++++++++++- src/node/services/workspaceService.ts | 67 ++++++++-- 26 files changed, 668 insertions(+), 58 deletions(-) create mode 100644 src/browser/components/ThinkingSlider/ProModeToggle.tsx create mode 100644 src/browser/hooks/useReasoningMode.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 5948e8a213..17d8657fde 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -48,7 +48,11 @@ import { import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import { getTopLevelProjectEntries } from "@/common/utils/subProjects"; -import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; +import { + THINKING_LEVELS, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { @@ -57,6 +61,7 @@ import { getModelKey, getNotifyOnResponseKey, getThinkingLevelByModelKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getWorkspaceLastReadKey, @@ -440,6 +445,17 @@ function AppInner() { [getModelForWorkspace] ); + const getReasoningModeForWorkspace = useCallback((workspaceId: string): OpenAIReasoningMode => { + if (!workspaceId) { + return "standard"; + } + const stored = readPersistedState( + getReasoningModeKey(workspaceId), + null + ); + return stored ?? "standard"; + }, []); + const setThinkingLevelFromPalette = useCallback( (workspaceId: string, level: ThinkingLevel) => { if (!workspaceId) { @@ -449,13 +465,19 @@ function AppInner() { const normalized = THINKING_LEVELS.includes(level) ? level : "off"; const model = getModelForWorkspace(workspaceId); const key = getThinkingLevelKey(workspaceId); + // Carry the current pro-mode choice: the backend replaces the agent's + // settings wholesale, so omitting reasoningMode would wipe it. + const reasoningMode = getReasoningModeForWorkspace(workspaceId); // Use the utility function which handles localStorage and event dispatch // ThinkingProvider will pick this up via its listener updatePersistedState(key, normalized); type WorkspaceAISettingsByAgentCache = Partial< - Record + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > >; const normalizedAgentId = @@ -470,7 +492,7 @@ function AppInner() { prev && typeof prev === "object" ? prev : {}; return { ...record, - [normalizedAgentId]: { model, thinkingLevel: normalized }, + [normalizedAgentId]: { model, thinkingLevel: normalized, reasoningMode }, }; }, {} @@ -481,13 +503,14 @@ function AppInner() { markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { model, thinkingLevel: normalized, + reasoningMode, }); api.workspace .updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel: normalized }, + aiSettings: { model, thinkingLevel: normalized, reasoningMode }, }) .then((result) => { if (!result.success) { @@ -509,7 +532,75 @@ function AppInner() { ); } }, - [api, getModelForWorkspace] + [api, getModelForWorkspace, getReasoningModeForWorkspace] + ); + + // Palette toggle for the OpenAI pro reasoning mode. Persists like the + // thinking-level palette action: localStorage first (ThinkingProvider listens), + // then best-effort backend sync with the full settings payload. + const toggleReasoningModeFromPalette = useCallback( + (workspaceId: string) => { + if (!workspaceId) { + return; + } + + const next: OpenAIReasoningMode = + getReasoningModeForWorkspace(workspaceId) === "pro" ? "standard" : "pro"; + const model = getModelForWorkspace(workspaceId); + const thinkingLevel = getThinkingLevelForWorkspace(workspaceId); + + updatePersistedState(getReasoningModeKey(workspaceId), next); + + type WorkspaceAISettingsByAgentCache = Partial< + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > + >; + + const normalizedAgentId = + readPersistedState(getAgentIdKey(workspaceId), WORKSPACE_DEFAULTS.agentId) + .trim() + .toLowerCase() || WORKSPACE_DEFAULTS.agentId; + + updatePersistedState( + getWorkspaceAISettingsByAgentKey(workspaceId), + (prev) => { + const record: WorkspaceAISettingsByAgentCache = + prev && typeof prev === "object" ? prev : {}; + return { + ...record, + [normalizedAgentId]: { model, thinkingLevel, reasoningMode: next }, + }; + }, + {} + ); + + if (api) { + markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { + model, + thinkingLevel, + reasoningMode: next, + }); + + api.workspace + .updateAgentAISettings({ + workspaceId, + agentId: normalizedAgentId, + aiSettings: { model, thinkingLevel, reasoningMode: next }, + }) + .then((result) => { + if (!result.success) { + clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); + } + }) + .catch(() => { + clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); + // Best-effort only. + }); + } + }, + [api, getModelForWorkspace, getReasoningModeForWorkspace, getThinkingLevelForWorkspace] ); const registerParamsRef = useRef(null); @@ -745,6 +836,8 @@ function AppInner() { themePreference, getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, + getReasoningMode: getReasoningModeForWorkspace, + onToggleReasoningMode: toggleReasoningModeFromPalette, getMinThinkingOverride, onStartWorkspaceCreation: openNewWorkspaceFromPalette, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, diff --git a/src/browser/components/ThinkingSlider/ProModeToggle.tsx b/src/browser/components/ThinkingSlider/ProModeToggle.tsx new file mode 100644 index 0000000000..d8e0037cc1 --- /dev/null +++ b/src/browser/components/ThinkingSlider/ProModeToggle.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { openaiSupportsProMode } from "@/common/types/thinking"; +import { useReasoningMode } from "@/browser/hooks/useReasoningMode"; +import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; + +interface ProModeToggleProps { + modelString: string; +} + +/** + * Small "PRO" toggle for OpenAI's pro reasoning mode (GPT-5.6 Sol/Terra only). + * Renders nothing for models without pro-mode support; the persisted setting + * stays inert for them (header gating guarantees wire-level inertness). + */ +export const ProModeToggle: React.FC = (props) => { + const [reasoningMode, setReasoningMode] = useReasoningMode(); + + if (!openaiSupportsProMode(props.modelString)) { + return null; + } + + const isActive = reasoningMode === "pro"; + + return ( + + + + + + Pro reasoning mode: slower, more thorough responses. Saved per workspace. + + + ); +}; diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index b720ef6ed4..32d9f22702 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,6 +1,10 @@ import type { ReactNode } from "react"; import React, { createContext, useContext, useEffect, useMemo, useCallback } from "react"; -import { THINKING_LEVEL_OFF, type ThinkingLevel } from "@/common/types/thinking"; +import { + THINKING_LEVEL_OFF, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { readPersistedState, updatePersistedState, @@ -10,6 +14,7 @@ import { getAgentIdKey, getModelKey, getProjectScopeId, + getReasoningModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, @@ -32,6 +37,9 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; setThinkingLevel: (level: ThinkingLevel) => void; + /** OpenAI pro reasoning-mode toggle; sibling of thinkingLevel (orthogonal on the wire). */ + reasoningMode: OpenAIReasoningMode; + setReasoningMode: (mode: OpenAIReasoningMode) => void; } const ThinkingContext = createContext(undefined); @@ -83,6 +91,13 @@ export const ThinkingProvider: React.FC = (props) => { const thinkingLevel = persistedThinkingLevel ?? metadataSettings.thinkingLevel ?? THINKING_LEVEL_OFF; + // Workspace-scoped OpenAI pro reasoning mode. Null = no explicit user choice yet; + // absent everywhere means "standard" (the API default). + const reasoningKey = getReasoningModeKey(scopeId); + const [persistedReasoningMode, setReasoningModeInternal] = + usePersistedState(reasoningKey, null, { listener: true }); + const reasoningMode = persistedReasoningMode ?? metadataSettings.reasoningMode ?? "standard"; + // One-time migration: if the new workspace-scoped key is missing, seed from the legacy per-model key. useEffect(() => { const existing = readPersistedState(thinkingKey, undefined); @@ -100,12 +115,16 @@ export const ThinkingProvider: React.FC = (props) => { updatePersistedState(thinkingKey, legacy); }, [defaultModel, scopeId, thinkingKey]); - const setThinkingLevel = useCallback( - (level: ThinkingLevel) => { - const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); - - setThinkingLevelInternal(level); - + // Shared persistence for both setters: caches the full per-agent settings and + // pushes them to the backend. updateAgentAISettings replaces the agent's + // settings wholesale, so every payload must carry BOTH thinkingLevel and + // reasoningMode or the omitted one gets wiped on the next sync. + const persistAgentAiSettings = useCallback( + (settings: { + model: string; + thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; + }) => { // Workspace variant: persist to backend so settings follow the workspace across devices. if (!props.workspaceId) { return; @@ -114,7 +133,10 @@ export const ThinkingProvider: React.FC = (props) => { const workspaceId = props.workspaceId; type WorkspaceAISettingsByAgentCache = Partial< - Record + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > >; const normalizedAgentId = @@ -129,7 +151,7 @@ export const ThinkingProvider: React.FC = (props) => { prev && typeof prev === "object" ? prev : {}; return { ...record, - [normalizedAgentId]: { model, thinkingLevel: level }, + [normalizedAgentId]: settings, }; }, {} @@ -141,16 +163,13 @@ export const ThinkingProvider: React.FC = (props) => { // Avoid stale backend metadata clobbering newer local preferences when users // click through levels quickly (tests reproduce this by cycling to xhigh). - markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { - model, - thinkingLevel: level, - }); + markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, settings); api.workspace .updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, - aiSettings: { model, thinkingLevel: level }, + aiSettings: settings, }) .then((result) => { if (!result.success) { @@ -162,16 +181,69 @@ export const ThinkingProvider: React.FC = (props) => { // Best-effort only. If offline or backend is old, the next sendMessage will persist. }); }, + [api, props.workspaceId, scopeId] + ); + + // Read the sibling setting at call time (not from the render closure) so + // rapid interleaved updates cannot persist a stale counterpart value. + const getCurrentReasoningMode = useCallback( + (): OpenAIReasoningMode => + readPersistedState(reasoningKey, null) ?? + metadataSettings.reasoningMode ?? + "standard", + [metadataSettings.reasoningMode, reasoningKey] + ); + + const getCurrentThinkingLevel = useCallback( + (): ThinkingLevel => + readPersistedState(thinkingKey, null) ?? + metadataSettings.thinkingLevel ?? + THINKING_LEVEL_OFF, + [metadataSettings.thinkingLevel, thinkingKey] + ); + + const setThinkingLevel = useCallback( + (level: ThinkingLevel) => { + const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); + + setThinkingLevelInternal(level); + persistAgentAiSettings({ + model, + thinkingLevel: level, + reasoningMode: getCurrentReasoningMode(), + }); + }, [ - api, defaultModel, + getCurrentReasoningMode, metadataSettings.model, - props.workspaceId, + persistAgentAiSettings, scopeId, setThinkingLevelInternal, ] ); + const setReasoningMode = useCallback( + (mode: OpenAIReasoningMode) => { + const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); + + setReasoningModeInternal(mode); + persistAgentAiSettings({ + model, + thinkingLevel: getCurrentThinkingLevel(), + reasoningMode: mode, + }); + }, + [ + defaultModel, + getCurrentThinkingLevel, + metadataSettings.model, + persistAgentAiSettings, + scopeId, + setReasoningModeInternal, + ] + ); + // Global keybinds for adjusting the thinking level. // Implemented at the ThinkingProvider level so they work in both the workspace view // and the "New Workspace" creation screen (which doesn't mount AIView). @@ -217,8 +289,8 @@ export const ThinkingProvider: React.FC = (props) => { // Memoize context value to prevent unnecessary re-renders of consumers. const contextValue = useMemo( - () => ({ thinkingLevel, setThinkingLevel }), - [thinkingLevel, setThinkingLevel] + () => ({ thinkingLevel, setThinkingLevel, reasoningMode, setReasoningMode }), + [thinkingLevel, setThinkingLevel, reasoningMode, setReasoningMode] ); return {props.children}; diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e938eccf8d..bc57e43029 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -13,7 +13,7 @@ import { import { useLocation } from "react-router-dom"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ArchivePreflightResult, ArchiveWorkspaceResult } from "@/common/orpc/schemas/api"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import type { MuxDeepLinkPayload } from "@/common/types/deepLink"; @@ -27,6 +27,7 @@ import { getPendingScopeId, getRightSidebarLayoutKey, getTerminalTitlesKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getWorkspaceNameStateKey, @@ -173,7 +174,10 @@ function shouldSeedWorkspaceAgentIdFromBackend(metadata: FrontendWorkspaceMetada function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadata): void { // Cache keyed by agentId (string) - includes exec, plan, and custom agents type WorkspaceAISettingsByAgentCache = Partial< - Record + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > >; const workspaceId = metadata.id; @@ -215,6 +219,7 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat !shouldApplyWorkspaceAiSettingsFromBackend(workspaceId, agentKey, { model: entry.model, thinkingLevel: entry.thinkingLevel, + reasoningMode: entry.reasoningMode, }) ) { continue; @@ -223,6 +228,7 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat nextByAgent[agentKey] = { model: entry.model, thinkingLevel: entry.thinkingLevel, + ...(entry.reasoningMode != null ? { reasoningMode: entry.reasoningMode } : {}), }; } @@ -251,6 +257,19 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat if (existingThinking !== active.thinkingLevel) { updatePersistedState(thinkingKey, active.thinkingLevel); } + + // Only seed when the backend actually carries the field so metadata persisted + // before the pro-mode feature cannot clear a newer local choice. + if (active.reasoningMode != null) { + const reasoningKey = getReasoningModeKey(workspaceId); + const existingReasoning = readPersistedState( + reasoningKey, + undefined + ); + if (existingReasoning !== active.reasoningMode) { + updatePersistedState(reasoningKey, active.reasoningMode); + } + } } export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 36645b7c27..5b2473ac63 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -29,6 +29,7 @@ import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useAgent } from "@/browser/contexts/AgentContext"; +import { ProModeToggle } from "@/browser/components/ThinkingSlider/ProModeToggle"; import { ThinkingSliderComponent } from "@/browser/components/ThinkingSlider/ThinkingSlider"; import { getAllowedRuntimeModesForUi, @@ -36,6 +37,7 @@ import { } from "@/browser/utils/policyUi"; import { usePolicy } from "@/browser/contexts/PolicyContext"; import { useAPI } from "@/browser/contexts/API"; +import { useReasoningMode } from "@/browser/hooks/useReasoningMode"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; @@ -144,7 +146,11 @@ import { import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; -import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { + coerceThinkingLevel, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { DEFAULT_RUNTIME_ENABLEMENT, normalizeRuntimeEnablement } from "@/common/types/runtime"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; import { @@ -270,6 +276,7 @@ const ChatInputInner: React.FC = (props) => { const creationProject = variant === "creation" ? userProjects.get(creationParentProjectPath) : undefined; const [thinkingLevel] = useThinkingLevel(); + const [reasoningMode] = useReasoningMode(); const dynamicWorkflowsExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); const workspaceHeartbeatsExperimentEnabled = useExperimentValue( EXPERIMENT_IDS.WORKSPACE_HEARTBEATS @@ -777,7 +784,10 @@ const ChatInputInner: React.FC = (props) => { const setPreferredModel = useCallback( (model: string) => { type WorkspaceAISettingsByAgentCache = Partial< - Record + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > >; const selectedModel = normalizeSelectedModel(model); @@ -821,7 +831,9 @@ const ChatInputInner: React.FC = (props) => { prev && typeof prev === "object" ? prev : {}; return { ...record, - [normalizedAgentId]: { model: selectedModel, thinkingLevel }, + // Include reasoningMode so a model change cannot wipe the persisted + // pro-mode choice (backend replaces the agent's settings wholesale). + [normalizedAgentId]: { model: selectedModel, thinkingLevel, reasoningMode }, }; }, {} @@ -835,13 +847,14 @@ const ChatInputInner: React.FC = (props) => { markPendingWorkspaceAiSettings(workspaceId, normalizedAgentId, { model: selectedModel, thinkingLevel, + reasoningMode, }); api.workspace .updateAgentAISettings({ workspaceId, agentId: normalizedAgentId, - aiSettings: { model: selectedModel, thinkingLevel }, + aiSettings: { model: selectedModel, thinkingLevel, reasoningMode }, }) .then((result) => { if (!result.success) { @@ -861,6 +874,7 @@ const ChatInputInner: React.FC = (props) => { providersConfig, onModelChange, thinkingLevel, + reasoningMode, variant, workspaceGoal, workspaceId, @@ -3315,6 +3329,7 @@ const ChatInputInner: React.FC = (props) => { data-component="ThinkingSliderGroup" > + diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 8f71cace38..f5755c3f61 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1462,6 +1462,7 @@ function createDraftSettingsHarness( const settings: DraftWorkspaceSettings = { model: "gpt-4", thinkingLevel: "medium", + reasoningMode: "standard", agentId: state.agentId, selectedRuntime: state.selectedRuntime, defaultRuntimeMode: state.defaultRuntimeMode, diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 1a0f3b3b8d..301c528544 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -9,7 +9,7 @@ import type { } from "@/common/types/runtime"; import type { RuntimeChoice } from "@/browser/utils/runtimeUi"; import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; @@ -21,6 +21,7 @@ import { getModelKey, getNotifyOnResponseAutoEnableKey, getNotifyOnResponseKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getPendingScopeId, @@ -110,16 +111,36 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel); } + // Mirror thinkingLevel: carry the creation-time pro reasoning-mode choice into + // the new workspace's scope so it survives the project→workspace transition. + const projectReasoningMode = readPersistedState( + getReasoningModeKey(projectScopeId), + null + ); + if (projectReasoningMode !== null) { + updatePersistedState(getReasoningModeKey(workspaceId), projectReasoningMode); + } + if (projectModel) { const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off"; - updatePersistedState>>( + type AgentSettingsCache = Partial< + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > + >; + updatePersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), (prev) => { const record = prev && typeof prev === "object" ? prev : {}; return { - ...(record as Partial>), - [effectiveAgentId]: { model: projectModel, thinkingLevel: effectiveThinking }, + ...(record as AgentSettingsCache), + [effectiveAgentId]: { + model: projectModel, + thinkingLevel: effectiveThinking, + ...(projectReasoningMode != null ? { reasoningMode: projectReasoningMode } : {}), + }, }; }, {} @@ -531,6 +552,7 @@ export function useCreationWorkspace({ aiSettings: { model: settings.model, thinkingLevel: settings.thinkingLevel, + reasoningMode: settings.reasoningMode, }, persistSelectedAgentId: true, }) @@ -692,6 +714,7 @@ export function useCreationWorkspace({ settings.agentId, settings.model, settings.thinkingLevel, + settings.reasoningMode, settings.trunkBranch, waitForGeneration, workspaceNameState.autoGenerate, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 7e8c8b7afa..1311a2a540 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { readPersistedState, usePersistedState } from "./usePersistedState"; +import { useReasoningMode } from "./useReasoningMode"; import { useThinkingLevel } from "./useThinkingLevel"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; @@ -29,7 +30,7 @@ import { getProjectScopeId, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import { normalizeAgentId } from "@/common/utils/agentIds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -41,6 +42,7 @@ export interface DraftWorkspaceSettings { // Model & AI settings (synced with global state) model: string; thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; agentId: string; // Workspace creation settings (project-specific) @@ -227,6 +229,7 @@ export function useDraftWorkspaceSettings( } { // Global AI settings (read-only from global state) const [thinkingLevel] = useThinkingLevel(); + const [reasoningMode] = useReasoningMode(); const projectScopeId = getProjectScopeId(projectPath); const { userProjects } = useProjectContext(); @@ -608,6 +611,7 @@ export function useDraftWorkspaceSettings( settings: { model, thinkingLevel, + reasoningMode, agentId, selectedRuntime, defaultRuntimeMode: defaultRuntimeChoice, diff --git a/src/browser/hooks/useReasoningMode.ts b/src/browser/hooks/useReasoningMode.ts new file mode 100644 index 0000000000..7fec5234ab --- /dev/null +++ b/src/browser/hooks/useReasoningMode.ts @@ -0,0 +1,12 @@ +import { useThinking } from "@/browser/contexts/ThinkingContext"; + +/** + * Custom hook for the OpenAI pro reasoning-mode toggle. + * Must be used within a ThinkingProvider (typically at workspace level). + * + * @returns [reasoningMode, setReasoningMode] tuple + */ +export function useReasoningMode() { + const { reasoningMode, setReasoningMode } = useThinking(); + return [reasoningMode, setReasoningMode] as const; +} diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 4e10e47643..8d730debbd 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -1,4 +1,5 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { useReasoningMode } from "./useReasoningMode"; import { useThinkingLevel } from "./useThinkingLevel"; import { useAgent } from "@/browser/contexts/AgentContext"; import { usePersistedState } from "./usePersistedState"; @@ -29,6 +30,7 @@ export interface SendMessageOptionsWithBase extends SendMessageOptions { */ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWithBase { const [thinkingLevel] = useThinkingLevel(); + const [reasoningMode] = useReasoningMode(); const { agentId, disableWorkspaceAgents } = useAgent(); const { workspaceMetadata } = useWorkspaceContext(); const { options: providerOptions } = useProviderOptions(); @@ -78,6 +80,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi const options = buildSendMessageOptions({ agentId, thinkingLevel, + reasoningMode, model: baseModel, providerOptions, experiments: { diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 1dfe65150c..92c715bff2 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -69,6 +69,7 @@ export const CommandIds = { modeToggle: () => "mode:toggle" as const, modelChange: () => "model:change" as const, thinkingSetLevel: () => "thinking:set-level" as const, + toggleProReasoning: () => "thinking:toggle-pro-reasoning" as const, // Project commands projectAdd: () => "project:add" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 947cbe2c78..e55e5d6a9e 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -45,6 +45,8 @@ const mk = (over: Partial[0]> = {}) => { streamingModels: new Map(), getThinkingLevel: () => "off", onSetThinkingLevel: () => undefined, + getReasoningMode: () => "standard", + onToggleReasoningMode: () => undefined, onStartWorkspaceCreation: () => undefined, onStartMultiProjectWorkspaceCreation: () => undefined, multiProjectWorkspacesEnabled: true, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 7508a09e2d..c5a396b84f 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -5,7 +5,12 @@ import type { ConfirmDialogOptions } from "@/browser/contexts/ConfirmDialogConte import { getContextResetSuccessMessage } from "@/browser/utils/contextResetFeedback"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { PinnedMoveDirection } from "@/browser/utils/ui/pinnedReorder"; -import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; +import { + openaiSupportsProMode, + THINKING_LEVELS, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { enforceThinkingPolicy, getAvailableThinkingLevels, @@ -81,6 +86,8 @@ export interface BuildSourcesParams { // UI actions getThinkingLevel: (workspaceId: string) => ThinkingLevel; onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; + getReasoningMode: (workspaceId: string) => OpenAIReasoningMode; + onToggleReasoningMode: (workspaceId: string) => void; /** * Explicit per-model minimum thinking override (undefined → built-in default floor). * Used to hide off/low from the "Set Thinking Effort" picker, matching the slider. @@ -1264,6 +1271,21 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, }, }); + + // Pro reasoning mode is only meaningful for models that support it + // (GPT-5.6 Sol/Terra); hide the action elsewhere to avoid inert toggles. + if (openaiSupportsProMode(currentModelString ?? "")) { + const proActive = p.getReasoningMode(workspaceId) === "pro"; + list.push({ + id: CommandIds.toggleProReasoning(), + title: "Toggle Pro Reasoning Mode", + subtitle: `Current: ${proActive ? "Pro — slower, more thorough" : "Standard"}`, + section: section.mode, + run: () => { + p.onToggleReasoningMode(workspaceId); + }, + }); + } } return list; diff --git a/src/browser/utils/messages/buildSendMessageOptions.ts b/src/browser/utils/messages/buildSendMessageOptions.ts index 5b0ca78c09..5a37062949 100644 --- a/src/browser/utils/messages/buildSendMessageOptions.ts +++ b/src/browser/utils/messages/buildSendMessageOptions.ts @@ -1,5 +1,5 @@ import type { SendMessageOptions } from "@/common/orpc/types"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; @@ -16,6 +16,7 @@ export interface ExperimentValues { export interface SendMessageOptionsInput { model: string; thinkingLevel: ThinkingLevel; + reasoningMode: OpenAIReasoningMode; agentId: string; providerOptions: MuxProviderOptions; experiments: ExperimentValues; @@ -36,6 +37,7 @@ export function normalizeModelPreference(rawModel: unknown, fallbackModel: strin export function buildSendMessageOptions(input: SendMessageOptionsInput): SendMessageOptions { return { thinkingLevel: input.thinkingLevel, + reasoningMode: input.reasoningMode, model: input.model, agentId: input.agentId, providerOptions: input.providerOptions, diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 08c5725c2d..3c815b5aea 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,6 +1,7 @@ import { getAgentIdKey, getModelKey, + getReasoningModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, getDisableWorkspaceAgentsKey, @@ -12,7 +13,7 @@ import { normalizeModelPreference, } from "@/browser/utils/messages/buildSendMessageOptions"; import type { SendMessageOptions } from "@/common/orpc/types"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; @@ -64,6 +65,11 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio WORKSPACE_DEFAULTS.agentId ); + // OpenAI pro reasoning mode (workspace-scoped); absent = standard. + const reasoningMode = + readPersistedState(getReasoningModeKey(workspaceId), null) ?? + "standard"; + const providerOptions = getProviderOptions(); const disableWorkspaceAgents = readPersistedState( @@ -75,6 +81,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio model: baseModel, agentId, thinkingLevel, + reasoningMode, providerOptions, disableWorkspaceAgents, experiments: { diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index 5988e00d8d..c8b9411641 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,20 +1,27 @@ -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; interface WorkspaceAiSettingsSnapshot { model: string; thinkingLevel: ThinkingLevel; + /** Optional: legacy settings (and non-OpenAI workflows) omit it. */ + reasoningMode?: OpenAIReasoningMode; } export function getWorkspaceAiSettingsFromMetadata( metadata: FrontendWorkspaceMetadata | undefined, agentId: string | undefined -): { model: string | undefined; thinkingLevel: ThinkingLevel | undefined } { +): { + model: string | undefined; + thinkingLevel: ThinkingLevel | undefined; + reasoningMode: OpenAIReasoningMode | undefined; +} { const settings = (agentId ? metadata?.aiSettingsByAgent?.[agentId] : undefined) ?? metadata?.aiSettings; return { model: settings?.model, thinkingLevel: settings?.thinkingLevel, + reasoningMode: settings?.reasoningMode, }; } @@ -58,7 +65,10 @@ export function shouldApplyWorkspaceAiSettingsFromBackend( } const matches = - pending.model === incoming.model && pending.thinkingLevel === incoming.thinkingLevel; + pending.model === incoming.model && + pending.thinkingLevel === incoming.thinkingLevel && + // Absent reasoningMode is semantically "standard" on both sides. + (pending.reasoningMode ?? "standard") === (incoming.reasoningMode ?? "standard"); if (matches) { pendingAiSettingsByWorkspace.delete(key); return true; diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 9ef0b4c542..6f42b25cd5 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -178,6 +178,14 @@ export function getThinkingLevelKey(scopeId: string): string { return `thinkingLevel:${scopeId}`; } +/** + * Get the localStorage key for the OpenAI pro reasoning-mode toggle per scope + * (workspace/project). Format: "reasoningMode:{scopeId}" + */ +export function getReasoningModeKey(scopeId: string): string { + return `reasoningMode:${scopeId}`; +} + /** * Get the localStorage key for per-agent workspace AI overrides cache. * Format: "workspaceAiSettingsByAgent:{workspaceId}" diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 82924fb4fc..9c8abb3f68 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { AgentIdSchema } from "./agentDefinition"; -import { ThinkingLevelSchema } from "../../types/thinking"; +import { OpenAIReasoningModeSchema, ThinkingLevelSchema } from "../../types/thinking"; import { AgentModeSchema } from "../../types/mode"; import { ChatUsageDisplaySchema } from "./chatStats"; import { StreamErrorTypeSchema } from "./errors"; @@ -726,6 +726,8 @@ export const GoalInterventionPolicySchema = z.enum(["steer", "pause"]); export const SendMessageOptionsSchema = z.object({ editMessageId: z.string().optional(), thinkingLevel: ThinkingLevelSchema.optional(), + /** OpenAI reasoning mode (pro toggle); inert for models without pro-mode support. */ + reasoningMode: OpenAIReasoningModeSchema.optional(), model: z.string("No model specified"), toolPolicy: ToolPolicySchema.optional(), additionalSystemInstructions: z.string().optional(), diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 10a0dd1e23..4429278253 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ThinkingLevelSchema } from "../../types/thinking"; +import { OpenAIReasoningModeSchema, ThinkingLevelSchema } from "../../types/thinking"; /** * Workspace-scoped AI settings that should persist across devices. @@ -14,6 +14,11 @@ export const WorkspaceAISettingsSchema = z.object({ thinkingLevel: ThinkingLevelSchema.meta({ description: "Thinking/reasoning effort level", }), + // Optional so legacy persisted settings without the field parse unchanged. + reasoningMode: OpenAIReasoningModeSchema.optional().meta({ + description: + 'OpenAI reasoning mode (orthogonal to effort). Currently applies only to OpenAI GPT-5.6 Sol/Terra; inert elsewhere. Absent = "standard".', + }), }); /** diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 014143b462..a1b3e2121d 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -52,6 +52,7 @@ export interface CompactionFollowUpInput extends UserMessageContent { type PreservedSendOptions = Pick< SendMessageOptions, | "thinkingLevel" + | "reasoningMode" | "additionalSystemInstructions" | "providerOptions" | "experiments" @@ -67,6 +68,7 @@ type PreservedSendOptions = Pick< export function pickPreservedSendOptions(options: SendMessageOptions): PreservedSendOptions { return { thinkingLevel: options.thinkingLevel, + reasoningMode: options.reasoningMode, additionalSystemInstructions: options.additionalSystemInstructions, providerOptions: options.providerOptions, experiments: options.experiments, @@ -81,6 +83,7 @@ export type StartupRetrySendOptions = Pick< | "model" | "agentId" | "thinkingLevel" + | "reasoningMode" | "toolPolicy" | "additionalSystemInstructions" | "maxOutputTokens" @@ -108,6 +111,7 @@ export function pickStartupRetrySendOptions( model: options.model, agentId: options.agentId, thinkingLevel: options.thinkingLevel, + reasoningMode: options.reasoningMode, toolPolicy: options.toolPolicy, additionalSystemInstructions: options.additionalSystemInstructions, maxOutputTokens: options.maxOutputTokens, diff --git a/src/common/types/thinking.ts b/src/common/types/thinking.ts index 6047496888..2cd4cee98e 100644 --- a/src/common/types/thinking.ts +++ b/src/common/types/thinking.ts @@ -254,6 +254,37 @@ export function openaiSupportsNativeMaxEffort(modelString: string): boolean { return /^gpt-5\.6-sol(?!-[a-z])/.test(withoutPrefix); } +/** + * OpenAI Responses API reasoning mode (orthogonal to reasoning effort). + * Absent/"standard" is the API default; "pro" enables the slower, more + * thorough pro-mode serving introduced with GPT-5.6 Sol/Terra. + */ +export const OPENAI_REASONING_MODES = ["standard", "pro"] as const; +export type OpenAIReasoningMode = (typeof OPENAI_REASONING_MODES)[number]; +export const OpenAIReasoningModeSchema = z.enum(OPENAI_REASONING_MODES); + +/** Coerce an untrusted persisted value to an OpenAIReasoningMode (or undefined). */ +export function coerceOpenAIReasoningMode(value: unknown): OpenAIReasoningMode | undefined { + return OPENAI_REASONING_MODES.includes(value as OpenAIReasoningMode) + ? (value as OpenAIReasoningMode) + : undefined; +} + +/** + * Whether the given OpenAI model supports `reasoning.mode: "pro"` on the + * Responses API. + * + * "GPT-5.6 Sol Pro" / "GPT-5.6 Terra Pro" are not separate model ids: the same + * `gpt-5.6-sol` / `gpt-5.6-terra` ids are served with `reasoning.mode: "pro"`. + * There is no Luna Pro. The `(?!-[a-z])` lookahead tolerates version-date + * suffixes (e.g. gpt-5.6-sol-2026-07-09) while rejecting hypothetical named + * variants (e.g. gpt-5.6-sol-mini). + */ +export function openaiSupportsProMode(modelString: string): boolean { + const withoutPrefix = stripModelProviderPrefixes(modelString); + return /^gpt-5\.6-(?:sol|terra)(?!-[a-z])/.test(withoutPrefix); +} + /** * Whether the given Anthropic model rejects `thinking: { type: "disabled" }`. * diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 8ecb3e790e..7cff21cdf3 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -14,7 +14,7 @@ import type { XaiProviderOptions } from "@ai-sdk/xai"; import { PROVIDER_DEFINITIONS, type ProviderName } from "@/common/constants/providers"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import { getAnthropicEffort, anthropicRejectsDisabledThinking, @@ -22,6 +22,7 @@ import { ANTHROPIC_THINKING_BUDGETS, GEMINI_THINKING_BUDGETS, getOpenAIReasoningEffort, + openaiSupportsProMode, OPENROUTER_REASONING_EFFORT, } from "@/common/types/thinking"; import { isGeminiFlashThinkingLevelModelName } from "@/common/utils/thinking/policy"; @@ -39,6 +40,15 @@ import { normalizeToCanonical, supports1MContext } from "./models"; */ export const MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER = "x-mux-anthropic-effort"; +/** + * Request header used to inject OpenAI's `reasoning.mode` at the wire level. + * @ai-sdk/openai only maps reasoningEffort/reasoningSummary into the wire + * `reasoning` object and drops unknown providerOptions keys, so pro mode for + * GPT-5.6 Sol/Terra is delivered via this Mux-internal header, which the + * OpenAI fetch wrapper strips and rewrites into `reasoning.mode` on the wire. + */ +export const MUX_OPENAI_REASONING_MODE_HEADER = "x-mux-openai-reasoning-mode"; + /** * OpenRouter reasoning options * @see https://openrouter.ai/docs/use-cases/reasoning-tokens @@ -584,7 +594,8 @@ export function buildRequestHeaders( workspaceId?: string, providersConfig?: ProvidersConfigMap | null, routeProvider?: ProviderName, - thinkingLevel?: ThinkingLevel + thinkingLevel?: ThinkingLevel, + reasoningMode?: OpenAIReasoningMode ): Record | undefined { const headers: Record = {}; @@ -624,5 +635,22 @@ export function buildRequestHeaders( headers[MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER] = "xhigh"; } + // OpenAI pro reasoning mode (GPT-5.6 Sol/Terra). The @ai-sdk/openai responses + // model drops unknown providerOptions keys, so `reasoning.mode` cannot ride + // providerOptions — emit a Mux-internal header that the OpenAI fetch wrapper + // strips and rewrites into the wire body. Mode is orthogonal to effort, so + // this is independent of thinkingLevel. Only emit when the route passes + // through our OpenAI fetch wrapper (direct OpenAI or passthrough gateways + // like mux-gateway); non-passthrough gateways (OpenRouter, github-copilot) + // must never see this header. + if ( + origin === "openai" && + routePassesHeaders && + reasoningMode === "pro" && + openaiSupportsProMode(modelString) + ) { + headers[MUX_OPENAI_REASONING_MODE_HEADER] = "pro"; + } + return Object.keys(headers).length > 0 ? headers : undefined; } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7104cdc907..97c83af444 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -55,7 +55,11 @@ import { } from "@/node/services/utils/fileChangeTracker"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; -import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { + coerceOpenAIReasoningMode, + coerceThinkingLevel, + type ThinkingLevel, +} from "@/common/types/thinking"; import { enforceThinkingPolicy, resolveMinimumThinkingLevel } from "@/common/utils/thinking/policy"; import { createMuxMessage, @@ -1416,6 +1420,17 @@ export class AgentSession { ? (agentSettingsThinkingLevel ?? persistedThinkingLevel ?? assistantThinkingLevel) : (persistedThinkingLevel ?? assistantThinkingLevel ?? agentSettingsThinkingLevel); + // Pro reasoning mode threads alongside thinkingLevel from the same sources + // (assistant message metadata does not carry it), so startup retries do not + // silently downgrade a pro-mode turn to standard. + const persistedReasoningMode = coerceOpenAIReasoningMode( + persistedRetrySendOptions?.reasoningMode + ); + const agentSettingsReasoningMode = coerceOpenAIReasoningMode(agentSettings?.reasoningMode); + const baseReasoningMode = isChildTaskWorkspace + ? (agentSettingsReasoningMode ?? persistedReasoningMode) + : (persistedReasoningMode ?? agentSettingsReasoningMode); + const persistedToolPolicy = lastUserMessage?.metadata?.toolPolicy ?? persistedRetrySendOptions?.toolPolicy; const persistedDisableWorkspaceAgents = @@ -1438,10 +1453,14 @@ export class AgentSession { const requestedThinkingLevel = baseThinkingLevel ?? coerceThinkingLevel(compactSettings?.thinkingLevel) ?? "off"; + const requestedReasoningMode = + baseReasoningMode ?? coerceOpenAIReasoningMode(compactSettings?.reasoningMode); + const compactionRequest: StartupRetrySendOptions = { model: compactionModel, agentId: "compact", thinkingLevel: enforceThinkingPolicy(compactionModel, requestedThinkingLevel), + ...(requestedReasoningMode != null ? { reasoningMode: requestedReasoningMode } : {}), maxOutputTokens: typeof lastUserMuxMetadata.parsed.maxOutputTokens === "number" ? lastUserMuxMetadata.parsed.maxOutputTokens @@ -1477,6 +1496,9 @@ export class AgentSession { if (baseThinkingLevel) { retryRequest.thinkingLevel = baseThinkingLevel; } + if (baseReasoningMode) { + retryRequest.reasoningMode = baseReasoningMode; + } if (persistedToolPolicy) { retryRequest.toolPolicy = persistedToolPolicy; } @@ -3625,6 +3647,8 @@ export class AgentSession { modelString, abortSignal, thinkingLevel: effectiveThinkingLevel, + // Orthogonal to thinking level; buildRequestHeaders gates it per model. + reasoningMode: options?.reasoningMode, toolPolicy: options?.toolPolicy, additionalSystemContext: options?.additionalSystemContext, additionalSystemInstructions: options?.additionalSystemInstructions, @@ -5479,6 +5503,7 @@ export class AgentSession { model: effectiveModel, agentId: effectiveAgentId, thinkingLevel: followUp.thinkingLevel, + reasoningMode: followUp.reasoningMode, additionalSystemInstructions: followUp.additionalSystemInstructions, providerOptions: followUp.providerOptions, experiments: followUp.experiments, diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 2752fc4610..6465fc4962 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -109,7 +109,11 @@ import { isWorkspaceTrustedForSharedExecution } from "@/node/services/utils/work import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults } from "@/constants/goals"; import { mergeGoalDefaults } from "@/common/utils/goals/resolveGoalSetIntent"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; -import { THINKING_LEVEL_OFF, type ThinkingLevel } from "@/common/types/thinking"; +import { + THINKING_LEVEL_OFF, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { enforceThinkingPolicy, resolveEffectiveThinkingLevel, @@ -225,6 +229,8 @@ export interface StreamMessageOptions { workspaceId: string; modelString: string; thinkingLevel?: ThinkingLevel; + /** OpenAI pro reasoning mode; delivered via buildRequestHeaders (inert for unsupported models). */ + reasoningMode?: OpenAIReasoningMode; toolPolicy?: ToolPolicy; abortSignal?: AbortSignal; /** Live workspace scratchpad snapshot from the renderer; when present it wins over disk. */ @@ -998,6 +1004,7 @@ export class AIService extends EventEmitter { workspaceId, modelString, thinkingLevel, + reasoningMode, toolPolicy, abortSignal, additionalSystemContext, @@ -2403,7 +2410,8 @@ export class AIService extends EventEmitter { workspaceId, this.providerService.getConfig(), routeProvider, - effectiveThinkingLevel + effectiveThinkingLevel, + reasoningMode ); // --- Model parameter overrides from providers.jsonc --- @@ -2705,13 +2713,16 @@ export class AIService extends EventEmitter { promptCacheScope ); + // The pro-mode predicate re-gates per fallback model inside + // buildRequestHeaders, so pro never leaks onto unsupported fallbacks. let nextHeaders = buildRequestHeaders( next.canonicalModelString, effectiveMuxProviderOptions, workspaceId, this.providerService.getConfig(), next.routeProvider, - nextThinkingLevel + nextThinkingLevel, + reasoningMode ); if (pendingRunMetadataId != null) { // Keep DevTools run correlation on fallback requests too. diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 5a85949186..e104024c82 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -51,6 +51,7 @@ import { createOpenAIWebSocketTransportFetch } from "@/node/services/openAIWebSo import { log } from "@/node/services/log"; import { MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, + MUX_OPENAI_REASONING_MODE_HEADER, resolveProviderOptionsNamespaceKey, } from "@/common/utils/ai/providerOptions"; import { resolveRoute, type RouteContext } from "@/common/routing"; @@ -460,6 +461,91 @@ export function wrapFetchWithAnthropicCacheControl( return Object.assign(cachingFetch, baseFetch) as typeof fetch; } +/** + * Wrap fetch to inject OpenAI's `reasoning.mode: "pro"` on the wire. + * + * The @ai-sdk/openai responses model only maps reasoningEffort/reasoningSummary + * into the wire `reasoning` object and drops unknown providerOptions keys, so + * pro mode rides the Mux-internal MUX_OPENAI_REASONING_MODE_HEADER (emitted by + * buildRequestHeaders) and is rewritten into the JSON body here — mirroring + * wrapFetchWithAnthropicCacheControl's header + body-rewrite pattern. + * + * The header is stripped unconditionally (before any early return) so no + * request shape can forward it upstream. Pass `inject: false` to strip-only + * (used for the Codex OAuth route, whose stricter ChatGPT backend could + * hard-fail on unknown nested reasoning fields — a silent standard-mode no-op + * is strictly safer there). + */ +export function wrapFetchWithOpenAIReasoningMode( + baseFetch: typeof fetch, + options?: { inject?: boolean } +): typeof fetch { + const inject = options?.inject ?? true; + const reasoningModeFetch = async ( + input: Parameters[0], + init?: Parameters[1] + ): Promise => { + // Fast path: nothing to strip or inject — forward byte-identical. + const incomingHeaders = new Headers(init?.headers); + const reasoningMode = incomingHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER); + if (reasoningMode == null) { + return baseFetch(input, init); + } + + // Strip the Mux-internal header before ANY forwarding path below. + incomingHeaders.delete(MUX_OPENAI_REASONING_MODE_HEADER); + const strippedInit: Parameters[1] = { ...init, headers: incomingHeaders }; + + // Only rewrite POST requests with a JSON string body; anything else is + // forwarded untouched (header already stripped). + if (init?.method?.toUpperCase() !== "POST" || typeof init?.body !== "string") { + return baseFetch(input, strippedInit); + } + + // Defensive: only "pro" is a valid injection value today; treat anything + // else as invalid and forward strip-only. + if (!inject || reasoningMode !== "pro") { + return baseFetch(input, strippedInit); + } + + try { + const json = JSON.parse(init.body) as Record; + + if (Array.isArray(json.prompt)) { + // AI SDK gateway body shape ({ prompt, providerOptions: { openai } }): + // best-effort inject providerOptions.openai.reasoningMode and let the + // gateway-side SDK translate it. NOTE: gateway-side support for this + // key is unverified — the gateway's @ai-sdk/openai may drop it; the + // guaranteed delivery path is the direct Responses body below. + const providerOpts = isRecord(json.providerOptions) ? json.providerOptions : {}; + const openaiOpts = isRecord(providerOpts.openai) ? providerOpts.openai : {}; + openaiOpts.reasoningMode = "pro"; + providerOpts.openai = openaiOpts; + json.providerOptions = providerOpts; + } else { + // Direct body shape. `reasoning.mode` is a Responses API field; blindly + // adding top-level `reasoning` to chat-completions bodies could + // hard-fail, so only inject when the URL path is Responses-like. + // Unknown URL shape ⇒ do not inject (strip-only). + const isResponsesUrl = /\/responses(\?|$)/.test(getFetchInputUrl(input)); + if (!isResponsesUrl) { + return baseFetch(input, strippedInit); + } + // Merge, never clobber existing effort/summary keys. + json.reasoning = { ...(isRecord(json.reasoning) ? json.reasoning : {}), mode: "pro" }; + } + + incomingHeaders.delete("content-length"); // Body size changed + return baseFetch(input, { ...strippedInit, body: JSON.stringify(json) }); + } catch { + // Malformed JSON: forward with the header stripped, body untouched. + return baseFetch(input, strippedInit); + } + }; + + return Object.assign(reasoningModeFetch, baseFetch) as typeof fetch; +} + /** * Wrap fetch so any mux-gateway 401 response clears local credentials (best-effort). * @@ -763,6 +849,18 @@ export function normalizeCodexResponsesBody(body: string): string { // and currently rejects the `truncation` field entirely. delete json.truncation; + // Defense in depth for pro reasoning mode: the Codex-routed fetch wrapper + // already skips injection (inject: false), but strip `reasoning.mode` here + // too so the stricter ChatGPT backend can never see it even if a future path + // emits it — unknown nested reasoning fields risk a hard 4xx, and a silent + // standard-mode no-op is strictly safer. Preserve effort/summary. + if (isRecord(json.reasoning)) { + delete json.reasoning.mode; + if (Object.keys(json.reasoning).length === 0) { + delete json.reasoning; + } + } + // Codex-compatible Responses requests must disable storage and strip unsupported params. json.store = false; @@ -1427,7 +1525,15 @@ export class ProviderModelFactory { // Lazy-load OpenAI provider to reduce startup time const { createOpenAI } = await PROVIDER_REGISTRY.openai(); - const providerFetch = webSocketTransport.fetch; + // Outermost (SDK-facing) so the pro-mode body rewrite happens before any + // transport decision and the Mux-internal header is stripped before the + // Codex/WebSocket layers regardless of route. Codex normalization runs + // later in the request flow and keeps top-level `reasoning`, so ordering + // is safe. Codex OAuth routes strip-only (see wrapper doc + D3 comment + // in normalizeCodexResponsesBody). + const providerFetch = wrapFetchWithOpenAIReasoningMode(webSocketTransport.fetch, { + inject: !shouldRouteThroughCodexOauth, + }); const provider = createOpenAI({ ...configWithCreds, // Cast is safe: our fetch implementation is compatible with the SDK's fetch type. @@ -1718,7 +1824,12 @@ export class ProviderModelFactory { fetchWithCacheControl, this.providerService ); - const providerFetch = fetchWithAutoLogout; + // OpenAI models via gateway: strip the Mux-internal pro-mode header + // (never leak it upstream, even to our own gateway) and best-effort + // inject reasoningMode into the gateway body shape. + const providerFetch = modelId.startsWith("openai/") + ? wrapFetchWithOpenAIReasoningMode(fetchWithAutoLogout) + : fetchWithAutoLogout; // Use configured baseURL or fall back to default gateway URL const gatewayBaseURL = providerConfig.baseURL ?? "https://gateway.mux.coder.com/api/v1/ai-gateway/v1/ai"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 19d056cda5..bc683742cd 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -175,7 +175,12 @@ import { modelHasPricingData, UNPRICED_TARGET_MODEL_GOAL_MESSAGE, } from "@/common/utils/goals/budgetPricing"; -import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { + coerceOpenAIReasoningMode, + coerceThinkingLevel, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { normalizeAgentId } from "@/common/utils/agentIds"; import { @@ -6797,6 +6802,7 @@ export class WorkspaceService extends EventEmitter { return Ok({ model, thinkingLevel: aiSettings.thinkingLevel, + ...(aiSettings.reasoningMode != null ? { reasoningMode: aiSettings.reasoningMode } : {}), }); } @@ -6837,7 +6843,11 @@ export class WorkspaceService extends EventEmitter { const thinkingLevel = requestedThinking; - return { model, thinkingLevel }; + // reasoningMode is optional: old clients omit it and the persist path then + // preserves any previously stored value instead of wiping it. + const reasoningMode = options?.reasoningMode; + + return { model, thinkingLevel, ...(reasoningMode != null ? { reasoningMode } : {}) }; } /** @@ -6944,7 +6954,11 @@ export class WorkspaceService extends EventEmitter { const prev = snapshotEntry.aiSettingsByAgent?.[normalizedAgentId]; const aiSettingsChanged = aiSettings != null && - (prev?.model !== aiSettings.model || prev?.thinkingLevel !== aiSettings.thinkingLevel); + (prev?.model !== aiSettings.model || + prev?.thinkingLevel !== aiSettings.thinkingLevel || + // Absent reasoningMode preserves the previous value (see write below), + // so only an explicit different value counts as a change. + (aiSettings.reasoningMode != null && prev?.reasoningMode !== aiSettings.reasoningMode)); const selectedAgentChanged = options?.persistSelectedAgentId === true && snapshotEntry.agentId !== normalizedAgentId; if (!aiSettingsChanged && !selectedAgentChanged) { @@ -6970,7 +6984,9 @@ export class WorkspaceService extends EventEmitter { const prev = workspaceEntry.aiSettingsByAgent?.[normalizedAgentId]; const aiSettingsChanged = aiSettings != null && - (prev?.model !== aiSettings.model || prev?.thinkingLevel !== aiSettings.thinkingLevel); + (prev?.model !== aiSettings.model || + prev?.thinkingLevel !== aiSettings.thinkingLevel || + (aiSettings.reasoningMode != null && prev?.reasoningMode !== aiSettings.reasoningMode)); const selectedAgentChanged = options?.persistSelectedAgentId === true && workspaceEntry.agentId !== normalizedAgentId; if (!aiSettingsChanged && !selectedAgentChanged) { @@ -6979,9 +6995,15 @@ export class WorkspaceService extends EventEmitter { } if (aiSettings != null) { + // Callers that omit reasoningMode (older clients, thinking-only updates) + // must not wipe a previously persisted value — self-healing merge. + const mergedReasoningMode = aiSettings.reasoningMode ?? prev?.reasoningMode; workspaceEntry.aiSettingsByAgent = { ...(workspaceEntry.aiSettingsByAgent ?? {}), - [normalizedAgentId]: aiSettings, + [normalizedAgentId]: { + ...aiSettings, + ...(mergedReasoningMode != null ? { reasoningMode: mergedReasoningMode } : {}), + }, }; } @@ -9700,17 +9722,30 @@ export class WorkspaceService extends EventEmitter { // with an implicit "off" (aiService defaults undefined -> off), which both // ignored the user's UI selection and sent `thinking: { type: "disabled" }` // to Anthropic models that reject disabled thinking (e.g. Fable/Mythos). - const candidates: Array<{ model?: string; thinkingLevel?: ThinkingLevel }> = [ - { model: selectedAgentSettings?.model, thinkingLevel: selectedAgentSettings?.thinkingLevel }, + const candidates: Array<{ + model?: string; + thinkingLevel?: ThinkingLevel; + reasoningMode?: OpenAIReasoningMode; + }> = [ + { + model: selectedAgentSettings?.model, + thinkingLevel: selectedAgentSettings?.thinkingLevel, + reasoningMode: selectedAgentSettings?.reasoningMode, + }, { model: workspaceEntry?.aiSettings?.model, thinkingLevel: workspaceEntry?.aiSettings?.thinkingLevel, + reasoningMode: workspaceEntry?.aiSettings?.reasoningMode, }, { model: config.agentAiDefaults?.[agentId]?.modelString, thinkingLevel: config.agentAiDefaults?.[agentId]?.thinkingLevel, }, - { model: execAgentSettings?.model, thinkingLevel: execAgentSettings?.thinkingLevel }, + { + model: execAgentSettings?.model, + thinkingLevel: execAgentSettings?.thinkingLevel, + reasoningMode: execAgentSettings?.reasoningMode, + }, agentId !== WORKSPACE_DEFAULTS.agentId ? { model: config.agentAiDefaults?.[WORKSPACE_DEFAULTS.agentId]?.modelString, @@ -9728,10 +9763,12 @@ export class WorkspaceService extends EventEmitter { const normalized = normalizeToCanonical(raw.trim()); if (isValidModelFormat(normalized)) { const thinkingLevel = coerceThinkingLevel(candidate.thinkingLevel); + const reasoningMode = coerceOpenAIReasoningMode(candidate.reasoningMode); return { model: normalized, agentId, ...(thinkingLevel != null ? { thinkingLevel } : {}), + ...(reasoningMode != null ? { reasoningMode } : {}), }; } } @@ -9961,10 +9998,17 @@ export class WorkspaceService extends EventEmitter { const normalizedThinkingLevel = coerceThinkingLevel(requestedThinking) ?? WORKSPACE_DEFAULTS.thinkingLevel; + // Same persisted-settings fallback order as thinkingLevel (global defaults + // and activity snapshots do not carry reasoningMode). + const reasoningMode = coerceOpenAIReasoningMode( + compactAgentSettings?.reasoningMode ?? execAgentSettings?.reasoningMode + ); + return { model, agentId: "compact", thinkingLevel: enforceThinkingPolicy(model, normalizedThinkingLevel), + ...(reasoningMode != null ? { reasoningMode } : {}), maxOutputTokens: undefined, // Disable all tools during compaction - regex .* matches all tool names. toolPolicy: [{ regex_match: ".*", action: "disable" }], @@ -10360,11 +10404,18 @@ export class WorkspaceService extends EventEmitter { const normalizedThinkingLevel = coerceThinkingLevel(requestedThinking) ?? WORKSPACE_DEFAULTS.thinkingLevel; + // Same persisted-settings fallback order as thinkingLevel (global defaults + // and activity snapshots do not carry reasoningMode). + const reasoningMode = coerceOpenAIReasoningMode( + agentSettings?.reasoningMode ?? execAgentSettings?.reasoningMode + ); + return { sendOptions: { model, agentId, thinkingLevel: enforceThinkingPolicy(model, normalizedThinkingLevel), + ...(reasoningMode != null ? { reasoningMode } : {}), maxOutputTokens: undefined, // Heartbeats are idle control loops; their prompt may ask the agent to seed a bounded // goal before continuing. AIService still gates set_goal to top-level exec-like agents. From 8dd7427d466a0f2bf06ef45b106e9fcd604165cc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 20:22:39 +0000 Subject: [PATCH 05/35] =?UTF-8?q?=F0=9F=A4=96=20test+docs:=20pro-mode=20pr?= =?UTF-8?q?edicate/header/wrapper/schema/UI=20tests;=20models.mdx=20PRO=20?= =?UTF-8?q?note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Kosiewski --- docs/config/models.mdx | 4 + .../ChatInput/useCreationWorkspace.ts | 4 +- .../orpc/schemas/workspaceAiSettings.test.ts | 36 ++++ src/common/types/thinking.test.ts | 17 ++ src/common/utils/ai/providerOptions.test.ts | 98 ++++++++++ .../builtInSkillContent.generated.ts | 4 + .../services/providerModelFactory.test.ts | 185 +++++++++++++++++- tests/ui/agents/proModeToggle.test.ts | 131 +++++++++++++ 8 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 src/common/orpc/schemas/workspaceAiSettings.test.ts create mode 100644 tests/ui/agents/proModeToggle.test.ts diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 7b91af0a1c..cffd7e4e8a 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -37,6 +37,10 @@ Mux ships with curated models kept up to date with the frontier. Use any custom {/* END KNOWN_MODELS_TABLE */} +### Pro reasoning mode (GPT-5.6 Sol/Terra) + +GPT-5.6 Sol and Terra support OpenAI's pro reasoning mode. Toggle the **PRO** badge next to the thinking slider (or run "Toggle Pro Reasoning Mode" from the Command Palette) to send `reasoning.mode: "pro"` with each request. The setting is saved per workspace. Pro mode uses the same per-token pricing but consumes more tokens and responses can take noticeably longer. It is not available for Luna, and requests routed through non-passthrough gateways (e.g. OpenRouter, GitHub Copilot) fall back to standard mode. + ## Model Selection Keyboard shortcuts: diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 301c528544..191efd2d82 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -133,9 +133,9 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), (prev) => { - const record = prev && typeof prev === "object" ? prev : {}; + const record: AgentSettingsCache = prev && typeof prev === "object" ? prev : {}; return { - ...(record as AgentSettingsCache), + ...record, [effectiveAgentId]: { model: projectModel, thinkingLevel: effectiveThinking, diff --git a/src/common/orpc/schemas/workspaceAiSettings.test.ts b/src/common/orpc/schemas/workspaceAiSettings.test.ts new file mode 100644 index 0000000000..bf74a252e0 --- /dev/null +++ b/src/common/orpc/schemas/workspaceAiSettings.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; + +describe("WorkspaceAISettingsSchema", () => { + test("parses legacy settings without reasoningMode (self-healing)", () => { + const result = WorkspaceAISettingsSchema.safeParse({ + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.reasoningMode).toBeUndefined(); + } + }); + + test("parses settings with reasoningMode=pro", () => { + const result = WorkspaceAISettingsSchema.safeParse({ + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + reasoningMode: "pro", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.reasoningMode).toBe("pro"); + } + }); + + test("rejects unknown reasoning modes", () => { + const result = WorkspaceAISettingsSchema.safeParse({ + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + reasoningMode: "ultra", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/common/types/thinking.test.ts b/src/common/types/thinking.test.ts index 51cec31d3d..b4d52197e2 100644 --- a/src/common/types/thinking.test.ts +++ b/src/common/types/thinking.test.ts @@ -6,6 +6,7 @@ import { getThinkingOptionLabel, MAX_THINKING_INDEX, openaiSupportsNativeMaxEffort, + openaiSupportsProMode, parseThinkingInput, } from "./thinking"; @@ -84,6 +85,22 @@ describe("openaiSupportsNativeMaxEffort", () => { }); }); +describe("openaiSupportsProMode", () => { + test("matches Sol and Terra including prefixed and dated variants", () => { + expect(openaiSupportsProMode("openai:gpt-5.6-sol")).toBe(true); + expect(openaiSupportsProMode("openai:gpt-5.6-terra")).toBe(true); + expect(openaiSupportsProMode("mux-gateway:openai/gpt-5.6-sol")).toBe(true); + expect(openaiSupportsProMode("gpt-5.6-terra-2026-07-09")).toBe(true); + }); + + test("rejects Luna, older models, and named variants", () => { + expect(openaiSupportsProMode("openai:gpt-5.6-luna")).toBe(false); + expect(openaiSupportsProMode("openai:gpt-5.5-pro")).toBe(false); + expect(openaiSupportsProMode("openai:gpt-5.6-sol-mini")).toBe(false); + expect(openaiSupportsProMode("anthropic:claude-opus-4-7")).toBe(false); + }); +}); + describe("getOpenAIReasoningEffort", () => { test("maps max to the native max effort on GPT-5.6 Sol only", () => { expect(getOpenAIReasoningEffort("max", "openai:gpt-5.6-sol")).toBe("max"); diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 6497122839..f4602c47db 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -14,6 +14,7 @@ import { resolveProviderOptionsNamespaceKey, ANTHROPIC_1M_CONTEXT_HEADER, MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, + MUX_OPENAI_REASONING_MODE_HEADER, MUX_WORKSPACE_ID_HEADER, } from "./providerOptions"; @@ -1094,6 +1095,103 @@ describe("buildRequestHeaders", () => { } }); + describe("OpenAI pro reasoning-mode header", () => { + for (const { name, model, routeProvider, reasoningMode, expected } of [ + { + name: "emits pro header for direct Sol with reasoningMode=pro", + model: "openai:gpt-5.6-sol", + routeProvider: undefined, + reasoningMode: "pro", + expected: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }, + { + name: "emits pro header for direct Terra with reasoningMode=pro", + model: "openai:gpt-5.6-terra", + routeProvider: undefined, + reasoningMode: "pro", + expected: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }, + { + name: "emits pro header for gateway-routed Sol (passthrough)", + model: "mux-gateway:openai/gpt-5.6-sol", + routeProvider: "mux-gateway", + reasoningMode: "pro", + expected: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }, + { + name: "does not emit for reasoningMode=standard", + model: "openai:gpt-5.6-sol", + routeProvider: undefined, + reasoningMode: "standard", + expected: undefined, + }, + { + name: "does not emit when reasoningMode is undefined", + model: "openai:gpt-5.6-sol", + routeProvider: undefined, + reasoningMode: undefined, + expected: undefined, + }, + { + name: "does not emit for Luna (no pro support)", + model: "openai:gpt-5.6-luna", + routeProvider: undefined, + reasoningMode: "pro", + expected: undefined, + }, + { + name: "does not emit for Anthropic origin even with reasoningMode=pro", + model: "anthropic:claude-opus-4-7", + routeProvider: undefined, + reasoningMode: "pro", + expected: undefined, + }, + { + // Non-passthrough gateways must never see the Mux-internal header. + name: "does not emit for non-passthrough route (openrouter)", + model: "openai:gpt-5.6-sol", + routeProvider: "openrouter", + reasoningMode: "pro", + expected: undefined, + }, + { + name: "does not emit for non-passthrough route (github-copilot)", + model: "openai:gpt-5.6-sol", + routeProvider: "github-copilot", + reasoningMode: "pro", + expected: undefined, + }, + ] as const) { + test(name, () => { + expect( + buildRequestHeaders( + model, + undefined, + undefined, + undefined, + routeProvider, + undefined, + reasoningMode + ) + ).toEqual(expected); + }); + } + + test("pro header is independent of thinkingLevel (mode is orthogonal to effort)", () => { + expect( + buildRequestHeaders( + "openai:gpt-5.6-sol", + undefined, + undefined, + undefined, + undefined, + "max", + "pro" + ) + ).toEqual({ [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }); + }); + }); + for (const { name, model, options, workspaceId, expected } of [ { name: "should include X-Mux-Workspace-Id for non-Anthropic provider when workspaceId provided", diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index aa431bba02..ab2035f783 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3116,6 +3116,10 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "{/* END KNOWN_MODELS_TABLE */}", "", + "### Pro reasoning mode (GPT-5.6 Sol/Terra)", + "", + 'GPT-5.6 Sol and Terra support OpenAI\'s pro reasoning mode. Toggle the **PRO** badge next to the thinking slider (or run "Toggle Pro Reasoning Mode" from the Command Palette) to send `reasoning.mode: "pro"` with each request. The setting is saved per workspace. Pro mode uses the same per-token pricing but consumes more tokens and responses can take noticeably longer. It is not available for Luna, and requests routed through non-passthrough gateways (e.g. OpenRouter, GitHub Copilot) fall back to standard mode.', + "", "## Model Selection", "", "Keyboard shortcuts:", diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index cd4316b6d7..c0ebdb5bdc 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -20,8 +20,12 @@ import { resolveAIProviderHeaderSource, resolveOpenAIWebSocketResponsesUrl, wrapFetchWithAnthropicCacheControl, + wrapFetchWithOpenAIReasoningMode, } from "./providerModelFactory"; -import { MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER } from "@/common/utils/ai/providerOptions"; +import { + MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, + MUX_OPENAI_REASONING_MODE_HEADER, +} from "@/common/utils/ai/providerOptions"; import { hasLanguageModelCleanup } from "./languageModelCleanup"; import type { DevToolsService } from "./devToolsService"; import { CodexOauthService } from "./codexOauthService"; @@ -227,6 +231,34 @@ describe("normalizeCodexResponsesBody", () => { expect(normalized.truncation).toBeUndefined(); expect(normalized.store).toBe(false); }); + + it("strips reasoning.mode while preserving effort/summary (Codex backend must never see it)", () => { + const normalized = JSON.parse( + normalizeCodexResponsesBody( + JSON.stringify({ + model: "gpt-5.6-sol", + input: [{ role: "user", content: "Hello" }], + reasoning: { effort: "high", summary: "auto", mode: "pro" }, + }) + ) + ) as { reasoning?: Record }; + + expect(normalized.reasoning).toEqual({ effort: "high", summary: "auto" }); + }); + + it("drops the reasoning object entirely when mode was its only key", () => { + const normalized = JSON.parse( + normalizeCodexResponsesBody( + JSON.stringify({ + model: "gpt-5.6-sol", + input: [{ role: "user", content: "Hello" }], + reasoning: { mode: "pro" }, + }) + ) + ) as { reasoning?: unknown }; + + expect(normalized.reasoning).toBeUndefined(); + }); }); describe("ProviderModelFactory.createModel", () => { @@ -1731,3 +1763,154 @@ describe("wrapFetchWithAnthropicCacheControl — gateway (AI SDK) body shape", ( expect(sent.providerOptions.anthropic.effort).toBe("xhigh"); }); }); + +describe("wrapFetchWithOpenAIReasoningMode", () => { + const RESPONSES_URL = "https://api.openai.com/v1/responses"; + + it("merges reasoning.mode=pro into an existing reasoning object, preserving effort/summary", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ + model: "gpt-5.6-sol", + reasoning: { effort: "high", summary: "auto" }, + }); + await wrapped(RESPONSES_URL, { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + expect(calls.length).toBe(1); + const sent = parseSentBody(calls[0]); + expect(sent.reasoning).toEqual({ effort: "high", summary: "auto", mode: "pro" }); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); + + it("creates the reasoning object when absent", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ model: "gpt-5.6-terra" }); + await wrapped(RESPONSES_URL, { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + expect(parseSentBody(calls[0]).reasoning).toEqual({ mode: "pro" }); + }); + + it("forwards byte-identical when the header is absent", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "high" } }); + await wrapped(RESPONSES_URL, { method: "POST", body, headers: { "x-other": "1" } }); + expect(calls[0].init.body).toBe(body); + expect(new Headers(calls[0].init.headers).get("x-other")).toBe("1"); + }); + + it("strips the header on non-POST requests without touching the body (never leak)", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + await wrapped(RESPONSES_URL, { + method: "GET", + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + expect(calls[0].init.body).toBeUndefined(); + }); + + it("strips the header but leaves the body unchanged for non-Responses URLs (chat completions)", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ model: "gpt-5.6-sol", messages: [] }); + await wrapped("https://api.openai.com/v1/chat/completions", { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + expect(calls[0].init.body).toBe(body); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); + + it("strips the header but does not inject for invalid header values", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ model: "gpt-5.6-sol" }); + await wrapped(RESPONSES_URL, { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "ultra" }, + }); + expect(calls[0].init.body).toBe(body); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); + + it("injects providerOptions.openai.reasoningMode for the gateway body shape", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + providerOptions: { openai: { reasoningEffort: "high" } }, + }); + await wrapped("https://gateway.example.com/v1/language-model", { + method: "POST", + body, + headers: { + "ai-model-id": "openai/gpt-5.6-sol", + [MUX_OPENAI_REASONING_MODE_HEADER]: "pro", + }, + }); + const sent = parseSentBody(calls[0]) as { + providerOptions: { openai: Record }; + }; + expect(sent.providerOptions.openai).toEqual({ reasoningEffort: "high", reasoningMode: "pro" }); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); + + it("creates providerOptions.openai for gateway bodies that lack it", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const body = JSON.stringify({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + }); + await wrapped("https://gateway.example.com/v1/language-model", { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + const sent = parseSentBody(calls[0]) as { + providerOptions: { openai: Record }; + }; + expect(sent.providerOptions.openai).toEqual({ reasoningMode: "pro" }); + }); + + it("strips the header without injecting when inject=false (Codex OAuth route)", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch, { inject: false }); + const body = JSON.stringify({ model: "gpt-5.6-sol" }); + await wrapped(RESPONSES_URL, { + method: "POST", + body, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + expect(calls[0].init.body).toBe(body); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); + + it("forwards malformed JSON with the header stripped", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + await wrapped(RESPONSES_URL, { + method: "POST", + body: "{not json", + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + expect(calls[0].init.body).toBe("{not json"); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); +}); diff --git a/tests/ui/agents/proModeToggle.test.ts b/tests/ui/agents/proModeToggle.test.ts new file mode 100644 index 0000000000..8a3d5f43fb --- /dev/null +++ b/tests/ui/agents/proModeToggle.test.ts @@ -0,0 +1,131 @@ +/** + * Integration test for the PRO reasoning-mode toggle: + * visibility gating per model + persistence across model switches. + */ + +import "../dom"; +import { fireEvent, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { getModelKey } from "@/common/constants/storage"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay"; + +import { shouldRunIntegrationTests } from "../../testUtils"; +import { createAppHarness } from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +const SOL_MODEL = KNOWN_MODELS.GPT.id; +const LUNA_MODEL = KNOWN_MODELS.GPT_56_LUNA.id; + +async function openModelSelector(container: HTMLElement): Promise { + window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); + + return await waitFor(() => { + const input = container.querySelector( + 'input[placeholder="Search [provider:model-name]"]' + ); + if (!input) { + throw new Error("Model selector input not found"); + } + return input; + }); +} + +async function selectModel( + container: HTMLElement, + workspaceId: string, + model: string +): Promise { + const input = await openModelSelector(container); + + const user = userEvent.setup({ document: container.ownerDocument }); + await user.clear(input); + await user.type(input, model); + + const modelName = model.split(":")[1] ?? model; + const modelDisplayName = formatModelDisplayName(modelName); + + const option = await waitFor(() => { + const match = within(container).getByText(modelDisplayName); + if (!match) { + throw new Error("Model option not found"); + } + return match; + }); + + fireEvent.click(option); + + await waitFor(() => { + const persisted = readPersistedState(getModelKey(workspaceId), ""); + if (persisted !== model) { + throw new Error(`Expected model ${model} but got ${persisted}`); + } + }); +} + +function getToggle(container: HTMLElement): HTMLButtonElement | null { + return container.querySelector('[data-component="ProModeToggle"]'); +} + +async function expectToggleVisible(container: HTMLElement): Promise { + return await waitFor(() => { + const toggle = getToggle(container); + if (!toggle) { + throw new Error("ProModeToggle not rendered"); + } + return toggle; + }); +} + +async function expectToggleHidden(container: HTMLElement): Promise { + await waitFor(() => { + if (getToggle(container)) { + throw new Error("ProModeToggle should not render for this model"); + } + }); +} + +async function expectTogglePressed(container: HTMLElement, pressed: boolean): Promise { + await waitFor(() => { + const toggle = getToggle(container); + if (!toggle) { + throw new Error("ProModeToggle not rendered"); + } + if (toggle.getAttribute("aria-pressed") !== String(pressed)) { + throw new Error( + `Expected aria-pressed=${pressed} but got ${toggle.getAttribute("aria-pressed")}` + ); + } + }); +} + +describeIntegration("Pro reasoning-mode toggle", () => { + test("renders only for pro-capable models and persists across model switches", async () => { + const harness = await createAppHarness({ branchPrefix: "promode" }); + + try { + const { container } = harness.view; + + // Visible on Sol; enable PRO. + await selectModel(container, harness.workspaceId, SOL_MODEL); + const toggle = await expectToggleVisible(container); + await expectTogglePressed(container, false); + fireEvent.click(toggle); + await expectTogglePressed(container, true); + + // Hidden on Luna (no pro support). + await selectModel(container, harness.workspaceId, LUNA_MODEL); + await expectToggleHidden(container); + + // Back to Sol: persisted PRO state survives the switch. + await selectModel(container, harness.workspaceId, SOL_MODEL); + await expectTogglePressed(container, true); + } finally { + await harness.dispose(); + } + }, 90_000); +}); From eb83f3af4d2d9e76ed02992bffdb126d4be991ba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 20:50:30 +0000 Subject: [PATCH 06/35] =?UTF-8?q?=F0=9F=A4=96=20tests:=20update=20aiSettin?= =?UTF-8?q?gs=20payload=20expectations=20to=20include=20reasoningMode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Kosiewski --- src/browser/contexts/ThinkingContext.test.tsx | 14 ++++++++++++-- .../ChatInput/useCreationWorkspace.test.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 56f504bebf..faa5daaeb0 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -349,7 +349,13 @@ describe("ThinkingContext", () => { button.click(); }); - const expectedSettings = { model: "metadataModel:abc", thinkingLevel: "medium" as const }; + // setThinkingLevel persists the full settings payload including the current + // reasoningMode (default "standard") so partial writes cannot clobber it. + const expectedSettings = { + model: "metadataModel:abc", + thinkingLevel: "medium" as const, + reasoningMode: "standard" as const, + }; await waitFor(() => { expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); }, METADATA_WAIT_OPTIONS); @@ -522,7 +528,11 @@ describe("ThinkingContext", () => { ); }); - const expectedSettings = { model: metadataModel, thinkingLevel: expectedThinkingLevel }; + const expectedSettings = { + model: metadataModel, + thinkingLevel: expectedThinkingLevel, + reasoningMode: "standard" as const, + }; await waitFor(() => { expect(readWorkspaceAISettingsCache(workspaceId).exec).toEqual(expectedSettings); }, METADATA_WAIT_OPTIONS); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index f5755c3f61..97a377b545 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -823,7 +823,7 @@ describe("useCreationWorkspace", () => { expect(workspaceApi.updateAgentAISettings).toHaveBeenCalledWith({ workspaceId: TEST_WORKSPACE_ID, agentId: "exec", - aiSettings: { model: "gpt-4", thinkingLevel: "medium" }, + aiSettings: { model: "gpt-4", thinkingLevel: "medium", reasoningMode: "standard" }, persistSelectedAgentId: true, }); expect(workspaceApi.getGoal.mock.calls.length).toBe(1); From a886b205a8cccbc58aa8e0dacc34e4dab9636580 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 9 Jul 2026 21:44:25 +0000 Subject: [PATCH 07/35] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20PRO=20chip?= =?UTF-8?q?=20on=20narrow=20layouts=20to=20prevent=20right-edge=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At <=420px container width the thinking paddles were already hidden but the PRO chip stayed visible, pushing past a 375px viewport (measured right edge 396px). Hide it under the same container query; pro mode stays reachable via the command palette on mobile. --- src/browser/components/ThinkingSlider/ProModeToggle.tsx | 1 + src/browser/features/ChatInput/index.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ThinkingSlider/ProModeToggle.tsx b/src/browser/components/ThinkingSlider/ProModeToggle.tsx index d8e0037cc1..53a03ef76e 100644 --- a/src/browser/components/ThinkingSlider/ProModeToggle.tsx +++ b/src/browser/components/ThinkingSlider/ProModeToggle.tsx @@ -27,6 +27,7 @@ export const ProModeToggle: React.FC = (props) => {