From c73a6d2f689fa20d035af478c0eb5a64a3be66d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 00:58:16 +0100 Subject: [PATCH] feat: support xhigh for Claude Opus 4.7 --- docs/tools/thinking.md | 12 +++-- extensions/anthropic/index.test.ts | 12 +++++ extensions/anthropic/register.runtime.ts | 12 ++++- src/agents/anthropic-transport-stream.test.ts | 45 ++++++++++++++++ src/agents/anthropic-transport-stream.ts | 27 +++++++--- src/agents/anthropic-vertex-stream.test.ts | 16 ++++++ src/agents/anthropic-vertex-stream.ts | 52 +++++++++++++------ src/agents/model-selection.test.ts | 30 +++++++++++ src/agents/model-thinking-default.ts | 5 +- src/auto-reply/thinking.test.ts | 6 +++ src/auto-reply/thinking.ts | 3 ++ 11 files changed, 188 insertions(+), 32 deletions(-) diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 3fc3c06ae06..22cced0a5ba 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -15,12 +15,13 @@ title: "Thinking Levels" - low → “think hard” - medium → “think harder” - high → “ultrathink” (max budget) - - xhigh → “ultrathink+” (GPT-5.2 + Codex models only) - - adaptive → provider-managed adaptive reasoning budget (supported for Anthropic Claude 4.6 model family) + - xhigh → “ultrathink+” (GPT-5.2 + Codex models and Anthropic Claude Opus 4.7) + - adaptive → provider-managed adaptive reasoning budget (supported for Anthropic Claude 4.6 and Opus 4.7) - `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`. - `highest`, `max` map to `high`. - Provider notes: - - Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set. + - Anthropic Claude 4.6 and Opus 4.7 models default to `adaptive` when no explicit thinking level is set. + - Anthropic Claude Opus 4.7 maps `/think xhigh` to `output_config.effort: "xhigh"`. - MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format. - Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`). - Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`. @@ -31,7 +32,7 @@ title: "Thinking Levels" 2. Session override (set by sending a directive-only message). 3. Per-agent default (`agents.list[].thinkingDefault` in config). 4. Global default (`agents.defaults.thinkingDefault` in config). -5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise. +5. Fallback: `adaptive` for Anthropic Claude 4.6 and Opus 4.7 models, `low` for other reasoning-capable models, `off` otherwise. ## Setting a session default @@ -104,8 +105,9 @@ title: "Thinking Levels" - The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads. - Picking another level writes the session override immediately via `sessions.patch`; it does not wait for the next send and it is not a one-shot `thinkingOnce` override. -- The first option is always `Default ()`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 on Anthropic/Bedrock, `low` for other reasoning-capable models, `off` otherwise. +- The first option is always `Default ()`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 and Opus 4.7 on Anthropic, `low` for other reasoning-capable models, `off` otherwise. - The picker stays provider-aware: - most providers show `off | minimal | low | medium | high | adaptive` + - Anthropic Claude Opus 4.7 shows `off | minimal | low | medium | high | xhigh | adaptive` - Z.AI shows binary `off | on` - `/think:` still works and updates the same stored session level, so chat directives and the picker stay in sync. diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 446d69b2613..c8ec785be1f 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -200,6 +200,18 @@ describe("anthropic provider replay hooks", () => { modelId: "claude-opus-4-7", } as never), ).toBe("adaptive"); + expect( + provider.supportsXHighThinking?.({ + provider: "anthropic", + modelId: "claude-opus-4-7", + } as never), + ).toBe(true); + expect( + provider.supportsXHighThinking?.({ + provider: "anthropic", + modelId: "claude-opus-4-6", + } as never), + ).toBe(false); }); it("resolves claude-cli synthetic oauth auth", async () => { diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 46ebe93ae42..79e13d96d66 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -260,8 +260,7 @@ function resolveAnthropicForwardCompatModel( function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean { const lowerModelId = normalizeLowercaseStringOrEmpty(modelId); return ( - lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) || - lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID) || + isAnthropicOpus47Model(lowerModelId) || lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) || lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) || lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) || @@ -269,6 +268,14 @@ function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean { ); } +function isAnthropicOpus47Model(modelId: string): boolean { + const lowerModelId = normalizeLowercaseStringOrEmpty(modelId); + return ( + lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) || + lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID) + ); +} + function matchesAnthropicModernModel(modelId: string): boolean { const lower = normalizeLowercaseStringOrEmpty(modelId); return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix)); @@ -481,6 +488,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { buildReplayPolicy: buildAnthropicReplayPolicy, isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), resolveReasoningOutputMode: () => "native", + supportsXHighThinking: ({ modelId }) => isAnthropicOpus47Model(modelId), wrapStreamFn: wrapAnthropicProviderStream, resolveDefaultThinkingLevel: ({ modelId }) => matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId) diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index e34d2d8cf50..e643a1adec4 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -471,4 +471,49 @@ describe("anthropic transport stream", () => { undefined, ); }); + + it("maps xhigh thinking effort for Claude Opus 4.7 transport runs", async () => { + const model = attachModelProviderRequestTransport( + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"anthropic-messages">, + { + proxy: { + mode: "env-proxy", + }, + }, + ); + const streamFn = createAnthropicMessagesTransportStreamFn(); + + const stream = await Promise.resolve( + streamFn( + model, + { + messages: [{ role: "user", content: "Think extra hard." }], + } as Parameters[1], + { + apiKey: "sk-ant-api", + reasoning: "xhigh", + } as Parameters[2], + ), + ); + await stream.result(); + + expect(anthropicMessagesStreamMock).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "adaptive" }, + output_config: { effort: "xhigh" }, + }), + undefined, + ); + }); }); diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 95d4a8a84ab..4ebdfb053d3 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -59,6 +59,7 @@ type AnthropicTransportModel = Model<"anthropic-messages"> & { type AnthropicTransportOptions = AnthropicOptions & Pick; +type AnthropicAdaptiveEffort = NonNullable | "xhigh"; type TransportContentBlock = | { type: "text"; text: string; index?: number } @@ -98,19 +99,24 @@ type MutableAssistantOutput = { errorMessage?: string; }; +function isClaudeOpus47Model(modelId: string): boolean { + return modelId.includes("opus-4-7") || modelId.includes("opus-4.7"); +} + +function isClaudeOpus46Model(modelId: string): boolean { + return modelId.includes("opus-4-6") || modelId.includes("opus-4.6"); +} + function supportsAdaptiveThinking(modelId: string): boolean { return ( - modelId.includes("opus-4-6") || - modelId.includes("opus-4.6") || + isClaudeOpus47Model(modelId) || + isClaudeOpus46Model(modelId) || modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4.6") ); } -function mapThinkingLevelToEffort( - level: ThinkingLevel, - modelId: string, -): NonNullable { +function mapThinkingLevelToEffort(level: ThinkingLevel, modelId: string): AnthropicAdaptiveEffort { switch (level) { case "minimal": case "low": @@ -118,7 +124,10 @@ function mapThinkingLevelToEffort( case "medium": return "medium"; case "xhigh": - return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high"; + if (isClaudeOpus47Model(modelId)) { + return "xhigh"; + } + return isClaudeOpus46Model(modelId) ? "max" : "high"; default: return "high"; } @@ -616,7 +625,9 @@ function resolveAnthropicTransportOptions( } if (supportsAdaptiveThinking(model.id)) { resolved.thinkingEnabled = true; - resolved.effort = mapThinkingLevelToEffort(options.reasoning, model.id); + resolved.effort = mapThinkingLevelToEffort(options.reasoning, model.id) as NonNullable< + AnthropicOptions["effort"] + >; return resolved; } const adjusted = adjustMaxTokensForThinking({ diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts index 0b2dcf06bf7..c1cb3a0b60c 100644 --- a/src/agents/anthropic-vertex-stream.test.ts +++ b/src/agents/anthropic-vertex-stream.test.ts @@ -146,6 +146,22 @@ describe("createAnthropicVertexStreamFn", () => { ); }); + it("maps xhigh reasoning to xhigh effort for Opus 4.7", () => { + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); + const model = makeModel({ id: "claude-opus-4-7", maxTokens: 64000 }); + + void streamFn(model, { messages: [] }, { reasoning: "xhigh" }); + + expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith( + model, + { messages: [] }, + expect.objectContaining({ + thinkingEnabled: true, + effort: "xhigh", + }), + ); + }); + it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => { const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5"); const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }); diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts index d8777d40203..534ab8fca9d 100644 --- a/src/agents/anthropic-vertex-stream.ts +++ b/src/agents/anthropic-vertex-stream.ts @@ -11,6 +11,38 @@ import { } from "./anthropic-payload-policy.js"; type AnthropicVertexEffort = NonNullable; +type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh"; + +function isClaudeOpus47Model(modelId: string): boolean { + return modelId.includes("opus-4-7") || modelId.includes("opus-4.7"); +} + +function isClaudeOpus46Model(modelId: string): boolean { + return modelId.includes("opus-4-6") || modelId.includes("opus-4.6"); +} + +function supportsAdaptiveThinking(modelId: string): boolean { + return ( + isClaudeOpus47Model(modelId) || + isClaudeOpus46Model(modelId) || + modelId.includes("sonnet-4-6") || + modelId.includes("sonnet-4.6") + ); +} + +function mapAnthropicAdaptiveEffort( + reasoning: string, + modelId: string, +): AnthropicVertexAdaptiveEffort { + const effortMap: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: isClaudeOpus47Model(modelId) ? "xhigh" : isClaudeOpus46Model(modelId) ? "max" : "high", + }; + return effortMap[reasoning] ?? "high"; +} function resolveAnthropicVertexMaxTokens(params: { modelMaxTokens: number | undefined; @@ -110,22 +142,12 @@ export function createAnthropicVertexStreamFn( }; if (options?.reasoning) { - const isAdaptive = - model.id.includes("opus-4-6") || - model.id.includes("opus-4.6") || - model.id.includes("sonnet-4-6") || - model.id.includes("sonnet-4.6"); - - if (isAdaptive) { + if (supportsAdaptiveThinking(model.id)) { opts.thinkingEnabled = true; - const effortMap: Record = { - minimal: "low", - low: "low", - medium: "medium", - high: "high", - xhigh: model.id.includes("opus-4-6") || model.id.includes("opus-4.6") ? "max" : "high", - }; - opts.effort = effortMap[options.reasoning] ?? "high"; + opts.effort = mapAnthropicAdaptiveEffort( + options.reasoning, + model.id, + ) as AnthropicVertexEffort; } else { opts.thinkingEnabled = true; const budgets = options.thinkingBudgets; diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index fca4f965e3a..51df92af395 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -48,6 +48,15 @@ const ANTHROPIC_OPUS_CATALOG = [ }, ]; +const ANTHROPIC_OPUS_47_CATALOG = [ + { + provider: "anthropic", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + reasoning: true, + }, +]; + function resolveAnthropicOpusThinking(cfg: OpenClawConfig) { return resolveThinkingDefault({ cfg, @@ -57,6 +66,15 @@ function resolveAnthropicOpusThinking(cfg: OpenClawConfig) { }); } +function resolveAnthropicOpus47Thinking(cfg: OpenClawConfig) { + return resolveThinkingDefault({ + cfg, + provider: "anthropic", + model: "claude-opus-4-7", + catalog: ANTHROPIC_OPUS_47_CATALOG, + }); +} + function createAgentFallbackConfig(params: { primary?: string; fallbacks?: string[]; @@ -1158,6 +1176,18 @@ describe("model-selection", () => { expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive"); }); + it("uses adaptive fallback for explicitly configured Anthropic Opus 4.7", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-7" }, + }, + }, + } as OpenClawConfig; + + expect(resolveAnthropicOpus47Thinking(cfg)).toBe("adaptive"); + }); + it("falls back to low when no provider thinking hook is active", () => { const cfg = {} as OpenClawConfig; diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index de415432a23..7ea7e0179d7 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -58,8 +58,9 @@ export function resolveThinkingDefault(params: { normalizedProvider === "anthropic" && explicitModelConfigured && typeof catalogCandidate?.name === "string" && - /4\.6\b/.test(catalogCandidate.name) && - (normalizedModel.startsWith("claude-opus-4-6") || + /4\.[67]\b/.test(catalogCandidate.name) && + (normalizedModel.startsWith("claude-opus-4-7") || + normalizedModel.startsWith("claude-opus-4-6") || normalizedModel.startsWith("claude-sonnet-4-6")) ) { return "adaptive"; diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index a095aa9e7bf..88b66383a44 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -81,6 +81,12 @@ describe("listThinkingLevels", () => { expect(listThinkingLevels("demo", "demo-model")).toContain("xhigh"); }); + it("uses provider runtime hooks for xhigh labels", () => { + providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(true); + + expect(listThinkingLevelLabels("demo", "demo-model")).toContain("xhigh"); + }); + it("includes xhigh for provider-advertised models", () => { providerRuntimeMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) => (provider === "openai" && ["gpt-5.4", "gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)) || diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index ce07047f727..f9edd5006ba 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -94,6 +94,9 @@ export function listThinkingLevelLabels(provider?: string | null, model?: string if (isBinaryThinkingProvider(provider, model)) { return ["off", "on"]; } + if (supportsXHighThinking(provider, model)) { + return listThinkingLevels(provider, model); + } return listThinkingLevelLabelsFallback(provider, model); }