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

@@ -139,8 +139,7 @@ function resolveThinkingTargetModel(state: AppViewState): {
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
labels: readonly string[],
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
@@ -160,9 +159,9 @@ function buildThinkingOptions(
);
};
for (const label of listThinkingLevelLabels(provider, model)) {
for (const label of labels) {
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
addOption(normalized);
addOption(normalized, label);
}
if (currentOverride) {
addOption(currentOverride);
@@ -178,18 +177,22 @@ function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelect
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const labels =
activeRow?.thinkingOptions ??
(provider && model ? listThinkingLevelLabels(provider, model) : listThinkingLevelLabels());
const defaultLevel =
provider && model
activeRow?.thinkingDefault ??
(provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
: "off");
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
options: buildThinkingOptions(labels, currentOverride),
};
}

View File

@@ -527,7 +527,21 @@ describe("executeSlashCommand directives", () => {
});
it("accepts minimal and xhigh thinking levels", async () => {
const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true });
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
thinkingOptions: ["off", "minimal", "low", "medium", "high", "xhigh"],
}),
],
};
}
if (method === "sessions.patch") {
return { ok: true, ...((payload ?? {}) as object) };
}
throw new Error(`unexpected method: ${method}`);
});
const minimal = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
@@ -544,11 +558,13 @@ describe("executeSlashCommand directives", () => {
expect(minimal.content).toBe("Thinking level set to **minimal**.");
expect(xhigh.content).toBe("Thinking level set to **xhigh**.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", {
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "minimal",
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
expect(request).toHaveBeenNthCalledWith(3, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(4, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "xhigh",
});

View File

@@ -258,7 +258,7 @@ async function executeThink(
return {
content: formatDirectiveOptions(
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
formatThinkingLevels(session?.modelProvider, session?.model),
formatThinkingOptionsForSession(session),
),
};
} catch (err) {
@@ -271,7 +271,7 @@ async function executeThink(
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingOptionsForSession(session)}.`,
};
} catch (err) {
return { content: `Failed to validate thinking level: ${String(err)}` };
@@ -279,6 +279,12 @@ async function executeThink(
}
try {
const session = await loadCurrentSession(client, sessionKey);
if (!isThinkingLevelOptionForSession(session, level)) {
return {
content: `Unsupported thinking level "${rawLevel}" for this model. Valid levels: ${formatThinkingOptionsForSession(session)}.`,
};
}
await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level });
return {
content: `Thinking level set to **${level}**.`,
@@ -594,6 +600,26 @@ function formatDirectiveOptions(text: string, options: string): string {
return `${text}\nOptions: ${options}.`;
}
function formatThinkingOptionsForSession(
session: GatewaySessionRow | undefined,
separator = ", ",
): string {
if (session?.thinkingOptions?.length) {
return session.thinkingOptions.join(separator);
}
return formatThinkingLevels(session?.modelProvider, session?.model);
}
function isThinkingLevelOptionForSession(
session: GatewaySessionRow | undefined,
level: string,
): boolean {
const labels = session?.thinkingOptions?.length
? session.thinkingOptions
: formatThinkingOptionsForSession(session).split(/\s*,\s*/);
return labels.some((label) => normalizeThinkLevel(label) === level);
}
async function loadCurrentSession(
client: GatewayBrowserClient,
sessionKey: string,
@@ -651,7 +677,13 @@ function resolveCurrentThinkingLevel(
): string {
const persisted = normalizeThinkLevel(session?.thinkingLevel);
if (persisted) {
return persisted;
return (
session?.thinkingOptions?.find((label) => normalizeThinkLevel(label) === persisted) ??
persisted
);
}
if (session?.thinkingDefault) {
return session.thinkingDefault;
}
if (!session?.modelProvider || !session.model) {
return "off";

View File

@@ -7,12 +7,6 @@ export type ThinkingCatalogEntry = {
};
const BASE_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
const BINARY_THINKING_LEVELS = ["off", "on"] as const;
const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
const ANTHROPIC_OPUS_47_MODEL_RE = /^claude-opus-4(?:\.|-)7(?:$|[-.])/i;
const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
const OPENAI_XHIGH_MODEL_RE =
/^(?:gpt-5\.[2-9](?:\.\d+)?|gpt-5\.[2-9](?:\.\d+)?-pro|gpt-5\.\d+-codex|gpt-5\.\d+-codex-spark|gpt-5\.1-codex|gpt-5\.2-codex)(?:$|-)/i;
export function normalizeThinkingProviderId(provider?: string | null): string {
if (!provider) {
@@ -29,7 +23,8 @@ export function normalizeThinkingProviderId(provider?: string | null): string {
}
export function isBinaryThinkingProvider(provider?: string | null): boolean {
return normalizeThinkingProviderId(provider) === "zai";
void provider;
return false;
}
export function normalizeThinkLevel(raw?: string | null): string | undefined {
@@ -71,49 +66,13 @@ export function normalizeThinkLevel(raw?: string | null): string | undefined {
return undefined;
}
function supportsAdaptiveThinking(provider?: string | null, model?: string | null): boolean {
const normalizedProvider = normalizeThinkingProviderId(provider);
const modelId = model?.trim() ?? "";
if (normalizedProvider === "anthropic") {
return ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId) || ANTHROPIC_OPUS_47_MODEL_RE.test(modelId);
}
if (normalizedProvider === "amazon-bedrock") {
return AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId);
}
return false;
}
function supportsXHighThinking(provider?: string | null, model?: string | null): boolean {
const normalizedProvider = normalizeThinkingProviderId(provider);
const modelId = model?.trim() ?? "";
if (normalizedProvider === "anthropic") {
return ANTHROPIC_OPUS_47_MODEL_RE.test(modelId);
}
if (["openai", "openai-codex", "github-copilot", "codex"].includes(normalizedProvider)) {
return OPENAI_XHIGH_MODEL_RE.test(modelId);
}
return false;
}
function supportsMaxThinking(provider?: string | null, model?: string | null): boolean {
return normalizeThinkingProviderId(provider) === "anthropic"
? ANTHROPIC_OPUS_47_MODEL_RE.test(model?.trim() ?? "")
: false;
}
export function listThinkingLevelLabels(
provider?: string | null,
model?: string | null,
): readonly string[] {
if (isBinaryThinkingProvider(provider)) {
return BINARY_THINKING_LEVELS;
}
return [
...BASE_THINKING_LEVELS,
...(supportsXHighThinking(provider, model) ? ["xhigh"] : []),
...(supportsAdaptiveThinking(provider, model) ? ["adaptive"] : []),
...(supportsMaxThinking(provider, model) ? ["max"] : []),
];
void provider;
void model;
return BASE_THINKING_LEVELS;
}
export function formatThinkingLevels(provider?: string | null, model?: string | null): string {
@@ -125,14 +84,6 @@ export function resolveThinkingDefaultForModel(params: {
model: string;
catalog?: ThinkingCatalogEntry[];
}): string {
const normalizedProvider = normalizeThinkingProviderId(params.provider);
const modelId = params.model.trim();
if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) {
return "adaptive";
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);

View File

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

View File

@@ -63,8 +63,7 @@ export type SessionsProps = {
onRestoreCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise<void>;
};
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const;
const BINARY_THINK_LEVELS = ["", "off", "on"] as const;
const DEFAULT_THINK_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
const VERBOSE_LEVELS = [
{ value: "", label: "inherit" },
{ value: "off", label: "off (explicit)" },
@@ -79,23 +78,13 @@ const FAST_LEVELS = [
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function normalizeProviderId(provider?: string | null): string {
if (!provider) {
return "";
}
const normalized = normalizeLowercaseStringOrEmpty(provider);
if (normalized === "z.ai" || normalized === "z-ai") {
return "zai";
}
return normalized;
function resolveThinkLevelOptions(row: GatewaySessionRow): readonly string[] {
const options = row.thinkingOptions?.length ? row.thinkingOptions : DEFAULT_THINK_LEVELS;
return ["", ...options];
}
function isBinaryThinkingProvider(provider?: string | null): boolean {
return normalizeProviderId(provider) === "zai";
}
function resolveThinkLevelOptions(provider?: string | null): readonly string[] {
return isBinaryThinkingProvider(provider) ? BINARY_THINK_LEVELS : THINK_LEVELS;
function isBinaryThinkingRow(row: GatewaySessionRow): boolean {
return row.thinkingOptions?.includes("on") === true;
}
function withCurrentOption(options: readonly string[], current: string): string[] {
@@ -453,9 +442,9 @@ export function renderSessions(props: SessionsProps) {
function renderRows(row: GatewaySessionRow, props: SessionsProps) {
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na");
const rawThinking = row.thinkingLevel ?? "";
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
const isBinaryThinking = isBinaryThinkingRow(row);
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);
const thinkLevels = withCurrentOption(resolveThinkLevelOptions(row.modelProvider), thinking);
const thinkLevels = withCurrentOption(resolveThinkLevelOptions(row), thinking);
const fastMode = row.fastMode === true ? "on" : row.fastMode === false ? "off" : "";
const fastLevels = withCurrentLabeledOption(FAST_LEVELS, fastMode);
const verbose = row.verboseLevel ?? "";