diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index e7b1a5d2..05ec5aaa 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 b49c352e..3515f44a 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, @@ -136,8 +137,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; @@ -225,11 +226,17 @@ export default defineCommand({ llm: createLLMClient({ baseUrl: ctx.client.baseUrl, apiKey: credential.token, + fetch: createInstrumentedFetch({ + identity: ctx.identity, + settings: ctx.settings, + debug: (diagnostic) => ctx.output.debug(diagnostic), + }), 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/src/commands/finetune/capability.ts b/packages/commands/src/commands/finetune/capability.ts index 5bafddea..6c725aa4 100644 --- a/packages/commands/src/commands/finetune/capability.ts +++ b/packages/commands/src/commands/finetune/capability.ts @@ -9,6 +9,7 @@ import { TRAINING_TYPES_CLI, anonymousConsoleCall, UsageError, + type HttpDeps, type Settings, type ModelCapability, type FlagsDef, @@ -20,8 +21,11 @@ import { * records so capability fields (`supports` / `trainingTypes`) are preserved * for filtering. */ -async function fetchAllFoundationModels(settings: Settings): Promise { - const all = await fetchModelListAll(anonymousConsoleCall(settings)); +async function fetchAllFoundationModels( + settings: Settings, + diagnostics: Pick, +): Promise { + const all = await fetchModelListAll(anonymousConsoleCall(settings, diagnostics)); return all as ModelCapability[]; } @@ -85,6 +89,9 @@ export default defineCommand({ }, async run(ctx) { const { settings, flags } = ctx; + const diagnostics: Pick = { + debug: (diagnostic) => ctx.output.debug(diagnostic), + }; const model = flags.baseModel || undefined; const trainingType = flags.trainingType || undefined; @@ -103,7 +110,7 @@ export default defineCommand({ // Direction 1: by model → which training types it supports. if (model) { - const capability = await fetchModelCapability(settings, model); + const capability = await fetchModelCapability(settings, model, diagnostics); if (!capability) { const resultData2 = { model, error: `No foundation model found matching "${model}".` }; ctx.output.result( @@ -136,7 +143,7 @@ export default defineCommand({ ); } const { method, variant } = trainingTypeMethodVariant(trainingType); - const all = await fetchAllFoundationModels(settings); + const all = await fetchAllFoundationModels(settings, diagnostics); const matched = all .filter((record) => modelSupportsTrainingType(record, trainingType)) .map((record) => ({ diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 5b4081f4..1146bf8a 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -14,6 +14,7 @@ import { formatIssue, BailianError, ExitCode, + type HttpDeps, type CommandContext, type CreateFineTuneRequest, type FineTuneHyperParameters, @@ -562,6 +563,9 @@ async function runCreate( ctx: CommandContext, ): Promise { const { settings } = ctx; + const diagnostics: Pick = { + debug: (diagnostic) => ctx.output.debug(diagnostic), + }; const flags = ctx.flags as Record; const model = flags.baseModel as string; const datasetsRaw = flags.datasets as string; @@ -818,7 +822,7 @@ async function runCreate( if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) { let capability: Awaited> | undefined; try { - capability = await fetchModelCapability(settings, model); + capability = await fetchModelCapability(settings, model, diagnostics); } catch (error) { ctx.output.warning({ code: "TRAINING_CAPABILITY_LOOKUP_FAILED", diff --git a/packages/commands/src/commands/finetune/fee.ts b/packages/commands/src/commands/finetune/fee.ts index f1325686..6c649fbf 100644 --- a/packages/commands/src/commands/finetune/fee.ts +++ b/packages/commands/src/commands/finetune/fee.ts @@ -13,6 +13,7 @@ import { effectiveConsoleGatewayConfig, unwrapResponse, MODEL_LIST_API, + type HttpDeps, type Settings, type ModelPriceInfo, } from "bailian-cli-core"; @@ -31,6 +32,7 @@ export interface ActualFee { async function fetchTrainingPrice( settings: Settings, model: string, + diagnostics: Pick, ): Promise { const eff = effectiveConsoleGatewayConfig(settings); const result = await callConsoleGateway( @@ -55,6 +57,7 @@ async function fetchTrainingPrice( }, }, }, + diagnostics, ); const responseData = unwrapResponse(result as Record); const list = (responseData.list as Record[]) ?? []; @@ -90,9 +93,10 @@ export async function computeActualFee( settings: Settings, model: string, usageTokens: number, + diagnostics: Pick = {}, ): Promise { try { - const ftEntry = await fetchTrainingPrice(settings, model); + const ftEntry = await fetchTrainingPrice(settings, model, diagnostics); const unitPrice = Number(ftEntry?.price); if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null; const priceUnit = ftEntry?.priceUnit ?? "每百万tokens"; diff --git a/packages/commands/src/commands/finetune/get.ts b/packages/commands/src/commands/finetune/get.ts index 546134de..e18ff6b5 100644 --- a/packages/commands/src/commands/finetune/get.ts +++ b/packages/commands/src/commands/finetune/get.ts @@ -1,4 +1,4 @@ -import { defineCommand, getFineTune, type FlagsDef } from "bailian-cli-core"; +import { defineCommand, getFineTune, type HttpDeps, type FlagsDef } from "bailian-cli-core"; import { computeActualFee } from "./fee.ts"; @@ -23,6 +23,9 @@ export default defineCommand({ exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"], async run(ctx) { const { settings, flags } = ctx; + const diagnostics: Pick = { + debug: (diagnostic) => ctx.output.debug(diagnostic), + }; const jobId = flags.jobId; if (settings.dryRun) { @@ -77,7 +80,7 @@ export default defineCommand({ // Actual fee: only when the platform reports a concrete token count // (SUCCEEDED / CANCELED). Best-effort — silently omitted on lookup failure. if (usageTokens !== undefined && usageTokens > 0 && job.model) { - const fee = await computeActualFee(settings, job.model, usageTokens); + const fee = await computeActualFee(settings, job.model, usageTokens, diagnostics); if (fee) { item.training_cost = fee.cost; item.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`; diff --git a/packages/commands/src/commands/finetune/watch.ts b/packages/commands/src/commands/finetune/watch.ts index 0f627bff..36b2b841 100644 --- a/packages/commands/src/commands/finetune/watch.ts +++ b/packages/commands/src/commands/finetune/watch.ts @@ -3,6 +3,7 @@ import { getFineTune, BailianError, ExitCode, + type HttpDeps, type FlagsDef, } from "bailian-cli-core"; @@ -134,6 +135,9 @@ export default defineCommand({ ], async run(ctx) { const { settings, flags } = ctx; + const diagnostics: Pick = { + debug: (diagnostic) => ctx.output.debug(diagnostic), + }; const jobId = flags.jobId; const follow = flags.follow; const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC); @@ -175,7 +179,12 @@ export default defineCommand({ const usageTokens = typeof job?.usage === "number" ? job.usage : undefined; if (terminal && usageTokens && usageTokens > 0 && job?.model) { output.usage_tokens = usageTokens; - const fee = await computeActualFee(settings, job.model as string, usageTokens); + const fee = await computeActualFee( + settings, + job.model as string, + usageTokens, + diagnostics, + ); if (fee) { output.training_cost = fee.cost; output.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`; @@ -226,7 +235,12 @@ export default defineCommand({ const usageTokens = typeof job?.usage === "number" ? job.usage : undefined; const enriched: Record = { ...response }; if (usageTokens && usageTokens > 0 && job?.model) { - const fee = await computeActualFee(settings, job.model as string, usageTokens); + const fee = await computeActualFee( + settings, + job.model as string, + usageTokens, + diagnostics, + ); if (fee) { enriched.training_cost = fee.cost; enriched.usage_tokens = usageTokens; diff --git a/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts b/packages/commands/tests/e2e/advisor-recommend.e2e.test.ts index 9fee55c2..fa0d2c54 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,112 @@ 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) { + const debugLines = stderr + .trim() + .split("\n") + .filter((line) => line.startsWith('{"debug":')); + expect(debugLines.length).toBeGreaterThanOrEqual(4); + const requestDebug = JSON.parse(debugLines[0]!).debug; + const responseDebug = JSON.parse(debugLines[1]!).debug; + expect(requestDebug.details).toMatchObject({ method: "POST", url: endpoint }); + expect(responseDebug.details).toMatchObject({ + status: 200, + request_id: "advisor-success-request", + }); + } else { + expect(stderr).not.toContain('{"debug":'); + 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/commands/tests/finetune-fee.test.ts b/packages/commands/tests/finetune-fee.test.ts new file mode 100644 index 00000000..d2e8bb3b --- /dev/null +++ b/packages/commands/tests/finetune-fee.test.ts @@ -0,0 +1,93 @@ +import { httpTransport, type HttpDeps, 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 diagnostics to the gateway", async () => { + const originalFetch = httpTransport.fetch; + const diagnostics: unknown[] = []; + 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" }, + }); + + try { + const fee = await computeActualFee(SETTINGS, "qwen3-8b", 1_000_000, { + debug: (diagnostic) => diagnostics.push(diagnostic), + } satisfies Pick); + expect(fee).toEqual({ cost: 2, unitPrice: 2, priceUnit: "每百万tokens" }); + } finally { + httpTransport.fetch = originalFetch; + } + + expect(diagnostics).toHaveLength(2); + expect(diagnostics).toMatchObject([ + { + details: { + method: "POST", + api: "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels", + }, + }, + { details: { status: 200 } }, + ]); + expect(JSON.stringify(diagnostics)).not.toContain("responseBody"); +}); + +test("computeActualFee omits gateway diagnostics when none are injected", 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/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/console/gateway.ts b/packages/core/src/console/gateway.ts index 1e0d7be2..1954400b 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -145,7 +145,7 @@ export async function callConsoleGateway( if (target.token) headers.Authorization = `Bearer ${target.token}`; const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`; - debugRequest(diagnostics, "POST", endpoint, { api }); + debugRequest(diagnostics, "POST", endpoint, { api, region: target.region }); const res = await httpTransport.fetch(endpoint, { method: "POST", diff --git a/packages/core/src/finetune/capability.ts b/packages/core/src/finetune/capability.ts index 28e5d30a..3d5bfc95 100644 --- a/packages/core/src/finetune/capability.ts +++ b/packages/core/src/finetune/capability.ts @@ -1,4 +1,5 @@ import type { Settings } from "../config/schema.ts"; +import type { HttpDeps } from "../client/http.ts"; import { anonymousConsoleCall } from "../console/gateway.ts"; import { findModelByName } from "../console/models.ts"; @@ -109,8 +110,9 @@ export function listSupportedTrainingTypes( export async function fetchModelCapability( settings: Settings, modelName: string, + diagnostics: Pick = {}, ): Promise { // Public model catalog — anonymous gateway call, no console token needed. - const match = await findModelByName(anonymousConsoleCall(settings), modelName); + const match = await findModelByName(anonymousConsoleCall(settings, diagnostics), modelName); return match as ModelCapability | null; } diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 4b9cafef..fb112006 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -6,6 +6,7 @@ import { Client, ExitCode, callConsoleGateway, + anonymousConsoleCall, mapApiError, request, sandboxEndpoint, @@ -272,6 +273,33 @@ test("callConsoleGateway emits injected diagnostics without printing request pay expect(JSON.stringify(diagnostics)).not.toContain("response-body"); }); +test("anonymousConsoleCall forwards injected diagnostics to the gateway", async () => { + const originalFetch = httpTransport.fetch; + const diagnostics: unknown[] = []; + + httpTransport.fetch = async () => + new Response(JSON.stringify({ data: { success: true } }), { + status: 200, + statusText: "OK", + }); + + try { + const settings = testDeps().settings; + await anonymousConsoleCall(settings, { + debug: (diagnostic) => diagnostics.push(diagnostic), + })("test.api", {}); + } finally { + httpTransport.fetch = originalFetch; + } + + expect(diagnostics).toHaveLength(2); + expect(diagnostics).toMatchObject([ + { details: { method: "POST", api: "test.api" } }, + { details: { status: 200 } }, + ]); + expect(JSON.stringify(diagnostics)).not.toContain("params"); +}); + test("callConsoleGateway keeps readable message and raw gateway response separately", async () => { const originalFetch = httpTransport.fetch; const originalWrite = process.stderr.write.bind(process.stderr); diff --git a/packages/core/tests/instrumented-fetch.test.ts b/packages/core/tests/instrumented-fetch.test.ts index 5e21a1ec..63ae2fc0 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,69 @@ test("passes non-URL-parseable inputs through without tracking headers", async ( expect(headers.get("x-dashscope-service")).toBeNull(); }); +test.each([200, 401])("emits safe request and response diagnostics(HTTP %s)", async (status) => { + const diagnostics: unknown[] = []; + const response = new Response("原始正文", { + status, + headers: { "x-request-id": "transport-request" }, + }); + vi.spyOn(httpTransport, "fetch").mockResolvedValue(response); + const result = await createInstrumentedFetch({ + identity, + settings, + debug: (diagnostic) => diagnostics.push(diagnostic), + })("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); + expect(diagnostics).toMatchObject([ + { + details: { + method: "POST", + url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", + }, + }, + { details: { status, request_id: "transport-request" } }, + ]); + expect(JSON.stringify(diagnostics)).not.toContain("test-secret-api-key"); + expect(JSON.stringify(diagnostics)).not.toContain("敏感提示词"); +}); + +test("omits diagnostics when no debug callback is injected and preserves cancellation", 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 |