Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { useTelemetry } from "./hooks/useTelemetry";
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation";
import { useAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import {
clearPendingWorkspaceAiSettings,
markPendingWorkspaceAiSettings,
Expand Down Expand Up @@ -534,6 +535,10 @@ function AppInner() {
clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId);
// Best-effort only.
});

// Mid-turn change: also apply to the active turn's next model step so
// the palette/keybind path behaves like the slider (ThinkingProvider).
requestActiveTurnThinkingLevel(api, workspaceId, normalized);
}

// Dispatch toast notification event for UI feedback
Expand Down
75 changes: 75 additions & 0 deletions src/browser/contexts/ThinkingContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,81 @@ describe("ThinkingContext", () => {
}
});

test("requests a mid-turn override for the active workspace turn on slider changes", async () => {
const workspaceId = "ws-set-thinking-mid-turn";
const setActiveTurnThinkingLevel = mock<
(args: {
workspaceId: string;
thinkingLevel: ThinkingLevel;
}) => Promise<{ success: true; data: { accepted: boolean } }>
>(() => Promise.resolve({ success: true as const, data: { accepted: true } }));
currentClientMock = {
workspace: {
updateAgentAISettings: mock(() =>
Promise.resolve({ success: true as const, data: undefined })
),
setActiveTurnThinkingLevel,
},
};

setWorkspaceMetadata(createWorkspaceMetadata({ id: workspaceId }));

const view = renderWithWorkspaceMetadata({
workspaceId,
modelOverride: null,
children: (
<ThinkingProvider workspaceId={workspaceId}>
<ThinkingSetterComponent />
</ThinkingProvider>
),
});

const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS);
act(() => {
button.click();
});

await waitFor(() => {
expect(setActiveTurnThinkingLevel).toHaveBeenCalledWith({
workspaceId,
thinkingLevel: "medium",
});
}, METADATA_WAIT_OPTIONS);
});

test("does not request a mid-turn override in project scope (no workspaceId)", async () => {
const projectPath = "/Users/dev/mid-turn-scope";
const setActiveTurnThinkingLevel = mock(() =>
Promise.resolve({ success: true as const, data: { accepted: false } })
);
currentClientMock = {
workspace: { setActiveTurnThinkingLevel },
};

const view = renderWithAPI(
<ThinkingProvider projectPath={projectPath}>
<ThinkingSetterComponent />
</ThinkingProvider>
);

const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS);
act(() => {
button.click();
});

// Project/global scopes have no active turn to override; the route must
// not fire (persisted settings alone drive the next turn).
await waitFor(() => {
expect(
readPersistedState<ThinkingLevel | null>(
getThinkingLevelKey(getProjectScopeId(projectPath)),
null
)
).toBe("medium");
}, METADATA_WAIT_OPTIONS);
expect(setActiveTurnThinkingLevel).not.toHaveBeenCalled();
});

test("cycles thinking level via keybind in project-scoped (creation) flow", async () => {
const projectPath = "/Users/dev/my-project";

Expand Down
9 changes: 9 additions & 0 deletions src/browser/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { enforceThinkingPolicy, getAvailableThinkingLevels } from "@/common/util
import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useAPI } from "@/browser/contexts/API";
import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking";
import {
clearPendingWorkspaceAiSettings,
getWorkspaceAiSettingsFromMetadata,
Expand Down Expand Up @@ -226,12 +227,20 @@ export const ThinkingProvider: React.FC<ThinkingProviderProps> = (props) => {
thinkingLevel: level,
reasoningMode: getCurrentReasoningMode(),
});
// Mid-turn change: also request the new level for the active turn's next
// model step. Non-workspace (project/global) mounts have no workspaceId
// and skip this inside the helper.
if (props.workspaceId) {
requestActiveTurnThinkingLevel(api, props.workspaceId, level);
}
},
[
api,
defaultModel,
getCurrentReasoningMode,
metadataSettings.model,
persistAgentAiSettings,
props.workspaceId,
scopeId,
setThinkingLevelInternal,
]
Expand Down
25 changes: 25 additions & 0 deletions src/browser/utils/activeTurnThinking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { APIClient } from "@/browser/contexts/API";
import type { ThinkingLevel } from "@/common/types/thinking";

