From 008da579263af7c583c553e257b919f57631adce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Mon, 2 Nov 2026 17:44:10 +0800 Subject: [PATCH 1/2] perf(advisor): disable thinking and instrument LLM requests --- docs/agents/telemetry-change.md | 14 +-- .../src/commands/advisor/recommend.ts | 15 ++- .../tests/e2e/advisor-recommend.e2e.test.ts | 109 +++++++++++++++++- .../src/advisor/engine/llm/client.test.ts | 106 +++++++++++++++++ .../core/src/advisor/engine/llm/client.ts | 6 +- .../core/src/client/instrumented-fetch.ts | 8 +- .../core/tests/instrumented-fetch.test.ts | 69 ++++++++++- skills/bailian-cli/reference/advisor.md | 2 +- 8 files changed, 306 insertions(+), 23 deletions(-) diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index dd35b278..8963145d 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -27,19 +27,19 @@ ### 1. 三套鉴权与埋点标识 -| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) | -| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------ | -| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config`、`X-Dashscope-Service: bailian-cli` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` | -| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 有:Console Gateway 后端直接标识 | 有:`pid=bailian-cli-node`、`authMethod=console` | -| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config`、`X-Dashscope-Service: bailian-cli` | 有:`pid=bailian-cli-node`、`authMethod=openapi` | -| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` | +| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) | +| ----------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------ | +| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent / Advisor instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config`、`X-Dashscope-Service: bailian-cli` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` | +| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 有:Console Gateway 后端直接标识 | 有:`pid=bailian-cli-node`、`authMethod=console` | +| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config`、`X-Dashscope-Service: bailian-cli` | 有:`pid=bailian-cli-node`、`authMethod=openapi` | +| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` | `authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。 鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录,Managed Agent 还可能访问其他 provider。 表中的后端埋点按该鉴权域的主要业务请求填写: -- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 与 `X-Dashscope-Service` 仅对阿里云 host 注入 +- Managed Agent 与 Advisor 通过宿主注入的 `createInstrumentedFetch` 统一携带 `User-Agent`;渠道头仅对阿里云 host 注入。Advisor 的引擎保持独立,普通聊天、工具调用、SSE 与 JSON 重试共用该传输,`--verbose` 在 stderr 输出 endpoint、HTTP 状态与响应头中的 request_id,凭证脱敏且不输出请求体 - DashScope 上传策略 `getPolicy` 走 `trackingHeaders()`(含 source-config 与 `X-Dashscope-Service`),没有显式 CLI `User-Agent` - Console Gateway 用户数据由后端直接区分,不依赖 `x-dashscope-source-config`;AEM 的 `authMethod=console` 是独立的命令侧统计 - OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识 diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index 69ac79bb..e3901add 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -1,6 +1,7 @@ import { agentLoop, BailianError, + createInstrumentedFetch, createLLMClient, createReadlineInteraction, DEFAULT_LLM_MODEL, @@ -133,8 +134,8 @@ const flags = { noThinking: { type: "switch", description: { - "en-US": "Disable thinking mode for intent analysis (on by default)", - "zh-CN": "关闭意图分析的思考模式(默认开启)", + "en-US": "Deprecated no-op: thinking is always disabled for advisor LLM calls", + "zh-CN": "已废弃且无效果:advisor 的 LLM 调用现已始终关闭思考模式", }, }, } as const; @@ -209,11 +210,13 @@ export default defineCommand({ llm: createLLMClient({ baseUrl: ctx.client.baseUrl, apiKey: credential.token, + fetch: createInstrumentedFetch(ctx), defaultModel: runtimeFlags.model ?? DEFAULT_LLM_MODEL, - // Intent analysis is the only LLM call that decides the slate, so it - // gets thinking mode by default (--no-thinking to disable). Other - // stages stay in plain mode: thinking tokens are billed per call. - thinkingStages: runtimeFlags.noThinking ? [] : ["intent"], + // Disable thinking for every advisor LLM call. Intent analysis was the + // only thinking stage, but it dominated latency without improving + // clear recommendation requests. `--no-thinking` remains accepted as + // a deprecated no-op for existing scripts. + thinkingStages: [], }), catalog: () => loadCatalog(), families: () => loadFamilies(), diff --git a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index 9fee55c2..b2f09849 100644 --- a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts +++ b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import * as core from "bailian-cli-core"; +import { + isDashScopeE2EReady, + parseStdoutJson, + runCommandHelp, + runCommandE2e, + runCommandInProcess, +} from "./helpers.ts"; +import { E2E_HARNESS_IDENTITY } from "./harness/identity.ts"; import { ADVISOR_ROUTES } from "./topic-routes.ts"; describe("e2e: advisor recommend", () => { @@ -14,6 +22,103 @@ describe("e2e: advisor recommend", () => { }); }); +describe("e2e: advisor recommend 传输(离线)", () => { + beforeEach(() => { + // 隔离目录、同步与原生网络,回归不读取真实目录或调用付费模型。 + vi.spyOn(core, "maybeSyncWikiData").mockResolvedValue(false); + vi.spyOn(core, "loadCatalog").mockResolvedValue([]); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("禁止原生网络请求"))); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test.each([true, false])( + "真实命令注入统一传输且默认关闭 thinking,verbose=%s", + async (verbose) => { + const endpoint = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; + const requests: RequestInit[] = []; + vi.spyOn(core.httpTransport, "fetch").mockImplementation(async (input, init) => { + expect(input).toBe(endpoint); + requests.push(init ?? {}); + const body = JSON.parse(init?.body as string); + const content = JSON.stringify( + requests.length === 1 + ? { + taskSummary: "文本生成", + inputModality: ["Text"], + outputModality: ["Text"], + requiredCapabilities: ["TG"], + matchedScenes: [], + sceneConfidence: 1, + } + : { scores: [] }, + ); + const headers = { "x-request-id": "advisor-success-request" }; + return body.stream + ? new Response( + `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\n\ndata: [DONE]\n\n`, + { headers }, + ) + : Response.json({ choices: [{ message: { content } }] }, { headers }); + }); + const { stdout, stderr, exitCode } = await runCommandInProcess( + ADVISOR_ROUTES, + [ + "advisor", + "recommend", + "--message", + "文本生成", + "--no-agent", + ...(verbose ? ["--verbose"] : ["--no-thinking"]), + ], + { + DASHSCOPE_API_KEY: "test-advisor-secret-key", + DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com", + DO_NOT_TRACK: "1", + NODE_OPTIONS: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + }, + ); + expect(exitCode, stderr).toBe(0); + expect(requests).toHaveLength(2); + for (const request of requests) { + const headers = new Headers(request.headers); + expect(headers.get("authorization")).toBe("Bearer test-advisor-secret-key"); + expect(headers.get("user-agent")).toBe( + `${E2E_HARNESS_IDENTITY.clientName}/${E2E_HARNESS_IDENTITY.version}`, + ); + expect(headers.get("x-dashscope-source-config")).toBe( + core.sourceConfig(E2E_HARNESS_IDENTITY), + ); + expect(headers.get("x-dashscope-openapisource")).toBe("BailianCLI"); + expect(headers.get("x-dashscope-service")).toBe("bailian-cli"); + expect(JSON.parse(request.body as string).model).toBe(core.DEFAULT_LLM_MODEL); + } + expect(JSON.parse(requests[0].body as string).enable_thinking).toBeUndefined(); + expect(JSON.parse(requests[0].body as string).stream).toBeUndefined(); + expect(JSON.parse(requests[1].body as string).stream).toBeUndefined(); + expect(parseStdoutJson<{ intent: { taskSummary: string } }>(stdout).intent.taskSummary).toBe( + "文本生成", + ); + expect(stdout).not.toContain("request_id:"); + expect(stderr).not.toContain("test-advisor-secret-key"); + if (verbose) { + expect(stderr).toContain(`> POST ${endpoint}`); + expect(stderr).toContain("< 200"); + expect(stderr).toContain("request_id: advisor-success-request"); + } else { + expect(stderr).not.toContain("> POST"); + expect(stderr).not.toContain("request_id:"); + } + }, + ); +}); + describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => { test("advisor recommend without --message errors as usage error (2)", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [ diff --git a/packages/core/src/advisor/engine/llm/client.test.ts b/packages/core/src/advisor/engine/llm/client.test.ts index f77655b0..ad83decf 100644 --- a/packages/core/src/advisor/engine/llm/client.test.ts +++ b/packages/core/src/advisor/engine/llm/client.test.ts @@ -196,6 +196,112 @@ describe("流式响应生命周期", () => { }); }); +describe("宿主传输注入", () => { + it("普通聊天、工具调用、SSE 与 JSON 重试均使用注入的 fetch", async () => { + const nativeFetch = vi.fn(); + vi.stubGlobal("fetch", nativeFetch); + const transport = vi + .fn<(input: string | URL | Request, init?: RequestInit) => Promise>() + .mockResolvedValueOnce(Response.json({ choices: [{ message: { content: "普通聊天" } }] })) + .mockResolvedValueOnce( + Response.json({ + choices: [ + { + message: { + tool_calls: [ + { + id: "tool-1", + type: "function", + function: { name: "recommend", arguments: "{}" }, + }, + ], + }, + }, + ], + }), + ) + .mockResolvedValueOnce( + new Response(delta({ content: "流式聊天" }) + finish() + "data: [DONE]\n\n"), + ) + .mockResolvedValueOnce(Response.json({ choices: [{ message: { content: "无效 JSON" } }] })) + .mockResolvedValueOnce(Response.json({ choices: [{ message: { content: '{"ok":true}' } }] })); + const onRequest = vi.fn(); + const llm = createLLMClient({ + baseUrl: "https://example.invalid/", + apiKey: "test-api-key", + fetch: transport, + thinkingStages: ["intent"], + onRequest, + }); + await expect(llm.chat([], { stage: "explain" })).resolves.toBe("普通聊天"); + await expect(llm.chatWithTools([], [])).resolves.toMatchObject({ + toolCalls: [{ id: "tool-1" }], + }); + await expect(llm.chat([], { stage: "intent" })).resolves.toBe("流式聊天"); + await expect(llm.chatJson([], { stage: "holistic-score" })).resolves.toEqual({ ok: true }); + expect(nativeFetch).not.toHaveBeenCalled(); + expect(transport).toHaveBeenCalledTimes(5); + expect(onRequest.mock.calls.flat()).toEqual([ + "explain", + "agent", + "intent", + "holistic-score", + "holistic-score", + ]); + for (const [url, init] of transport.mock.calls) { + expect(url).toBe("https://example.invalid/compatible-mode/v1/chat/completions"); + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-api-key"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + } + const bodies = transport.mock.calls.map(([, init]) => JSON.parse(init?.body as string)); + expect(bodies[1].tools).toEqual([]); + expect(bodies[2]).toMatchObject({ stream: true, enable_thinking: true }); + expect(bodies[4].messages).toHaveLength(2); + }); + + it("注入传输仍保留 HTTP 错误与 request_id", async () => { + const transport = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { code: "invalid_api_key", message: "原始服务端错误" } }, + { status: 401, headers: { "x-request-id": "advisor-error-request" } }, + ), + ); + const llm = createLLMClient({ + baseUrl: "https://example.invalid", + apiKey: "test", + fetch: transport, + }); + await expect(llm.chat([])).rejects.toMatchObject({ + kind: "auth", + status: 401, + code: "invalid_api_key", + requestId: "advisor-error-request", + message: "LLM request failed: 原始服务端错误", + }); + }); + + it("调用方取消仍透传到注入传输", async () => { + const controller = new AbortController(); + const transport = vi.fn().mockImplementation(async (_input: unknown, init?: RequestInit) => { + controller.abort(); + expect(init?.signal?.aborted).toBe(true); + return Response.json({ choices: [{ message: { content: "不应返回" } }] }); + }); + const llm = createLLMClient({ + baseUrl: "https://example.invalid", + apiKey: "test", + fetch: transport, + }); + await expect(llm.chat([], { signal: controller.signal })).rejects.toMatchObject({ + kind: "cancelled", + }); + expect(transport).toHaveBeenCalledOnce(); + }); +}); + describe("按阶段开启思考模式(thinkingStages)", () => { const sse = (content: string) => `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content } }] })}\n\n` + diff --git a/packages/core/src/advisor/engine/llm/client.ts b/packages/core/src/advisor/engine/llm/client.ts index 8e437e5d..f7074d3e 100644 --- a/packages/core/src/advisor/engine/llm/client.ts +++ b/packages/core/src/advisor/engine/llm/client.ts @@ -18,6 +18,8 @@ export const DEFAULT_LLM_MODEL = "qwen-flash-2025-07-28"; export interface LLMClientOptions { baseUrl: string; apiKey: string; + /** 宿主注入带渠道标识与诊断的传输;独立使用时保留原生 fetch。 */ + fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; /** Default model for chat/chatJson calls. */ defaultModel?: string; timeoutMs?: number; @@ -54,7 +56,7 @@ interface ChatCompletionResponse { /** * Self-contained OpenAI-compatible LLM client over fetch. * Kept free of bailian-cli-core imports so the engine compiles standalone; - * the command layer wires it up from ctx.client.baseUrl + credentials. + * 宿主通过选项注入连接凭证与传输,不改变引擎的超时、SSE 或错误语义。 */ export function createLLMClient(options: LLMClientOptions): LLMClient { const { @@ -81,7 +83,7 @@ export function createLLMClient(options: LLMClientOptions): LLMClient { const signal = opts.signal ? AbortSignal.any([opts.signal, timeout.signal]) : timeout.signal; try { options.onRequest?.(opts.stage ?? "llm"); - const res = await fetch(endpoint, { + const res = await (options.fetch ?? fetch)(endpoint, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(body), diff --git a/packages/core/src/client/instrumented-fetch.ts b/packages/core/src/client/instrumented-fetch.ts index 64169946..15e067ea 100644 --- a/packages/core/src/client/instrumented-fetch.ts +++ b/packages/core/src/client/instrumented-fetch.ts @@ -57,18 +57,18 @@ export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation { } if (deps.settings.verbose) { - console.error(`> ${init.method ?? "GET"} ${url}`); + process.stderr.write(`> ${init.method ?? "GET"} ${url}\n`); const auth = headers.get("authorization"); - if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`); + if (auth) process.stderr.write(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}\n`); } const res = await httpTransport.fetch(input, { ...init, headers }); if (deps.settings.verbose) { - console.error(`< ${res.status} ${res.statusText}`); + process.stderr.write(`< ${res.status} ${res.statusText}\n`); const reqId = res.headers.get("x-request-id"); if (reqId) { - console.error(`request_id: ${reqId}`); + process.stderr.write(`request_id: ${reqId}\n`); } } diff --git a/packages/core/tests/instrumented-fetch.test.ts b/packages/core/tests/instrumented-fetch.test.ts index 5e21a1ec..bc3b2cca 100644 --- a/packages/core/tests/instrumented-fetch.test.ts +++ b/packages/core/tests/instrumented-fetch.test.ts @@ -1,5 +1,5 @@ import { httpTransport } from "../src/client/transport.ts"; -import { expect, test } from "vite-plus/test"; +import { afterEach, expect, test, vi } from "vite-plus/test"; import type { Identity, Settings } from "../src/index.ts"; import { createInstrumentedFetch, sourceConfig } from "../src/index.ts"; @@ -12,6 +12,8 @@ const identity: Identity = { const settings: Settings = { timeout: 60, verbose: false } as Settings; +afterEach(() => vi.restoreAllMocks()); + interface CapturedRequest { url: string; headers: Headers; @@ -95,6 +97,71 @@ test("passes non-URL-parseable inputs through without tracking headers", async ( expect(headers.get("x-dashscope-service")).toBeNull(); }); +test.each([200, 401])( + "verbose 将 endpoint、状态和 request_id 写入 stderr(HTTP %s)", + async (status) => { + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true); + const response = new Response("原始正文", { + status, + headers: { "x-request-id": "transport-request" }, + }); + vi.spyOn(httpTransport, "fetch").mockResolvedValue(response); + const result = await createInstrumentedFetch({ + identity, + settings: { ...settings, verbose: true }, + })("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", { + method: "POST", + headers: { Authorization: "Bearer test-secret-api-key" }, + body: "敏感提示词", + }); + expect(result).toBe(response); + expect(response.bodyUsed).toBe(false); + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(output).toContain( + "> POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", + ); + expect(output).toContain(`< ${status}`); + expect(output).toContain("request_id: transport-request"); + expect(output).toContain("test...-key"); + expect(output).not.toContain("test-secret-api-key"); + expect(output).not.toContain("敏感提示词"); + expect(stdout).not.toHaveBeenCalled(); + }, +); + +test("未开启 verbose 时不输出诊断且保持响应与取消信号", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const response = new Response("data: [DONE]\n\n", { + headers: { "x-request-id": "silent-request" }, + }); + const transport = vi.spyOn(httpTransport, "fetch").mockResolvedValue(response); + const controller = new AbortController(); + const result = await createInstrumentedFetch({ identity, settings })("https://example.invalid", { + signal: controller.signal, + }); + expect(result).toBe(response); + expect(response.bodyUsed).toBe(false); + expect(transport.mock.calls[0][1]?.signal).toBe(controller.signal); + expect(stderr).not.toHaveBeenCalled(); +}); + +test("kscli 传输使用宿主身份与版本生成渠道头", async () => { + const transport = vi.spyOn(httpTransport, "fetch").mockResolvedValue(new Response("{}")); + const ksIdentity = { + ...identity, + binName: "kscli", + clientName: "knowledge-studio-cli", + version: "9.8.7", + }; + await createInstrumentedFetch({ identity: ksIdentity, settings })( + "https://dashscope.aliyuncs.com", + ); + const headers = new Headers(transport.mock.calls[0][1]?.headers); + expect(headers.get("user-agent")).toBe("knowledge-studio-cli/9.8.7"); + expect(headers.get("x-dashscope-source-config")).toBe(sourceConfig(ksIdentity)); +}); + test("uses kscli identity and version in source config", () => { expect(sourceConfig({ binName: "kscli", version: "1.13.1" })).toBe( JSON.stringify({ diff --git a/skills/bailian-cli/reference/advisor.md b/skills/bailian-cli/reference/advisor.md index bc444551..13636261 100644 --- a/skills/bailian-cli/reference/advisor.md +++ b/skills/bailian-cli/reference/advisor.md @@ -32,7 +32,7 @@ Index: [index.md](index.md) | `--no-agent` | switch | no | Skip the agent loop and run the deterministic engine directly | | `--trace` | switch | no | Emit full routing/performance trace to stderr | | `--think` | switch | no | Show the engine decision process to stderr | -| `--no-thinking` | switch | no | Disable thinking mode for intent analysis (on by default) | +| `--no-thinking` | switch | no | Deprecated no-op: thinking is always disabled for advisor LLM calls | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | From 19ae830b94c34b3076360b6684c22a20c049de52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Mon, 2 Nov 2026 17:44:47 +0800 Subject: [PATCH 2/2] fix(finetune): emit verbose gateway diagnostics for fee queries --- .../commands/src/commands/finetune/fee.ts | 1 + packages/commands/tests/finetune-fee.test.ts | 94 +++++++++++++++++++ packages/core/src/console/gateway.ts | 39 ++++++-- packages/core/tests/index.test.ts | 50 ++++++++-- 4 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 packages/commands/tests/finetune-fee.test.ts diff --git a/packages/commands/src/commands/finetune/fee.ts b/packages/commands/src/commands/finetune/fee.ts index f1325686..602ec329 100644 --- a/packages/commands/src/commands/finetune/fee.ts +++ b/packages/commands/src/commands/finetune/fee.ts @@ -55,6 +55,7 @@ async function fetchTrainingPrice( }, }, }, + settings, ); const responseData = unwrapResponse(result as Record); const list = (responseData.list as Record[]) ?? []; diff --git a/packages/commands/tests/finetune-fee.test.ts b/packages/commands/tests/finetune-fee.test.ts new file mode 100644 index 00000000..f6e904fe --- /dev/null +++ b/packages/commands/tests/finetune-fee.test.ts @@ -0,0 +1,94 @@ +import { httpTransport, type Settings } from "bailian-cli-core"; +import { expect, test } from "vite-plus/test"; +import { computeActualFee } from "../src/commands/finetune/fee.ts"; + +const SETTINGS: Settings = { + output: "json", + outputExplicit: true, + timeout: 30, + watermark: true, + verbose: false, + quiet: true, + dryRun: false, + telemetry: false, +}; + +test("computeActualFee forwards verbose settings to the gateway", async () => { + const originalFetch = httpTransport.fetch; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ""; + const responseBody = { + data: { + success: true, + DataV2: { + data: { + data: { + list: [ + { + items: [ + { + model: "qwen3-8b", + prices: [{ type: "ft", price: 2, priceUnit: "每百万tokens" }], + }, + ], + }, + ], + }, + }, + }, + }, + }; + + httpTransport.fetch = async () => + new Response(JSON.stringify(responseBody), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + const fee = await computeActualFee({ ...SETTINGS, verbose: true }, "qwen3-8b", 1_000_000); + expect(fee).toEqual({ cost: 2, unitPrice: 2, priceUnit: "每百万tokens" }); + } finally { + httpTransport.fetch = originalFetch; + process.stderr.write = originalWrite; + } + + const lines = stderr.trim().split("\n"); + expect(lines).toHaveLength(2); + for (const line of lines) { + expect(JSON.parse(line)).toHaveProperty("debug"); + } + expect(JSON.parse(lines[0]!).debug).toMatchObject({ type: "request", method: "POST" }); + expect(JSON.parse(lines[1]!).debug).toMatchObject({ type: "response", status: 200 }); + expect(JSON.parse(lines[0]!).debug).not.toHaveProperty("payload"); +}); + +test("computeActualFee hides gateway diagnostics when verbose is disabled", async () => { + const originalFetch = httpTransport.fetch; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ""; + + httpTransport.fetch = async () => + new Response(JSON.stringify({ data: { success: true, DataV2: { data: { data: {} } } } }), { + status: 200, + statusText: "OK", + }); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + await computeActualFee(SETTINGS, "qwen3-8b", 1_000_000); + } finally { + httpTransport.fetch = originalFetch; + process.stderr.write = originalWrite; + } + + expect(stderr).toBe(""); +}); diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 2cbb88f5..4817d00e 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -72,7 +72,10 @@ export type ConsoleCall = (api: string, data: Record) => Promis * as `listFoundationModels` — no console login required. */ export function anonymousConsoleCall( - config: Pick, + config: Pick< + Settings, + "consoleRegion" | "consoleSite" | "consoleSwitchAgent" | "timeout" | "verbose" | "output" + >, ): ConsoleCall { const eff = effectiveConsoleGatewayConfig(config); return (api, data) => @@ -80,6 +83,7 @@ export function anonymousConsoleCall( { region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent }, config.timeout, { api, data }, + config, ); } @@ -124,7 +128,7 @@ export async function callConsoleGateway( target: ConsoleGatewayTarget, timeoutSec: number, { api, data }: ConsoleGatewayRequest, - settings?: Pick, + settings?: Pick & Partial>, ): Promise { const resolved = resolveGateway(target.region, target.site); const gatewayBase = `https://${resolved.csGateway}`; @@ -142,10 +146,20 @@ export async function callConsoleGateway( const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`; if (settings?.verbose) { - process.stderr.write(`> POST ${endpoint}\n`); - process.stderr.write( - `> payload ${JSON.stringify({ params: JSON.parse(params), region: target.region }, null, 2)}\n`, - ); + const requestDebug = { + type: "request", + method: "POST", + endpoint, + api, + region: target.region, + }; + if (settings.output === "json") { + process.stderr.write(`${JSON.stringify({ debug: requestDebug })}\n`); + } else { + process.stderr.write(`> POST ${endpoint}\n`); + process.stderr.write(`> api ${api}\n`); + process.stderr.write(`> region ${target.region}\n`); + } } const res = await httpTransport.fetch(endpoint, { @@ -156,7 +170,18 @@ export async function callConsoleGateway( }); if (settings?.verbose) { - process.stderr.write(`< ${res.status} ${res.statusText}\n`); + const requestId = res.headers.get("x-request-id") ?? undefined; + const responseDebug = { + type: "response", + status: res.status, + statusText: res.statusText, + ...(requestId ? { requestId } : {}), + }; + if (settings.output === "json") { + process.stderr.write(`${JSON.stringify({ debug: responseDebug })}\n`); + } else { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } } if (!res.ok) { diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 9660fb31..fc38ea1b 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -7,6 +7,7 @@ import { ExitCode, McpClient, callConsoleGateway, + anonymousConsoleCall, mapApiError, request, sandboxEndpoint, @@ -220,7 +221,7 @@ test("toJSON splits service-error metadata into structured fields", () => { }); }); -test("callConsoleGateway verbose prints structured request payload", async () => { +test("callConsoleGateway verbose prints a safe request summary", async () => { const originalFetch = httpTransport.fetch; const originalWrite = process.stderr.write.bind(process.stderr); let stderr = ""; @@ -260,15 +261,48 @@ test("callConsoleGateway verbose prints structured request payload", async () => } expect(requestBody).toBeDefined(); - expect(stderr).toContain('> payload {\n "params": {'); - expect(stderr).toContain(' "region": "ap-southeast-1"'); - expect(stderr).toContain(' "Api": "test.api"'); - expect(stderr).toContain(' "workspaceId": "ws-1"'); - expect(stderr).toContain(' "switchUserType": 3'); - expect(stderr).toContain(' "switchAgent": 123'); - expect(stderr).toContain(' "custom": "value"'); + expect(stderr).toContain("> POST https://"); + expect(stderr).toContain("> api test.api"); + expect(stderr).toContain("> region ap-southeast-1"); expect(stderr).toContain("< 200 OK"); expect(stderr).not.toContain("response-body"); + expect(stderr).not.toContain("ws-1"); + expect(stderr).not.toContain("switchAgent"); + expect(stderr).not.toContain("custom"); +}); + +test("anonymousConsoleCall forwards verbose settings to the gateway", async () => { + const originalFetch = httpTransport.fetch; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ""; + + httpTransport.fetch = async () => + new Response(JSON.stringify({ data: { success: true } }), { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + try { + const settings = { ...testDeps().settings, verbose: true }; + await anonymousConsoleCall(settings)("test.api", {}); + } finally { + httpTransport.fetch = originalFetch; + process.stderr.write = originalWrite; + } + + const lines = stderr.trim().split("\n"); + expect(lines).toHaveLength(2); + for (const line of lines) { + expect(JSON.parse(line)).toHaveProperty("debug"); + } + expect(JSON.parse(lines[0]!).debug).toMatchObject({ type: "request", method: "POST" }); + expect(JSON.parse(lines[1]!).debug).toMatchObject({ type: "response", status: 200 }); + expect(JSON.parse(lines[0]!).debug).not.toHaveProperty("payload"); }); test("callConsoleGateway keeps readable message and raw gateway response separately", async () => {