refactor(providers): share Claude thinking profiles

This commit is contained in:
Peter Steinberger
2026-04-27 14:22:04 +01:00
parent 93bbbe5e37
commit f6bda8d36b
5 changed files with 77 additions and 80 deletions

View File

@@ -123,6 +123,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed `<think>
## 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`.

View File

@@ -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) =>

View File

@@ -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);
},

View File

@@ -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);
});
});

View File

@@ -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,