fix(text,mcp): keep thinking_budget on enable_thinking retry and hint MCP activation on 404

This commit is contained in:
clh02467605
2026-07-28 09:25:02 +08:00
parent 36ebd63716
commit 8211268bd8
12 changed files with 223 additions and 45 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
MCP_WEBSEARCH_PAGE BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/WebSearch MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
core/files/upload.ts ← 文件上传 endpoint(cn-pinned) core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
UPLOAD_API ${REGIONS.cn}/api/v1/uploads UPLOAD_API ${REGIONS.cn}/api/v1/uploads
@@ -0,0 +1,39 @@
import { BailianError } from "bailian-cli-core";
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
export function isMcpNotActivated(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);
}
/** Activation hint; URL from runtime/urls.ts. */
export function mcpActivateHint(serverCode: string): string {
const lines = [
`Activate (or re-activate) the ${serverCode} MCP in the Bailian MCP marketplace, then retry.`,
];
if (serverCode === "WebSearch") {
lines.push(
"If it was previously on SSE, cancel and activate again to upgrade to Streamable HTTP.",
);
}
lines.push(`Open: ${mcpMarketplaceDetailPage(serverCode)}`);
return lines.join("\n");
}
/**
* For not-activated errors, keep the original message / exitCode and append a hint only.
* Do not replace the server error message.
*/
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
throw error;
}
+15 -7
View File
@@ -8,6 +8,7 @@ import {
type ParsedFlags, type ParsedFlags,
} from "bailian-cli-core"; } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime";
import { rethrowWithMcpActivateHint } from "./activate-hint.ts";
const CALL_FLAGS = { const CALL_FLAGS = {
target: { target: {
@@ -130,14 +131,21 @@ export default defineCommand({
} }
const client = ctx.client.mcp(url); const client = ctx.client.mcp(url);
await client.initialize(); try {
const result = await client.callTool(toolName, toolArgs); await client.initialize();
const result = await client.callTool(toolName, toolArgs);
if (result.isError) { if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n"); const errText = result.content.map((c) => c.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`); throw new BailianError(`Tool error: ${errText}`);
}
emitResult(result, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} }
emitResult(result, format);
}, },
}); });
+11 -3
View File
@@ -1,5 +1,6 @@
import { defineCommand, bailianMcpPath, detectOutputFormat } from "bailian-cli-core"; import { defineCommand, bailianMcpPath, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime";
import { rethrowWithMcpActivateHint } from "./activate-hint.ts";
export default defineCommand({ export default defineCommand({
description: "List tools exposed by an MCP server (tools/list)", description: "List tools exposed by an MCP server (tools/list)",
@@ -36,8 +37,15 @@ export default defineCommand({
} }
const client = ctx.client.mcp(url); const client = ctx.client.mcp(url);
await client.initialize(); try {
const tools = await client.listTools(); await client.initialize();
emitResult({ server: code, url, tools }, format); const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
}
}, },
}); });
@@ -1,34 +1,18 @@
import { BailianError } from "bailian-cli-core"; import {
import { MCP_WEBSEARCH_PAGE } from "bailian-cli-runtime"; isMcpNotActivated,
mcpActivateHint,
rethrowWithMcpActivateHint,
} from "../mcp/activate-hint.ts";
/** recoginze WebSearch MCP not activated / invalid caused 404 (CLI wrapped message from server)。 */ /** Detect WebSearch MCP not-activated / invalid 404 errors. */
export function isWebSearchMcpNotActivated(error: unknown): boolean { export const isWebSearchMcpNotActivated = isMcpNotActivated;
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。 */ /** WebSearch activation hint. */
export function webSearchActivateHint(): string { export function webSearchActivateHint(): string {
return [ return mcpActivateHint("WebSearch");
"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 the original message; append a hint for WebSearch not-activated errors. */
* 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 { export function rethrowWithWebSearchActivateHint(error: unknown): never {
if (isWebSearchMcpNotActivated(error) && error instanceof BailianError && !error.hint) { rethrowWithMcpActivateHint(error, "WebSearch");
throw new BailianError(error.message, error.exitCode, webSearchActivateHint(), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
throw error;
} }
+6 -6
View File
@@ -4,7 +4,7 @@ import {
parseSSE, parseSSE,
detectOutputFormat, detectOutputFormat,
readTextFromPathOrStdin, readTextFromPathOrStdin,
applyChatEnableThinking, applyChatEnableThinkingWithBudget,
resolveChatEnableThinking, resolveChatEnableThinking,
withEnableThinkingRetry, withEnableThinkingRetry,
type ChatMessage, type ChatMessage,
@@ -152,10 +152,10 @@ export default defineCommand({
enableThinking: flags.enableThinking, enableThinking: flags.enableThinking,
stream: shouldStream, stream: shouldStream,
}); });
applyChatEnableThinking(body, enableThinking); const applyThinking = (value: boolean | undefined) => {
if (enableThinking === true && flags.thinkingBudget !== undefined) { applyChatEnableThinkingWithBudget(body, value, flags.thinkingBudget);
body.thinking_budget = flags.thinkingBudget; };
} applyThinking(enableThinking);
if (flags.tool) { if (flags.tool) {
const tools = flags.tool.map((t) => { const tools = flags.tool.map((t) => {
@@ -232,7 +232,7 @@ export default defineCommand({
} else { } else {
const response = await withEnableThinkingRetry({ const response = await withEnableThinkingRetry({
initial: enableThinking, initial: enableThinking,
apply: (value) => applyChatEnableThinking(body, value), apply: applyThinking,
run: () => run: () =>
ctx.client.requestJson<ChatResponse>({ ctx.client.requestJson<ChatResponse>({
path: chatPath(), path: chatPath(),
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vite-plus/test";
import { BailianError, ExitCode } from "bailian-cli-core";
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
import {
isMcpNotActivated,
mcpActivateHint,
rethrowWithMcpActivateHint,
} from "../src/commands/mcp/activate-hint.ts";
describe("mcp-activate-hint", () => {
test("识别 404 + 未开通 / MCP不存在 / MCP_IS_INVALID", () => {
expect(
isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP不存在或未开通")),
).toBe(true);
expect(
isMcpNotActivated(new BailianError("MCP request failed: 404 - MCP不存在或未开通")),
).toBe(true);
expect(
isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP_IS_INVALID")),
).toBe(true);
});
test("裸 404 或非 MCP 错误不加开通判定", () => {
expect(isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found"))).toBe(false);
expect(isMcpNotActivated(new BailianError("MCP request failed: 405 Method Not Allowed"))).toBe(
false,
);
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
});
test("hint 含对应 server 的 MCP 广场深链", () => {
const serverCode = "market-cmapi00073529";
expect(mcpActivateHint(serverCode)).toContain(mcpMarketplaceDetailPage(serverCode));
expect(mcpActivateHint(serverCode)).toMatch(/Activate|re-activate/i);
});
test("WebSearch hint 含 SSE 升级说明", () => {
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
});
test("rethrow 保留原 message补 hint", () => {
const serverCode = "market-cmapi00073529";
const original = new BailianError(
"MCP request failed: 404 Not Found - MCP不存在或未开通",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, serverCode);
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(mcpMarketplaceDetailPage(serverCode));
expect(wrapped.cause).toBe(original);
}
});
test("已有 hint 或非未开通错误原样抛出", () => {
const withHint = new BailianError(
"MCP request failed: 404 Not Found - MCP不存在或未开通",
ExitCode.GENERAL,
"already hinted",
);
try {
rethrowWithMcpActivateHint(withHint, "WebSearch");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBe(withHint);
}
const other = new BailianError("MCP request failed: 401 Unauthorized");
try {
rethrowWithMcpActivateHint(other, "WebSearch");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBe(other);
}
});
});
+1
View File
@@ -1,6 +1,7 @@
export { export {
adjustEnableThinkingAfterError, adjustEnableThinkingAfterError,
applyChatEnableThinking, applyChatEnableThinking,
applyChatEnableThinkingWithBudget,
resolveChatEnableThinking, resolveChatEnableThinking,
withEnableThinkingRetry, withEnableThinkingRetry,
type EnableThinkingAdjustResult, type EnableThinkingAdjustResult,
+12
View File
@@ -53,6 +53,18 @@ export function applyChatEnableThinking(
body.enable_thinking = true; body.enable_thinking = true;
} }
/** Set `enable_thinking` and optionally write `thinking_budget` when enabled. */
export function applyChatEnableThinkingWithBudget(
body: { enable_thinking?: boolean; thinking_budget?: number },
value: boolean | undefined,
thinkingBudget?: number,
): void {
applyChatEnableThinking(body, value);
if (value === true && thinkingBudget !== undefined) {
body.thinking_budget = thinkingBudget;
}
}
function errorMessageOf(error: unknown): string { function errorMessageOf(error: unknown): string {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
return String(error); return String(error);
+38
View File
@@ -2,6 +2,7 @@ import { expect, test } from "vite-plus/test";
import { import {
adjustEnableThinkingAfterError, adjustEnableThinkingAfterError,
applyChatEnableThinking, applyChatEnableThinking,
applyChatEnableThinkingWithBudget,
resolveChatEnableThinking, resolveChatEnableThinking,
withEnableThinkingRetry, withEnableThinkingRetry,
} from "../src/models/thinking.ts"; } from "../src/models/thinking.ts";
@@ -123,6 +124,43 @@ test("withEnableThinkingRetrymust-be-false 时从 omit 重试为 false", asyn
expect(values).toEqual([undefined, false]); expect(values).toEqual([undefined, false]);
}); });
test("applyChatEnableThinkingWithBudget仅在 enable_thinking=true 时写入 budget", () => {
const body: { enable_thinking?: boolean; thinking_budget?: number } = {};
applyChatEnableThinkingWithBudget(body, false, 2048);
expect(body.enable_thinking).toBe(false);
expect(body).not.toHaveProperty("thinking_budget");
applyChatEnableThinkingWithBudget(body, undefined, 2048);
expect(body).not.toHaveProperty("enable_thinking");
expect(body).not.toHaveProperty("thinking_budget");
applyChatEnableThinkingWithBudget(body, true, 2048);
expect(body.enable_thinking).toBe(true);
expect(body.thinking_budget).toBe(2048);
});
test("withEnableThinkingRetryrestricted-to-true 重试时保留 thinking_budget", async () => {
const body: { enable_thinking?: boolean; thinking_budget?: number } = {};
let calls = 0;
const result = await withEnableThinkingRetry({
initial: false,
apply: (value) => applyChatEnableThinkingWithBudget(body, value, 2048),
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(body.enable_thinking).toBe(true);
expect(body.thinking_budget).toBe(2048);
});
test("withEnableThinkingRetry无关错误原样抛出", async () => { test("withEnableThinkingRetry无关错误原样抛出", async () => {
await expect( await expect(
withEnableThinkingRetry({ withEnableThinkingRetry({
+1
View File
@@ -35,6 +35,7 @@ export {
API_KEY_PAGE, API_KEY_PAGE,
TOKEN_PLAN_PAGE, TOKEN_PLAN_PAGE,
MCP_WEBSEARCH_PAGE, MCP_WEBSEARCH_PAGE,
mcpMarketplaceDetailPage,
VOICE_TTS_PAGE, VOICE_TTS_PAGE,
} from "./urls.ts"; } from "./urls.ts";
+6 -1
View File
@@ -18,11 +18,16 @@ export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`;
/** Direct deep link to the Token Plan subscription overview and API key entry. */ /** 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`; export const TOKEN_PLAN_PAGE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing?tab=plan#/efm/subscription/overview`;
/** MCP marketplace detail page for a server code (e.g. WebSearch, market-cmapi00073529). */
export function mcpMarketplaceDetailPage(serverCode: string): string {
return `${BAILIAN_CONSOLE}?tab=mcp#/mcp-market/detail/${serverCode}`;
}
/** /**
* MCP marketplace detail for the built-in WebSearch server. * MCP marketplace detail for the built-in WebSearch server.
* Users must activate (or re-activate for Streamable HTTP) before `search web` works. * 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`; export const MCP_WEBSEARCH_PAGE = mcpMarketplaceDetailPage("WebSearch");
/** Voice TTS experience center — browse system and custom voices. */ /** Voice TTS experience center — browse system and custom voices. */
export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list"; export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list";