fix: centralize provider thinking profiles

This commit is contained in:
Peter Steinberger
2026-04-21 09:04:37 +01:00
parent 1cc2fc82ca
commit f1805ab54d
57 changed files with 718 additions and 572 deletions

View File

@@ -90,6 +90,7 @@ vi.mock("../auto-reply/thinking.js", () => ({
formatXHighModelHint: () => "model-x",
normalizeThinkLevel: (v?: string) => v || undefined,
normalizeVerboseLevel: (v?: string) => v || undefined,
isThinkingLevelSupported: () => true,
resolveSupportedThinkingLevel: ({ level }: { level?: string }) => level,
supportsXHighThinking: () => false,
}));

View File

@@ -1,10 +1,9 @@
import {
formatThinkingLevels,
formatXHighModelHint,
isThinkingLevelSupported,
normalizeThinkLevel,
normalizeVerboseLevel,
resolveSupportedThinkingLevel,
supportsXHighThinking,
type VerboseLevel,
} from "../auto-reply/thinking.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -783,33 +782,27 @@ async function agentCommandInternal(
catalog: catalogForThinking,
});
}
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
if (!isThinkingLevelSupported({ provider, model, level: resolvedThinkLevel })) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
if (explicitThink) {
throw new Error(`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`);
throw new Error(
`Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model)}.`,
);
}
resolvedThinkLevel = "high";
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
const entry = sessionEntry;
entry.thinkingLevel = "high";
entry.updatedAt = Date.now();
await persistSessionEntry({
sessionStore,
sessionKey,
storePath,
entry,
});
}
}
if (resolvedThinkLevel === "max") {
const fallbackThinkLevel = resolveSupportedThinkingLevel({
provider,
model,
level: resolvedThinkLevel,
});
if (fallbackThinkLevel !== resolvedThinkLevel) {
const previousThinkLevel = resolvedThinkLevel;
resolvedThinkLevel = fallbackThinkLevel;
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "max") {
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === previousThinkLevel
) {
const entry = sessionEntry;
entry.thinkingLevel = fallbackThinkLevel;
entry.updatedAt = Date.now();

View File

@@ -10,9 +10,8 @@ import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import {
formatThinkingLevels,
formatXHighModelHint,
isThinkingLevelSupported,
resolveSupportedThinkingLevel,
supportsXHighThinking,
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js";
@@ -291,41 +290,38 @@ export async function handleDirectiveOnly(
if (
directives.hasThinkDirective &&
directives.thinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel)
directives.thinkLevel &&
!isThinkingLevelSupported({
provider: resolvedProvider,
model: resolvedModel,
level: directives.thinkLevel,
})
) {
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`,
};
}
const resolvedDirectiveThinkLevel =
directives.hasThinkDirective && directives.thinkLevel
? resolveSupportedThinkingLevel({
provider: resolvedProvider,
model: resolvedModel,
level: directives.thinkLevel,
})
: directives.thinkLevel;
const resolvedDirectiveThinkLevel = directives.thinkLevel;
const nextThinkLevel = directives.hasThinkDirective
? resolvedDirectiveThinkLevel
: ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? currentThinkLevel);
const shouldDowngradeXHigh =
const remappedUnsupportedThinkLevel =
!directives.hasThinkDirective &&
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
const remappedMaxThinkLevel =
nextThinkLevel === "max"
nextThinkLevel &&
!isThinkingLevelSupported({
provider: resolvedProvider,
model: resolvedModel,
level: nextThinkLevel,
})
? resolveSupportedThinkingLevel({
provider: resolvedProvider,
model: resolvedModel,
level: nextThinkLevel,
})
: undefined;
const shouldRemapMax =
nextThinkLevel === "max" &&
remappedMaxThinkLevel !== undefined &&
remappedMaxThinkLevel !== "max";
const shouldRemapUnsupportedThinkLevel =
Boolean(remappedUnsupportedThinkLevel) && remappedUnsupportedThinkLevel !== nextThinkLevel;
const prevElevatedLevel =
currentElevatedLevel ??
@@ -351,8 +347,7 @@ export async function handleDirectiveOnly(
(directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) ||
Boolean(modelSelection) ||
directives.hasQueueDirective ||
shouldDowngradeXHigh ||
shouldRemapMax;
shouldRemapUnsupportedThinkLevel;
const fastModeChanged =
directives.hasFastDirective &&
directives.fastMode !== undefined &&
@@ -366,11 +361,8 @@ export async function handleDirectiveOnly(
if (directives.hasFastDirective && directives.fastMode !== undefined) {
sessionEntry.fastMode = directives.fastMode;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (shouldRemapMax && remappedMaxThinkLevel) {
sessionEntry.thinkingLevel = remappedMaxThinkLevel;
if (shouldRemapUnsupportedThinkLevel && remappedUnsupportedThinkLevel) {
sessionEntry.thinkingLevel = remappedUnsupportedThinkLevel;
}
if (
directives.hasVerboseDirective &&
@@ -573,14 +565,13 @@ export async function handleDirectiveOnly(
if (directives.hasExecDirective && directives.hasExecOptions && !allowInternalExecPersistence) {
parts.push(formatDirectiveAck(formatInternalExecPersistenceDeniedText()));
}
if (shouldDowngradeXHigh) {
if (
!directives.hasThinkDirective &&
shouldRemapUnsupportedThinkLevel &&
remappedUnsupportedThinkLevel
) {
parts.push(
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (!directives.hasThinkDirective && shouldRemapMax && remappedMaxThinkLevel) {
parts.push(
`Thinking level set to ${remappedMaxThinkLevel} (max not supported for ${resolvedProvider}/${resolvedModel}).`,
`Thinking level set to ${remappedUnsupportedThinkLevel} (${nextThinkLevel} not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection) {

View File

@@ -27,11 +27,11 @@ import { resolveEnvelopeFormatOptions } from "../envelope.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import {
type ElevatedLevel,
formatXHighModelHint,
formatThinkingLevels,
isThinkingLevelSupported,
normalizeThinkLevel,
type ReasoningLevel,
resolveSupportedThinkingLevel,
supportsXHighThinking,
type ThinkLevel,
type VerboseLevel,
} from "../thinking.js";
@@ -414,10 +414,7 @@ export async function runPreparedReply(
if (!resolvedThinkLevel && prefixedBodyBase) {
const parts = prefixedBodyBase.split(/\s+/);
const maybeLevel = normalizeThinkLevel(parts[0]);
if (
maybeLevel &&
(maybeLevel === "max" || maybeLevel !== "xhigh" || supportsXHighThinking(provider, model))
) {
if (maybeLevel && isThinkingLevelSupported({ provider, model, level: maybeLevel })) {
resolvedThinkLevel = maybeLevel;
prefixedBodyBase = parts.slice(1).join(" ").trim();
}
@@ -487,15 +484,28 @@ export async function runPreparedReply(
if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
if (resolvedThinkLevel === "max") {
if (!isThinkingLevelSupported({ provider, model, level: resolvedThinkLevel })) {
const explicitThink = directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
text: `Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model)}.`,
};
}
const fallbackThinkLevel = resolveSupportedThinkingLevel({
provider,
model,
level: resolvedThinkLevel,
});
if (fallbackThinkLevel !== resolvedThinkLevel) {
const previousThinkLevel = resolvedThinkLevel;
resolvedThinkLevel = fallbackThinkLevel;
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "max") {
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.thinkingLevel === previousThinkLevel
) {
sessionEntry.thinkingLevel = fallbackThinkLevel;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
@@ -508,27 +518,6 @@ export async function runPreparedReply(
}
}
}
if (resolvedThinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
const explicitThink = directives.hasThinkDirective && directives.thinkLevel !== undefined;
if (explicitThink) {
typing.cleanup();
return {
text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`,
};
}
resolvedThinkLevel = "high";
if (sessionEntry && sessionStore && sessionKey && sessionEntry.thinkingLevel === "xhigh") {
sessionEntry.thinkingLevel = "high";
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
const { updateSessionStore } = await loadSessionStoreRuntime();
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFilePathOptions = resolveSessionFilePathOptions({ agentId, storePath });
const resolvePreparedSessionState = (): {

View File

@@ -28,7 +28,17 @@ export type ThinkingCatalogEntry = {
reasoning?: boolean;
};
const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
export const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
export const THINKING_LEVEL_RANKS: Record<ThinkLevel, number> = {
off: 0,
minimal: 10,
low: 20,
medium: 30,
high: 40,
adaptive: 50,
xhigh: 60,
max: 70,
};
const NO_THINKING_LEVELS: ThinkLevel[] = [...BASE_THINKING_LEVELS];
export function isBinaryThinkingProvider(provider?: string | null): boolean {
@@ -102,10 +112,6 @@ export function formatXHighModelHint(): string {
return "provider models that advertise xhigh reasoning";
}
export function formatMaxModelHint(): string {
return "provider models that advertise max reasoning";
}
export function resolveThinkingDefaultForModel(params: {
provider: string;
model: string;

View File

@@ -1,10 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const providerRuntimeMocks = vi.hoisted(() => ({
resolveProviderAdaptiveThinking: vi.fn(),
resolveProviderBinaryThinking: vi.fn(),
resolveProviderDefaultThinkingLevel: vi.fn(),
resolveProviderMaxThinking: vi.fn(),
resolveProviderThinkingProfile: vi.fn(),
resolveProviderXHighThinking: vi.fn(),
}));
@@ -12,29 +11,27 @@ let listThinkingLevelLabels: typeof import("./thinking.js").listThinkingLevelLab
let listThinkingLevels: typeof import("./thinking.js").listThinkingLevels;
let normalizeReasoningLevel: typeof import("./thinking.js").normalizeReasoningLevel;
let normalizeThinkLevel: typeof import("./thinking.js").normalizeThinkLevel;
let resolveSupportedThinkingLevel: typeof import("./thinking.js").resolveSupportedThinkingLevel;
let resolveThinkingDefaultForModel: typeof import("./thinking.js").resolveThinkingDefaultForModel;
async function loadFreshThinkingModuleForTest() {
vi.resetModules();
vi.doMock("../plugins/provider-thinking.js", () => ({
resolveProviderAdaptiveThinking: providerRuntimeMocks.resolveProviderAdaptiveThinking,
resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking,
resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel,
resolveProviderMaxThinking: providerRuntimeMocks.resolveProviderMaxThinking,
resolveProviderThinkingProfile: providerRuntimeMocks.resolveProviderThinkingProfile,
resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking,
}));
return await import("./thinking.js");
}
beforeEach(async () => {
providerRuntimeMocks.resolveProviderAdaptiveThinking.mockReset();
providerRuntimeMocks.resolveProviderAdaptiveThinking.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderBinaryThinking.mockReset();
providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReset();
providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderMaxThinking.mockReset();
providerRuntimeMocks.resolveProviderMaxThinking.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderThinkingProfile.mockReset();
providerRuntimeMocks.resolveProviderThinkingProfile.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderXHighThinking.mockReset();
providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(undefined);
@@ -43,6 +40,7 @@ beforeEach(async () => {
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
resolveSupportedThinkingLevel,
resolveThinkingDefaultForModel,
} = await loadFreshThinkingModuleForTest());
});
@@ -126,18 +124,6 @@ describe("listThinkingLevels", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain("xhigh");
});
it("uses provider runtime hooks for adaptive support", () => {
providerRuntimeMocks.resolveProviderAdaptiveThinking.mockReturnValue(true);
expect(listThinkingLevels("demo", "demo-model")).toContain("adaptive");
});
it("uses provider runtime hooks for max support", () => {
providerRuntimeMocks.resolveProviderMaxThinking.mockReturnValue(true);
expect(listThinkingLevels("demo", "demo-model")).toContain("max");
});
it("does not include max without provider support", () => {
expect(listThinkingLevels("openai", "gpt-5.4")).not.toContain("max");
});
@@ -147,13 +133,40 @@ describe("listThinkingLevels", () => {
expect(listThinkingLevels("openai", "gpt-5.4")).not.toContain("adaptive");
});
it("includes adaptive for provider-advertised models", () => {
providerRuntimeMocks.resolveProviderAdaptiveThinking.mockImplementation(
({ provider, context }) =>
provider === "anthropic" && context.modelId === "claude-opus-4-6" ? true : undefined,
it("uses provider thinking profiles for adaptive and max support", () => {
providerRuntimeMocks.resolveProviderThinkingProfile.mockImplementation(({ provider }) =>
provider === "anthropic"
? { levels: [{ id: "off" }, { id: "adaptive" }, { id: "max" }] }
: undefined,
);
expect(listThinkingLevels("anthropic", "claude-opus-4-6")).toContain("adaptive");
expect(listThinkingLevels("anthropic", "claude-opus-4-7")).toContain("max");
});
it("uses provider thinking profiles ahead of legacy hooks", () => {
providerRuntimeMocks.resolveProviderThinkingProfile.mockReturnValue({
levels: [{ id: "off" }, { id: "low", label: "on" }],
defaultLevel: "off",
});
providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(true);
expect(listThinkingLevels("demo", "demo-model")).toEqual(["off", "low"]);
expect(listThinkingLevelLabels("demo", "demo-model")).toEqual(["off", "on"]);
});
it("maps stale unsupported levels to the largest profile level", () => {
providerRuntimeMocks.resolveProviderThinkingProfile.mockReturnValue({
levels: [{ id: "off" }, { id: "high" }],
});
expect(
resolveSupportedThinkingLevel({
provider: "demo",
model: "demo-model",
level: "max",
}),
).toBe("high");
});
});

View File

@@ -1,7 +1,9 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import {
listThinkingLevels as listThinkingLevelsFallback,
BASE_THINKING_LEVELS,
normalizeThinkLevel,
resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback,
THINKING_LEVEL_RANKS,
} from "./thinking.shared.js";
import type { ThinkLevel, ThinkingCatalogEntry } from "./thinking.shared.js";
export {
@@ -29,118 +31,182 @@ export type {
VerboseLevel,
} from "./thinking.shared.js";
import {
resolveProviderAdaptiveThinking,
resolveProviderBinaryThinking,
resolveProviderDefaultThinkingLevel,
resolveProviderMaxThinking,
resolveProviderThinkingProfile,
resolveProviderXHighThinking,
} from "../plugins/provider-thinking.js";
import type { ProviderThinkingProfile } from "../plugins/provider-thinking.types.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean {
const providerRaw = normalizeOptionalString(provider);
type ThinkingLevelOption = {
id: ThinkLevel;
label: string;
rank: number;
};
type ResolvedThinkingProfile = {
levels: ThinkingLevelOption[];
defaultLevel?: ThinkLevel | null;
};
function resolveThinkingPolicyContext(params: {
provider?: string | null;
model?: string | null;
catalog?: ThinkingCatalogEntry[];
}) {
const providerRaw = normalizeOptionalString(params.provider);
const normalizedProvider = providerRaw ? normalizeProviderId(providerRaw) : "";
if (!normalizedProvider) {
return false;
const modelId = normalizeOptionalString(params.model) ?? "";
const modelKey = normalizeOptionalLowercaseString(params.model) ?? "";
const candidate = params.catalog?.find(
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
);
return { normalizedProvider, modelId, modelKey, reasoning: candidate?.reasoning };
}
function normalizeProfileLevel(
level: ProviderThinkingProfile["levels"][number],
): ThinkingLevelOption | undefined {
const normalized = normalizeThinkLevel(level.id);
if (!normalized) {
return undefined;
}
return {
id: normalized,
label: normalizeOptionalString(level.label) ?? normalized,
rank: Number.isFinite(level.rank) ? (level.rank as number) : THINKING_LEVEL_RANKS[normalized],
};
}
function normalizeThinkingProfile(profile: ProviderThinkingProfile): ResolvedThinkingProfile {
const byId = new Map<ThinkLevel, ThinkingLevelOption>();
for (const raw of profile.levels) {
const level = normalizeProfileLevel(raw);
if (level) {
byId.set(level.id, level);
}
}
const levels = [...byId.values()].toSorted((a, b) => a.rank - b.rank);
const rawDefaultLevel = profile.defaultLevel
? normalizeThinkLevel(profile.defaultLevel)
: undefined;
const defaultLevel = rawDefaultLevel && byId.has(rawDefaultLevel) ? rawDefaultLevel : undefined;
return { levels, defaultLevel };
}
function buildBaseThinkingProfile(defaultLevel?: ThinkLevel | null): ResolvedThinkingProfile {
return {
levels: BASE_THINKING_LEVELS.map((id) => ({
id,
label: id,
rank: THINKING_LEVEL_RANKS[id],
})),
defaultLevel,
};
}
function buildBinaryThinkingProfile(defaultLevel?: ThinkLevel | null): ResolvedThinkingProfile {
return {
levels: [
{ id: "off", label: "off", rank: THINKING_LEVEL_RANKS.off },
{ id: "low", label: "on", rank: THINKING_LEVEL_RANKS.low },
],
defaultLevel,
};
}
function appendProfileLevel(profile: ResolvedThinkingProfile, id: ThinkLevel) {
if (profile.levels.some((level) => level.id === id)) {
return;
}
profile.levels.push({ id, label: id, rank: THINKING_LEVEL_RANKS[id] });
profile.levels = profile.levels.toSorted((a, b) => a.rank - b.rank);
}
export function resolveThinkingProfile(params: {
provider?: string | null;
model?: string | null;
catalog?: ThinkingCatalogEntry[];
}): ResolvedThinkingProfile {
const context = resolveThinkingPolicyContext(params);
if (!context.normalizedProvider) {
return buildBaseThinkingProfile();
}
const providerContext = {
provider: context.normalizedProvider,
modelId: context.modelId,
reasoning: context.reasoning,
};
const pluginProfile = resolveProviderThinkingProfile({
provider: context.normalizedProvider,
context: providerContext,
});
if (pluginProfile) {
const normalized = normalizeThinkingProfile(pluginProfile);
if (normalized.levels.length > 0) {
return normalized;
}
}
const pluginDecision = resolveProviderBinaryThinking({
provider: normalizedProvider,
const defaultLevel = resolveProviderDefaultThinkingLevel({
provider: context.normalizedProvider,
context: providerContext,
});
const binaryDecision = resolveProviderBinaryThinking({
provider: context.normalizedProvider,
context: {
provider: normalizedProvider,
modelId: normalizeOptionalString(model) ?? "",
provider: context.normalizedProvider,
modelId: context.modelId,
},
});
if (typeof pluginDecision === "boolean") {
return pluginDecision;
const profile =
binaryDecision === true
? buildBinaryThinkingProfile(defaultLevel)
: buildBaseThinkingProfile(defaultLevel);
const policyContext = {
provider: context.normalizedProvider,
modelId: context.modelKey || context.modelId,
};
if (
resolveProviderXHighThinking({
provider: context.normalizedProvider,
context: policyContext,
}) === true
) {
appendProfileLevel(profile, "xhigh");
}
return false;
return profile;
}
export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean {
const profile = resolveThinkingProfile({ provider, model });
return profile.levels.length === 2 && profile.levels.some((level) => level.label === "on");
}
function supportsThinkingLevel(
provider: string | null | undefined,
model: string | null | undefined,
level: ThinkLevel,
): boolean {
return resolveThinkingProfile({ provider, model }).levels.some((entry) => entry.id === level);
}
export function supportsXHighThinking(provider?: string | null, model?: string | null): boolean {
const modelKey = normalizeOptionalLowercaseString(model);
if (!modelKey) {
return false;
}
const providerRaw = normalizeOptionalString(provider);
const providerKey = providerRaw ? normalizeProviderId(providerRaw) : "";
if (providerKey) {
const pluginDecision = resolveProviderXHighThinking({
provider: providerKey,
context: {
provider: providerKey,
modelId: modelKey,
},
});
if (typeof pluginDecision === "boolean") {
return pluginDecision;
}
}
return false;
}
export function supportsAdaptiveThinking(provider?: string | null, model?: string | null): boolean {
const modelKey = normalizeOptionalLowercaseString(model);
if (!modelKey) {
return false;
}
const providerRaw = normalizeOptionalString(provider);
const providerKey = providerRaw ? normalizeProviderId(providerRaw) : "";
if (!providerKey) {
return false;
}
const pluginDecision = resolveProviderAdaptiveThinking({
provider: providerKey,
context: {
provider: providerKey,
modelId: modelKey,
},
});
return pluginDecision === true;
}
export function supportsMaxThinking(provider?: string | null, model?: string | null): boolean {
const modelKey = normalizeOptionalLowercaseString(model);
if (!modelKey) {
return false;
}
const providerRaw = normalizeOptionalString(provider);
const providerKey = providerRaw ? normalizeProviderId(providerRaw) : "";
if (!providerKey) {
return false;
}
const pluginDecision = resolveProviderMaxThinking({
provider: providerKey,
context: {
provider: providerKey,
modelId: modelKey,
},
});
return pluginDecision === true;
return supportsThinkingLevel(provider, model, "xhigh");
}
export function listThinkingLevels(provider?: string | null, model?: string | null): ThinkLevel[] {
const levels = listThinkingLevelsFallback(provider, model);
if (supportsXHighThinking(provider, model)) {
levels.push("xhigh");
}
if (supportsAdaptiveThinking(provider, model)) {
levels.push("adaptive");
}
if (supportsMaxThinking(provider, model)) {
levels.push("max");
}
return levels;
const profile = resolveThinkingProfile({ provider, model });
return profile.levels.map((level) => level.id);
}
export function listThinkingLevelLabels(provider?: string | null, model?: string | null): string[] {
if (isBinaryThinkingProvider(provider, model)) {
return ["off", "on"];
}
return listThinkingLevels(provider, model);
const profile = resolveThinkingProfile({ provider, model });
return profile.levels.map((level) => level.label);
}
export function formatThinkingLevels(
@@ -156,20 +222,13 @@ export function resolveThinkingDefaultForModel(params: {
model: string;
catalog?: ThinkingCatalogEntry[];
}): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider);
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
const pluginDecision = resolveProviderDefaultThinkingLevel({
provider: normalizedProvider,
context: {
provider: normalizedProvider,
modelId: params.model,
reasoning: candidate?.reasoning,
},
const profile = resolveThinkingProfile({
provider: params.provider,
model: params.model,
catalog: params.catalog,
});
if (pluginDecision) {
return pluginDecision;
if (profile.defaultLevel) {
return profile.defaultLevel;
}
return resolveThinkingDefaultForModelFallback(params);
}
@@ -178,19 +237,19 @@ export function resolveLargestSupportedThinkingLevel(
provider?: string | null,
model?: string | null,
): ThinkLevel {
if (isBinaryThinkingProvider(provider, model)) {
return "low";
}
if (supportsMaxThinking(provider, model)) {
return "max";
}
if (supportsXHighThinking(provider, model)) {
return "xhigh";
}
if (supportsAdaptiveThinking(provider, model)) {
return "adaptive";
}
return "high";
const profile = resolveThinkingProfile({ provider, model });
return (
profile.levels.filter((level) => level.id !== "off").toSorted((a, b) => b.rank - a.rank)[0]
?.id ?? "off"
);
}
export function isThinkingLevelSupported(params: {
provider?: string | null;
model?: string | null;
level: ThinkLevel;
}): boolean {
return supportsThinkingLevel(params.provider, params.model, params.level);
}
export function resolveSupportedThinkingLevel(params: {
@@ -198,10 +257,8 @@ export function resolveSupportedThinkingLevel(params: {
model?: string | null;
level: ThinkLevel;
}): ThinkLevel {
if (params.level !== "max") {
if (isThinkingLevelSupported(params)) {
return params.level;
}
return supportsMaxThinking(params.provider, params.model)
? "max"
: resolveLargestSupportedThinkingLevel(params.provider, params.model);
return resolveLargestSupportedThinkingLevel(params.provider, params.model);
}

View File

@@ -28,7 +28,10 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high | xhigh")
.option(
"--thinking <level>",
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",
)
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",

View File

@@ -13,9 +13,9 @@ export { resolveAgentTimeoutMs } from "../../agents/timeout.js";
export { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js";
export { DEFAULT_IDENTITY_FILENAME, ensureAgentWorkspace } from "../../agents/workspace.js";
export {
isThinkingLevelSupported,
normalizeThinkLevel,
resolveSupportedThinkingLevel,
supportsXHighThinking,
} from "../../auto-reply/thinking.js";
export { resolveSessionTranscriptPath } from "../../config/sessions/paths.js";
export { setSessionRuntimeModel } from "../../config/sessions/types.js";

View File

@@ -78,6 +78,7 @@ const hasNonzeroUsageMock = createMock();
const ensureAgentWorkspaceMock = createMock();
const normalizeThinkLevelMock = createMock();
const normalizeVerboseLevelMock = createMock();
const isThinkingLevelSupportedMock = createMock();
const resolveSupportedThinkingLevelMock = createMock();
const supportsXHighThinkingMock = createMock();
const resolveSessionTranscriptPathMock = createMock();
@@ -110,6 +111,7 @@ vi.mock("./run.runtime.js", () => ({
DEFAULT_IDENTITY_FILENAME: "IDENTITY.md",
ensureAgentWorkspace: ensureAgentWorkspaceMock,
normalizeThinkLevel: normalizeThinkLevelMock,
isThinkingLevelSupported: isThinkingLevelSupportedMock,
resolveSupportedThinkingLevel: resolveSupportedThinkingLevelMock,
supportsXHighThinking: supportsXHighThinkingMock,
resolveSessionTranscriptPath: resolveSessionTranscriptPathMock,
@@ -308,6 +310,7 @@ function resetRunConfigMocks(): void {
hasNonzeroUsageMock.mockReturnValue(true);
ensureAgentWorkspaceMock.mockResolvedValue({ dir: "/tmp/workspace" });
normalizeThinkLevelMock.mockImplementation((value: unknown) => value);
isThinkingLevelSupportedMock.mockReturnValue(true);
resolveSupportedThinkingLevelMock.mockImplementation(({ level }: { level?: unknown }) => level);
supportsXHighThinkingMock.mockReturnValue(false);
buildSafeExternalPromptMock.mockImplementation(

View File

@@ -46,11 +46,11 @@ import {
resolveCronStyleNow,
resolveDefaultAgentId,
resolveHookExternalContentSource,
isThinkingLevelSupported,
resolveSupportedThinkingLevel,
resolveSessionTranscriptPath,
resolveThinkingDefault,
setSessionRuntimeModel,
supportsXHighThinking,
} from "./run.runtime.js";
import type { RunCronAgentTurnResult } from "./run.types.js";
import { resolveCronAgentSessionKey } from "./session-key.js";
@@ -500,13 +500,7 @@ async function prepareCronRunContext(params: {
catalog: await loadCatalog(),
});
}
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
logWarn(
`[cron:${input.job.id}] Thinking level "xhigh" is not supported for ${provider}/${model}; downgrading to "high".`,
);
thinkLevel = "high";
}
if (thinkLevel === "max") {
if (!isThinkingLevelSupported({ provider, model, level: thinkLevel })) {
const fallbackThinkLevel = resolveSupportedThinkingLevel({
provider,
model,
@@ -514,7 +508,7 @@ async function prepareCronRunContext(params: {
});
if (fallbackThinkLevel !== thinkLevel) {
logWarn(
`[cron:${input.job.id}] Thinking level "max" is not supported for ${provider}/${model}; downgrading to "${fallbackThinkLevel}".`,
`[cron:${input.job.id}] Thinking level "${thinkLevel}" is not supported for ${provider}/${model}; downgrading to "${fallbackThinkLevel}".`,
);
thinkLevel = fallbackThinkLevel;
}

View File

@@ -25,6 +25,7 @@ import {
listSubagentRunsForController,
resolveSubagentSessionStatus,
} from "../agents/subagent-registry-read.js";
import { listThinkingLevelLabels, resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import { resolveStateDir } from "../config/paths.js";
@@ -1372,6 +1373,11 @@ export function buildGatewaySessionRow(params: {
}
}
const rowModelProvider = selectedModel?.provider ?? modelProvider;
const rowModel = selectedModel?.model ?? model;
const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER;
const thinkingModel = rowModel ?? DEFAULT_MODEL;
return {
key,
spawnedBy: subagentOwner || entry?.spawnedBy,
@@ -1396,6 +1402,11 @@ export function buildGatewaySessionRow(params: {
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
thinkingOptions: listThinkingLevelLabels(thinkingProvider, thinkingModel),
thinkingDefault: resolveThinkingDefaultForModel({
provider: thinkingProvider,
model: thinkingModel,
}),
fastMode: entry?.fastMode,
verboseLevel: entry?.verboseLevel,
traceLevel: entry?.traceLevel,
@@ -1414,8 +1425,8 @@ export function buildGatewaySessionRow(params: {
parentSessionKey: subagentOwner || entry?.parentSessionKey,
childSessions,
responseUsage: entry?.responseUsage,
modelProvider: selectedModel?.provider ?? modelProvider,
model: selectedModel?.model ?? model,
modelProvider: rowModelProvider,
model: rowModel,
contextTokens,
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,

View File

@@ -39,6 +39,8 @@ export type GatewaySessionRow = {
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
thinkingOptions?: string[];
thinkingDefault?: string;
fastMode?: boolean;
verboseLevel?: string;
traceLevel?: string;

View File

@@ -9,14 +9,13 @@ import {
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatThinkingLevels,
formatXHighModelHint,
isThinkingLevelSupported,
normalizeElevatedLevel,
normalizeFastMode,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeUsageDisplay,
resolveSupportedThinkingLevel,
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -435,25 +434,31 @@ export async function applySessionsPatchToStore(params: {
}
}
if (next.thinkingLevel === "xhigh") {
if (next.thinkingLevel) {
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
const thinkingLevel = normalizeThinkLevel(next.thinkingLevel);
if (!thinkingLevel) {
delete next.thinkingLevel;
} else if (
!isThinkingLevelSupported({
provider: effectiveProvider,
model: effectiveModel,
level: thinkingLevel,
})
) {
if ("thinkingLevel" in patch) {
return invalid(`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`);
return invalid(
`thinkingLevel "${thinkingLevel}" is not supported for ${effectiveProvider}/${effectiveModel} (use ${formatThinkingLevels(effectiveProvider, effectiveModel, "|")})`,
);
}
next.thinkingLevel = "high";
next.thinkingLevel = resolveSupportedThinkingLevel({
provider: effectiveProvider,
model: effectiveModel,
level: thinkingLevel,
});
}
}
if (next.thinkingLevel === "max") {
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
next.thinkingLevel = resolveSupportedThinkingLevel({
provider: effectiveProvider,
model: effectiveModel,
level: next.thinkingLevel,
});
}
if ("sendPolicy" in patch) {
const raw = patch.sendPolicy;

View File

@@ -81,6 +81,7 @@ export type {
ProviderTransportTurnState,
ProviderToolSchemaDiagnostic,
ProviderResolveUsageAuthContext,
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,

View File

@@ -6,6 +6,7 @@ export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export {
formatThinkingLevels,
formatXHighModelHint,
isThinkingLevelSupported,
normalizeThinkLevel,
resolveSupportedThinkingLevel,
supportsXHighThinking,

View File

@@ -63,6 +63,7 @@ import type {
ProviderTransportTurnState,
ProviderToolSchemaDiagnostic,
ProviderResolveUsageAuthContext,
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,
@@ -119,6 +120,7 @@ export type {
ProviderPrepareRuntimeAuthContext,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
ProviderThinkingProfile,
ProviderResolveDynamicModelContext,
ProviderResolveTransportTurnStateContext,
ProviderResolveWebSocketSessionPolicyContext,

View File

@@ -19,6 +19,7 @@ import {
} from "./provider-hook-runtime.js";
import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js";
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
import type { ProviderThinkingProfile } from "./provider-thinking.types.js";
import { resolveCatalogHookProviderPluginIds } from "./providers.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js";
@@ -640,24 +641,14 @@ export function resolveProviderXHighThinking(params: {
return resolveProviderRuntimePlugin(params)?.supportsXHighThinking?.(params.context);
}
export function resolveProviderAdaptiveThinking(params: {
export function resolveProviderThinkingProfile(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderThinkingPolicyContext;
}) {
return resolveProviderRuntimePlugin(params)?.supportsAdaptiveThinking?.(params.context);
}
export function resolveProviderMaxThinking(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderThinkingPolicyContext;
}) {
return resolveProviderRuntimePlugin(params)?.supportsMaxThinking?.(params.context);
context: ProviderDefaultThinkingPolicyContext;
}): ProviderThinkingProfile | null | undefined {
return resolveProviderRuntimePlugin(params)?.resolveThinkingProfile?.(params.context);
}
export function resolveProviderDefaultThinkingLevel(params: {

View File

@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type {
ProviderDefaultThinkingPolicyContext,
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
} from "./provider-thinking.types.js";
@@ -8,22 +9,13 @@ type ThinkingProviderPlugin = {
id: string;
aliases?: string[];
isBinaryThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
supportsAdaptiveThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
supportsMaxThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
supportsXHighThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
resolveThinkingProfile?: (
ctx: ProviderDefaultThinkingPolicyContext,
) => ProviderThinkingProfile | null | undefined;
resolveDefaultThinkingLevel?: (
ctx: ProviderDefaultThinkingPolicyContext,
) =>
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max"
| null
| undefined;
) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined;
};
const PLUGIN_REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
@@ -73,16 +65,10 @@ export function resolveProviderXHighThinking(
return resolveActiveThinkingProvider(params.provider)?.supportsXHighThinking?.(params.context);
}
export function resolveProviderAdaptiveThinking(
params: ThinkingHookParams<ProviderThinkingPolicyContext>,
export function resolveProviderThinkingProfile(
params: ThinkingHookParams<ProviderDefaultThinkingPolicyContext>,
) {
return resolveActiveThinkingProvider(params.provider)?.supportsAdaptiveThinking?.(params.context);
}
export function resolveProviderMaxThinking(
params: ThinkingHookParams<ProviderThinkingPolicyContext>,
) {
return resolveActiveThinkingProvider(params.provider)?.supportsMaxThinking?.(params.context);
return resolveActiveThinkingProvider(params.provider)?.resolveThinkingProfile?.(params.context);
}
export function resolveProviderDefaultThinkingLevel(

View File

@@ -20,3 +20,33 @@ export type ProviderThinkingPolicyContext = {
export type ProviderDefaultThinkingPolicyContext = ProviderThinkingPolicyContext & {
reasoning?: boolean;
};
export type ProviderThinkingLevelId =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max";
export type ProviderThinkingLevel = {
id: ProviderThinkingLevelId;
/**
* Optional display label. Use this when the stored value differs from the
* provider-facing UX, for example binary providers storing `low` but showing
* `on`.
*/
label?: string;
/**
* Relative strength used when downgrading a stored level that the selected
* model no longer supports.
*/
rank?: number;
};
export type ProviderThinkingProfile = {
levels: ProviderThinkingLevel[] | ReadonlyArray<ProviderThinkingLevel>;
defaultLevel?: ProviderThinkingLevelId | null;
};

View File

@@ -109,6 +109,7 @@ import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
import type {
ProviderDefaultThinkingPolicyContext,
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
} from "./provider-thinking.types.js";
import type { PluginRuntime } from "./runtime/types.js";
@@ -902,6 +903,7 @@ export type ProviderBuiltInModelSuppressionResult = {
export type {
ProviderDefaultThinkingPolicyContext,
ProviderThinkingProfile,
ProviderThinkingPolicyContext,
} from "./provider-thinking.types.js";
@@ -1399,45 +1401,40 @@ export type ProviderPlugin = {
*
* Return true when the provider exposes a coarse on/off reasoning control
* instead of the normal multi-level ladder shown by `/think`.
*
* @deprecated Prefer `resolveThinkingProfile`.
*/
isBinaryThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
/**
* Provider-owned xhigh reasoning support.
*
* Return true only for models that should expose the `xhigh` thinking level.
*
* @deprecated Prefer `resolveThinkingProfile`.
*/
supportsXHighThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
/**
* Provider-owned adaptive thinking support.
* Provider-owned thinking level profile.
*
* Return true only for models that should expose the `adaptive` thinking level.
* Prefer this over the individual thinking capability hooks when a provider
* or model exposes a custom set of thinking levels. OpenClaw stores the
* canonical `id`, shows `label` when provided, and downgrades stale stored
* values by profile rank.
*/
supportsAdaptiveThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
/**
* Provider-owned max thinking support.
*
* Return true only for models that should expose the `max` thinking level.
*/
supportsMaxThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined;
resolveThinkingProfile?: (
ctx: ProviderDefaultThinkingPolicyContext,
) => ProviderThinkingProfile | null | undefined;
/**
* Provider-owned default thinking level.
*
* Use this to keep model-family defaults (for example Claude 4.6 =>
* adaptive) out of core command logic.
*
* @deprecated Prefer `resolveThinkingProfile`.
*/
resolveDefaultThinkingLevel?: (
ctx: ProviderDefaultThinkingPolicyContext,
) =>
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "adaptive"
| "max"
| null
| undefined;
) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined;
/**
* Provider-owned system-prompt contribution.
*