fix: gate max thinking by model support

This commit is contained in:
Peter Steinberger
2026-04-21 06:47:06 +01:00
parent f89740a62c
commit 6ce17db11a
49 changed files with 510 additions and 73 deletions

View File

@@ -216,7 +216,8 @@ type ActiveMemoryThinkingLevel =
| "medium"
| "high"
| "xhigh"
| "adaptive";
| "adaptive"
| "max";
type ActiveMemoryPromptStyle =
| "balanced"
| "strict"
@@ -698,7 +699,8 @@ function resolveThinkingLevel(thinking: unknown): ActiveMemoryThinkingLevel {
thinking === "medium" ||
thinking === "high" ||
thinking === "xhigh" ||
thinking === "adaptive"
thinking === "adaptive" ||
thinking === "max"
) {
return thinking;
}

View File

@@ -218,6 +218,18 @@ describe("anthropic provider replay hooks", () => {
modelId: "claude-opus-4-6",
} as never),
).toBe(false);
expect(
provider.supportsMaxThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-7",
} as never),
).toBe(true);
expect(
provider.supportsMaxThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-6",
} as never),
).toBe(false);
expect(
provider.supportsAdaptiveThinking?.({
provider: "anthropic",

View File

@@ -495,6 +495,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
resolveReasoningOutputMode: () => "native",
supportsXHighThinking: ({ modelId }) => isAnthropicOpus47Model(modelId),
supportsAdaptiveThinking: ({ modelId }) => supportsAnthropicAdaptiveThinking(modelId),
supportsMaxThinking: ({ modelId }) => isAnthropicOpus47Model(modelId),
wrapStreamFn: wrapAnthropicProviderStream,
resolveDefaultThinkingLevel: ({ modelId }) =>
isAnthropicOpus47Model(modelId)

View File

@@ -37,6 +37,7 @@ const providerThinkingMocks = vi.hoisted(() => ({
resolveProviderAdaptiveThinking: vi.fn(),
resolveProviderBinaryThinking: vi.fn(),
resolveProviderDefaultThinkingLevel: vi.fn(),
resolveProviderMaxThinking: vi.fn(),
resolveProviderXHighThinking: vi.fn(),
}));
const buildModelsProviderDataMock = vi.hoisted(() => vi.fn());
@@ -131,6 +132,7 @@ async function loadDiscordThinkAutocompleteModulesForTest() {
resolveProviderAdaptiveThinking: providerThinkingMocks.resolveProviderAdaptiveThinking,
resolveProviderBinaryThinking: providerThinkingMocks.resolveProviderBinaryThinking,
resolveProviderDefaultThinkingLevel: providerThinkingMocks.resolveProviderDefaultThinkingLevel,
resolveProviderMaxThinking: providerThinkingMocks.resolveProviderMaxThinking,
resolveProviderXHighThinking: providerThinkingMocks.resolveProviderXHighThinking,
}));
const commandAuth = await import("openclaw/plugin-sdk/command-auth");
@@ -147,6 +149,7 @@ describe("discord native /think autocomplete", () => {
providerThinkingMocks.resolveProviderBinaryThinking.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderAdaptiveThinking.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderMaxThinking.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
? true
@@ -177,6 +180,10 @@ describe("discord native /think autocomplete", () => {
providerThinkingMocks.resolveProviderAdaptiveThinking.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReset();
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerThinkingMocks.resolveProviderMaxThinking.mockReset();
providerThinkingMocks.resolveProviderMaxThinking.mockImplementation(({ provider, context }) =>
provider === "anthropic" && context.modelId === "claude-opus-4-7" ? true : undefined,
);
providerThinkingMocks.resolveProviderXHighThinking.mockReset();
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
@@ -263,9 +270,65 @@ describe("discord native /think autocomplete", () => {
});
const values = choices.map((choice) => choice.value);
expect(values).toContain("xhigh");
expect(values).not.toContain("max");
expect(values).not.toContain("adaptive");
});
it("includes max only for provider-advertised models", async () => {
fs.writeFileSync(
STORE_PATH,
JSON.stringify({
[SESSION_KEY]: {
updatedAt: Date.now(),
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
},
}),
"utf8",
);
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
const interaction = {
options: {
getFocused: () => ({ value: "ma" }),
},
respond: async (_choices: Array<{ name: string; value: string }>) => {},
rawData: {
member: { roles: [] },
},
channel: { id: "C1", type: ChannelType.GuildText },
user: { id: "U1" },
guild: { id: "G1" },
client: {},
} as unknown as AutocompleteInteraction & {
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
};
const context = await resolveDiscordNativeChoiceContext({
interaction,
cfg,
accountId: "default",
threadBindings: createNoopThreadBindingManager("default"),
});
const command = findCommandByNativeName("think", "discord");
const levelArg = command?.args?.find((entry) => entry.name === "level");
expect(command).toBeTruthy();
expect(levelArg).toBeTruthy();
if (!command || !levelArg) {
return;
}
const choices = resolveCommandArgChoices({
command,
arg: levelArg,
cfg,
provider: context?.provider,
model: context?.model,
});
const values = choices.map((choice) => choice.value);
expect(values).toContain("max");
});
it("falls back when a configured binding is unavailable", async () => {
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);

View File

@@ -21,7 +21,15 @@ type KimiToolCallBlock = {
};
type KimiThinkingType = "enabled" | "disabled";
type KimiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive";
type KimiThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max";
function normalizeKimiThinkingType(value: unknown): KimiThinkingType | undefined {
if (typeof value === "boolean") {

View File

@@ -7,6 +7,7 @@ import {
formatXHighModelHint,
normalizeThinkLevel,
resolvePreferredOpenClawTmpDir,
resolveSupportedThinkingLevel,
supportsXHighThinking,
} from "../api.js";
import type { OpenClawPluginApi } from "../api.js";
@@ -61,7 +62,7 @@ type LlmTaskParams = {
};
const INVALID_THINKING_LEVELS_HINT =
"off, minimal, low, medium, high, adaptive, and xhigh where supported";
"off, minimal, low, medium, high, adaptive, xhigh where supported, and max where supported";
export function createLlmTaskTool(api: OpenClawPluginApi) {
return {
@@ -143,9 +144,17 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
`Invalid thinking level "${thinkingRaw}". Use one of: ${INVALID_THINKING_LEVELS_HINT}.`,
);
}
let resolvedThinkLevel = thinkLevel;
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
}
if (thinkLevel === "max") {
resolvedThinkLevel = resolveSupportedThinkingLevel({
provider,
model,
level: thinkLevel,
});
}
const timeoutMs =
(typeof params.timeoutMs === "number" && params.timeoutMs > 0
@@ -204,7 +213,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
thinkLevel,
thinkLevel: resolvedThinkLevel,
streamParams,
disableTools: true,
});

View File

@@ -28,6 +28,7 @@ export const MISTRAL_SMALL_LATEST_REASONING_EFFORT_MAP: Record<string, string> =
high: "high",
xhigh: "high",
adaptive: "high",
max: "high",
};
export const MISTRAL_SMALL_LATEST_ID = "mistral-small-latest";

View File

@@ -202,7 +202,7 @@ export function createConfiguredOllamaCompatStreamWrapper(
if (ctx.thinkingLevel === "off") {
streamFn = createOllamaThinkingWrapper(streamFn, false);
} else if (ctx.thinkingLevel) {
// Any non-off ThinkLevel (minimal, low, medium, high, xhigh, adaptive)
// Any non-off ThinkLevel (minimal, low, medium, high, xhigh, adaptive, max)
// should enable Ollama's native thinking mode.
streamFn = createOllamaThinkingWrapper(streamFn, true);
}

View File

@@ -73,7 +73,9 @@ function parseQaThinkingLevel(
}
const normalized = normalizeQaThinkingLevel(value);
if (!normalized) {
throw new Error(`${label} must be one of off, minimal, low, medium, high, xhigh, adaptive`);
throw new Error(
`${label} must be one of off, minimal, low, medium, high, xhigh, adaptive, max`,
);
}
return normalized;
}
@@ -238,7 +240,7 @@ function parseQaModelSpecs(label: string, entries: readonly string[] | undefined
const thinkingDefault = parseQaThinkingLevel(`${label} thinking`, value);
if (!thinkingDefault) {
throw new Error(
`${label} thinking must be one of off, minimal, low, medium, high, xhigh, adaptive`,
`${label} thinking must be one of off, minimal, low, medium, high, xhigh, adaptive, max`,
);
}
options.thinkingDefault = thinkingDefault;

View File

@@ -339,7 +339,7 @@ export function registerQaLabCli(program: Command) {
.option("--fast", "Enable provider fast mode for all candidate runs")
.option(
"--thinking <level>",
"Candidate thinking default: off|minimal|low|medium|high|xhigh|adaptive",
"Candidate thinking default: off|minimal|low|medium|high|xhigh|adaptive|max",
)
.option(
"--model-thinking <ref=level>",

View File

@@ -1,4 +1,12 @@
export type QaThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive";
export type QaThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max";
export function normalizeQaThinkingLevel(input: unknown): QaThinkingLevel | undefined {
const value = typeof input === "string" ? input.trim().toLowerCase() : "";
@@ -24,5 +32,8 @@ export function normalizeQaThinkingLevel(input: unknown): QaThinkingLevel | unde
if (collapsed === "adaptive" || collapsed === "auto") {
return "adaptive";
}
if (collapsed === "max") {
return "max";
}
return undefined;
}