/**
* Best-effort request to apply a thinking-level change to the active turn's
* NEXT model step (mid-turn override). Shared by every UI path that changes a
* workspace's thinking level (slider, keybinds, command palette) so they all
* behave identically during a stream.
*
* No streaming gate on purpose: the backend no-ops cheaply (accepted: false)
* when the workspace is idle or unknown, and the persisted workspace setting
* (updated separately by callers) still covers future turns.
*/
export function requestActiveTurnThinkingLevel(
api: APIClient | null | undefined,
workspaceId: string,
thinkingLevel: ThinkingLevel
): void {
if (!api || !workspaceId) {
return;
}
api.workspace.setActiveTurnThinkingLevel({ workspaceId, thinkingLevel }).catch(() => {
// Best-effort: transient IPC failure loses only the mid-turn nudge.
});
}
11 changes: 11 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,17 @@ export const workspace = {
}),
output: ResultSchema(z.void(), z.string()),
},
// Mid-turn thinking change: request that the active turn's NEXT model step
// uses this level. `accepted: false` (success) = no turn active β€” persisted
// settings already cover the next turn. `accepted: true` = the level applies
// to the current turn's next step if one occurs (expires silently otherwise).
setActiveTurnThinkingLevel: {
input: z.object({
workspaceId: z.string(),
thinkingLevel: ThinkingLevelSchema,
}),
output: ResultSchema(z.object({ accepted: z.boolean() }), z.string()),
},
preflightArchive: {
input: z.object({ workspaceId: z.string() }),
output: ResultSchema(ArchivePreflightResultSchema, z.string()),
Expand Down
36 changes: 23 additions & 13 deletions src/common/types/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,37 +168,47 @@ export const ANTHROPIC_THINKING_BUDGETS: Record<ThinkingLevel, number> = {

/**
* Anthropic effort type - matches SDK's AnthropicProviderOptions["effort"].
*
* Note: Opus 4.7 and Sonnet 5 introduced a native "xhigh" effort level in the API,
* but the SDK's Zod validator still rejects "xhigh". Mux handles this by sending
* "max" through the SDK and rewriting `output_config.effort` to "xhigh" in a fetch
* wrapper for native-xhigh Anthropic models when the user selected the xhigh ThinkingLevel.
* See `wrapFetchWithAnthropicCacheControl` and `buildRequestHeaders`.
*/
export type AnthropicEffortLevel = "low" | "medium" | "high" | "max";
export type AnthropicEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";

/**
* Anthropic effort parameter mapping (Opus 4.5+)
*
* The effort parameter controls how much computational work the model applies.
* - Opus 4.5 supports: low, medium, high (policy clamps xhigh β†’ high)
* - Opus 4.6 supports: low, medium, high, max (xhigh maps to "max" effort)
* - Opus 4.7+ and Sonnet 5+ support: low, medium, high, xhigh, max (xhigh requires wire override)
* - Opus 4.7+ and Sonnet 5+ support: low, medium, high, xhigh, max (native xhigh)
*
* The table keeps `xhigh: "max"` as the non-native fallback; `getAnthropicEffort`
* upgrades it to the native "xhigh" wire value on models that support it.
*
* Because the @ai-sdk/anthropic Zod schema doesn't accept "xhigh" yet, we send
* "max" through the SDK for native-xhigh Anthropic models and rewrite
* `output_config.effort` to "xhigh" in the Anthropic fetch wrapper.
* SDK coupling note: explicit `providerOptions.anthropic.effort` values flow
* verbatim into `output_config.effort` as of @ai-sdk/anthropic 4.0.11 (its Zod
* schema accepts "xhigh"; its `supportsXhighEffort` model gating applies only to
* the SDK's top-level `reasoning` CallOption mapping, which Mux does not use).
* Re-verify this pass-through on major SDK upgrades.
*/
const ANTHROPIC_EFFORT: Record<ThinkingLevel, AnthropicEffortLevel> = {
off: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "max", // SDK placeholder; fetch wrapper rewrites to "xhigh" on native-xhigh models
xhigh: "max", // Non-native fallback; native-xhigh models get "xhigh" via getAnthropicEffort
max: "max",
};

export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel {
/**
* Model-aware Anthropic effort resolution: `xhigh` maps to the native "xhigh"
* wire value only on models that support it (Opus 4.7+, Sonnet 5+, Mythos);
* everywhere else it falls back to "max" per the table above.
*/
export function getAnthropicEffort(
level: ThinkingLevel,
capabilityModel: string
): AnthropicEffortLevel {
if (level === "xhigh" && anthropicSupportsNativeXhigh(capabilityModel)) {
return "xhigh";
}
return ANTHROPIC_EFFORT[level];
}

Expand Down
111 changes: 47 additions & 64 deletions src/common/utils/ai/providerOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
preserveAnthropic1MContextForFollowUp,
resolveProviderOptionsNamespaceKey,
ANTHROPIC_1M_CONTEXT_HEADER,
MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER,
MUX_WORKSPACE_ID_HEADER,
} from "./providerOptions";

Expand Down Expand Up @@ -111,7 +110,9 @@ describe("buildProviderOptions - Anthropic", () => {
});
});

