From f6bda8d36b7b73eab422f799526e3485ab841379 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:22:04 +0100 Subject: [PATCH] refactor(providers): share Claude thinking profiles --- docs/tools/thinking.md | 1 + extensions/anthropic/register.runtime.ts | 42 ++---------------- extensions/opencode/index.ts | 45 ++------------------ src/plugin-sdk/provider-model-shared.test.ts | 24 +++++++++++ src/plugin-sdk/provider-model-shared.ts | 45 ++++++++++++++++++++ 5 files changed, 77 insertions(+), 80 deletions(-) diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 7693e4228e3..7699bcbbb9f 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -123,6 +123,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed ` ## Provider profiles - Provider plugins can expose `resolveThinkingProfile(ctx)` to define the model's supported levels and default. +- Provider plugins that proxy Claude models should reuse `resolveClaudeThinkingProfile(modelId)` from `openclaw/plugin-sdk/provider-model-shared` so direct Anthropic and proxy catalogs stay aligned. - Each profile level has a stored canonical `id` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, or `max`) and may include a display `label`. Binary providers use `{ id: "low", label: "on" }`. - Tool plugins that need to validate an explicit thinking override should use `api.runtime.agent.resolveThinkingPolicy({ provider, model })` plus `api.runtime.agent.normalizeThinkingLevel(...)`; they should not keep their own provider/model level lists. - Published legacy hooks (`supportsXHighThinking`, `isBinaryThinking`, and `resolveDefaultThinkingLevel`) remain as compatibility adapters, but new custom level sets should use `resolveThinkingProfile`. diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index a2290fdd387..a7f82fa9bb8 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -21,7 +21,9 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { cloneFirstTemplateModel, + isClaudeOpus47ModelId, type ProviderPlugin, + resolveClaudeThinkingProfile, } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; @@ -273,26 +275,8 @@ function resolveAnthropicForwardCompatModel( ); } -function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean { - const lowerModelId = normalizeLowercaseStringOrEmpty(modelId); - return ( - lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) || - lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) || - lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) || - lowerModelId.startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID) - ); -} - 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 supportsAnthropicAdaptiveThinking(modelId: string): boolean { - return shouldUseAnthropicAdaptiveThinkingDefault(modelId) || isAnthropicOpus47Model(modelId); + return isClaudeOpus47ModelId(modelId); } function hasConfiguredModelContextOverride( @@ -592,25 +576,7 @@ export function buildAnthropicProvider(): ProviderPlugin { buildReplayPolicy: buildAnthropicReplayPolicy, isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), resolveReasoningOutputMode: () => "native", - resolveThinkingProfile: ({ modelId }) => { - const levels: Array<{ - id: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max"; - }> = [{ id: "off" }, { id: "minimal" }, { id: "low" }, { id: "medium" }, { id: "high" }]; - if (isAnthropicOpus47Model(modelId)) { - levels.push({ id: "xhigh" }, { id: "adaptive" }, { id: "max" }); - } else if (supportsAnthropicAdaptiveThinking(modelId)) { - levels.push({ id: "adaptive" }); - } - return { - levels, - defaultLevel: isAnthropicOpus47Model(modelId) - ? "off" - : matchesAnthropicModernModel(modelId) && - shouldUseAnthropicAdaptiveThinkingDefault(modelId) - ? "adaptive" - : undefined, - }; - }, + resolveThinkingProfile: ({ modelId }) => resolveClaudeThinkingProfile(modelId), wrapStreamFn: wrapAnthropicProviderStream, resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 16095fe4e90..aad4f1a2934 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,8 +1,9 @@ -import { definePluginEntry, type ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { matchesExactOrPrefix, PASSTHROUGH_GEMINI_REPLAY_HOOKS, + resolveClaudeThinkingProfile, } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js"; @@ -17,20 +18,6 @@ const OPENCODE_SHARED_WIZARD_GROUP = { groupLabel: "OpenCode", groupHint: OPENCODE_SHARED_HINT, } as const; -const ANTHROPIC_OPUS_47_MODEL_PREFIXES = ["claude-opus-4-7", "claude-opus-4.7"] as const; -const ANTHROPIC_ADAPTIVE_MODEL_PREFIXES = [ - "claude-opus-4-6", - "claude-opus-4.6", - "claude-sonnet-4-6", - "claude-sonnet-4.6", -] as const; -const BASE_ANTHROPIC_THINKING_LEVELS = [ - { id: "off" }, - { id: "minimal" }, - { id: "low" }, - { id: "medium" }, - { id: "high" }, -] as const satisfies ProviderThinkingProfile["levels"]; function isModernOpencodeModel(modelId: string): boolean { const lower = normalizeLowercaseStringOrEmpty(modelId); @@ -40,32 +27,6 @@ function isModernOpencodeModel(modelId: string): boolean { return !matchesExactOrPrefix(lower, MINIMAX_MODERN_MODEL_MATCHERS); } -function matchesAnyPrefix(modelId: string, prefixes: readonly string[]): boolean { - const lower = normalizeLowercaseStringOrEmpty(modelId); - return prefixes.some((prefix) => lower.startsWith(prefix)); -} - -function resolveOpencodeThinkingProfile(modelId: string): ProviderThinkingProfile { - if (matchesAnyPrefix(modelId, ANTHROPIC_OPUS_47_MODEL_PREFIXES)) { - return { - levels: [ - ...BASE_ANTHROPIC_THINKING_LEVELS, - { id: "xhigh" }, - { id: "adaptive" }, - { id: "max" }, - ], - defaultLevel: "off", - }; - } - if (matchesAnyPrefix(modelId, ANTHROPIC_ADAPTIVE_MODEL_PREFIXES)) { - return { - levels: [...BASE_ANTHROPIC_THINKING_LEVELS, { id: "adaptive" }], - defaultLevel: "adaptive", - }; - } - return { levels: BASE_ANTHROPIC_THINKING_LEVELS }; -} - export default definePluginEntry({ id: PROVIDER_ID, name: "OpenCode Zen Provider", @@ -106,7 +67,7 @@ export default definePluginEntry({ ], ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId), - resolveThinkingProfile: ({ modelId }) => resolveOpencodeThinkingProfile(modelId), + resolveThinkingProfile: ({ modelId }) => resolveClaudeThinkingProfile(modelId), }); api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider); }, diff --git a/src/plugin-sdk/provider-model-shared.test.ts b/src/plugin-sdk/provider-model-shared.test.ts index 5b2e96a4378..8fcb498a344 100644 --- a/src/plugin-sdk/provider-model-shared.test.ts +++ b/src/plugin-sdk/provider-model-shared.test.ts @@ -5,6 +5,7 @@ import { NATIVE_ANTHROPIC_REPLAY_HOOKS, OPENAI_COMPATIBLE_REPLAY_HOOKS, PASSTHROUGH_GEMINI_REPLAY_HOOKS, + resolveClaudeThinkingProfile, } from "./provider-model-shared.js"; describe("buildProviderReplayFamilyHooks", () => { @@ -249,3 +250,26 @@ describe("buildProviderReplayFamilyHooks", () => { }); }); }); + +describe("resolveClaudeThinkingProfile", () => { + it("exposes Opus 4.7 thinking levels for direct and proxied Claude providers", () => { + expect(resolveClaudeThinkingProfile("claude-opus-4-7")).toMatchObject({ + levels: expect.arrayContaining([{ id: "xhigh" }, { id: "adaptive" }, { id: "max" }]), + defaultLevel: "off", + }); + expect(resolveClaudeThinkingProfile("claude-opus-4.7-20260219")).toMatchObject({ + levels: expect.arrayContaining([{ id: "xhigh" }, { id: "adaptive" }, { id: "max" }]), + defaultLevel: "off", + }); + }); + + it("keeps adaptive-only Claude variants from advertising xhigh or max", () => { + const profile = resolveClaudeThinkingProfile("claude-sonnet-4-6"); + + expect(profile).toMatchObject({ + levels: expect.arrayContaining([{ id: "adaptive" }]), + defaultLevel: "adaptive", + }); + expect(profile.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + }); +}); diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index b30c2efff2c..b1d3437ee88 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -20,6 +20,7 @@ import type { ProviderReasoningOutputModeContext, ProviderReplayPolicyContext, ProviderSanitizeReplayHistoryContext, + ProviderThinkingProfile, } from "./plugin-entry.js"; import { normalizeAntigravityPreviewModelId, @@ -82,6 +83,21 @@ export { } from "../plugins/provider-model-helpers.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +const CLAUDE_OPUS_47_MODEL_PREFIXES = ["claude-opus-4-7", "claude-opus-4.7"] as const; +const CLAUDE_ADAPTIVE_THINKING_DEFAULT_MODEL_PREFIXES = [ + "claude-opus-4-6", + "claude-opus-4.6", + "claude-sonnet-4-6", + "claude-sonnet-4.6", +] as const; +const BASE_CLAUDE_THINKING_LEVELS = [ + { id: "off" }, + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, +] as const satisfies ProviderThinkingProfile["levels"]; + export function getModelProviderHint(modelId: string): string | null { const trimmed = normalizeOptionalLowercaseString(modelId); if (!trimmed) { @@ -98,6 +114,35 @@ export function isProxyReasoningUnsupportedModelHint(modelId: string): boolean { return getModelProviderHint(modelId) === "x-ai"; } +function matchesClaudeModelPrefix(modelId: string, prefixes: readonly string[]): boolean { + const lower = normalizeOptionalLowercaseString(modelId); + return Boolean(lower && prefixes.some((prefix) => lower.startsWith(prefix))); +} + +export function isClaudeOpus47ModelId(modelId: string): boolean { + return matchesClaudeModelPrefix(modelId, CLAUDE_OPUS_47_MODEL_PREFIXES); +} + +export function isClaudeAdaptiveThinkingDefaultModelId(modelId: string): boolean { + return matchesClaudeModelPrefix(modelId, CLAUDE_ADAPTIVE_THINKING_DEFAULT_MODEL_PREFIXES); +} + +export function resolveClaudeThinkingProfile(modelId: string): ProviderThinkingProfile { + if (isClaudeOpus47ModelId(modelId)) { + return { + levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }], + defaultLevel: "off", + }; + } + if (isClaudeAdaptiveThinkingDefaultModelId(modelId)) { + return { + levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }], + defaultLevel: "adaptive", + }; + } + return { levels: BASE_CLAUDE_THINKING_LEVELS }; +} + export { normalizeAntigravityPreviewModelId, normalizeGooglePreviewModelId,