diff --git a/docs/config/models.mdx b/docs/config/models.mdx index 98f9c99733..8bc9407d6e 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` | | @@ -35,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) + +Every GPT-5.6 model (Sol, Terra, and Luna) supports 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. 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/App.tsx b/src/browser/App.tsx index 5948e8a213..e8c5b67331 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -48,7 +48,12 @@ 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, + coerceOpenAIReasoningMode, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { @@ -57,6 +62,7 @@ import { getModelKey, getNotifyOnResponseKey, getThinkingLevelByModelKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getWorkspaceLastReadKey, @@ -99,6 +105,8 @@ import { WindowsToolchainBanner } from "./components/WindowsToolchainBanner/Wind import { RosettaBanner } from "./components/RosettaBanner/RosettaBanner"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { useRouting } from "@/browser/hooks/useRouting"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { getErrorMessage } from "@/common/utils/errors"; import assert from "@/common/utils/assert"; @@ -440,6 +448,27 @@ function AppInner() { [getModelForWorkspace] ); + // Pro mode is Responses-only; the palette command hides under chatCompletions + // and on non-passthrough routes (mirroring the send path's header gating). + const { config: providersConfig } = useProvidersConfig(); + const routing = useRouting(); + const getRouteForModel = useCallback( + (canonicalModel: string) => routing.resolveRoute(canonicalModel).route, + [routing] + ); + + const getReasoningModeForWorkspace = useCallback((workspaceId: string): OpenAIReasoningMode => { + if (!workspaceId) { + return "standard"; + } + const stored = readPersistedState( + getReasoningModeKey(workspaceId), + null + ); + // Coerce untrusted persisted values so corrupt entries self-heal to "standard". + return coerceOpenAIReasoningMode(stored) ?? "standard"; + }, []); + const setThinkingLevelFromPalette = useCallback( (workspaceId: string, level: ThinkingLevel) => { if (!workspaceId) { @@ -449,13 +478,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 +505,7 @@ function AppInner() { prev && typeof prev === "object" ? prev : {}; return { ...record, - [normalizedAgentId]: { model, thinkingLevel: normalized }, + [normalizedAgentId]: { model, thinkingLevel: normalized, reasoningMode }, }; }, {} @@ -481,13 +516,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 +545,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 +849,10 @@ function AppInner() { themePreference, getThinkingLevel: getThinkingLevelForWorkspace, onSetThinkingLevel: setThinkingLevelFromPalette, + getReasoningMode: getReasoningModeForWorkspace, + onToggleReasoningMode: toggleReasoningModeFromPalette, + providersConfig, + getRouteForModel, 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..104fbc9cc5 --- /dev/null +++ b/src/browser/components/ThinkingSlider/ProModeToggle.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { normalizeToCanonical } from "@/common/utils/ai/models"; +import { openaiProModeAvailable } from "@/common/utils/ai/proMode"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { useReasoningMode } from "@/browser/hooks/useReasoningMode"; +import { useRouting } from "@/browser/hooks/useRouting"; +import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; + +interface ProModeToggleProps { + modelString: string; +} + +/** + * Small "PRO" toggle for OpenAI's pro reasoning mode (GPT-5.6 family). + * Renders nothing for models without pro-mode support and for gateway routes + * where the pro-mode header is never emitted — otherwise the toggle would + * persist a setting that can never affect the request. An explicit gateway + * prefix (openrouter:openai/...) only hides the toggle while that gateway can + * win the route; when it is disabled/unconfigured the backend falls back to + * the resolved route, which openaiProModeAvailable re-checks. + */ +export const ProModeToggle: React.FC = (props) => { + const [reasoningMode, setReasoningMode] = useReasoningMode(); + // Availability mirrors the send path (see openaiProModeAvailable): hides for + // chatCompletions wire format, non-passthrough routes, and Codex OAuth auth. + const { config: providersConfig } = useProvidersConfig(); + const routing = useRouting(); + const resolvedRoute = routing.resolveRoute(normalizeToCanonical(props.modelString)).route; + + if ( + !openaiProModeAvailable(props.modelString, { + providersConfig, + resolvedRouteProvider: resolvedRoute, + }) + ) { + return null; + } + + const isActive = reasoningMode === "pro"; + + return ( + + + + + + Pro reasoning mode: slower, more thorough responses. Saved per workspace. + + + ); +}; diff --git a/src/browser/components/ThinkingSlider/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider/ThinkingSlider.tsx index 894b9e8be9..452e542a1a 100644 --- a/src/browser/components/ThinkingSlider/ThinkingSlider.tsx +++ b/src/browser/components/ThinkingSlider/ThinkingSlider.tsx @@ -7,6 +7,7 @@ import { } from "@/common/types/thinking"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { enforceThinkingPolicy, getAvailableThinkingLevels } from "@/common/utils/thinking/policy"; @@ -45,9 +46,17 @@ export const ThinkingSliderComponent: React.FC = ({ modelS // Apply the per-model minimum floor so off/low are hidden unless the user lowers it // in Models settings. The floor must match the backend send-path enforcement. const { getMinimum } = useMinThinkingLevels(); + // Resolve mapped aliases so the slider offers the target model's ladder + // (e.g. an alias mapped to GPT-5.6 exposes native max). + const { config: providersConfig } = useProvidersConfig(); const minimum = getMinimum(modelString); - const allowed = getAvailableThinkingLevels(modelString, minimum); - const effectiveThinkingLevel = enforceThinkingPolicy(modelString, thinkingLevel, minimum); + const allowed = getAvailableThinkingLevels(modelString, minimum, providersConfig); + const effectiveThinkingLevel = enforceThinkingPolicy( + modelString, + thinkingLevel, + minimum, + providersConfig + ); // Map current level to index within the *allowed* subset const currentIndex = allowed.indexOf(effectiveThinkingLevel); diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 703a850bfa..751d5013f0 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -7,6 +7,7 @@ import { } from "@/browser/hooks/usePersistedState"; import { getModelKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, AGENT_AI_DEFAULTS_KEY, @@ -17,7 +18,7 @@ import { resolveWorkspaceAiSettingsForAgent, type WorkspaceAISettingsCache, } from "@/browser/utils/workspaceModeAi"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import { normalizeAgentId } from "@/common/utils/agentIds"; @@ -60,19 +61,23 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { const existingModel = readPersistedState(modelKey, fallbackModel); const existingThinking = readPersistedState(thinkingKey, "off"); + const reasoningKey = getReasoningModeKey(workspaceId); + const existingReasoning = readPersistedState(reasoningKey, "standard"); - const { resolvedModel, resolvedThinking } = resolveWorkspaceAiSettingsForAgent({ - agentId: normalizedAgentId, - agentAiDefaults, - // Keep deterministic handoff behavior: background sync should trust the - // currently active workspace model, but explicit mode switches should - // restore the selected agent's per-workspace override (if any). - workspaceByAgent, - useWorkspaceByAgentFallback: isExplicitAgentSwitch, - fallbackModel, - existingModel, - existingThinking, - }); + const { resolvedModel, resolvedThinking, resolvedReasoningMode } = + resolveWorkspaceAiSettingsForAgent({ + agentId: normalizedAgentId, + agentAiDefaults, + // Keep deterministic handoff behavior: background sync should trust the + // currently active workspace model, but explicit mode switches should + // restore the selected agent's per-workspace override (if any). + workspaceByAgent, + useWorkspaceByAgentFallback: isExplicitAgentSwitch, + fallbackModel, + existingModel, + existingThinking, + existingReasoningMode: existingReasoning, + }); if (existingModel !== resolvedModel) { setWorkspaceModelWithOrigin( @@ -85,6 +90,10 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { if (existingThinking !== resolvedThinking) { updatePersistedState(thinkingKey, resolvedThinking); } + + if (existingReasoning !== resolvedReasoningMode) { + updatePersistedState(reasoningKey, resolvedReasoningMode); + } }, [agentAiDefaults, agentId, workspaceByAgent, workspaceId]); return null; diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 56f504bebf..aacc694331 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -17,10 +17,12 @@ import type { RecursivePartial } from "@/browser/testUtils"; import { getModelKey, getProjectScopeId, + getReasoningModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; +import { useReasoningMode } from "@/browser/hooks/useReasoningMode"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; @@ -90,6 +92,11 @@ const SendOptionsComponent: React.FC<{ workspaceId: string }> = (props) => { return
{options.baseModel}
; }; +const ReasoningModeComponent: React.FC = () => { + const [reasoningMode] = useReasoningMode(); + return
{reasoningMode}
; +}; + function renderWithAPI(children: React.ReactNode) { return render({children}); } @@ -349,7 +356,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); @@ -363,6 +376,43 @@ describe("ThinkingContext", () => { } }); + test("self-heals corrupt persisted reasoningMode to standard but keeps valid pro", async () => { + // Corrupt persisted values (e.g. from a future downgrade) must coerce to + // "standard" instead of flowing into SendMessageOptionsSchema and bricking sends. + const cases = [ + { workspaceId: "ws-reasoning-corrupt", persisted: "ultra", expected: "standard" }, + { workspaceId: "ws-reasoning-valid", persisted: "pro", expected: "pro" }, + ]; + + for (const testCase of cases) { + const metadata = createWorkspaceMetadata({ id: testCase.workspaceId }); + setWorkspaceMetadata(metadata); + window.localStorage.setItem( + getReasoningModeKey(testCase.workspaceId), + JSON.stringify(testCase.persisted) + ); + + const view = renderWithWorkspaceMetadata({ + workspaceId: testCase.workspaceId, + modelOverride: null, + children: ( + + + + + + + + ), + }); + + await waitFor(() => { + expect(view.getByTestId("reasoning-mode").textContent).toBe(testCase.expected); + }, METADATA_WAIT_OPTIONS); + cleanup(); + } + }); + test("uses metadata thinking before off but keeps explicit thinking", async () => { const cases = [ { @@ -522,7 +572,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/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index b720ef6ed4..9dfea68d0e 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,6 +1,11 @@ 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, + coerceOpenAIReasoningMode, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { readPersistedState, updatePersistedState, @@ -10,6 +15,7 @@ import { getAgentIdKey, getModelKey, getProjectScopeId, + getReasoningModeKey, getThinkingLevelByModelKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, @@ -19,6 +25,7 @@ import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { normalizeToCanonical } from "@/common/utils/ai/models"; import { enforceThinkingPolicy, getAvailableThinkingLevels } from "@/common/utils/thinking/policy"; import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useAPI } from "@/browser/contexts/API"; import { clearPendingWorkspaceAiSettings, @@ -32,6 +39,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); @@ -65,6 +75,8 @@ export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); const workspaceContext = useOptionalWorkspaceContext(); const { getMinimum } = useMinThinkingLevels(); + // Resolve mapped aliases so keybind stepping walks the target model's ladder. + const { config: providersConfig } = useProvidersConfig(); const defaultModel = getDefaultModel(); const scopeId = getScopeId(props.workspaceId, props.projectPath); const thinkingKey = getThinkingLevelKey(scopeId); @@ -83,6 +95,19 @@ 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 }); + // Coerce untrusted persisted values (corrupt entries or a future downgrade) so + // bad state self-heals to "standard" instead of failing SendMessageOptionsSchema + // validation and bricking sends until storage is cleared. + const reasoningMode = + coerceOpenAIReasoningMode(persistedReasoningMode) ?? + coerceOpenAIReasoningMode(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 +125,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 +143,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 +161,7 @@ export const ThinkingProvider: React.FC = (props) => { prev && typeof prev === "object" ? prev : {}; return { ...record, - [normalizedAgentId]: { model, thinkingLevel: level }, + [normalizedAgentId]: settings, }; }, {} @@ -141,16 +173,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 +191,73 @@ 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. + // Coerced like the render path: a corrupt persisted value must not ride a + // thinking-level change into updateAgentAISettings and fail backend sync. + const getCurrentReasoningMode = useCallback( + (): OpenAIReasoningMode => + coerceOpenAIReasoningMode( + readPersistedState(reasoningKey, null) + ) ?? + coerceOpenAIReasoningMode(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). @@ -191,12 +277,17 @@ export const ThinkingProvider: React.FC = (props) => { const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); // Step only within levels at or above the model's minimum floor. const minimum = getMinimum(model); - const allowed = getAvailableThinkingLevels(model, minimum); + const allowed = getAvailableThinkingLevels(model, minimum, providersConfig); if (allowed.length <= 1) { return; } - const effectiveThinkingLevel = enforceThinkingPolicy(model, thinkingLevel, minimum); + const effectiveThinkingLevel = enforceThinkingPolicy( + model, + thinkingLevel, + minimum, + providersConfig + ); const currentIndex = allowed.indexOf(effectiveThinkingLevel); // Increase/decrease are directional: clamp at the ends instead of wrapping, @@ -213,12 +304,20 @@ export const ThinkingProvider: React.FC = (props) => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [defaultModel, getMinimum, metadataSettings.model, scopeId, thinkingLevel, setThinkingLevel]); + }, [ + defaultModel, + getMinimum, + metadataSettings.model, + providersConfig, + scopeId, + thinkingLevel, + setThinkingLevel, + ]); // 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..23014f9828 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,21 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat if (existingThinking !== active.thinkingLevel) { updatePersistedState(thinkingKey, active.thinkingLevel); } + + // Absent reasoningMode means "standard": seed it explicitly so switching to + // an agent whose settings never carried the field cannot inherit another + // agent's "pro" from the shared workspace-scoped key. Newer local choices + // are already protected by the pending-settings guard above + // (shouldApplyWorkspaceAiSettingsFromBackend). + const reasoningKey = getReasoningModeKey(workspaceId); + const nextReasoning = active.reasoningMode ?? "standard"; + const existingReasoning = readPersistedState( + reasoningKey, + undefined + ); + if (existingReasoning !== nextReasoning) { + updatePersistedState(reasoningKey, nextReasoning); + } } export function toWorkspaceSelection(metadata: FrontendWorkspaceMetadata): WorkspaceSelection { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 36645b7c27..4882567b03 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, @@ -3309,12 +3323,15 @@ const ChatInputInner: React.FC = (props) => { /> - {/* On narrow layouts, hide the thinking paddles to prevent control overlap. */} + {/* On narrow layouts, hide the thinking paddles and PRO chip to prevent + right-edge overflow (the chip pushed past a 375px viewport); pro mode + stays reachable via the command palette. */}
+
diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 8f71cace38..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); @@ -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..38e6e0c79c 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -9,7 +9,11 @@ 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 { + coerceOpenAIReasoningMode, + type OpenAIReasoningMode, + type 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 +25,7 @@ import { getModelKey, getNotifyOnResponseAutoEnableKey, getNotifyOnResponseKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, getPendingScopeId, @@ -110,16 +115,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. + // Coerced so a corrupt persisted value is dropped instead of copied forward. + const projectReasoningMode = coerceOpenAIReasoningMode( + 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 : {}; + const record: AgentSettingsCache = prev && typeof prev === "object" ? prev : {}; return { - ...(record as Partial>), - [effectiveAgentId]: { model: projectModel, thinkingLevel: effectiveThinking }, + ...record, + [effectiveAgentId]: { + model: projectModel, + thinkingLevel: effectiveThinking, + ...(projectReasoningMode != null ? { reasoningMode: projectReasoningMode } : {}), + }, }; }, {} @@ -531,6 +556,7 @@ export function useCreationWorkspace({ aiSettings: { model: settings.model, thinkingLevel: settings.thinkingLevel, + reasoningMode: settings.reasoningMode, }, persistSelectedAgentId: true, }) @@ -692,6 +718,7 @@ export function useCreationWorkspace({ settings.agentId, settings.model, settings.thinkingLevel, + settings.reasoningMode, settings.trunkBranch, waitForGeneration, workspaceNameState.autoGenerate, diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index e19938f544..154b493596 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -45,6 +45,7 @@ import { getAgentIdKey, getModelKey, getPlanContentKey, + getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; @@ -59,7 +60,7 @@ import { import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { ReviewActionCallbacks } from "../Shared/InlineReviewNote"; import { isPlanFilePath, normalizePlanFilePath } from "@/common/types/review"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import { Check, Clipboard, @@ -477,27 +478,31 @@ export const ProposePlanToolCall: React.FC = (props) = }): { resolvedModel: string; resolvedThinking: ThinkingLevel } => { const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); + const reasoningKey = getReasoningModeKey(args.workspaceId); const fallbackModel = getDefaultModel(); const existingModel = readPersistedState(modelKey, fallbackModel); const existingThinking = readPersistedState(thinkingKey, "off"); + const existingReasoning = readPersistedState(reasoningKey, "standard"); const agentAiDefaults = readPersistedState(AGENT_AI_DEFAULTS_KEY, {}); const workspaceByAgent = readPersistedState( getWorkspaceAISettingsByAgentKey(args.workspaceId), {} ); - const { resolvedModel, resolvedThinking } = resolveWorkspaceAiSettingsForAgent({ - agentId: args.targetAgentId, - agentAiDefaults, - // Propose-plan actions are explicit mode switches; honor any per-agent - // workspace override before inheriting the previously active plan settings. - workspaceByAgent, - useWorkspaceByAgentFallback: true, - fallbackModel, - existingModel, - existingThinking, - }); + const { resolvedModel, resolvedThinking, resolvedReasoningMode } = + resolveWorkspaceAiSettingsForAgent({ + agentId: args.targetAgentId, + agentAiDefaults, + // Propose-plan actions are explicit mode switches; honor any per-agent + // workspace override before inheriting the previously active plan settings. + workspaceByAgent, + useWorkspaceByAgentFallback: true, + fallbackModel, + existingModel, + existingThinking, + existingReasoningMode: existingReasoning, + }); updatePersistedState(getAgentIdKey(args.workspaceId), args.targetAgentId); @@ -507,6 +512,10 @@ export const ProposePlanToolCall: React.FC = (props) = if (existingThinking !== resolvedThinking) { updatePersistedState(thinkingKey, resolvedThinking); } + // Persist before getSendOptionsFromStorage reads the key for the follow-up send. + if (existingReasoning !== resolvedReasoningMode) { + updatePersistedState(reasoningKey, resolvedReasoningMode); + } return { resolvedModel, resolvedThinking }; }; diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 3ce07796ee..4fdff57339 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -40,6 +40,7 @@ async function importIsolatedDraftWorkspaceSettingsModules() { const isolatedProjectPath = join(contextsDir, `ProjectContext.real.${suffix}.tsx`); const isolatedThinkingPath = join(contextsDir, `ThinkingContext.real.${suffix}.tsx`); const isolatedThinkingLevelPath = join(hooksDir, `useThinkingLevel.real.${suffix}.ts`); + const isolatedReasoningModePath = join(hooksDir, `useReasoningMode.real.${suffix}.ts`); const isolatedHookPath = join(hooksDir, `useDraftWorkspaceSettings.real.${suffix}.ts`); await copyFile(join(contextsDir, "API.tsx"), isolatedApiPath); @@ -80,6 +81,21 @@ async function importIsolatedDraftWorkspaceSettingsModules() { await writeFile(isolatedThinkingLevelPath, isolatedThinkingLevelSource); + // useReasoningMode reads the same ThinkingContext; without this rewrite the + // hook copy would resolve the canonical context (a different React context + // instance than the isolated ThinkingProvider in the wrapper) and throw. + const reasoningModeSource = await readFile(join(hooksDir, "useReasoningMode.ts"), "utf8"); + const isolatedReasoningModeSource = reasoningModeSource.replace( + 'from "@/browser/contexts/ThinkingContext";', + `from "../contexts/ThinkingContext.real.${suffix}.tsx";` + ); + + if (isolatedReasoningModeSource === reasoningModeSource) { + throw new Error("Failed to rewrite useReasoningMode ThinkingContext import"); + } + + await writeFile(isolatedReasoningModePath, isolatedReasoningModeSource); + const hookSource = await readFile(join(hooksDir, "useDraftWorkspaceSettings.ts"), "utf8"); const hookWithIsolatedThinkingLevel = hookSource.replace( 'from "./useThinkingLevel";', @@ -90,12 +106,21 @@ async function importIsolatedDraftWorkspaceSettingsModules() { throw new Error("Failed to rewrite useDraftWorkspaceSettings thinking hook import"); } - const isolatedHookSource = hookWithIsolatedThinkingLevel.replace( + const hookWithIsolatedReasoningMode = hookWithIsolatedThinkingLevel.replace( + 'from "./useReasoningMode";', + `from "./useReasoningMode.real.${suffix}.ts";` + ); + + if (hookWithIsolatedReasoningMode === hookWithIsolatedThinkingLevel) { + throw new Error("Failed to rewrite useDraftWorkspaceSettings reasoning-mode hook import"); + } + + const isolatedHookSource = hookWithIsolatedReasoningMode.replace( 'from "@/browser/contexts/ProjectContext";', `from "../contexts/ProjectContext.real.${suffix}.tsx";` ); - if (isolatedHookSource === hookWithIsolatedThinkingLevel) { + if (isolatedHookSource === hookWithIsolatedReasoningMode) { throw new Error("Failed to rewrite useDraftWorkspaceSettings project context import"); } @@ -119,6 +144,7 @@ async function importIsolatedDraftWorkspaceSettingsModules() { isolatedProjectPath, isolatedThinkingPath, isolatedThinkingLevelPath, + isolatedReasoningModePath, isolatedHookPath, ]; } 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/useMinThinkingLevels.ts b/src/browser/hooks/useMinThinkingLevels.ts index 9400230292..ce08de6c38 100644 --- a/src/browser/hooks/useMinThinkingLevels.ts +++ b/src/browser/hooks/useMinThinkingLevels.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useOptionalAPI } from "@/browser/contexts/API"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { normalizeToCanonical } from "@/common/utils/ai/models"; import { getAvailableThinkingLevels, @@ -33,6 +34,9 @@ export function useMinThinkingLevels(): MinThinkingLevelsState { // Optional so providers that mount this (e.g. ThinkingProvider) don't crash when rendered // outside an APIProvider in isolated test harnesses; without an api we degrade to defaults. const api = useOptionalAPI()?.api ?? null; + // Resolve mapped aliases (mappedToModel) to their capability model so floors + // and ladders match the target model; degrades to null outside APIProvider. + const { config: providersConfig } = useProvidersConfig(); const [minThinkingLevelByModel, setMap] = useState>({}); // Ignore stale config fetches so backend refreshes can't overwrite newer optimistic edits. const fetchVersionRef = useRef(0); @@ -103,9 +107,10 @@ export function useMinThinkingLevels(): MinThinkingLevelsState { (modelString: string): ThinkingLevel => resolveMinimumThinkingLevel( modelString, - minThinkingLevelByModel[normalizeToCanonical(modelString)] + minThinkingLevelByModel[normalizeToCanonical(modelString)], + providersConfig ), - [minThinkingLevelByModel] + [minThinkingLevelByModel, providersConfig] ); const setMinThinkingLevel = useCallback( @@ -118,9 +123,11 @@ export function useMinThinkingLevels(): MinThinkingLevelsState { // medium default and an explicit "high" both yield ["high"]). const defaultFloor = getAvailableThinkingLevels( modelString, - getDefaultMinimumThinkingLevel(modelString) + getDefaultMinimumThinkingLevel(modelString, providersConfig), + providersConfig )[0]; - const pickedFloor = level == null ? null : getAvailableThinkingLevels(modelString, level)[0]; + const pickedFloor = + level == null ? null : getAvailableThinkingLevels(modelString, level, providersConfig)[0]; if (level == null || pickedFloor === defaultFloor) { delete next[key]; } else { @@ -137,7 +144,7 @@ export function useMinThinkingLevels(): MinThinkingLevelsState { void fetchConfig(); }); }, - [api, fetchConfig, minThinkingLevelByModel] + [api, fetchConfig, minThinkingLevelByModel, providersConfig] ); return { 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..b8aebc8df2 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -5,7 +5,14 @@ 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 { + THINKING_LEVELS, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { normalizeToCanonical } from "@/common/utils/ai/models"; +import { openaiProModeAvailable } from "@/common/utils/ai/proMode"; import { enforceThinkingPolicy, getAvailableThinkingLevels, @@ -81,6 +88,12 @@ export interface BuildSourcesParams { // UI actions getThinkingLevel: (workspaceId: string) => ThinkingLevel; onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; + getReasoningMode: (workspaceId: string) => OpenAIReasoningMode; + onToggleReasoningMode: (workspaceId: string) => void; + /** Providers config for pro-mode availability (wire format + Codex OAuth detection). */ + providersConfig?: ProvidersConfigMap | null; + /** Settings-resolved route for a canonical model ("direct" = no gateway). */ + getRouteForModel?: (canonicalModel: string) => string; /** * 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. @@ -1201,14 +1214,18 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // medium floor reads as "medium"). const currentModelString = p.selectedWorkspaceState?.currentModel; const rawCurrentLevel = p.getThinkingLevel(workspaceId); + // Pass providersConfig so mapped aliases (mappedToModel -> e.g. GPT-5.6) + // resolve to the target's ladder, matching the slider and send path. const currentLevel = currentModelString ? enforceThinkingPolicy( currentModelString, rawCurrentLevel, resolveMinimumThinkingLevel( currentModelString, - p.getMinThinkingOverride?.(currentModelString) - ) + p.getMinThinkingOverride?.(currentModelString), + p.providersConfig + ), + p.providersConfig ) : rawCurrentLevel; @@ -1238,8 +1255,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi modelString, resolveMinimumThinkingLevel( modelString, - p.getMinThinkingOverride?.(modelString) - ) + p.getMinThinkingOverride?.(modelString), + p.providersConfig + ), + p.providersConfig ) : THINKING_LEVELS; return allowedLevels.map((level) => ({ @@ -1264,6 +1283,41 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, }, }); + + // Pro reasoning mode is only meaningful for models that support it + // (GPT-5.6 family) on routes that emit the pro-mode header (direct + // OpenAI) with the Responses wire format; hide the action elsewhere to + // avoid inert toggles. Gate on the chat input's persisted selection — + // that is the model the NEXT send will use — and only fall back to the + // activity snapshot's currentModel (last streamed model, stale after a + // model switch) when no selection exists. The mobile layout hides the + // PRO chip and relies on this palette action being reachable before the + // first send with the newly selected model. + const persistedSelectionModel = + typeof window === "undefined" + ? undefined + : getSendOptionsFromStorage(workspaceId).model || undefined; + const proGateModelString = persistedSelectionModel ?? currentModelString; + const currentModelRoute = proGateModelString + ? p.getRouteForModel?.(normalizeToCanonical(proGateModelString)) + : undefined; + if ( + openaiProModeAvailable(proGateModelString ?? "", { + providersConfig: p.providersConfig, + resolvedRouteProvider: currentModelRoute, + }) + ) { + 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..e451e65034 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,11 @@ import { normalizeModelPreference, } from "@/browser/utils/messages/buildSendMessageOptions"; import type { SendMessageOptions } from "@/common/orpc/types"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import { + coerceOpenAIReasoningMode, + type OpenAIReasoningMode, + type 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 +69,14 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio WORKSPACE_DEFAULTS.agentId ); + // OpenAI pro reasoning mode (workspace-scoped); absent = standard. + // Coerce untrusted persisted values so corrupt entries self-heal to "standard" + // instead of failing SendMessageOptionsSchema on retry/resume/creation flows. + const reasoningMode = + coerceOpenAIReasoningMode( + readPersistedState(getReasoningModeKey(workspaceId), null) + ) ?? "standard"; + const providerOptions = getProviderOptions(); const disableWorkspaceAgents = readPersistedState( @@ -75,6 +88,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/browser/utils/workspaceModeAi.test.ts b/src/browser/utils/workspaceModeAi.test.ts index dd12015250..a386b7566d 100644 --- a/src/browser/utils/workspaceModeAi.test.ts +++ b/src/browser/utils/workspaceModeAi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import { resolveWorkspaceAiSettingsForAgent } from "./workspaceModeAi"; describe("resolveWorkspaceAiSettingsForAgent", () => { @@ -17,6 +17,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "openai:gpt-5.3-codex", resolvedThinking: "high", + resolvedReasoningMode: "standard", }); }); @@ -32,6 +33,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "anthropic:claude-opus-4-6", resolvedThinking: "medium", + resolvedReasoningMode: "standard", }); }); @@ -51,6 +53,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "openai:gpt-5.2", resolvedThinking: "medium", + resolvedReasoningMode: "standard", }); }); @@ -70,6 +73,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "anthropic:claude-opus-4-6", resolvedThinking: "off", + resolvedReasoningMode: "standard", }); }); @@ -87,6 +91,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "anthropic:claude-opus-4-6", resolvedThinking: "low", + resolvedReasoningMode: "standard", }); }); @@ -104,9 +109,105 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "anthropic:claude-opus-4-6", resolvedThinking: "off", + resolvedReasoningMode: "standard", }); }); + // Per-agent pro-mode restore: explicit switches (useWorkspaceByAgentFallback) + // must restore the agent's saved reasoningMode alongside model/thinking; + // background sync inherits the workspace's current mode. + test("restores the agent's saved pro mode on explicit switches", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + workspaceByAgent: { + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium", reasoningMode: "pro" }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.6-sol", + existingThinking: "off", + existingReasoningMode: "standard", + }); + + expect(result.resolvedReasoningMode).toBe("pro"); + }); + + test("inherits the workspace's current pro mode during background sync", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + workspaceByAgent: { + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium", reasoningMode: "standard" }, + }, + useWorkspaceByAgentFallback: false, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.6-sol", + existingThinking: "off", + existingReasoningMode: "pro", + }); + + expect(result.resolvedReasoningMode).toBe("pro"); + }); + + test("defaults legacy per-agent entries without reasoningMode to standard on explicit switches", () => { + // A workspaceByAgent entry saved before pro mode shipped has no + // reasoningMode field. Explicitly switching to that agent must not inherit + // the previous agent's pro mode — absent means "standard" (same semantics + // as WorkspaceContext seeding). + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + workspaceByAgent: { + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "medium" }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.6-sol", + existingThinking: "off", + existingReasoningMode: "pro", + }); + + expect(result.resolvedReasoningMode).toBe("standard"); + }); + + test("inherits the workspace mode on explicit switches without a per-agent entry", () => { + // No workspaceByAgent entry at all: nothing saved for this agent, so the + // workspace's current mode carries over (distinct from the legacy-entry case). + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.6-sol", + existingThinking: "off", + existingReasoningMode: "pro", + }); + + expect(result.resolvedReasoningMode).toBe("pro"); + }); + + test("self-heals a corrupt saved reasoning mode to standard", () => { + const result = resolveWorkspaceAiSettingsForAgent({ + agentId: "exec", + agentAiDefaults: {}, + workspaceByAgent: { + exec: { + model: "openai:gpt-5.6-sol", + thinkingLevel: "medium", + reasoningMode: "ultra" as unknown as OpenAIReasoningMode, + }, + }, + useWorkspaceByAgentFallback: true, + fallbackModel: "openai:gpt-5.2-mini", + existingModel: "openai:gpt-5.6-sol", + existingThinking: "off", + existingReasoningMode: "corrupt" as unknown as OpenAIReasoningMode, + }); + + expect(result.resolvedReasoningMode).toBe("standard"); + }); + test("self-heals invalid inherited workspace settings", () => { const result = resolveWorkspaceAiSettingsForAgent({ agentId: "exec", @@ -119,6 +220,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "openai:gpt-5.2", resolvedThinking: "off", + resolvedReasoningMode: "standard", }); }); @@ -134,6 +236,7 @@ describe("resolveWorkspaceAiSettingsForAgent", () => { expect(result).toEqual({ resolvedModel: "openai:gpt-5.2", resolvedThinking: "off", + resolvedReasoningMode: "standard", }); }); }); diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 704eb71e48..c950de24f1 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -1,9 +1,17 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; -import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { + coerceOpenAIReasoningMode, + coerceThinkingLevel, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; import { normalizeAgentId as normalizeWorkspaceAgentId } from "@/common/utils/agentIds"; export type WorkspaceAISettingsCache = Partial< - Record + Record< + string, + { model: string; thinkingLevel: ThinkingLevel; reasoningMode?: OpenAIReasoningMode } + > >; function normalizeAgentId(agentId: string): string { @@ -20,7 +28,12 @@ export function resolveWorkspaceAiSettingsForAgent(args: { fallbackModel: string; existingModel: string; existingThinking: ThinkingLevel; -}): { resolvedModel: string; resolvedThinking: ThinkingLevel } { + existingReasoningMode?: OpenAIReasoningMode; +}): { + resolvedModel: string; + resolvedThinking: ThinkingLevel; + resolvedReasoningMode: OpenAIReasoningMode; +} { const normalizedAgentId = normalizeAgentId(args.agentId); const globalDefault = args.agentAiDefaults[normalizedAgentId]; const workspaceOverride = args.workspaceByAgent?.[normalizedAgentId]; @@ -53,5 +66,17 @@ export function resolveWorkspaceAiSettingsForAgent(args: { const resolvedThinking = coerceThinkingLevel(globalDefault?.thinkingLevel) ?? inheritedThinking ?? "off"; - return { resolvedModel, resolvedThinking }; + // Restore the agent's saved pro-mode choice alongside model/thinking on + // explicit switches; otherwise inherit the workspace's current mode. + // (Agent AI defaults carry no reasoningMode — it is a per-workspace choice.) + // When a per-agent entry exists but lacks reasoningMode (legacy entry saved + // before pro mode shipped), treat absent as "standard" — matching the + // WorkspaceContext seeding semantics — instead of inheriting a possibly-pro + // workspace mode from the previously active agent. + const resolvedReasoningMode = + args.useWorkspaceByAgentFallback && workspaceOverride != null + ? (coerceOpenAIReasoningMode(workspaceOverride.reasoningMode) ?? "standard") + : (coerceOpenAIReasoningMode(args.existingReasoningMode) ?? "standard"); + + return { resolvedModel, resolvedThinking, resolvedReasoningMode }; } diff --git a/src/common/constants/codexOAuth.test.ts b/src/common/constants/codexOAuth.test.ts index 8d7b56080d..c6725b9455 100644 --- a/src/common/constants/codexOAuth.test.ts +++ b/src/common/constants/codexOAuth.test.ts @@ -19,6 +19,16 @@ 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", () => { + // Includes the bare alias: it is a servable model id (OpenAI routes it to Sol). + for (const model of ["gpt-5.6", "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..75f591088e 100644 --- a/src/common/constants/codexOAuth.ts +++ b/src/common/constants/codexOAuth.ts @@ -99,6 +99,13 @@ 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. + // The bare alias is a servable model id (OpenAI routes it to Sol), so it must + // be allowed too or OAuth-only users selecting it fall to the API-key path. + "gpt-5.6", + "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..fbceac3116 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; 1.05M 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; 1.05M context (GA model page; 400K was a stale 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/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.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/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.test.ts b/src/common/types/thinking.test.ts index a80b9a32b4..39ca6a70f7 100644 --- a/src/common/types/thinking.test.ts +++ b/src/common/types/thinking.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { coerceThinkingLevel, + getOpenAIReasoningEffort, getThinkingDisplayLabel, getThinkingOptionLabel, MAX_THINKING_INDEX, + openaiSupportsNativeMaxEffort, + openaiSupportsProMode, parseThinkingInput, } from "./thinking"; @@ -22,6 +25,16 @@ describe("getThinkingDisplayLabel", () => { expect(getThinkingDisplayLabel("max", "mux-gateway:openai/gpt-5.2")).toBe("XHIGH"); }); + test("returns MAX for max on the GPT-5.6 family (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"); + expect(getThinkingDisplayLabel("max", "openai:gpt-5.6-terra")).toBe("MAX"); + expect(getThinkingDisplayLabel("max", "openai:gpt-5.6-luna")).toBe("MAX"); + // Pre-5.6 OpenAI models keep the max -> XHIGH display. + 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 +58,77 @@ 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 the GPT-5.6 family 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); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-terra")).toBe(true); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6-luna")).toBe(true); + // The bare alias routes to Sol and shares the family capabilities. + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.6")).toBe(true); + }); + + test("rejects other models and named variants", () => { + 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); + expect(openaiSupportsNativeMaxEffort("openai:gpt-5.61")).toBe(false); + }); +}); + +describe("openaiSupportsProMode", () => { + test("matches the GPT-5.6 family 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("openai:gpt-5.6-luna")).toBe(true); + expect(openaiSupportsProMode("mux-gateway:openai/gpt-5.6-sol")).toBe(true); + expect(openaiSupportsProMode("gpt-5.6-terra-2026-07-09")).toBe(true); + // The bare alias routes to Sol and shares the family capabilities. + expect(openaiSupportsProMode("openai:gpt-5.6")).toBe(true); + }); + + test("rejects older models and named variants", () => { + expect(openaiSupportsProMode("openai:gpt-5.5-pro")).toBe(false); + expect(openaiSupportsProMode("openai:gpt-5.6-sol-mini")).toBe(false); + expect(openaiSupportsProMode("openai:gpt-5.61")).toBe(false); + expect(openaiSupportsProMode("anthropic:claude-opus-4-7")).toBe(false); + }); +}); + +describe("getOpenAIReasoningEffort", () => { + test("maps max to the native max effort on the GPT-5.6 family 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("max"); + expect(getOpenAIReasoningEffort("max", "openai:gpt-5.6-luna")).toBe("max"); + expect(getOpenAIReasoningEffort("max", "openai:gpt-5.5-pro")).toBe("xhigh"); + }); + + test("maps off to the explicit none effort on GPT-5.6 (omission defaults to medium)", () => { + expect(getOpenAIReasoningEffort("off", "openai:gpt-5.6-sol")).toBe("none"); + expect(getOpenAIReasoningEffort("off", "openai:gpt-5.6-luna")).toBe("none"); + // Pre-5.6 models keep the omit-on-off behavior. + expect(getOpenAIReasoningEffort("off", "openai:gpt-5.5")).toBeUndefined(); + }); + + test("keeps the standard mapping for lower levels", () => { + expect(getOpenAIReasoningEffort("high", "openai:gpt-5.6-sol")).toBe("high"); + expect(getOpenAIReasoningEffort("low", "openai:gpt-5.6-sol")).toBe("low"); + }); +}); + 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..a6d80c9af4 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,59 @@ export function anthropicSupportsNativeXhigh(modelString: string): boolean { ); } +/** + * GPT-5.6 family matcher: the bare `gpt-5.6` alias (OpenAI routes it to Sol) + * plus the Sol/Terra/Luna tiers. The `\b` + lookaheads tolerate version-date + * suffixes (e.g. gpt-5.6-sol-2026-07-09) while rejecting hypothetical named + * variants (e.g. gpt-5.6-sol-mini) and other ids (e.g. gpt-5.61). + */ +function isGpt56FamilyModel(modelString: string): boolean { + const withoutPrefix = stripModelProviderPrefixes(modelString); + return /^gpt-5\.6(?:-(?:sol|terra|luna))?\b(?!\.)(?!-[a-z])/.test(withoutPrefix); +} + +/** + * Whether the given OpenAI model supports the native "max" reasoning effort. + * + * The GPT-5.6 GA launch (July 9, 2026) added a top reasoning effort above + * xhigh for the whole family — Sol, Terra, Luna, and the bare `gpt-5.6` alias + * (see the OpenAI changelog: "GPT-5.6 adds ... max reasoning effort, and Pro + * mode"). Earlier preview coverage described it as Sol-only, which is stale. + */ +export function openaiSupportsNativeMaxEffort(modelString: string): boolean { + return isGpt56FamilyModel(modelString); +} + +/** + * 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 the GPT-5.6 family. + */ +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" is not a separate model id: the same GPT-5.6 ids are + * served with `reasoning.mode: "pro"`. Per the Responses API reasoning guide, + * pro mode is available on every GPT-5.6 model (Sol, Terra, Luna, and the bare + * `gpt-5.6` alias) — the Sol/Terra-only restriction came from stale preview + * coverage. + */ +export function openaiSupportsProMode(modelString: string): boolean { + return isGpt56FamilyModel(modelString); +} + /** * Whether the given Anthropic model rejects `thinking: { type: "disabled" }`. * @@ -279,6 +336,29 @@ 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). The GPT-5.6 family ships a distinct native + * effort above xhigh with the wire value "max" on the Responses API (live-verified: + * the response echoes `effort: max`; not yet in the SDK's typed union). + * + * GPT-5.6 "off" maps to the explicit "none" effort: omitting the field defaults + * the request to medium (live-verified 2026-07-10 — an effort-less request echoed + * `effort: medium`), which would silently ignore the user's off selection. + */ +export function getOpenAIReasoningEffort( + level: ThinkingLevel, + modelString: string +): string | undefined { + if (isGpt56FamilyModel(modelString)) { + if (level === "max") return "max"; + if (level === "off") return "none"; + } + return OPENAI_REASONING_EFFORT[level]; +} + /** * OpenRouter reasoning effort mapping * diff --git a/src/common/utils/ai/models.ts b/src/common/utils/ai/models.ts index 833801bd9b..de4f3f7055 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -78,6 +78,30 @@ export function getExplicitGatewayPrefix(modelString: string): ProviderName | un return PROVIDER_DEFINITIONS[providerName]?.kind === "gateway" ? providerName : undefined; } +/** + * Resolve which providerOptions namespace a request will use for a given + * canonical origin and route provider. Passthrough gateways (mux-gateway) + * keep the origin namespace; transforming gateways (openrouter, + * github-copilot, bedrock) use their own. + */ +export function resolveProviderOptionsNamespaceKey( + canonicalProviderName: string, + routeProvider?: ProviderName +): string { + const routeDefinition = routeProvider ? PROVIDER_DEFINITIONS[routeProvider] : undefined; + if ( + !routeProvider || + routeProvider === canonicalProviderName || + (routeDefinition != null && + "passthrough" in routeDefinition && + routeDefinition.passthrough === true) + ) { + return canonicalProviderName; + } + + return routeProvider; +} + /** * Normalize a selected model while preserving explicit gateway routing choices. * User-selected gateway identities like openrouter:openai/gpt-5 should stay intact. diff --git a/src/common/utils/ai/proMode.ts b/src/common/utils/ai/proMode.ts new file mode 100644 index 0000000000..c6e2eb9cf6 --- /dev/null +++ b/src/common/utils/ai/proMode.ts @@ -0,0 +1,99 @@ +/** + * Route-aware pro-mode availability for UI surfaces (PRO toggle, palette command). + * + * Mirrors the send path's wire gating so the UI never offers a toggle that + * cannot affect the request: + * - model must be pro-capable (GPT-5.6 Sol/Terra — openaiSupportsProMode); + * - pro mode is a Responses API field, so `wireFormat: "chatCompletions"` + * disables it (the OpenAI fetch wrapper only rewrites /responses bodies); + * - only the direct `openai:` route delivers the mode. Gateways hide it: + * non-passthrough ones (openrouter:openai/..., github-copilot:...) never see + * the header, and mux-gateway currently drops the rewritten + * `providerOptions.openai.reasoningMode` server-side (verified empirically — + * the Responses API echoed `mode: "standard"`), so it fails closed until the + * gateway forwards the field; + * - Codex OAuth routes never inject (`inject: false` — the ChatGPT backend is + * stricter than the public API), so when OAuth is the effective auth path + * for the model, pro mode is unavailable too. + * + * Lives in its own module because the Codex OAuth mirror imports the codexOAuth + * constants, which sit above models.ts in the import graph (codexOAuth → + * modelEntries → models); adding it to models.ts would create a cycle. + */ + +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { PROVIDER_DEFINITIONS } from "@/common/constants/providers"; +import { openaiSupportsProMode } from "@/common/types/thinking"; +import { getExplicitGatewayPrefix, normalizeToCanonical } from "@/common/utils/ai/models"; +import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; +import { wouldRouteOpenAIThroughCodexOauth } from "@/common/utils/providers/codexOauthRouting"; + +export interface ProModeAvailabilityOptions { + /** Overrides the providersConfig-derived OpenAI wire format when provided. */ + openaiWireFormat?: "responses" | "chatCompletions" | null; + /** Settings-resolved route for the canonical model ("direct" = no gateway). */ + resolvedRouteProvider?: string | null; + /** Providers config for wire format + Codex OAuth auth-path detection. */ + providersConfig?: ProvidersConfigMap | null; +} + +export function openaiProModeAvailable( + modelString: string, + options?: ProModeAvailabilityOptions +): boolean { + const wireFormat = + options?.openaiWireFormat ?? options?.providersConfig?.openai?.wireFormat ?? "responses"; + if (wireFormat === "chatCompletions") { + return false; + } + const normalized = normalizeToCanonical(modelString); + const [origin] = normalized.split(":", 2); + if (origin !== "openai") { + return false; + } + + // Mapped aliases (models: [{ id, mappedToModel }]) inherit capabilities from + // their target, mirroring buildProviderOptions' capabilityModel resolution. + const capabilityModel = resolveModelForMetadata(normalized, options?.providersConfig ?? null); + if (!openaiSupportsProMode(capabilityModel)) { + return false; + } + + // Direct-only: any gateway route (explicit model-string prefix or + // settings-resolved) fails closed — including mux-gateway, which drops the + // field today. Unknown routes fail closed too. + // + // An explicit prefix only wins the route while the gateway can actually + // serve it: the backend (resolveModelString) preserves the prefix only when + // the gateway is configured and enabled, and otherwise falls back to the + // settings-resolved route — which may be direct OpenAI, where pro mode + // works. Mirror that here so the toggle isn't hidden in the fallback case. + // Without a providersConfig we cannot tell, so fail closed conservatively. + const explicitGateway = getExplicitGatewayPrefix(modelString); + if (explicitGateway != null) { + const gatewayConfig = options?.providersConfig?.[explicitGateway]; + const gatewayDefinition = PROVIDER_DEFINITIONS[explicitGateway]; + const gatewayWinsRoute = + options?.providersConfig == null || + (gatewayConfig?.isConfigured === true && + gatewayConfig.isEnabled !== false && + gatewayDefinition.kind === "gateway" && + // Each gateway definition narrows routes to its literal tuple; widen for the membership check. + (gatewayDefinition.routes as readonly string[]).includes("openai")); + if (gatewayWinsRoute) { + return false; + } + } + const resolvedRouteProvider = options?.resolvedRouteProvider; + if (resolvedRouteProvider != null && resolvedRouteProvider !== "direct") { + return false; + } + + // Codex OAuth routes strip the pro-mode header without injecting. Checked + // after route resolution so the exclusion only applies to direct OpenAI + // routing — gateway sends never use Codex OAuth (they fail closed above). + return !( + options?.providersConfig != null && + wouldRouteOpenAIThroughCodexOauth(normalized, options.providersConfig) + ); +} diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 192f52e9a9..513cf3b65c 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -10,10 +10,12 @@ import { buildProviderOptions, buildRequestHeaders, isAnthropic1MEffectivelyEnabled, + openaiProModeAvailable, preserveAnthropic1MContextForFollowUp, resolveProviderOptionsNamespaceKey, ANTHROPIC_1M_CONTEXT_HEADER, MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, + MUX_OPENAI_REASONING_MODE_HEADER, MUX_WORKSPACE_ID_HEADER, } from "./providerOptions"; @@ -672,6 +674,133 @@ 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("maps max to the native effort across the GPT-5.6 family", () => { + for (const model of ["openai:gpt-5.6-terra", "openai:gpt-5.6-luna"]) { + const result = buildProviderOptions(model, "max"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("max"); + } + }); + + test("keeps max -> xhigh for pre-5.6 OpenAI models", () => { + const result = buildProviderOptions("openai:gpt-5.5-pro", "max"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("xhigh"); + }); + + test("sends the explicit none effort for GPT-5.6 off (omission defaults to medium)", () => { + const result = buildProviderOptions("openai:gpt-5.6-sol", "off"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + // Live-verified: effort-less GPT-5.6 requests run at medium; none + summary coexist. + expect(openai!.reasoningEffort).toBe("none"); + }); + + test("keeps omitting reasoning options for pre-5.6 OpenAI off", () => { + const result = buildProviderOptions("openai:gpt-5.5", "off"); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBeUndefined(); + }); + + test("omits the effort for GPT-5.6 off on the Copilot gateway (none unpublished upstream)", () => { + const result = buildProviderOptions( + "openai:gpt-5.6-sol", + "off", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "github-copilot" + ); + + expect(result).toEqual({}); + }); + + test("degrades native max to xhigh on the chatCompletions wire format", () => { + // @ai-sdk/openai's Chat Completions schema caps reasoningEffort at xhigh + // (z.enum without "max"); sending "max" would throw client-side. + const result = buildProviderOptions("openai:gpt-5.6-sol", "max", undefined, undefined, { + openai: { wireFormat: "chatCompletions" }, + }); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("xhigh"); + }); + + test("degrades native max to xhigh through the Copilot-routed gateway call site", () => { + // Copilot's Chat Completions upstream has not published native-max + // support, so the gateway path degrades to the pre-5.6 top effort. + const result = buildProviderOptions( + "openai:gpt-5.6-sol", + "max", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "github-copilot" + ); + + expect(result).toEqual({ + "github-copilot": { + reasoningEffort: "xhigh", + }, + }); + }); + + // Mapped aliases inherit capabilities from their target like the other + // capability checks (resolveModelForMetadata), so a custom entry mapped to + // Sol must also get the native max effort. + test("resolves mapped aliases to the target for native max effort", () => { + const providersConfig = createMockProvidersConfig({ + "openai:team-sol": "openai:gpt-5.6-sol", + }); + + const result = buildProviderOptions( + "openai:team-sol", + "max", + undefined, + undefined, + undefined, + undefined, + undefined, + providersConfig + ); + const openai = getOpenAIOptions(result); + + expect(openai).toBeDefined(); + expect(openai!.reasoningEffort).toBe("max"); + }); + }); + describe("OpenAI conversation state management", () => { test("does not reuse previousResponseId when Mux already sends explicit GPT-5.5 history", () => { const messages = [ @@ -1046,6 +1175,344 @@ 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" }, + }, + { + // Direct-route-only: mux-gateway drops the rewritten reasoningMode + // server-side today, so even passthrough gateways get no header. + name: "does not emit for gateway-routed Sol (mux-gateway drops the field)", + model: "mux-gateway:openai/gpt-5.6-sol", + routeProvider: "mux-gateway", + reasoningMode: "pro", + expected: undefined, + }, + { + 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, + }, + { + // Pro mode is family-wide at GA, including Luna. + name: "emits pro header for direct Luna with reasoningMode=pro", + model: "openai:gpt-5.6-luna", + routeProvider: undefined, + reasoningMode: "pro", + expected: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }, + { + name: "does not emit for pre-5.6 models (no pro support)", + model: "openai:gpt-5.5-pro", + 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" }); + }); + + // Pro mode is Responses-only: the wrapper never injects into + // chat-completions bodies, so the header must not be emitted either — + // regardless of whether wireFormat comes from provider config or + // request-level options (config wins, mirroring providerModelFactory). + const openaiProviderInfoBase = { apiKeySet: true, isEnabled: true, isConfigured: true }; + + test("does not emit when provider config sets wireFormat chatCompletions", () => { + expect( + buildRequestHeaders( + "openai:gpt-5.6-sol", + undefined, + undefined, + { openai: { ...openaiProviderInfoBase, wireFormat: "chatCompletions" } }, + undefined, + "off", + "pro" + ) + ).toBeUndefined(); + }); + + test("does not emit when request options set wireFormat chatCompletions", () => { + expect( + buildRequestHeaders( + "openai:gpt-5.6-sol", + { openai: { wireFormat: "chatCompletions" } }, + undefined, + undefined, + undefined, + "off", + "pro" + ) + ).toBeUndefined(); + }); + + test("emits when provider config sets wireFormat responses explicitly", () => { + expect( + buildRequestHeaders( + "openai:gpt-5.6-sol", + undefined, + undefined, + { openai: { ...openaiProviderInfoBase, wireFormat: "responses" } }, + undefined, + "off", + "pro" + ) + ).toEqual({ [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }); + }); + + // The send-path gate must resolve mapped aliases like the UI gate does, + // otherwise a persisted "pro" choice silently stops emitting the header. + test("emits for mapped aliases whose target supports pro mode", () => { + const providersConfig = createMockProvidersConfig({ + "openai:team-sol": "openai:gpt-5.6-sol", + }); + + expect( + buildRequestHeaders( + "openai:team-sol", + undefined, + undefined, + providersConfig, + undefined, + "off", + "pro" + ) + ).toEqual({ [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }); + + // Unmapped custom ids still fail closed. + expect( + buildRequestHeaders( + "openai:team-sol", + undefined, + undefined, + undefined, + undefined, + "off", + "pro" + ) + ).toBeUndefined(); + }); + }); + + describe("openaiProModeAvailable", () => { + // UI gating must mirror the wire gating: only routes that emit the + // pro-mode header (direct OpenAI or passthrough gateways) surface the toggle. + const cases: Array<[string, boolean]> = [ + ["openai:gpt-5.6-sol", true], + ["openai:gpt-5.6-terra", true], + // Pro mode is family-wide at GA (including Luna and the bare alias). + ["openai:gpt-5.6-luna", true], + ["openai:gpt-5.6", true], + // All gateways fail closed — mux-gateway drops the field server-side. + ["mux-gateway:openai/gpt-5.6-sol", false], + ["openrouter:openai/gpt-5.6-sol", false], + ["github-copilot:gpt-5.6-sol", false], + // Non-pro-capable models. + ["openai:gpt-5.5-pro", false], + ["anthropic:claude-opus-4-8", false], + ["", false], + ]; + + for (const [model, expected] of cases) { + test(`${JSON.stringify(model)} -> ${expected}`, () => { + expect(openaiProModeAvailable(model)).toBe(expected); + }); + } + + // Explicit gateway prefixes hide the toggle only while that gateway can + // win the route. When the gateway is disabled/unconfigured the backend + // (resolveModelString) falls back to the settings-resolved route, which + // may be direct OpenAI — where the send path delivers pro mode. + describe("explicit gateway prefix with route fallback", () => { + const openaiDirect = { + apiKeySet: true, + isEnabled: true, + isConfigured: true, + }; + + test("fails closed while the explicit gateway is configured and enabled", () => { + const providersConfig: ProvidersConfigMap = { + openai: openaiDirect, + openrouter: { apiKeySet: true, isEnabled: true, isConfigured: true }, + }; + expect( + openaiProModeAvailable("openrouter:openai/gpt-5.6-sol", { + providersConfig, + resolvedRouteProvider: "direct", + }) + ).toBe(false); + }); + + test("allows pro mode when the gateway is unavailable and the route falls back to direct", () => { + // openrouter absent from config (unconfigured) — backend routing falls + // back to direct OpenAI, so the toggle must stay visible. + const unconfigured: ProvidersConfigMap = { openai: openaiDirect }; + expect( + openaiProModeAvailable("openrouter:openai/gpt-5.6-sol", { + providersConfig: unconfigured, + resolvedRouteProvider: "direct", + }) + ).toBe(true); + + // Disabled gateway behaves the same as unconfigured. + const disabled: ProvidersConfigMap = { + openai: openaiDirect, + openrouter: { apiKeySet: true, isEnabled: false, isConfigured: true }, + }; + expect( + openaiProModeAvailable("openrouter:openai/gpt-5.6-sol", { + providersConfig: disabled, + resolvedRouteProvider: "direct", + }) + ).toBe(true); + }); + + test("still fails closed when the fallback route is another gateway", () => { + const providersConfig: ProvidersConfigMap = { openai: openaiDirect }; + expect( + openaiProModeAvailable("openrouter:openai/gpt-5.6-sol", { + providersConfig, + resolvedRouteProvider: "mux-gateway", + }) + ).toBe(false); + }); + }); + + // Mapped aliases (models: [{ id, mappedToModel }]) inherit pro capability + // from their target via resolveModelForMetadata, matching the send path. + test("mapped aliases inherit pro capability from their target", () => { + const providersConfig = createMockProvidersConfig({ + "openai:team-sol": "openai:gpt-5.6-sol", + "openai:team-pro": "openai:gpt-5.5-pro", + }); + + expect(openaiProModeAvailable("openai:team-sol", { providersConfig })).toBe(true); + // Targets without pro capability stay hidden. + expect(openaiProModeAvailable("openai:team-pro", { providersConfig })).toBe(false); + // Without a mapping the custom id fails closed. + expect(openaiProModeAvailable("openai:team-sol")).toBe(false); + }); + + // Pro mode is Responses-only: chatCompletions wire format disables it even + // for pro-capable models on passthrough routes. + test("chatCompletions wire format disables pro mode", () => { + expect( + openaiProModeAvailable("openai:gpt-5.6-sol", { openaiWireFormat: "chatCompletions" }) + ).toBe(false); + expect(openaiProModeAvailable("openai:gpt-5.6-sol", { openaiWireFormat: "responses" })).toBe( + true + ); + expect(openaiProModeAvailable("openai:gpt-5.6-sol", { openaiWireFormat: null })).toBe(true); + }); + + // Canonical model strings can be routed to a non-passthrough gateway by + // routing settings; the resolved route must gate availability like the + // send path gates the header. + test("settings-resolved route gates canonical model strings", () => { + const route = (r: string) => + openaiProModeAvailable("openai:gpt-5.6-sol", { resolvedRouteProvider: r }); + expect(route("direct")).toBe(true); + // mux-gateway drops the field server-side today — fail closed. + expect(route("mux-gateway")).toBe(false); + expect(route("openrouter")).toBe(false); + expect(route("github-copilot")).toBe(false); + // Unknown route names fail closed. + expect(route("some-future-gateway")).toBe(false); + }); + + // Codex OAuth routes compose the fetch wrapper with inject:false (the + // ChatGPT backend is stricter than the public API), so when OAuth is the + // effective auth path pro mode must be unavailable. + test("Codex OAuth as the effective auth path disables pro mode", () => { + const withOpenAI = (openai: Partial>) => + openaiProModeAvailable("openai:gpt-5.6-sol", { + providersConfig: { + openai: { isEnabled: true, isConfigured: true, apiKeySet: false, ...openai }, + }, + }); + + // OAuth-only: routes through Codex OAuth. + expect(withOpenAI({ apiKeySet: false, codexOauthSet: true })).toBe(false); + // Both auth methods, default prefers OAuth (unset -> oauth). + expect(withOpenAI({ apiKeySet: true, codexOauthSet: true })).toBe(false); + // Both auth methods, user prefers the API key: pro stays available. + expect( + withOpenAI({ apiKeySet: true, codexOauthSet: true, codexOauthDefaultAuth: "apiKey" }) + ).toBe(true); + // API key only: no OAuth routing. + expect(withOpenAI({ apiKeySet: true, codexOauthSet: false })).toBe(true); + }); + }); + 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/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 842cc0bb5d..427138652d 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -11,24 +11,35 @@ import type { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; import type { JSONValue } from "@ai-sdk/provider"; import type { XaiProviderOptions } from "@ai-sdk/xai"; -import { PROVIDER_DEFINITIONS, type ProviderName } from "@/common/constants/providers"; +import 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, anthropicSupportsNativeXhigh, ANTHROPIC_THINKING_BUDGETS, GEMINI_THINKING_BUDGETS, - OPENAI_REASONING_EFFORT, + getOpenAIReasoningEffort, + openaiSupportsProMode, OPENROUTER_REASONING_EFFORT, } from "@/common/types/thinking"; import { isGeminiFlashThinkingLevelModelName } from "@/common/utils/thinking/policy"; import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; import { log } from "@/node/services/log"; import type { MuxMessage } from "@/common/types/message"; -import { normalizeToCanonical, supports1MContext } from "./models"; +import { + normalizeToCanonical, + resolveProviderOptionsNamespaceKey, + supports1MContext, +} from "./models"; + +// Re-export for existing consumers (aiService, providerModelFactory, tests): +// the implementations moved to browser-safe modules because this module +// imports node-only logging. +export { resolveProviderOptionsNamespaceKey } from "./models"; +export { openaiProModeAvailable } from "./proMode"; /** * Request header used to override Anthropic's `output_config.effort` at the @@ -39,6 +50,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 @@ -79,24 +99,6 @@ function supportsOpenAIReasoningSummary(modelName: string): boolean { return !OPENAI_REASONING_SUMMARY_UNSUPPORTED_MODELS.has(modelName); } -export function resolveProviderOptionsNamespaceKey( - canonicalProviderName: string, - routeProvider?: ProviderName -): string { - const routeDefinition = routeProvider ? PROVIDER_DEFINITIONS[routeProvider] : undefined; - if ( - !routeProvider || - routeProvider === canonicalProviderName || - (routeDefinition != null && - "passthrough" in routeDefinition && - routeDefinition.passthrough === true) - ) { - return canonicalProviderName; - } - - return routeProvider; -} - function resolveAnthropic1MCapabilityModel( modelString: string, providersConfig?: ProvidersConfigMap | null @@ -353,7 +355,23 @@ export function buildProviderOptions( // Build OpenAI-specific options if (formatProvider === "openai") { - const reasoningEffort = OPENAI_REASONING_EFFORT[effectiveThinking]; + // Model-aware: the GPT-5.6 family maps ThinkingLevel "max" to the native + // "max" effort; other OpenAI models keep the max -> "xhigh" downgrade. Use + // capabilityModel so mapped aliases (mappedToModel) inherit their target's + // native effort. Live-verified (2026-07-10): the Responses API accepts + // reasoning.effort "max" on every GPT-5.6 tier (gpt-5.5 rejects it), but + // @ai-sdk/openai's Chat Completions schema caps reasoningEffort at xhigh + // (z.enum(["none", ..., "xhigh"]) — no "max"), so a native-max request + // over the chatCompletions wire format would fail client-side Zod + // validation — degrade it to xhigh there instead. "none" needs no such + // handling: it is a first-class member of that enum with dedicated + // chat-model handling, and omitting it would default GPT-5.6 back to + // medium reasoning (the exact bug the explicit "none" mapping fixes). + const nativeReasoningEffort = getOpenAIReasoningEffort(effectiveThinking, capabilityModel); + const chatCompletionsWire = + (muxProviderOptions?.openai?.wireFormat ?? "responses") === "chatCompletions"; + const reasoningEffort = + chatCompletionsWire && nativeReasoningEffort === "max" ? "xhigh" : nativeReasoningEffort; // 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 +525,17 @@ export function buildProviderOptions( } if (origin === "openai" && formatProvider !== origin) { - const reasoningEffort = OPENAI_REASONING_EFFORT[effectiveThinking]; + // capabilityModel keeps mapped aliases consistent with raw ids on the same route. + // Copilot's Chat Completions upstream has not published native-max or + // explicit-none support, so degrade GPT-5.6 "max" to xhigh (the pre-5.6 top + // effort) and "none" back to omission instead of risking a rejection. + const nativeReasoningEffort = getOpenAIReasoningEffort(effectiveThinking, capabilityModel); + const reasoningEffort = + nativeReasoningEffort === "max" + ? "xhigh" + : nativeReasoningEffort === "none" + ? undefined + : nativeReasoningEffort; if (!reasoningEffort) { log.debug( "buildProviderOptions: OpenAI-compatible gateway (thinking off, no provider options)", @@ -582,7 +610,8 @@ export function buildRequestHeaders( workspaceId?: string, providersConfig?: ProvidersConfigMap | null, routeProvider?: ProviderName, - thinkingLevel?: ThinkingLevel + thinkingLevel?: ThinkingLevel, + reasoningMode?: OpenAIReasoningMode ): Record | undefined { const headers: Record = {}; @@ -622,5 +651,34 @@ 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. + // + // Pro mode is direct-route-only: mux-gateway currently drops the rewritten + // providerOptions.openai.reasoningMode server-side (verified — the Responses + // API echoed mode "standard"), so passthrough gateways don't get the header + // either until the gateway forwards the field. Direct-only emission also + // scopes the wireFormat gate to the one config that actually applies here: + // the direct OpenAI provider's (config wins over request-level options, + // mirroring providerModelFactory). Pro mode is Responses-only — with + // wireFormat "chatCompletions" the wrapper never injects (it only rewrites + // /responses bodies), so skip the header to keep header state honest. + const routeIsDirect = routeProvider == null || routeProvider === origin; + const openaiWireFormat = + providersConfig?.openai?.wireFormat ?? muxProviderOptions?.openai?.wireFormat ?? "responses"; + if ( + origin === "openai" && + routeIsDirect && + reasoningMode === "pro" && + openaiWireFormat === "responses" && + // Mapped aliases (mappedToModel) inherit pro capability from their target. + openaiSupportsProMode(resolveModelForMetadata(normalized, providersConfig ?? null)) + ) { + headers[MUX_OPENAI_REASONING_MODE_HEADER] = "pro"; + } + return Object.keys(headers).length > 0 ? headers : undefined; } 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/compaction/contextLimit.test.ts b/src/common/utils/compaction/contextLimit.test.ts index d3b55b37dc..fcf7703f2f 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_050_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", diff --git a/src/common/utils/compaction/contextLimit.ts b/src/common/utils/compaction/contextLimit.ts index 9f436dff14..71806c19a4 100644 --- a/src/common/utils/compaction/contextLimit.ts +++ b/src/common/utils/compaction/contextLimit.ts @@ -8,9 +8,8 @@ import { getCodexOauthCompatibilityModelId, getCodexOauthContextWindowOverride, - isCodexOauthAllowedModel, - isCodexOauthRequiredModel, } from "@/common/constants/codexOAuth"; +import { wouldRouteOpenAIThroughCodexOauth } from "@/common/utils/providers/codexOauthRouting"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { supports1MContext } from "@/common/utils/ai/models"; import { @@ -19,59 +18,12 @@ import { } from "@/common/utils/providers/modelEntries"; import { getModelStats } from "@/common/utils/tokens/modelStats"; -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return null; - } - return value as Record; -} - -function hasNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function hasCodexOauthTokens(config: unknown): boolean { - const record = asRecord(config); - if (!record) { - return false; - } - - if (record.codexOauthSet === true) { - return true; - } - - // Backend compaction can receive raw providers.jsonc config in older tests/fallback paths. - // Detect the stored token shape without importing node-only OAuth parsing into common code. - const oauth = asRecord(record.codexOauth); - return ( - oauth?.type === "oauth" && - hasNonEmptyString(oauth.access) && - hasNonEmptyString(oauth.refresh) && - typeof oauth.expires === "number" && - Number.isFinite(oauth.expires) - ); -} - -function hasOpenAIApiKey(config: unknown): boolean { - const record = asRecord(config); - if (!record) { - return false; - } - - const apiKeySource = record.apiKeySource; - if (apiKeySource === "config" || apiKeySource === "file" || apiKeySource === "env") { - return true; - } - - return record.apiKeySet === true || hasNonEmptyString(record.apiKey); -} - function getCodexOauthContextLimit( model: string, providersConfig: ProvidersConfigMap | null ): number | null { const compatibilityModelId = getCodexOauthCompatibilityModelId(model, providersConfig); - if (compatibilityModelId === null || !isCodexOauthAllowedModel(model, providersConfig)) { + if (compatibilityModelId === null) { return null; } @@ -80,21 +32,7 @@ function getCodexOauthContextLimit( return null; } - const openAIConfig = providersConfig?.openai; - if (!hasCodexOauthTokens(openAIConfig)) { - return null; - } - - if (isCodexOauthRequiredModel(model, providersConfig)) { - return oauthLimit; - } - - if (!hasOpenAIApiKey(openAIConfig)) { - return oauthLimit; - } - - const record = asRecord(openAIConfig); - return record?.codexOauthDefaultAuth === "apiKey" ? null : oauthLimit; + return wouldRouteOpenAIThroughCodexOauth(model, providersConfig) ? oauthLimit : null; } /** diff --git a/src/common/utils/providers/codexOauthRouting.ts b/src/common/utils/providers/codexOauthRouting.ts new file mode 100644 index 0000000000..e961342bee --- /dev/null +++ b/src/common/utils/providers/codexOauthRouting.ts @@ -0,0 +1,88 @@ +/** + * Browser-safe mirror of providerModelFactory's Codex OAuth routing decision. + * + * The factory decides `shouldRouteThroughCodexOauth` from parsed stored tokens + * (node-only); this mirror detects the same outcome from the providers config + * shapes visible to common/browser code (API config map with `codexOauthSet`, + * or raw providers.jsonc with stored token objects). Used by compaction + * context-limit capping and pro-mode availability, both of which must match + * where requests actually route. + */ + +import { isCodexOauthAllowedModel, isCodexOauthRequiredModel } from "@/common/constants/codexOAuth"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function hasNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function hasCodexOauthTokens(config: unknown): boolean { + const record = asRecord(config); + if (!record) { + return false; + } + + if (record.codexOauthSet === true) { + return true; + } + + // Backend compaction can receive raw providers.jsonc config in older tests/fallback paths. + // Detect the stored token shape without importing node-only OAuth parsing into common code. + const oauth = asRecord(record.codexOauth); + return ( + oauth?.type === "oauth" && + hasNonEmptyString(oauth.access) && + hasNonEmptyString(oauth.refresh) && + typeof oauth.expires === "number" && + Number.isFinite(oauth.expires) + ); +} + +export function hasOpenAIApiKey(config: unknown): boolean { + const record = asRecord(config); + if (!record) { + return false; + } + + const apiKeySource = record.apiKeySource; + if (apiKeySource === "config" || apiKeySource === "file" || apiKeySource === "env") { + return true; + } + + return record.apiKeySet === true || hasNonEmptyString(record.apiKey); +} + +/** + * Would a direct-OpenAI request for this model route through Codex OAuth? + * + * Mirrors providerModelFactory: allowed model + stored OAuth tokens, then + * required models always route OAuth; otherwise OAuth wins when no API key is + * configured or when `codexOauthDefaultAuth` prefers OAuth over a present key. + */ +export function wouldRouteOpenAIThroughCodexOauth( + model: string, + providersConfig: ProvidersConfigMap | null | undefined +): boolean { + const openAIConfig = providersConfig?.openai; + if (!isCodexOauthAllowedModel(model, providersConfig ?? null)) { + return false; + } + if (!hasCodexOauthTokens(openAIConfig)) { + return false; + } + if (isCodexOauthRequiredModel(model, providersConfig ?? null)) { + return true; + } + if (!hasOpenAIApiKey(openAIConfig)) { + return true; + } + + return asRecord(openAIConfig)?.codexOauthDefaultAuth !== "apiKey"; +} diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index 4a6fee5eb7..b7256e7411 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -196,6 +196,62 @@ 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", + ]); + }); + + // Native max is family-wide at GA (Sol/Terra/Luna and the bare alias). + test("returns 6 levels including max for gpt-5.6-terra and gpt-5.6-luna", () => { + expect(getThinkingPolicyForModel("openai:gpt-5.6-terra")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getThinkingPolicyForModel("openai:gpt-5.6-luna")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getThinkingPolicyForModel("mux-gateway:openai/gpt-5.6-terra-2026-07-09")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); + + 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", @@ -428,6 +484,36 @@ describe("getThinkingPolicyForModel", () => { expect(resolveEffectiveThinkingLevel("anthropic:internal-fable", undefined)).toBe("off"); }); + test("policy path resolves mappedToModel aliases to the target's capability", () => { + // An alias mapped to a GPT-5.6 model must expose the target's 6-level + // ladder (incl. native max) and clamp against it — otherwise AgentSession + // strips "max" before buildProviderOptions can resolve the alias. + const providersConfig: ProvidersConfigMap = { + openai: { + apiKeySet: true, + isEnabled: true, + isConfigured: true, + models: [{ id: "team-sol", mappedToModel: "openai:gpt-5.6-sol" }], + }, + }; + expect(getThinkingPolicyForModel("openai:team-sol", providersConfig)).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getAvailableThinkingLevels("openai:team-sol", null, providersConfig)).toContain("max"); + expect(enforceThinkingPolicy("openai:team-sol", "max", null, providersConfig)).toBe("max"); + // Aliases inherit the target's default medium floor (recognized reasoning model). + expect(getDefaultMinimumThinkingLevel("openai:team-sol", providersConfig)).toBe("medium"); + expect(resolveMinimumThinkingLevel("openai:team-sol", null, providersConfig)).toBe("medium"); + // Without providers config the alias is unknown: default 4-level policy clamps max down. + expect(enforceThinkingPolicy("openai:team-sol", "max")).toBe("high"); + expect(getDefaultMinimumThinkingLevel("openai:team-sol")).toBe("off"); + }); + test("returns all 6 levels for Sonnet 5 (native xhigh)", () => { // Sonnet 5 introduced the native xhigh effort level for the Sonnet tier, so it exposes // all 6 levels (unlike Sonnet 4.6, which maps xhigh -> "max" and stops at 5). diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index 7d47db9ea5..874a679d7a 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -19,6 +19,7 @@ import { THINKING_LEVEL_OFF, anthropicRejectsDisabledThinking, anthropicSupportsNativeXhigh, + openaiSupportsNativeMaxEffort, stripModelProviderPrefixes, type ThinkingLevel, type ParsedThinkingInput, @@ -54,6 +55,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 family (Sol/Terra/Luna and the bare alias) → + * ["off", "low", "medium", "high", "xhigh", "max"] (6 levels; native max at GA) * - 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"] @@ -62,9 +65,20 @@ export function isGeminiFlashThinkingLevelModelName(modelName: string): boolean * * Tolerates version suffixes (e.g., gpt-5-pro-2025-10-06). * Does NOT match gpt-5-pro-mini (uses negative lookahead). + * + * Pass `providersConfig` so configured aliases (`mappedToModel`, e.g. + * `openai:team-sol` -> `openai:gpt-5.6-sol`) resolve to their capability model + * before rule matching — matching how `buildProviderOptions` / + * `openaiProModeAvailable` detect capabilities. Without it, mapped aliases fall + * through to the default 4-level policy and clamping strips levels (e.g. native + * max) the target model actually supports. */ -export function getThinkingPolicyForModel(modelString: string): ThinkingPolicy { - return getExplicitThinkingPolicy(modelString) ?? DEFAULT_THINKING_POLICY; +export function getThinkingPolicyForModel( + modelString: string, + providersConfig?: ProvidersConfigMap | null +): ThinkingPolicy { + const capabilityModel = resolveModelForMetadata(modelString, providersConfig ?? null); + return getExplicitThinkingPolicy(capabilityModel) ?? DEFAULT_THINKING_POLICY; } /** @@ -120,6 +134,12 @@ function getExplicitThinkingPolicy(modelString: string): ThinkingPolicy | null { return ["off", "low", "medium", "high", "xhigh"]; } + // The GPT-5.6 family (Sol/Terra/Luna and the bare gpt-5.6 alias) supports + // the native "max" reasoning effort introduced at GA. + if (openaiSupportsNativeMaxEffort(withoutProviderNamespace)) { + return ["off", "low", "medium", "high", "xhigh", "max"]; + } + // 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"]; @@ -172,8 +192,13 @@ function thinkingLevelIndex(level: ThinkingLevel): number { * * This is only a default; users can override it per-model on the Models settings page. */ -export function getDefaultMinimumThinkingLevel(modelString: string): ThinkingLevel { - return hasExplicitThinkingPolicy(modelString) ? DEFAULT_THINKING_LEVEL : THINKING_LEVEL_OFF; +export function getDefaultMinimumThinkingLevel( + modelString: string, + providersConfig?: ProvidersConfigMap | null +): ThinkingLevel { + return hasExplicitThinkingPolicy(modelString, providersConfig) + ? DEFAULT_THINKING_LEVEL + : THINKING_LEVEL_OFF; } /** @@ -184,8 +209,14 @@ export function getDefaultMinimumThinkingLevel(modelString: string): ThinkingLev * expose a floor selector and default to medium. Unrecognized / non-reasoning models keep * the legacy off-default behavior. */ -export function hasExplicitThinkingPolicy(modelString: string): boolean { - return getExplicitThinkingPolicy(modelString) !== null; +export function hasExplicitThinkingPolicy( + modelString: string, + providersConfig?: ProvidersConfigMap | null +): boolean { + return ( + getExplicitThinkingPolicy(resolveModelForMetadata(modelString, providersConfig ?? null)) !== + null + ); } /** @@ -196,9 +227,10 @@ export function hasExplicitThinkingPolicy(modelString: string): boolean { */ export function resolveMinimumThinkingLevel( modelString: string, - override?: ThinkingLevel | null + override?: ThinkingLevel | null, + providersConfig?: ProvidersConfigMap | null ): ThinkingLevel { - return override ?? getDefaultMinimumThinkingLevel(modelString); + return override ?? getDefaultMinimumThinkingLevel(modelString, providersConfig); } /** @@ -243,9 +275,10 @@ export function resolveEffectiveThinkingLevel( */ export function getAvailableThinkingLevels( modelString: string, - minimum?: ThinkingLevel | null + minimum?: ThinkingLevel | null, + providersConfig?: ProvidersConfigMap | null ): ThinkingPolicy { - const capability = getThinkingPolicyForModel(modelString); + const capability = getThinkingPolicyForModel(modelString, providersConfig); if (minimum == null) { return capability; } @@ -280,9 +313,10 @@ export function getAvailableThinkingLevels( export function enforceThinkingPolicy( modelString: string, requested: ThinkingLevel, - minimum?: ThinkingLevel | null + minimum?: ThinkingLevel | null, + providersConfig?: ProvidersConfigMap | null ): ThinkingLevel { - const allowed = getAvailableThinkingLevels(modelString, minimum); + const allowed = getAvailableThinkingLevels(modelString, minimum, providersConfig); if (allowed.includes(requested)) { return requested; @@ -324,13 +358,16 @@ export function enforceThinkingPolicy( */ export function resolveThinkingInput( input: ParsedThinkingInput, - modelString: string + modelString: string, + providersConfig?: ProvidersConfigMap | null ): ThinkingLevel { // Named levels pass through directly if (typeof input === "string") return input; - // Numeric: index into the model's allowed levels (sorted lowest → highest) - const policy = getThinkingPolicyForModel(modelString); + // Numeric: index into the model's allowed levels (sorted lowest → highest). + // providersConfig resolves mapped aliases (mappedToModel) to their target's + // policy so indices map into the real ladder (e.g. GPT-5.6 native max). + const policy = getThinkingPolicyForModel(modelString, providersConfig); const sorted = [...policy].sort( (a, b) => THINKING_LEVELS.indexOf(a) - THINKING_LEVELS.indexOf(b) ); diff --git a/src/common/utils/tokens/modelStats.test.ts b/src/common/utils/tokens/modelStats.test.ts index 70a975afb7..c91ae74f95 100644 --- a/src/common/utils/tokens/modelStats.test.ts +++ b/src/common/utils/tokens/modelStats.test.ts @@ -28,10 +28,48 @@ 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("resolves the bare gpt-5.6 alias to Sol's stats", () => { + // The bare alias is a servable id that OpenAI routes to Sol; without its + // own entry, token meters/compaction/pricing would treat it as unknown. + expect(expectStats("openai:gpt-5.6")).toEqual(expectStats("openai:gpt-5.6-sol")); + }); + + test.each([ + // [model, input, output, cacheRead, cacheCreation] + ["openai:gpt-5.6-sol", 0.000005, 0.00003, 0.0000005, 0.00000625], + ["openai:gpt-5.6-terra", 0.0000025, 0.000015, 0.00000025, 0.000003125], + ["openai:gpt-5.6-luna", 0.000001, 0.000006, 0.0000001, 0.00000125], + ] as const)( + "resolves %s with the GA pricing and limits", + (model, input, output, cacheRead, cacheCreation) => { + 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); + // GA model pages list a 1.05M context window / 128K max output for every tier + // (Luna's 400K launch figure was stale and caused premature compaction). + expect(stats.max_input_tokens).toBe(1050000); + expect(stats.max_output_tokens).toBe(128000); + // Long-context tier: >272K prompt tokens bill the full request at 2x + // input / 1.5x output, with cache writes at 1.25x the active input rate + // (so 2x their base rate). Assert the multipliers, not fresh constants. + expect(stats.tiered_pricing_threshold_tokens).toBe(272000); + expect(stats.input_cost_per_token_above_200k_tokens).toBeCloseTo(input * 2, 12); + expect(stats.output_cost_per_token_above_200k_tokens).toBeCloseTo(output * 1.5, 12); + expect(stats.cache_read_input_token_cost_above_200k_tokens).toBeCloseTo(cacheRead * 2, 12); + expect(stats.cache_creation_input_token_cost_above_200k_tokens).toBeCloseTo( + cacheCreation * 2, + 12 + ); + } + ); + 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..59b81b5b16 100644 --- a/src/common/utils/tokens/models-extra.ts +++ b/src/common/utils/tokens/models-extra.ts @@ -36,6 +36,38 @@ interface ModelData { supported_endpoints?: string[]; } +// GPT-5.6 Sol - Released July 9, 2026 (flagship tier of the GPT-5.6 family). +// GA model page: 1.05M context window, 128K max output, Feb 16 2026 cutoff. +// Base pricing: $5/M input, $30/M output, $0.50/M cached input; cache writes +// billed at 1.25x the active input rate ($6.25/M base). Prompts above 272K +// input tokens bill the full request at 2x input / 1.5x output: $10/M input, +// $45/M output, $1/M cached input, $12.50/M cache writes. +// Shared const: the bare "gpt-5.6" alias is a servable id that OpenAI routes +// to Sol (the response echoes model gpt-5.6-sol), so both ids resolve to the +// same stats — otherwise token meters/compaction/pricing treat the documented +// bare alias as unknown. +const GPT_56_SOL_STATS: ModelData = { + max_input_tokens: 1050000, + max_output_tokens: 128000, + input_cost_per_token: 0.000005, // $5 per million input tokens (<272K prompt tokens) + input_cost_per_token_above_200k_tokens: 0.00001, // $10 per million input tokens (>272K) + output_cost_per_token: 0.00003, // $30 per million output tokens (<272K prompt tokens) + output_cost_per_token_above_200k_tokens: 0.000045, // $45 per million output tokens (>272K) + cache_read_input_token_cost: 0.0000005, // $0.50 per million cached input tokens (<272K) + cache_read_input_token_cost_above_200k_tokens: 0.000001, // $1 per million cached input tokens (>272K) + cache_creation_input_token_cost: 0.00000625, // $6.25 per million tokens (1.25x input) + cache_creation_input_token_cost_above_200k_tokens: 0.0000125, // $12.50 per million tokens (1.25x long-context input) + // OpenAI's published long-context boundary is 272K even though LiteLLM's field names say 200K. + tiered_pricing_threshold_tokens: 272000, + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + knowledge_cutoff: "2026-02-16", +}; + export const modelsExtra: Record = { // GPT Image 2 - image-generation model not yet in LiteLLM's bundled models.json. // OpenAI prices text input at $5/M tokens, image input at $8/M, cached text input at @@ -239,6 +271,64 @@ export const modelsExtra: Record = { knowledge_cutoff: "2025-08-31", }, + // GPT-5.6 Sol (see GPT_56_SOL_STATS above for pricing/context details). + "gpt-5.6-sol": GPT_56_SOL_STATS, + // Bare GPT-5.6 alias — servable id that OpenAI routes to Sol; same stats. + "gpt-5.6": GPT_56_SOL_STATS, + + // GPT-5.6 Terra - Released July 9, 2026 (balanced everyday tier). + // GA docs: 1.05M context window, 128K max output, Feb 16 2026 cutoff (family). + // Base pricing: $2.50/M input, $15/M output, $0.25/M cached input; cache + // writes 1.25x the active input rate ($3.125/M base). Same 272K long-context + // tier as Sol (2x input / 1.5x output for the full request). + "gpt-5.6-terra": { + max_input_tokens: 1050000, + max_output_tokens: 128000, + input_cost_per_token: 0.0000025, // $2.50 per million input tokens (<272K prompt tokens) + input_cost_per_token_above_200k_tokens: 0.000005, // $5 per million input tokens (>272K) + output_cost_per_token: 0.000015, // $15 per million output tokens (<272K prompt tokens) + output_cost_per_token_above_200k_tokens: 0.0000225, // $22.50 per million output tokens (>272K) + cache_read_input_token_cost: 0.00000025, // $0.25 per million cached input tokens (<272K) + cache_read_input_token_cost_above_200k_tokens: 0.0000005, // $0.50 per million cached input tokens (>272K) + cache_creation_input_token_cost: 0.000003125, // $3.125 per million tokens (1.25x input) + cache_creation_input_token_cost_above_200k_tokens: 0.00000625, // $6.25 per million tokens (1.25x long-context input) + tiered_pricing_threshold_tokens: 272000, // OpenAI's published boundary is 272K (field names say 200K) + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + knowledge_cutoff: "2026-02-16", + }, + + // GPT-5.6 Luna - Released July 9, 2026 (fastest, most cost-efficient tier). + // GA model page: 1.05M context window (the 400K figure was a stale launch + // value that caused premature compaction), 128K max output, Feb 16 2026 cutoff. + // Base pricing: $1/M input, $6/M output, $0.10/M cached input; cache writes + // 1.25x the active input rate ($1.25/M base). Same 272K long-context tier as + // Sol (2x input / 1.5x output for the full request). + "gpt-5.6-luna": { + max_input_tokens: 1050000, + max_output_tokens: 128000, + input_cost_per_token: 0.000001, // $1 per million input tokens (<272K prompt tokens) + input_cost_per_token_above_200k_tokens: 0.000002, // $2 per million input tokens (>272K) + output_cost_per_token: 0.000006, // $6 per million output tokens (<272K prompt tokens) + output_cost_per_token_above_200k_tokens: 0.000009, // $9 per million output tokens (>272K) + cache_read_input_token_cost: 0.0000001, // $0.10 per million cached input tokens (<272K) + cache_read_input_token_cost_above_200k_tokens: 0.0000002, // $0.20 per million cached input tokens (>272K) + cache_creation_input_token_cost: 0.00000125, // $1.25 per million tokens (1.25x input) + cache_creation_input_token_cost_above_200k_tokens: 0.0000025, // $2.50 per million tokens (1.25x long-context input) + tiered_pricing_threshold_tokens: 272000, // OpenAI's published boundary is 272K (field names say 200K) + litellm_provider: "openai", + mode: "chat", + supports_function_calling: true, + supports_vision: true, + supports_reasoning: true, + supports_response_schema: true, + knowledge_cutoff: "2026-02-16", + }, + // 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/common/utils/tokens/tokenMeterUtils.test.ts b/src/common/utils/tokens/tokenMeterUtils.test.ts index 668e39d6c0..b6c0be88cf 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,21 @@ 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, + }, + }); + + // GA model page: 1.05M-token context window for every GPT-5.6 tier. + expect(result.maxTokens).toBe(1_050_000); + expect(result.totalPercentage).toBeCloseTo((11_000 / 1_050_000) * 100); + }); + 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); diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index b74b04ba35..2af094893f 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -587,6 +587,9 @@ export class MuxAgent implements Agent { options: { model: sessionState.aiSettings.model, thinkingLevel: sessionState.aiSettings.thinkingLevel, + // Per-workspace pro mode from workspace metadata; the send path + // re-gates per model/route so this is inert for unsupported models. + reasoningMode: sessionState.aiSettings.reasoningMode, agentId: sessionState.agentId, }, fileParts: parsedPrompt.fileParts, @@ -867,6 +870,7 @@ export class MuxAgent implements Agent { const options: SendMessageOptions = { model: sessionState.aiSettings.model, thinkingLevel: sessionState.aiSettings.thinkingLevel, + reasoningMode: sessionState.aiSettings.reasoningMode, agentId: sessionState.agentId, muxMetadata: buildAgentSkillMetadata({ rawCommand: parsedCommand.rawCommand, @@ -917,6 +921,7 @@ export class MuxAgent implements Agent { options: { model: sessionState.aiSettings.model, thinkingLevel: sessionState.aiSettings.thinkingLevel, + reasoningMode: sessionState.aiSettings.reasoningMode, agentId: sessionState.agentId, }, }); @@ -980,6 +985,7 @@ export class MuxAgent implements Agent { options: { model: sessionState.aiSettings.model, thinkingLevel: sessionState.aiSettings.thinkingLevel, + reasoningMode: sessionState.aiSettings.reasoningMode, agentId: sessionState.agentId, }, }); @@ -1023,6 +1029,7 @@ export class MuxAgent implements Agent { model: sessionState.aiSettings.model, agentId: sessionState.agentId, thinkingLevel: sessionState.aiSettings.thinkingLevel, + reasoningMode: sessionState.aiSettings.reasoningMode, } : undefined; @@ -1044,6 +1051,7 @@ export class MuxAgent implements Agent { const options: SendMessageOptions = { model: compactionModel, thinkingLevel: sessionState.aiSettings.thinkingLevel, + reasoningMode: sessionState.aiSettings.reasoningMode, agentId: "compact", maxOutputTokens: command.maxOutputTokens, skipAiSettingsPersistence: true, diff --git a/src/node/acp/resolveAgentAiSettings.ts b/src/node/acp/resolveAgentAiSettings.ts index 2b33c8866b..6e5d30cde1 100644 --- a/src/node/acp/resolveAgentAiSettings.ts +++ b/src/node/acp/resolveAgentAiSettings.ts @@ -1,11 +1,18 @@ import assert from "node:assert/strict"; import { DEFAULT_MODEL } from "@/common/constants/knownModels"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { ORPCClient } from "./serverConnection"; export interface ResolvedAiSettings { model: string; thinkingLevel: ThinkingLevel; + /** + * OpenAI pro reasoning mode. A per-workspace choice: populated only when + * session state is read from workspace metadata buckets (agent AI defaults + * never carry it), and threaded into prompt sends so ACP sessions do not + * silently downgrade pro workspaces to standard. + */ + reasoningMode?: OpenAIReasoningMode; } const DEFAULT_PLAN_AGENT_ID = "plan"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7104cdc907..62d6d286ab 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,19 @@ 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), + thinkingLevel: enforceThinkingPolicy( + compactionModel, + requestedThinkingLevel, + undefined, + this.getProvidersConfigSafe() + ), + ...(requestedReasoningMode != null ? { reasoningMode: requestedReasoningMode } : {}), maxOutputTokens: typeof lastUserMuxMetadata.parsed.maxOutputTokens === "number" ? lastUserMuxMetadata.parsed.maxOutputTokens @@ -1477,6 +1501,9 @@ export class AgentSession { if (baseThinkingLevel) { retryRequest.thinkingLevel = baseThinkingLevel; } + if (baseReasoningMode) { + retryRequest.reasoningMode = baseReasoningMode; + } if (persistedToolPolicy) { retryRequest.toolPolicy = persistedToolPolicy; } @@ -2646,7 +2673,7 @@ export class AgentSession { // stream events have populated lastUsageState. await this.seedUsageStateFromHistory(); - const providersConfigForCompaction = this.getProvidersConfigForCompaction(); + const providersConfigForCompaction = this.getProvidersConfigSafe(); const compactionResult = this.compactionMonitor.checkBeforeSend({ model: modelForStream, usage: this.getUsageState(), @@ -2989,7 +3016,7 @@ export class AgentSession { return this.lastUsageState; } - private getProvidersConfigForCompaction(): ProvidersConfigMap | null { + private getProvidersConfigSafe(): ProvidersConfigMap | null { try { // Prefer ProviderService's safe config view: it includes env/file API-key source // metadata plus the Codex OAuth presence bit, which context-limit resolution needs @@ -3284,7 +3311,9 @@ export class AgentSession { // stream's level was chosen for its model, not the compaction model. thinkingLevel: enforceThinkingPolicy( compactionModel, - compactSettings.thinkingLevel ?? params.baseOptions.thinkingLevel ?? "off" + compactSettings.thinkingLevel ?? params.baseOptions.thinkingLevel ?? "off", + undefined, + this.getProvidersConfigSafe() ), maxOutputTokens: undefined, toolPolicy: [{ regex_match: ".*", action: "disable" }], @@ -3492,14 +3521,14 @@ export class AgentSession { this.activeStreamErrorEventReceived = false; this.activeStreamFailureHandled = false; this.activeStreamHadPostCompactionInjection = false; - const providersConfigForCompaction = this.getProvidersConfigForCompaction(); + const providersConfig = this.getProvidersConfigSafe(); this.activeStreamContext = { modelString, options, agentInitiated, openaiTruncationModeOverride, ...(goalKind != null ? { goalKind } : {}), - providersConfig: providersConfigForCompaction, + providersConfig, }; this.activeStreamUserMessageId = undefined; @@ -3597,9 +3626,17 @@ export class AgentSession { normalizeToCanonical(modelString) ] : undefined; - const minThinkingLevel = resolveMinimumThinkingLevel(modelString, minThinkingOverride); + // Pass providersConfig so mapped aliases (mappedToModel -> e.g. GPT-5.6) + // clamp against the target model's policy — otherwise a capability level + // like native max would be stripped here before buildProviderOptions can + // resolve the alias. + const minThinkingLevel = resolveMinimumThinkingLevel( + modelString, + minThinkingOverride, + providersConfig + ); const effectiveThinkingLevel = options?.thinkingLevel - ? enforceThinkingPolicy(modelString, options.thinkingLevel, minThinkingLevel) + ? enforceThinkingPolicy(modelString, options.thinkingLevel, minThinkingLevel, providersConfig) : undefined; // Bind recordFileState to this session for the propose_plan tool @@ -3625,6 +3662,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 +5518,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/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 412815d093..85452b2108 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` | |", @@ -3114,6 +3116,10 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "{/* END KNOWN_MODELS_TABLE */}", "", + "### Pro reasoning mode (GPT-5.6)", + "", + 'Every GPT-5.6 model (Sol, Terra, and Luna) supports 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. 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/aiService.ts b/src/node/services/aiService.ts index 2752fc4610..5948862646 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, @@ -1789,7 +1796,9 @@ export class AIService extends EventEmitter { // providerOptions actually sent to generateText(). const advisorReasoningLevel = enforceThinkingPolicy( advisorModelString, - cfg.advisorThinkingLevel ?? THINKING_LEVEL_OFF + cfg.advisorThinkingLevel ?? THINKING_LEVEL_OFF, + undefined, + this.providerService.getConfig() ); const runtimeType = getRuntimeType(metadata.runtimeConfig); const muxEnv = getMuxEnv(metadata.projectPath, runtimeType, metadata.name, { @@ -1900,6 +1909,9 @@ export class AIService extends EventEmitter { { model: modelString, thinkingLevel: effectiveThinkingLevel, + // Carry the turn's pro mode so the workflow-result + // continuation does not silently drop back to standard. + reasoningMode, agentId: effectiveAgentId, toolPolicy: effectiveToolPolicy, additionalSystemInstructions: scratchpadAdditionalSystemInstructions, @@ -2403,7 +2415,8 @@ export class AIService extends EventEmitter { workspaceId, this.providerService.getConfig(), routeProvider, - effectiveThinkingLevel + effectiveThinkingLevel, + reasoningMode ); // --- Model parameter overrides from providers.jsonc --- @@ -2570,8 +2583,10 @@ export class AIService extends EventEmitter { nextModelString, this.config.loadConfigOrDefault().minThinkingLevelByModel?.[ normalizeToCanonical(nextModelString) - ] - ) + ], + this.providerService.getConfig() + ), + this.providerService.getConfig() ); const nextModelResult = await this.providerModelFactory.resolveAndCreateModel( @@ -2705,13 +2720,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.test.ts b/src/node/services/providerModelFactory.test.ts index cd4316b6d7..7771d24d48 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,209 @@ 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(); + }); + + it("injects and strips when called with a Request object carrying headers and body", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const request = new Request(RESPONSES_URL, { + method: "POST", + body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "high" } }), + headers: { + "content-type": "application/json", + [MUX_OPENAI_REASONING_MODE_HEADER]: "pro", + }, + }); + await wrapped(request); + expect(calls.length).toBe(1); + const sent = parseSentBody(calls[0]); + expect(sent.reasoning).toEqual({ effort: "high", mode: "pro" }); + // The stripped header set is passed via init, overriding the Request's + // headers so the internal header cannot leak upstream. + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + expect(outHeaders.get("content-type")).toBe("application/json"); + }); + + it("strips Request-object headers on non-POST without touching the body", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const request = new Request(RESPONSES_URL, { + method: "GET", + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + await wrapped(request); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + expect(calls[0].init.body).toBeUndefined(); + }); + + it("prefers init members over Request members per fetch semantics", async () => { + const { calls, fakeFetch } = createCapturingFetch(); + const wrapped = wrapFetchWithOpenAIReasoningMode(fakeFetch); + const initBody = JSON.stringify({ model: "gpt-5.6-terra" }); + const request = new Request(RESPONSES_URL, { + method: "POST", + body: JSON.stringify({ model: "ignored-request-body" }), + headers: { "content-type": "application/json" }, + }); + // init.headers fully replaces the Request's headers; init.body wins too. + await wrapped(request, { + body: initBody, + headers: { [MUX_OPENAI_REASONING_MODE_HEADER]: "pro" }, + }); + const sent = parseSentBody(calls[0]); + expect(sent).toEqual({ model: "gpt-5.6-terra", reasoning: { mode: "pro" } }); + const outHeaders = new Headers(calls[0].init.headers); + expect(outHeaders.get(MUX_OPENAI_REASONING_MODE_HEADER)).toBeNull(); + }); +}); diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 5a85949186..503778f327 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,121 @@ 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 => { + // Callers may pass a Request object instead of (url, init) — read headers, + // method, and body from the same source the underlying fetch would use + // (init members override the Request's per fetch semantics), or the header + // would both be missed here and leak upstream inside the Request. + const requestInput = input instanceof Request ? input : undefined; + + // Fast path: nothing to strip or inject — forward byte-identical. + // init.headers, when present, fully replaces Request-input headers. + const incomingHeaders = new Headers(init?.headers ?? requestInput?.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. Passing + // the stripped set via init also overrides Request-input headers, so the + // header cannot leak through either source. + incomingHeaders.delete(MUX_OPENAI_REASONING_MODE_HEADER); + const strippedInit: Parameters[1] = { ...init, headers: incomingHeaders }; + + // Only rewrite POST requests; anything else is forwarded untouched + // (header already stripped). + const method = init?.method ?? requestInput?.method; + if (method?.toUpperCase() !== "POST") { + 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); + } + + // Resolve a JSON string body with the same precedence (init.body wins over + // the Request's body). Non-string init bodies and body-read failures fall + // through to strip-only forwarding. + let body: string; + if (init?.body != null) { + if (typeof init.body !== "string") { + return baseFetch(input, strippedInit); + } + body = init.body; + } else if (requestInput?.body != null) { + try { + // Read from a clone so the original stays forwardable on failure paths. + body = await requestInput.clone().text(); + } catch { + return baseFetch(input, strippedInit); + } + } else { + return baseFetch(input, strippedInit); + } + + try { + const json = JSON.parse(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 +879,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 +1555,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 +1854,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/taskService.test.ts b/src/node/services/taskService.test.ts index 2cc33a9be9..fef81bdfcd 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -57,6 +57,7 @@ import { WORKFLOW_RUN_CARD_DISPLAY_METADATA_TYPE, } from "@/common/utils/workflowRunMessages"; import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; import type { AIService } from "@/node/services/aiService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -293,6 +294,7 @@ function createAIServiceMocks( stopStream: ReturnType; createModel: ReturnType; getStreamInfo: ReturnType; + getProvidersConfig: ReturnType; on: ReturnType; off: ReturnType; }> @@ -303,6 +305,7 @@ function createAIServiceMocks( stopStream: ReturnType; createModel: ReturnType; getStreamInfo: ReturnType; + getProvidersConfig: ReturnType; on: ReturnType; off: ReturnType; } { @@ -321,6 +324,7 @@ function createAIServiceMocks( overrides?.createModel ?? mock((): Promise> => Promise.resolve(Err("createModel not mocked"))); const getStreamInfo = overrides?.getStreamInfo ?? mock(() => undefined); + const getProvidersConfig = overrides?.getProvidersConfig ?? mock(() => null); const on = overrides?.on ?? mock(() => undefined); const off = overrides?.off ?? mock(() => undefined); @@ -332,6 +336,7 @@ function createAIServiceMocks( stopStream, createModel, getStreamInfo, + getProvidersConfig, on, off, } as unknown as AIService, @@ -340,6 +345,7 @@ function createAIServiceMocks( stopStream, createModel, getStreamInfo, + getProvidersConfig, on, off, }; @@ -1170,6 +1176,76 @@ describe("TaskService", () => { }); }); + test("createWorkspaceTurn inherits pro mode from the parent's active non-exec agent", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["childworkspace", "turnhandle"]); + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + // Pro was toggled while a custom agent was active — no exec bucket + // exists, so inheritance must read the active-agent bucket. + agentId: "researcher", + aiSettingsByAgent: { + researcher: { + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + reasoningMode: "pro", + }, + }, + }, + ], + testTaskSettings() + ); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize the repo", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(sendMessage).toHaveBeenCalledTimes(1); + const sendMessageCall = sendMessage.mock.calls[0] as unknown[]; + expect(sendMessageCall[2]).toMatchObject({ reasoningMode: "pro" }); + }); + test("createWorkspaceTurn rejects multi-project owners instead of dropping secondary repos", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["handle", "turn"]); @@ -5761,6 +5837,167 @@ describe("TaskService", () => { expect(childEntry?.taskThinkingLevel).toBe("xhigh"); }, 20_000); + test("inherits the parent's pro reasoning mode into task sends and child settings", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + aiSettings: { model: "openai:gpt-5.6-sol", thinkingLevel: "high", reasoningMode: "pro" }, + }, + ], + testTaskSettings() + ); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await createAgentTask(taskService, parentId, "run task inheriting pro mode"); + expect(created.success).toBe(true); + if (!created.success) return; + + // The child's kickoff send must carry the parent's pro mode (the send path + // re-gates per model, so this is safe even for non-GPT-5.6 task models). + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run task inheriting pro mode", + { + model: "openai:gpt-5.6-sol", + agentId: "explore", + thinkingLevel: "high", + reasoningMode: "pro", + experiments: undefined, + }, + { agentInitiated: true } + ); + + // Persisted child settings carry it too, so queued/restart resumes + // (which rebuild options from the record) keep pro mode. + const postCfg = config.loadConfigOrDefault(); + const childEntry = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === created.data.taskId); + expect(childEntry?.aiSettings).toEqual({ + model: "openai:gpt-5.6-sol", + thinkingLevel: "high", + reasoningMode: "pro", + }); + }, 20_000); + + test("falls back to the parent's active-agent pro mode when spawning another agent type", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + // Pro was toggled while the exec agent was active; the spawned + // explore agent has no per-agent bucket of its own, so inheritance + // must fall back to the parent's active-agent settings. + agentId: "exec", + aiSettingsByAgent: { + exec: { model: "openai:gpt-5.6-sol", thinkingLevel: "high", reasoningMode: "pro" }, + }, + }, + ], + testTaskSettings() + ); + + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await createAgentTask( + taskService, + parentId, + "run explore with parent pro mode" + ); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run explore with parent pro mode", + expect.objectContaining({ agentId: "explore", reasoningMode: "pro" }), + { agentInitiated: true } + ); + }, 20_000); + + test("keeps a mapped alias's native max thinking level when spawning a task", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + // A configured alias mapped to GPT-5.6: without the providers config + // threaded into the task-path clamp, "max" would be downgraded to + // "high" against the default four-level ladder. + aiSettings: { model: "openai:team-sol", thinkingLevel: "max" }, + }, + ], + testTaskSettings() + ); + + const providersConfig: ProvidersConfigMap = { + openai: { + apiKeySet: true, + isEnabled: true, + isConfigured: true, + models: [{ id: "team-sol", mappedToModel: "openai:gpt-5.6-sol" }], + }, + }; + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const aiMocks = createAIServiceMocks(config, { + getProvidersConfig: mock(() => providersConfig), + }); + const { taskService } = createTaskServiceHarness(config, { + aiService: aiMocks.aiService, + workspaceService, + }); + + const created = await createAgentTask(taskService, parentId, "run with mapped alias max"); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith( + created.data.taskId, + "run with mapped alias max", + expect.objectContaining({ model: "openai:team-sol", thinkingLevel: "max" }), + { agentInitiated: true } + ); + }, 20_000); + test("resolves a numeric thinking override against the inherited model's policy", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -15540,6 +15777,7 @@ describe("TaskService", () => { async function setupPlanModeStreamEndHarness(options?: { childAgentId?: string; childTaskStatus?: WorkspaceConfigEntry["taskStatus"]; + childAiSettingsByAgent?: WorkspaceConfigEntry["aiSettingsByAgent"]; workflowTask?: WorkspaceConfigEntry["workflowTask"]; projectName?: string; maxTaskNestingDepth?: number; @@ -15603,6 +15841,7 @@ describe("TaskService", () => { taskStatus: options?.childTaskStatus ?? "running", workflowTask: options?.workflowTask, aiSettings: { model: "anthropic:claude-opus-4-6", thinkingLevel: "max" }, + aiSettingsByAgent: options?.childAiSettingsByAgent, taskModelString: "openai:gpt-4o-mini", runtimeConfig, }, @@ -15708,6 +15947,33 @@ describe("TaskService", () => { expect(updatedTask?.taskStatus).toBe("running"); }); + test("plan handoff preserves a pro mode persisted under the plan agent bucket", async () => { + // A PRO toggle during the plan phase lands in aiSettingsByAgent.plan; + // legacy workspace.aiSettings still holds the original standard setting. + const { config, childId, sendMessage, internal } = await setupPlanModeStreamEndHarness({ + childAiSettingsByAgent: { + plan: { model: "openai:gpt-5.6-sol", thinkingLevel: "high", reasoningMode: "pro" }, + }, + }); + + await internal.handleStreamEnd(makeSuccessfulProposePlanStreamEndEvent(childId)); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + childId, + expect.stringContaining("Implement the plan"), + expect.objectContaining({ agentId: "exec", reasoningMode: "pro" }), + expect.objectContaining({ synthetic: true }) + ); + + // The rewritten exec-phase settings persist it too. + const postCfg = config.loadConfigOrDefault(); + const updatedTask = Array.from(postCfg.projects.values()) + .flatMap((project) => project.workspaces) + .find((workspace) => workspace.id === childId); + expect(updatedTask?.aiSettings?.reasoningMode).toBe("pro"); + }); + test("stream-end with propose_plan success uses global exec defaults for handoff", async () => { const { config, childId, sendMessage, internal } = await setupPlanModeStreamEndHarness({ parentAiSettingsByAgent: { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 10f681d81d..8aa023f645 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -78,7 +78,12 @@ import { getWorkspaceProjectRepos } from "@/node/services/workspaceProjectRepos" import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; import { getTotalCost, sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; -import type { ParsedThinkingInput, ThinkingLevel } from "@/common/types/thinking"; +import { + coerceOpenAIReasoningMode, + type OpenAIReasoningMode, + type ParsedThinkingInput, + type ThinkingLevel, +} from "@/common/types/thinking"; import type { ErrorEvent, StreamAbortEvent, StreamEndEvent } from "@/common/types/stream"; import { isActiveWorkflowRunStatus, @@ -164,6 +169,8 @@ export type AgentTaskStatus = NonNullable; interface ResolvedWorkspaceAiSettings { model: string; thinkingLevel?: ThinkingLevel; + /** OpenAI pro reasoning mode; per-workspace choice inherited by spawned tasks. */ + reasoningMode?: OpenAIReasoningMode; } export interface AgentTaskStatusLookup { @@ -569,6 +576,7 @@ interface TaskLaunchPlan { taskModelString: string; canonicalModel: string; effectiveThinkingLevel?: ThinkingLevel; + effectiveReasoningMode?: OpenAIReasoningMode; skipInitHook: boolean; preferredTrunkBranch?: string; workflowTask?: TaskCreateArgs["workflowTask"]; @@ -1578,6 +1586,8 @@ export class TaskService { private resolveTaskAISettings(params: { cfg: ReturnType; parentMeta: { + /** Parent's persisted selected agent (see persistSelectedAgentId). */ + agentId?: string; aiSettingsByAgent?: Record; aiSettings?: ResolvedWorkspaceAiSettings; }; @@ -1589,6 +1599,7 @@ export class TaskService { taskModelString: string; canonicalModel: string; effectiveThinkingLevel: ThinkingLevel; + effectiveReasoningMode?: OpenAIReasoningMode; } { const parentAiSettings = this.resolveWorkspaceAISettings(params.parentMeta, params.agentId); // Sub-agent defaults take priority over UI agent defaults per field for any agent invoked as a sub-agent. @@ -1608,9 +1619,13 @@ export class TaskService { // Resolve an explicit override first so numeric thinking indices map into the // chosen model's allowed levels (named levels pass through unchanged). + // Providers config threads through so mapped aliases (mappedToModel, e.g. + // openai:team-sol -> gpt-5.6-sol) keep their target's native levels instead + // of being clamped to the default four-level ladder. + const providersConfig = this.aiService.getProvidersConfig(); const overrideThinkingLevel = params.thinkingLevel != null - ? resolveThinkingInput(params.thinkingLevel, canonicalModel) + ? resolveThinkingInput(params.thinkingLevel, canonicalModel, providersConfig) : undefined; const requestedThinkingLevel: ThinkingLevel = overrideThinkingLevel ?? @@ -1619,9 +1634,34 @@ export class TaskService { parentRuntimeAiSettings?.thinkingLevel ?? parentAiSettings?.thinkingLevel ?? "off"; - const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, requestedThinkingLevel); + const effectiveThinkingLevel = enforceThinkingPolicy( + canonicalModel, + requestedThinkingLevel, + undefined, + providersConfig + ); + + // Pro reasoning mode is a per-workspace choice (agent/subagent defaults do + // not carry it), so tasks inherit it from the parent's persisted settings. + // The user toggles pro on the parent's ACTIVE agent, so the target agent's + // bucket rarely carries one — fall back to the parent's active-agent bucket + // (persisted selected agent, defaulting to exec), then legacy settings. + // Safe to pass through unconditionally: the send path re-gates per model + // (buildRequestHeaders is inert for unsupported models/routes). + const activeParentAiSettings = this.resolveWorkspaceAISettings( + params.parentMeta, + normalizeAgentId(params.parentMeta.agentId) + ); + const effectiveReasoningMode = coerceOpenAIReasoningMode( + parentAiSettings?.reasoningMode ?? activeParentAiSettings?.reasoningMode + ); - return { taskModelString, canonicalModel, effectiveThinkingLevel }; + return { + taskModelString, + canonicalModel, + effectiveThinkingLevel, + ...(effectiveReasoningMode != null ? { effectiveReasoningMode } : {}), + }; } /** @@ -1640,7 +1680,12 @@ export class TaskService { }, fallbackModel: string, hint?: ParentAutoResumeHint - ): Promise<{ model: string; agentId: string; thinkingLevel?: ThinkingLevel }> { + ): Promise<{ + model: string; + agentId: string; + thinkingLevel?: ThinkingLevel; + reasoningMode?: OpenAIReasoningMode; + }> { // 1) Try stream-end hint metadata (available in handleStreamEnd path) let agentId = hint?.agentId; @@ -1668,10 +1713,14 @@ export class TaskService { agentId = agentId ?? TASK_RECOVERY_FALLBACK_AGENT_ID; const aiSettings = this.resolveWorkspaceAISettings(parentEntry.workspace, agentId); + const reasoningMode = coerceOpenAIReasoningMode(aiSettings?.reasoningMode); return { model: aiSettings?.model ?? fallbackModel, agentId, thinkingLevel: aiSettings?.thinkingLevel, + // Per-workspace pro mode carries into synthetic auto-resumes; the send + // path re-gates per model/route so this is inert for unsupported models. + ...(reasoningMode != null ? { reasoningMode } : {}), }; } @@ -2017,6 +2066,7 @@ export class TaskService { model, agentId, thinkingLevel: task.taskThinkingLevel, + reasoningMode: coerceOpenAIReasoningMode(task.aiSettings?.reasoningMode), experiments: task.taskExperiments, }, { synthetic: true, agentInitiated: true } @@ -2292,7 +2342,7 @@ export class TaskService { ); } - const { taskModelString, canonicalModel, effectiveThinkingLevel } = + const { taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = this.resolveTaskAISettings({ cfg, parentMeta, @@ -2424,6 +2474,7 @@ export class TaskService { taskModelString, canonicalModel, effectiveThinkingLevel, + effectiveReasoningMode, skipInitHook, workflowTask: args.workflowTask, bestOf: normalizedBestOf, @@ -2480,7 +2531,13 @@ export class TaskService { runtimeConfig: plan.taskRuntimeConfig, aiSettings: plan.effectiveThinkingLevel !== undefined - ? { model: plan.canonicalModel, thinkingLevel: plan.effectiveThinkingLevel } + ? { + model: plan.canonicalModel, + thinkingLevel: plan.effectiveThinkingLevel, + ...(plan.effectiveReasoningMode != null + ? { reasoningMode: plan.effectiveReasoningMode } + : {}), + } : undefined, parentWorkspaceId: plan.parentWorkspaceId, agentId: plan.agentId, @@ -2890,6 +2947,9 @@ export class TaskService { model: plan.taskModelString, agentId: plan.agentId, thinkingLevel: plan.effectiveThinkingLevel, + // Inherited pro mode: the send path re-gates per model/route, so this is + // inert for non-GPT-5.6 task models. + reasoningMode: plan.effectiveReasoningMode, experiments: plan.experiments, }; const sendResult = @@ -3044,10 +3104,31 @@ export class TaskService { defaultModel; const thinkingLevel = args.thinkingLevel != null - ? resolveThinkingInput(args.thinkingLevel, normalizeToCanonical(model)) + ? // Providers config keeps mapped aliases on their target's ladder + // (see resolveTaskAISettings). + resolveThinkingInput( + args.thinkingLevel, + normalizeToCanonical(model), + this.aiService.getProvidersConfig() + ) : (args.parentRuntimeAiSettings?.thinkingLevel ?? parentMeta.aiSettingsByAgent?.exec?.thinkingLevel ?? parentMeta.aiSettings?.thinkingLevel); + // Per-workspace pro mode inherits alongside model/thinking; the send path + // re-gates per model/route so this is inert for non-GPT-5.6 models. + // The user toggles pro on the parent's ACTIVE agent, so after the exec + // bucket fall back to the active-agent bucket (then legacy settings) — + // mirroring resolveTaskAISettings — or a workspace turn launched from a + // non-exec parent agent would silently drop back to standard mode. + const activeParentAiSettings = this.resolveWorkspaceAISettings( + parentMeta, + normalizeAgentId(parentMeta.agentId) + ); + const reasoningMode = coerceOpenAIReasoningMode( + parentMeta.aiSettingsByAgent?.exec?.reasoningMode ?? + activeParentAiSettings?.reasoningMode ?? + parentMeta.aiSettings?.reasoningMode + ); const record: WorkspaceTurnTaskHandleRecord = { kind: "workspace_turn", @@ -3107,6 +3188,7 @@ export class TaskService { model, agentId: "exec", ...(thinkingLevel != null ? { thinkingLevel } : {}), + ...(reasoningMode != null ? { reasoningMode } : {}), muxMetadata: this.buildWorkspaceTurnMuxMetadata(record), experiments: args.experiments, ...(mode === "existing" ? { queueDispatchMode } : {}), @@ -3301,14 +3383,15 @@ export class TaskService { ); } - const { taskModelString, canonicalModel, effectiveThinkingLevel } = this.resolveTaskAISettings({ - cfg, - parentMeta, - agentId, - modelString: args.modelString, - thinkingLevel: args.thinkingLevel, - parentRuntimeAiSettings: args.parentRuntimeAiSettings, - }); + const { taskModelString, canonicalModel, effectiveThinkingLevel, effectiveReasoningMode } = + this.resolveTaskAISettings({ + cfg, + parentMeta, + agentId, + modelString: args.modelString, + thinkingLevel: args.thinkingLevel, + parentRuntimeAiSettings: args.parentRuntimeAiSettings, + }); const parentRuntimeConfig = parentMeta.runtimeConfig; const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; @@ -3488,7 +3571,11 @@ export class TaskService { title: args.title, createdAt, runtimeConfig: taskRuntimeConfig, - aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + aiSettings: { + model: canonicalModel, + thinkingLevel: effectiveThinkingLevel, + ...(effectiveReasoningMode != null ? { reasoningMode: effectiveReasoningMode } : {}), + }, parentWorkspaceId, agentId, agentType, @@ -3648,7 +3735,11 @@ export class TaskService { title: args.title, createdAt, runtimeConfig: forkedRuntimeConfig, - aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + aiSettings: { + model: canonicalModel, + thinkingLevel: effectiveThinkingLevel, + ...(effectiveReasoningMode != null ? { reasoningMode: effectiveReasoningMode } : {}), + }, agentId, parentWorkspaceId, agentType, @@ -3707,6 +3798,7 @@ export class TaskService { model: taskModelString, agentId, thinkingLevel: effectiveThinkingLevel, + reasoningMode: effectiveReasoningMode, experiments: args.experiments, }, { agentInitiated: true } @@ -4801,6 +4893,7 @@ export class TaskService { model: resumeOptions.model, agentId: resumeOptions.agentId, thinkingLevel: resumeOptions.thinkingLevel, + reasoningMode: resumeOptions.reasoningMode, }; let sendResult = await this.workspaceService.sendMessage( ownerWorkspaceId, @@ -5308,6 +5401,7 @@ export class TaskService { model, agentId, thinkingLevel: freshEntry.workspace.taskThinkingLevel, + reasoningMode: coerceOpenAIReasoningMode(freshEntry.workspace.aiSettings?.reasoningMode), experiments: freshEntry.workspace.taskExperiments, toolPolicy: [{ regex_match: `^${completionToolName}$`, action: "require" }], }, @@ -7416,6 +7510,9 @@ export class TaskService { taskModelString: task.taskModelString ?? defaultModel, canonicalModel, effectiveThinkingLevel: task.taskThinkingLevel, + // Durable pro-mode source: the task record's aiSettings (written at + // creation, kept current by subsequent sends' persistence merge). + effectiveReasoningMode: coerceOpenAIReasoningMode(task.aiSettings?.reasoningMode), skipInitHook, preferredTrunkBranch: task.taskTrunkBranch, workflowTask: task.workflowTask, @@ -7685,6 +7782,7 @@ export class TaskService { model, agentId, thinkingLevel: entry.workspace.taskThinkingLevel, + reasoningMode: coerceOpenAIReasoningMode(entry.workspace.aiSettings?.reasoningMode), experiments: entry.workspace.taskExperiments, toolPolicy: [{ regex_match: `^${completionToolName}$`, action: "require" }], }, @@ -7746,6 +7844,7 @@ export class TaskService { model, agentId, thinkingLevel: entry.workspace.taskThinkingLevel, + reasoningMode: coerceOpenAIReasoningMode(entry.workspace.aiSettings?.reasoningMode), experiments: entry.workspace.taskExperiments, }, { synthetic: true, agentInitiated: true } @@ -8291,6 +8390,7 @@ export class TaskService { model: resumeOptions.model, agentId: resumeOptions.agentId, thinkingLevel: resumeOptions.thinkingLevel, + reasoningMode: resumeOptions.reasoningMode, ...(workspaceTurnMuxMetadata != null ? { muxMetadata: workspaceTurnMuxMetadata } : {}), }; let sendResult = await this.workspaceService.sendMessage( @@ -9111,10 +9211,26 @@ export class TaskService { }, }); + // Plan -> Exec continues in the same workspace, so its own persisted pro + // choice carries over (resolveTaskAISettings sees an empty parentMeta here). + // A PRO toggle during the plan phase persists under the plan agent's + // bucket (aiSettingsByAgent), so read the still-current plan agent's + // settings first and only then fall back to legacy aiSettings. + const planAgentSettings = this.resolveWorkspaceAISettings( + args.entry.workspace, + normalizeAgentId(args.entry.workspace.agentId, "plan") + ); + const planPhaseReasoningMode = coerceOpenAIReasoningMode( + planAgentSettings?.reasoningMode ?? args.entry.workspace.aiSettings?.reasoningMode + ); await this.editWorkspaceEntry(args.workspaceId, (workspace) => { workspace.agentId = targetAgentId; workspace.agentType = targetAgentId; - workspace.aiSettings = { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }; + workspace.aiSettings = { + model: canonicalModel, + thinkingLevel: effectiveThinkingLevel, + ...(planPhaseReasoningMode != null ? { reasoningMode: planPhaseReasoningMode } : {}), + }; workspace.taskModelString = taskModelString; workspace.taskThinkingLevel = effectiveThinkingLevel; // A successful propose_plan is a successful completion-tool outcome: the @@ -9133,6 +9249,7 @@ export class TaskService { model: taskModelString, agentId: targetAgentId, thinkingLevel: effectiveThinkingLevel, + ...(planPhaseReasoningMode != null ? { reasoningMode: planPhaseReasoningMode } : {}), experiments: args.entry.workspace.taskExperiments, }, { synthetic: true, agentInitiated: true } 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. diff --git a/tests/ui/agents/proModeToggle.test.ts b/tests/ui/agents/proModeToggle.test.ts new file mode 100644 index 0000000000..18d0965529 --- /dev/null +++ b/tests/ui/agents/proModeToggle.test.ts @@ -0,0 +1,133 @@ +/** + * 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; +// Pro mode is family-wide across GPT-5.6 (incl. Luna), so the "hidden" case +// must use a pre-5.6 model without pro support. +const NON_PRO_MODEL = KNOWN_MODELS.GPT_PRO.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 GPT-5.5 Pro (no reasoning.mode pro support). + await selectModel(container, harness.workspaceId, NON_PRO_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); +});