for (const model of ["claude-opus-4-6", "claude-sonnet-4-6", "claude-sonnet-5"] as const) {
// Non-native-xhigh adaptive models: xhigh falls back to "max" effort and
// adaptive thinking carries no `display` field.
for (const model of ["claude-opus-4-6", "claude-sonnet-4-6"] as const) {
describe(`${model} (adaptive thinking + effort)`, () => {
for (const { thinking, expectedThinking, effort } of [
{ thinking: "medium", expectedThinking: { type: "adaptive" }, effort: "medium" },
Expand All @@ -134,12 +135,49 @@ describe("buildProviderOptions - Anthropic", () => {
});
}

// Native-xhigh models (Opus 4.7+ / Sonnet 5+): xhigh is a distinct native
// effort and adaptive thinking requires `display: "summarized"` to return
// thinking content.
for (const model of ["claude-opus-4-7", "claude-sonnet-5"] as const) {
describe(`${model} (native xhigh effort + summarized display)`, () => {
for (const { thinking, expectedThinking, effort } of [
{
thinking: "medium",
expectedThinking: { type: "adaptive", display: "summarized" },
effort: "medium",
},
{
thinking: "xhigh",
expectedThinking: { type: "adaptive", display: "summarized" },
effort: "xhigh",
},
{
thinking: "max",
expectedThinking: { type: "adaptive", display: "summarized" },
effort: "max",
},
{ thinking: "off", expectedThinking: { type: "disabled" }, effort: "low" },
] as const) {
test(`maps ${thinking} to ${effort} effort`, () => {
const anthropic = anthropicProviderOptions(
buildProviderOptions(`anthropic:${model}`, thinking)
);

expect(anthropic.thinking).toEqual(expectedThinking);
expect(anthropic.effort).toBe(effort);
});
}
});
}

describe("claude-fable-5 (Mythos-class: API rejects disabled thinking)", () => {
test("maps medium to adaptive thinking like other adaptive models", () => {
const anthropic = anthropicProviderOptions(
buildProviderOptions("anthropic:claude-fable-5", "medium")
);
expect(anthropic.thinking).toEqual({ type: "adaptive" });
// Mythos-class models are native-xhigh tier, so adaptive thinking carries
// the summarized display flag.
expect(anthropic.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(anthropic.effort).toBe("medium");
});

Expand Down Expand Up @@ -1590,67 +1628,12 @@ describe("buildRequestHeaders", () => {
});
}

describe("Opus 4.7+ xhigh effort override", () => {
for (const { name, model, routeProvider, thinkingLevel, expected } of [
{
name: "emits override header when thinkingLevel=xhigh for Opus 4.7",
model: "anthropic:claude-opus-4-7",
routeProvider: undefined,
thinkingLevel: "xhigh",
expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" },
},
{
name: "emits override header for gateway-routed Opus 4.7 with xhigh (passthrough)",
model: "mux-gateway:anthropic/claude-opus-4-7",
routeProvider: "mux-gateway",
thinkingLevel: "xhigh",
expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" },
},
{
name: "emits override header for Opus 4.8",
model: "anthropic:claude-opus-4-8",
routeProvider: undefined,
thinkingLevel: "xhigh",
expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" },
},
{
// Sonnet 5 added native xhigh for the Sonnet tier, so it needs the wire rewrite too.
name: "emits override header for Sonnet 5",
model: "anthropic:claude-sonnet-5",
routeProvider: undefined,
thinkingLevel: "xhigh",
expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" },
},
{
name: "does not emit override header for Opus 4.7 with thinkingLevel=max",
model: "anthropic:claude-opus-4-7",
routeProvider: undefined,
thinkingLevel: "max",
expected: undefined,
},
{
name: "does not emit override header for Opus 4.6 with xhigh",
// Opus 4.6 maps xhigh -> "max" effort; SDK accepts "max" so no wire rewrite needed.
model: "anthropic:claude-opus-4-6",
routeProvider: undefined,
thinkingLevel: "xhigh",
expected: undefined,
},
{
name: "does not emit override header for non-passthrough gateway (openrouter)",
// Non-passthrough gateways must not receive this Mux-internal header.
model: "anthropic:claude-opus-4-7",
routeProvider: "openrouter",
thinkingLevel: "xhigh",
expected: undefined,
},
] as const) {
test(name, () => {
expect(
buildRequestHeaders(model, undefined, undefined, undefined, routeProvider, thinkingLevel)
).toEqual(expected);
});
}
// Native xhigh effort no longer needs a Mux-internal override header: the
// SDK accepts effort "xhigh" directly (see buildProviderOptions tests above),
// so buildRequestHeaders is thinking-level-independent.
test("does not emit any Mux-internal effort header for native-xhigh models", () => {
expect(buildRequestHeaders("anthropic:claude-opus-4-7")).toBeUndefined();
expect(buildRequestHeaders("anthropic:claude-sonnet-5")).toBeUndefined();
});

describe("openaiProModeAvailable", () => {
Expand Down
Loading
Loading