Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/agents/url-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 38 additions & 19 deletions packages/commands/src/commands/auth/login-api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
chatPath,
requestJson,
normalizeModelBaseUrl,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type AuthPersistPatch,
type AuthStore,
type Identity,
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions packages/commands/src/commands/image/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ const EDIT_FLAGS = {
},
size: {
type: "string",
valueHint: "<W*H>",
description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048)",
valueHint: "<W*H|WxH>",
description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048 or 1024x1024)",
},
n: {
type: "number",
Expand Down
4 changes: 2 additions & 2 deletions packages/commands/src/commands/image/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ const GENERATE_FLAGS = {
},
size: {
type: "string",
valueHint: "<W*H>",
description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048)",
valueHint: "<W*H|WxH>",
description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048 or 1024x1024)",
},
n: {
type: "number",
Expand Down
34 changes: 34 additions & 0 deletions packages/commands/src/commands/search/web-activate-hint.ts
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 11 additions & 8 deletions packages/commands/src/commands/search/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<text>", description: "Search query text" },
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -123,7 +126,7 @@ export default defineCommand({
}
} catch (error) {
spinner.stop("Failed.");
throw error;
rethrowWithWebSearchActivateHint(error);
}
},
});
36 changes: 21 additions & 15 deletions packages/commands/src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type ChatMessage,
type ChatRequest,
type ChatResponse,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 ?? "";
Expand Down
2 changes: 1 addition & 1 deletion packages/commands/tests/e2e/auth.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ describe("e2e: auth", () => {
body: {
model: "qwen3.8-max-preview",
stream: false,
enable_thinking: true,
enable_thinking: false,
},
});

Expand Down
13 changes: 10 additions & 3 deletions packages/commands/tests/e2e/text-chat.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,25 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
"--model",
"qwen3.7-max",
"--message",
"干跑",
"dry-run",
"--max-tokens",
"8",
"--output",
"json",
]);
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 () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/commands/tests/search-web-activate-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 7 additions & 0 deletions packages/core/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
adjustEnableThinkingAfterError,
applyChatEnableThinking,
resolveChatEnableThinking,
withEnableThinkingRetry,
type EnableThinkingAdjustResult,
} from "./thinking.ts";
Loading