From 179f9ea7e460e9eb7af49c5cbbaab6410feb42c2 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 24 Jul 2026 10:56:48 +0800 Subject: [PATCH 1/3] feat: add MCP WebSearch page URL and enhance error handling in web search command --- docs/agents/url-change.md | 1 + .../src/commands/search/web-activate-hint.ts | 34 ++++++++ packages/commands/src/commands/search/web.ts | 19 +++-- .../tests/search-web-activate-hint.test.ts | 81 +++++++++++++++++++ packages/runtime/src/index.ts | 1 + packages/runtime/src/urls.ts | 6 ++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 packages/commands/src/commands/search/web-activate-hint.ts create mode 100644 packages/commands/tests/search-web-activate-hint.test.ts diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index 0292eecd..515e3a42 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -20,6 +20,7 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only) BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview + MCP_WEBSEARCH_PAGE BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/WebSearch core/files/upload.ts ← 文件上传 endpoint(cn-pinned) UPLOAD_API ${REGIONS.cn}/api/v1/uploads diff --git a/packages/commands/src/commands/search/web-activate-hint.ts b/packages/commands/src/commands/search/web-activate-hint.ts new file mode 100644 index 00000000..6f5d93e9 --- /dev/null +++ b/packages/commands/src/commands/search/web-activate-hint.ts @@ -0,0 +1,34 @@ +import { BailianError } from "bailian-cli-core"; +import { MCP_WEBSEARCH_PAGE } from "bailian-cli-runtime"; + +/** recoginze WebSearch MCP not activated / invalid caused 404 (CLI wrapped message from server)。 */ +export function isWebSearchMcpNotActivated(error: unknown): boolean { + if (!(error instanceof BailianError)) return false; + const message = error.message; + if (!/MCP request failed:\s*404\b/i.test(message)) return false; + return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message); +} + +/** activate hint; URL from runtime/urls.ts。 */ +export function webSearchActivateHint(): string { + return [ + "Activate (or re-activate) the WebSearch MCP in the Bailian MCP marketplace, then retry.", + "If it was previously on SSE, cancel and activate again to upgrade to Streamable HTTP.", + `Open: ${MCP_WEBSEARCH_PAGE}`, + ].join("\n"); +} + +/** + * keep original message / exitCode for not activated errors, add hint only; other errors throw as is. + * do not replace server error message. + */ +export function rethrowWithWebSearchActivateHint(error: unknown): never { + if (isWebSearchMcpNotActivated(error) && error instanceof BailianError && !error.hint) { + throw new BailianError(error.message, error.exitCode, webSearchActivateHint(), { + cause: error, + api: error.api, + rawResponse: error.rawResponse, + }); + } + throw error; +} diff --git a/packages/commands/src/commands/search/web.ts b/packages/commands/src/commands/search/web.ts index 9804527e..b294ea20 100644 --- a/packages/commands/src/commands/search/web.ts +++ b/packages/commands/src/commands/search/web.ts @@ -5,8 +5,8 @@ import { mcpWebSearchPath, type FlagsDef, } from "bailian-cli-core"; -import { createSpinner } from "bailian-cli-runtime"; -import { emitResult } from "bailian-cli-runtime"; +import { createSpinner, emitResult } from "bailian-cli-runtime"; +import { rethrowWithWebSearchActivateHint } from "./web-activate-hint.ts"; const WEB_SEARCH_FLAGS = { query: { type: "string", valueHint: "", description: "Search query text" }, @@ -41,11 +41,14 @@ export default defineCommand({ return; } - const client = ctx.client.mcp(mcpWebSearchPath()); - await client.initialize(); - const tools = await client.listTools(); - - emitResult({ tools }, format); + try { + const client = ctx.client.mcp(mcpWebSearchPath()); + await client.initialize(); + const tools = await client.listTools(); + emitResult({ tools }, format); + } catch (error) { + rethrowWithWebSearchActivateHint(error); + } return; } @@ -123,7 +126,7 @@ export default defineCommand({ } } catch (error) { spinner.stop("Failed."); - throw error; + rethrowWithWebSearchActivateHint(error); } }, }); diff --git a/packages/commands/tests/search-web-activate-hint.test.ts b/packages/commands/tests/search-web-activate-hint.test.ts new file mode 100644 index 00000000..045185cf --- /dev/null +++ b/packages/commands/tests/search-web-activate-hint.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "vite-plus/test"; +import { BailianError, ExitCode } from "bailian-cli-core"; +import { MCP_WEBSEARCH_PAGE } from "bailian-cli-runtime"; +import { + isWebSearchMcpNotActivated, + rethrowWithWebSearchActivateHint, + webSearchActivateHint, +} from "../src/commands/search/web-activate-hint.ts"; + +describe("web-activate-hint", () => { + test("识别 404 + 未开通 / MCP不存在 / MCP_IS_INVALID", () => { + expect( + isWebSearchMcpNotActivated( + new BailianError("MCP request failed: 404 Not Found - MCP不存在或未开通"), + ), + ).toBe(true); + expect( + isWebSearchMcpNotActivated(new BailianError("MCP request failed: 404 - MCP不存在或未开通")), + ).toBe(true); + expect( + isWebSearchMcpNotActivated( + new BailianError("MCP request failed: 404 Not Found - MCP_IS_INVALID"), + ), + ).toBe(true); + }); + + test("裸 404 或非 MCP 错误不加开通判定", () => { + expect(isWebSearchMcpNotActivated(new BailianError("MCP request failed: 404 Not Found"))).toBe( + false, + ); + expect( + isWebSearchMcpNotActivated(new BailianError("MCP request failed: 405 Method Not Allowed")), + ).toBe(false); + expect(isWebSearchMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false); + }); + + test("hint 含 MCP 广场 WebSearch 深链", () => { + expect(webSearchActivateHint()).toContain(MCP_WEBSEARCH_PAGE); + expect(webSearchActivateHint()).toMatch(/Activate|re-activate/i); + }); + + test("rethrow 保留原 message,补 Hint", () => { + const original = new BailianError( + "MCP request failed: 404 Not Found - MCP不存在或未开通", + ExitCode.GENERAL, + ); + try { + rethrowWithWebSearchActivateHint(original); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + const wrapped = error as BailianError; + expect(wrapped.message).toBe(original.message); + expect(wrapped.exitCode).toBe(ExitCode.GENERAL); + expect(wrapped.hint).toContain(MCP_WEBSEARCH_PAGE); + expect(wrapped.cause).toBe(original); + } + }); + + test("已有 hint 或非未开通错误原样抛出", () => { + const withHint = new BailianError( + "MCP request failed: 404 Not Found - MCP不存在或未开通", + ExitCode.GENERAL, + "already hinted", + ); + try { + rethrowWithWebSearchActivateHint(withHint); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBe(withHint); + } + + const other = new BailianError("MCP request failed: 401 Unauthorized"); + try { + rethrowWithWebSearchActivateHint(other); + expect.unreachable("should throw"); + } catch (error) { + expect(error).toBe(other); + } + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4db579c1..7556c7a2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -34,6 +34,7 @@ export { BAILIAN_CONSOLE, API_KEY_PAGE, TOKEN_PLAN_PAGE, + MCP_WEBSEARCH_PAGE, VOICE_TTS_PAGE, } from "./urls.ts"; diff --git a/packages/runtime/src/urls.ts b/packages/runtime/src/urls.ts index 60a3b33a..c230fe0c 100644 --- a/packages/runtime/src/urls.ts +++ b/packages/runtime/src/urls.ts @@ -18,5 +18,11 @@ export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`; /** Direct deep link to the Token Plan subscription overview and API key entry. */ export const TOKEN_PLAN_PAGE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing?tab=plan#/efm/subscription/overview`; +/** + * MCP marketplace detail for the built-in WebSearch server. + * Users must activate (or re-activate for Streamable HTTP) before `search web` works. + */ +export const MCP_WEBSEARCH_PAGE = `${BAILIAN_CONSOLE}?tab=mcp#/mcp-market/detail/WebSearch`; + /** Voice TTS experience center — browse system and custom voices. */ export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list"; From b7daf026df4ee9c3286f66885feef7078652a3f0 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Mon, 27 Jul 2026 11:20:23 +0800 Subject: [PATCH 2/3] fix(image): accept OpenAI-style WxH for --size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize 1024x1024 / 1024×1024 to width*height before the API call, and reject unknown ratios locally with USAGE instead of a server 400. --- packages/commands/src/commands/image/edit.ts | 4 +- .../commands/src/commands/image/generate.ts | 4 +- packages/runtime/src/utils/image-size.ts | 25 ++++++++++-- skills/bailian-cli/reference/image.md | 38 +++++++++---------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index a6996e40..bb57cd1a 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -63,8 +63,8 @@ const EDIT_FLAGS = { }, size: { type: "string", - valueHint: "", - description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048)", + valueHint: "", + description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048 or 1024x1024)", }, n: { type: "number", diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 00bd7f5f..ab0ee720 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -52,8 +52,8 @@ const GENERATE_FLAGS = { }, size: { type: "string", - valueHint: "", - description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048)", + valueHint: "", + description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048 or 1024x1024)", }, n: { type: "number", diff --git a/packages/runtime/src/utils/image-size.ts b/packages/runtime/src/utils/image-size.ts index b986b5ca..178c8586 100644 --- a/packages/runtime/src/utils/image-size.ts +++ b/packages/runtime/src/utils/image-size.ts @@ -1,8 +1,10 @@ +import { UsageError } from "bailian-cli-core"; + /** * Resolve image `size` flag for image generate/edit. * * Users may pass either a ratio (e.g. "1:1", "3:4", "16:9") or a pixel size - * (e.g. "2048*2048"). The DashScope API only accepts the pixel format, so we + * (e.g. "2048*2048" / 1024×1024). The DashScope API only accepts the pixel format, so we * map known ratios to the recommended pixel size for each model family. * * Sync models (qwen-image-2.0 / qwen-image-max / qwen-image-edit-2.0): @@ -29,11 +31,28 @@ export const ASYNC_RATIO_MAP: Record = { "9:16": "928*1664", }; -/** Resolve `--size` value: accept ratio (3:4) or pixel (W*H) format. */ +/** Match pixel sizes: 1024*1024 / 1024x1024 / 1024×1024. */ +const PIXEL_SIZE_PATTERN = /^(\d+)\s*[x×*]\s*(\d+)$/i; + +/** Resolve `--size`: map known ratios, or normalize pixels to W*H. */ export function resolveImageSize(input: string, useSync: boolean): string; export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined; export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined { if (!input) return undefined; + + const trimmed = input.trim(); const map = useSync ? SYNC_RATIO_MAP : ASYNC_RATIO_MAP; - return map[input] ?? input; + + const mappedRatio = map[trimmed]; + if (mappedRatio) return mappedRatio; + + const pixelMatch = trimmed.match(PIXEL_SIZE_PATTERN); + if (pixelMatch) { + return `${pixelMatch[1]}*${pixelMatch[2]}`; + } + + const knownRatios = Object.keys(map).join(", "); + throw new UsageError( + `Invalid --size "${input}". Use pixels (1024*1024 or 1024x1024) or a known ratio (${knownRatios}).`, + ); } diff --git a/skills/bailian-cli/reference/image.md b/skills/bailian-cli/reference/image.md index 84419f8b..9174c1ff 100644 --- a/skills/bailian-cli/reference/image.md +++ b/skills/bailian-cli/reference/image.md @@ -24,24 +24,24 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------- | -------- | ----------------------------------------------------------------------- | -| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | -| `--prompt ` | string | yes | Edit instruction text | -| `--model ` | string | no | Model ID (default: qwen-image-2.0) | -| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | -| `--n ` | number | no | Number of images (default: 1, max: 6) | -| `--seed ` | number | no | Random seed for reproducible results | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--out-dir ` | string | no | Download images to directory | -| `--out-prefix ` | string | no | Filename prefix (default: edited) | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------- | -------- | ------------------------------------------------------------------------ | +| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | +| `--prompt ` | string | yes | Edit instruction text | +| `--model ` | string | no | Model ID (default: qwen-image-2.0) | +| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048 or 1024x1024) | +| `--n ` | number | no | Number of images (default: 1, max: 6) | +| `--seed ` | number | no | Random seed for reproducible results | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--out-dir ` | string | no | Download images to directory | +| `--out-prefix ` | string | no | Filename prefix (default: edited) | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -83,7 +83,7 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach" | --------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `--prompt ` | string | yes | Image description | | `--model ` | string | no | Model ID (default: qwen-image-2.0) | -| `--size ` | string | no | Image size: ratio (3:4, 16:9, 1:1) or pixels (2048\*2048) | +| `--size ` | string | no | Image size: ratio (3:4, 16:9, 1:1) or pixels (2048\*2048 or 1024x1024) | | `--n ` | number | no | Number of images per request (default: 1, max: 6) | | `--seed ` | number | no | Random seed for reproducible generation | | `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | From 031d0a8a7a45be77c8174f19b60a50434d7f5fa2 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Mon, 27 Jul 2026 14:57:50 +0800 Subject: [PATCH 3/3] 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. --- .../src/commands/auth/login-api-key.ts | 57 +++++--- packages/commands/src/commands/text/chat.ts | 36 +++-- packages/commands/tests/e2e/auth.e2e.test.ts | 2 +- .../commands/tests/e2e/text-chat.e2e.test.ts | 13 +- packages/core/src/index.ts | 1 + packages/core/src/models/index.ts | 7 + packages/core/src/models/thinking.ts | 76 ++++++++++ packages/core/tests/thinking.test.ts | 136 ++++++++++++++++++ 8 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/models/index.ts create mode 100644 packages/core/src/models/thinking.ts create mode 100644 packages/core/tests/thinking.test.ts diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 6200f8e6..94084984 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -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(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(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"); diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 0ab05a30..6117fa44 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -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({ - path: chatPath(), - method: "POST", - body, + const response = await withEnableThinkingRetry({ + initial: enableThinking, + apply: (value) => applyChatEnableThinking(body, value), + run: () => + ctx.client.requestJson({ + path: chatPath(), + method: "POST", + body, + }), }); const text = response.choices?.[0]?.message?.content ?? ""; diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 5db70c16..3a04b473 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -317,7 +317,7 @@ describe("e2e: auth", () => { body: { model: "qwen3.8-max-preview", stream: false, - enable_thinking: true, + enable_thinking: false, }, }); diff --git a/packages/commands/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts index a5f4d5e3..cbd70648 100644 --- a/packages/commands/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -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 () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e49ee6c4..10d0911b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts new file mode 100644 index 00000000..4af5e3d4 --- /dev/null +++ b/packages/core/src/models/index.ts @@ -0,0 +1,7 @@ +export { + adjustEnableThinkingAfterError, + applyChatEnableThinking, + resolveChatEnableThinking, + withEnableThinkingRetry, + type EnableThinkingAdjustResult, +} from "./thinking.ts"; diff --git a/packages/core/src/models/thinking.ts b/packages/core/src/models/thinking.ts new file mode 100644 index 00000000..a5d25b11 --- /dev/null +++ b/packages/core/src/models/thinking.ts @@ -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(options: { + initial: boolean | undefined; + apply: (value: boolean | undefined) => void; + run: () => Promise; +}): Promise { + 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(); + } +} diff --git a/packages/core/tests/thinking.test.ts b/packages/core/tests/thinking.test.ts new file mode 100644 index 00000000..547acf45 --- /dev/null +++ b/packages/core/tests/thinking.test.ts @@ -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 = []; + 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 = []; + 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/); +});