fix(text): omit enable_thinking by default and retry when API requires false

Non-streaming chat no longer forces enable_thinking=false, which breaks
thinking-only models. Retry once with false only when the server demands it.
This commit is contained in:
clh02467605
2026-07-27 14:57:50 +08:00
parent dac254af86
commit 36ebd63716
8 changed files with 290 additions and 38 deletions
@@ -4,6 +4,9 @@ import {
chatPath,
requestJson,
normalizeModelBaseUrl,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type AuthPersistPatch,
type AuthStore,
type Identity,
@@ -58,32 +61,48 @@ export async function validateAndPersistApiKey(
? normalizeModelBaseUrl(profile.persistBaseUrl)
: undefined;
const validationModel = profile.defaultTextModel || "qwen3.7-max";
const body: {
model: string;
messages: Array<{ role: string; content: string }>;
max_tokens: number;
stream: boolean;
enable_thinking?: boolean;
} = {
model: validationModel,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
};
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.timeout, 30),
body: {
model: validationModel,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
enable_thinking: validationModel === "qwen3.8-max-preview",
},
body,
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
break;
} catch (error) {
if (attempt >= 3 || !canRetry(error)) {
process.stderr.write("Failed\n");
throw error;
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
try {
await withEnableThinkingRetry({
// Validation requests are always non-streaming.
initial: resolveChatEnableThinking({ stream: false }),
apply: (value) => applyChatEnableThinking(body, value),
run: async () => {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
return;
} catch (error) {
if (attempt >= 3 || !canRetry(error)) throw error;
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
},
});
} catch (error) {
process.stderr.write("Failed\n");
throw error;
}
process.stderr.write("Valid\n");
+21 -15
View File
@@ -4,6 +4,9 @@ import {
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type ChatMessage,
type ChatRequest,
type ChatResponse,
@@ -124,7 +127,8 @@ export default defineCommand({
const { system, messages } = parseMessages(flags);
const model = flags.model || settings.defaultTextModel || "qwen3.7-max";
const shouldStream = flags.stream || process.stdout.isTTY;
// Coerce isTTY (may be undefined) so stream:false is serialized.
const shouldStream = Boolean(flags.stream || process.stdout.isTTY);
const format = detectOutputFormat(settings.output);
// Build messages array with system prompt
@@ -144,16 +148,13 @@ export default defineCommand({
if (flags.temperature !== undefined) body.temperature = flags.temperature;
if (flags.topP !== undefined) body.top_p = flags.topP;
if (flags.enableThinking) {
body.enable_thinking = true;
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
} else if (!shouldStream) {
// DashScope qwen3 models default to enable_thinking=true server-side, but
// non-streaming calls require it to be explicitly false. Stream calls
// support thinking, so leave the field unset there (server handles it).
body.enable_thinking = false;
const enableThinking = resolveChatEnableThinking({
enableThinking: flags.enableThinking,
stream: shouldStream,
});
applyChatEnableThinking(body, enableThinking);
if (enableThinking === true && flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
if (flags.tool) {
@@ -229,10 +230,15 @@ export default defineCommand({
resultOut.write("\n");
}
} else {
const response = await ctx.client.requestJson<ChatResponse>({
path: chatPath(),
method: "POST",
body,
const response = await withEnableThinkingRetry({
initial: enableThinking,
apply: (value) => applyChatEnableThinking(body, value),
run: () =>
ctx.client.requestJson<ChatResponse>({
path: chatPath(),
method: "POST",
body,
}),
});
const text = response.choices?.[0]?.message?.content ?? "";
+1 -1
View File
@@ -317,7 +317,7 @@ describe("e2e: auth", () => {
body: {
model: "qwen3.8-max-preview",
stream: false,
enable_thinking: true,
enable_thinking: false,
},
});
@@ -34,7 +34,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
"--model",
"qwen3.7-max",
"--message",
"干跑",
"dry-run",
"--max-tokens",
"8",
"--output",
@@ -42,10 +42,17 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { model?: string; messages?: Array<{ content?: string }> };
request?: {
model?: string;
messages?: Array<{ content?: string }>;
enable_thinking?: boolean;
stream?: boolean;
};
}>(stdout);
expect(data.request?.model).toBe("qwen3.7-max");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
expect(data.request?.messages?.some((message) => message.content === "dry-run")).toBe(true);
expect(data.request?.stream).toBe(false);
expect(data.request?.enable_thinking).toBe(false);
});
test("【qwen3.7-max】文本对话", async () => {
+1
View File
@@ -14,5 +14,6 @@ export * from "./finetune/index.ts";
export * from "./deploy/index.ts";
export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./models/index.ts";
export * from "./telemetry/index.ts";
export * from "./advisor/index.ts";
+7
View File
@@ -0,0 +1,7 @@
export {
adjustEnableThinkingAfterError,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type EnableThinkingAdjustResult,
} from "./thinking.ts";
+76
View File
@@ -0,0 +1,76 @@
/** resolve / adjust / retry helpers for chat `enable_thinking`. */
/** Resolve the initial `enable_thinking` value (`undefined` omits the field). */
export function resolveChatEnableThinking(options: {
enableThinking?: boolean;
/** Whether the request is streaming. */
stream?: boolean;
}): boolean | undefined {
if (options.enableThinking) return true;
if (options.stream === false) return false;
return undefined;
}
export type EnableThinkingAdjustResult =
| { kind: "retry"; value: boolean | undefined }
| { kind: "none" };
/** Map clear `enable_thinking` constraint errors to a one-shot retry adjustment. */
export function adjustEnableThinkingAfterError(
current: boolean | undefined,
errorMessage: string,
): EnableThinkingAdjustResult {
if (current !== true && /enable_thinking parameter is restricted to\s*true/i.test(errorMessage)) {
return { kind: "retry", value: true };
}
if (current === undefined && /enable_thinking must be set to false/i.test(errorMessage)) {
return { kind: "retry", value: false };
}
if (current !== undefined && /does not support enable_thinking/i.test(errorMessage)) {
return { kind: "retry", value: undefined };
}
return { kind: "none" };
}
/** Set or remove `enable_thinking`; clear `thinking_budget` when disabled or omitted. */
export function applyChatEnableThinking(
body: { enable_thinking?: boolean; thinking_budget?: number },
value: boolean | undefined,
): void {
if (value === undefined) {
delete body.enable_thinking;
delete body.thinking_budget;
return;
}
if (value === false) {
body.enable_thinking = false;
delete body.thinking_budget;
return;
}
body.enable_thinking = true;
}
function errorMessageOf(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
/** Run once, then retry once if the error indicates an `enable_thinking` constraint. */
export async function withEnableThinkingRetry<T>(options: {
initial: boolean | undefined;
apply: (value: boolean | undefined) => void;
run: () => Promise<T>;
}): Promise<T> {
options.apply(options.initial);
try {
return await options.run();
} catch (error) {
const adjusted = adjustEnableThinkingAfterError(options.initial, errorMessageOf(error));
if (adjusted.kind === "none") throw error;
options.apply(adjusted.value);
return await options.run();
}
}
+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from "vite-plus/test";
import {
adjustEnableThinkingAfterError,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
} from "../src/models/thinking.ts";
test("resolveChatEnableThinking:显式开启为 true,非流式默认 false,流式默认 omit", () => {
expect(resolveChatEnableThinking({ enableThinking: true })).toBe(true);
expect(resolveChatEnableThinking({ enableThinking: true, stream: false })).toBe(true);
expect(resolveChatEnableThinking({ stream: false })).toBe(false);
expect(resolveChatEnableThinking({ enableThinking: false, stream: false })).toBe(false);
expect(resolveChatEnableThinking({ stream: true })).toBeUndefined();
expect(resolveChatEnableThinking({})).toBeUndefined();
});
test("adjustEnableThinkingAfterError:false/omit 被要求 true 时重试为 true", () => {
expect(
adjustEnableThinkingAfterError(
false,
"The value of the enable_thinking parameter is restricted to True.",
),
).toEqual({ kind: "retry", value: true });
expect(
adjustEnableThinkingAfterError(
undefined,
"The value of the enable_thinking parameter is restricted to True.",
),
).toEqual({ kind: "retry", value: true });
});
test("adjustEnableThinkingAfterError:omit 被要求 false 时重试为 false", () => {
expect(
adjustEnableThinkingAfterError(
undefined,
"parameter.enable_thinking must be set to false for non-streaming calls",
),
).toEqual({ kind: "retry", value: false });
});
test("adjustEnableThinkingAfterError:不支持时去掉字段", () => {
expect(
adjustEnableThinkingAfterError(false, "The model qwen-turbo does not support enable_thinking."),
).toEqual({ kind: "retry", value: undefined });
expect(
adjustEnableThinkingAfterError(true, "The model qwen-turbo does not support enable_thinking."),
).toEqual({ kind: "retry", value: undefined });
});
test("adjustEnableThinkingAfterError:无关错误不调整", () => {
expect(adjustEnableThinkingAfterError(undefined, "Access denied")).toEqual({ kind: "none" });
expect(adjustEnableThinkingAfterError(false, "Model not exist")).toEqual({ kind: "none" });
expect(
adjustEnableThinkingAfterError(
true,
"The value of the enable_thinking parameter is restricted to True.",
),
).toEqual({ kind: "none" });
});
test("applyChatEnableThinking:设置 / 删除字段,并在关闭时清 thinking_budget", () => {
const body: { enable_thinking?: boolean; thinking_budget?: number } = {
thinking_budget: 1024,
};
applyChatEnableThinking(body, true);
expect(body.enable_thinking).toBe(true);
expect(body.thinking_budget).toBe(1024);
applyChatEnableThinking(body, false);
expect(body.enable_thinking).toBe(false);
expect(body).not.toHaveProperty("thinking_budget");
body.thinking_budget = 2048;
applyChatEnableThinking(body, undefined);
expect(body).not.toHaveProperty("enable_thinking");
expect(body).not.toHaveProperty("thinking_budget");
});
test("withEnableThinkingRetry:restricted-to-true 时从 false 重试为 true", async () => {
const values: Array<boolean | undefined> = [];
let calls = 0;
const result = await withEnableThinkingRetry({
initial: false,
apply: (value) => {
values.push(value);
},
run: async () => {
calls += 1;
if (calls === 1) {
throw new Error("The value of the enable_thinking parameter is restricted to True.");
}
return "ok";
},
});
expect(result).toBe("ok");
expect(calls).toBe(2);
expect(values).toEqual([false, true]);
});
test("withEnableThinkingRetry:must-be-false 时从 omit 重试为 false", async () => {
const values: Array<boolean | undefined> = [];
let calls = 0;
const result = await withEnableThinkingRetry({
initial: undefined,
apply: (value) => {
values.push(value);
},
run: async () => {
calls += 1;
if (calls === 1) {
throw new Error("parameter.enable_thinking must be set to false for non-streaming calls");
}
return "ok";
},
});
expect(result).toBe("ok");
expect(calls).toBe(2);
expect(values).toEqual([undefined, false]);
});
test("withEnableThinkingRetry:无关错误原样抛出", async () => {
await expect(
withEnableThinkingRetry({
initial: false,
apply: () => {},
run: async () => {
throw new Error("Access denied");
},
}),
).rejects.toThrow(/Access denied/);
});