From 531605a5f27140e449827ae9ca24fbe69cc05f8a Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 11:03:34 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs(div-anthropic):=20Phase=200=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基线: typecheck/test/build 全绿, 38 tests 0 fail, bundle 162.6MB 审计: 运行时 SDK import 6 文件, 类型垫片 103 消费者, 5 个 @anthropic-ai/* 依赖 Co-Authored-By: Claude Fable 5 --- docs/div-anthropic-audit.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/div-anthropic-audit.md diff --git a/docs/div-anthropic-audit.md b/docs/div-anthropic-audit.md new file mode 100644 index 0000000..fc6d630 --- /dev/null +++ b/docs/div-anthropic-audit.md @@ -0,0 +1,31 @@ +# 去 Anthropic SDK 审计清单 + +> 分支 feat/div-anthropic 基线 (2026-08-15) +> typecheck/test/build 全绿 | 38 tests 0 fail | bundle 162650210 bytes + +## 运行时 (value) import @anthropic-ai/sdk + +src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +src/hooks/useCanUseTool.tsx +src/services/api/client.ts +src/services/api/errors.ts +src/services/api/logging.ts +src/services/api/withRetry.ts +src/services/compact/compact.ts +src/tools/BashTool/bashPermissions.ts +src/types/anthropic-protocol.ts +src/utils/permissions/permissions.ts + +## 类型 (type-only) import @anthropic-ai/sdk + +src/types/anthropic-protocol.ts + +## anthropic-protocol.ts 垫片消费者数 + 103 + +## package.json @anthropic-ai/* 依赖 + "@anthropic-ai/claude-agent-sdk": "^0.2.87", + "@anthropic-ai/foundry-sdk": "^0.2.3", + "@anthropic-ai/mcpb": "^2.1.2", + "@anthropic-ai/sandbox-runtime": "^0.0.44", + "@anthropic-ai/sdk": "^0.80.0", From 70be9108b67f402f92b680d41983cc973e1ed9ef Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 11:09:18 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(llm):=20Phase=201=20=E4=B8=AD?= =?UTF-8?q?=E7=AB=8B=E7=B1=BB=E5=9E=8B=E5=B1=82=20=E2=80=94=20LlmAdapter/S?= =?UTF-8?q?treamChunk/GenerateOptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/services/llm/types.ts: provider 中立 LLM 接缝类型, 参考 deepseek-harness - StreamChunk 联合与 claude.ts 现有 SSE switch 分支逐一映射 - LlmFailure 稳定错误码 (AUTH/RATE_LIMIT/...) 替代 instanceof APIError - LlmAdapter 接口: 唯一必需方法 stream() (静态分派, 非 Cordis) 单测: 适配器契约/chunk 穷尽性/错误码稳定性 (4 用例, 38→42) 零行为变更: 纯新增类型层, 运行时仍用 Anthropic SDK checkpoint: typecheck/test/build 全绿 Co-Authored-By: Claude Fable 5 --- src/__tests__/llm/types.test.ts | 122 ++++++++++++++++++++++++ src/services/llm/types.ts | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/__tests__/llm/types.test.ts create mode 100644 src/services/llm/types.ts diff --git a/src/__tests__/llm/types.test.ts b/src/__tests__/llm/types.test.ts new file mode 100644 index 0000000..e02b6ae --- /dev/null +++ b/src/__tests__/llm/types.test.ts @@ -0,0 +1,122 @@ +// LLM 接缝中立类型 — 结构与契约单测 +// +// 验证: (1) 中立类型可被适配器实现满足; (2) chunk 联合可按 type 穷举 (exhaustive); +// (3) 错误码集合稳定。纯类型层, 不涉及网络。 + +import { describe, expect, test } from "bun:test"; +import type { + FinishReason, + LlmAdapter, + LlmErrorCode, + LlmFailure, + StreamChunk, +} from "../../services/llm/types.js"; + +// 一个最小可用适配器: 把预设 chunk 流式吐出, 用于验证 LlmAdapter 契约。 +function makeFakeAdapter(chunks: StreamChunk[]): LlmAdapter { + return { + async *stream(_options) { + for (const c of chunks) { + yield c; + } + }, + }; +} + +async function collect(iter: AsyncIterable): Promise { + const out: StreamChunk[] = []; + for await (const c of iter) { + out.push(c); + } + return out; +} + +describe("llm neutral types", () => { + test("adapter.stream yields chunks in order", async () => { + const adapter = makeFakeAdapter([ + { type: "message-start", usage: { inputTokens: 10, outputTokens: 0 } }, + { type: "block-start", index: 0, block: { type: "text", text: "" } }, + { type: "text-delta", index: 0, text: "hello" }, + { type: "block-end", index: 0 }, + { type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }, + { type: "finish", reason: "end_turn" }, + ]); + const got = await collect(adapter.stream({ model: "m", messages: [] })); + expect(got.map((c) => c.type)).toEqual([ + "message-start", + "block-start", + "text-delta", + "block-end", + "usage", + "finish", + ]); + }); + + test("StreamChunk type union is exhaustive over known events", () => { + const all: StreamChunk["type"][] = [ + "message-start", + "block-start", + "text-delta", + "thinking-delta", + "tool-call-delta", + "connector-delta", + "block-end", + "usage", + "finish", + ]; + // 编译期穷尽性: switch 覆盖所有分支, default 不可达。 + const seen = new Set(); + for (const c of all) { + switch (c) { + case "message-start": + case "block-start": + case "text-delta": + case "thinking-delta": + case "tool-call-delta": + case "connector-delta": + case "block-end": + case "usage": + case "finish": + seen.add(c); + break; + default: { + const _exhaustive: never = c; + throw new Error(`unhandled chunk type: ${_exhaustive as string}`); + } + } + } + expect(seen.size).toBe(all.length); + }); + + test("LlmFailure carries stable error codes", () => { + const codes: LlmErrorCode[] = [ + "AUTH", + "RATE_LIMIT", + "INVALID_REQUEST", + "SERVER", + "TIMEOUT", + "TRANSPORT", + "ABORTED", + ]; + const f: LlmFailure = { + code: "RATE_LIMIT", + message: "429", + status: 429, + providerRetryAfterMs: 1000, + }; + expect(codes).toContain(f.code); + expect(f.status).toBe(429); + }); + + test("FinishReason covers terminal states including aborted/error", () => { + const reasons: FinishReason[] = [ + "end_turn", + "tool_use", + "max_tokens", + "stop_sequence", + "aborted", + "error", + ]; + expect(new Set(reasons).size).toBe(reasons.length); + }); +}); diff --git a/src/services/llm/types.ts b/src/services/llm/types.ts new file mode 100644 index 0000000..3c55f0d --- /dev/null +++ b/src/services/llm/types.ts @@ -0,0 +1,158 @@ +// LLM 接缝 — provider 中立类型 (参考 deepseek-harness LlmAdapter) +// +// 这是 fusion-code 去 Anthropic SDK 的核心抽象层。所有 provider (fusion-mlx / firstParty / +// openai / foundry / bedrock / vertex) 经各自的 LlmAdapter 把线上消息翻译成下面的中立 chunk。 +// 主调用循环 (src/services/api/claude.ts) 消费 AsyncIterable, 不再 instanceof SDK 类型。 +// +// 设计依据: claude.ts 现有 switch(part.type) 消费 Anthropic SSE 事件, 本类型与之逐一映射。 + +// ─── 内容块类型 ───────────────────────────────────────────── +// 中立内容块标签, 与 Anthropic content_block.type 对齐, 便于适配器零损耗映射。 +export type ContentBlockType = + | "text" + | "thinking" + | "tool_use" + | "server_tool_use" + | "tool_result" + | "connector_text"; + +// ─── token 计费 ───────────────────────────────────────────── +// cache 字段可选: 仅 provider 上报非零时出现 (参考 dsh TokenUsage)。 +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; +} + +// ─── 流式 chunk ───────────────────────────────────────────── +// 适配器把一次模型调用流式吐成下列 chunk。block index 关联同一块的交错 delta。 +// 与 claude.ts 现有 Anthropic SSE 事件 switch 分支逐一对应: +// message-start ← message_start (携带初始 usage / ttft) +// block-start ← content_block_start +// text-delta ← content_block_delta (text_delta) +// thinking-delta ← content_block_delta (thinking_delta / signature_delta) +// tool-call-delta ← content_block_delta (input_json_delta) +// connector-delta ← content_block_delta (connector_text_delta) +// block-end ← content_block_stop +// usage ← message_delta.usage +// finish ← message_stop (+ stop_reason → FinishReason) +export type StreamChunk = + | { type: "message-start"; usage?: TokenUsage } + | { type: "block-start"; index: number; block: RawContentBlock } + | { type: "text-delta"; index: number; text: string } + | { type: "thinking-delta"; index: number; text: string; signature?: string } + | { type: "tool-call-delta"; index: number; argumentsDelta: string } + | { type: "connector-delta"; index: number; text: string } + | { type: "block-end"; index: number } + | { type: "usage"; usage: TokenUsage } + | { type: "finish"; reason: FinishReason }; + +// block-start 携带的原始内容块 (展开字段, 非 SDK 类型)。 +// 适配器从 provider wire 原样搬运, 主循环按 type 分派累积。 +export interface RawContentBlock { + type: ContentBlockType; + // text/thinking 块 + text?: string; + thinking?: string; + signature?: string; + // tool_use / server_tool_use 块 + id?: string; + name?: string; + input?: string | Record; + // tool_result 块 + toolUseId?: string; + content?: unknown; + isError?: boolean; + // 透传未识别字段 (advisor_tool_result 等 server 扩展) + [extra: string]: unknown; +} + +// ─── 结束原因 ─────────────────────────────────────────────── +// 参考 dsh FinishReasonMap: 稳定中立码, 非 SDK 的 stop_reason 字符串。 +export type FinishReason = + | "end_turn" + | "tool_use" + | "max_tokens" + | "stop_sequence" + | "aborted" + | "error"; + +// ─── 失败 ─────────────────────────────────────────────────── +// provider 中立失败事实 (参考 dsh LlmFailure)。替代 instanceof APIError 判定。 +// code 是稳定机器路由码; withRetry/errors 据此判重试/分类。 +export type LlmErrorCode = + | "AUTH" + | "RATE_LIMIT" + | "INVALID_REQUEST" + | "SERVER" + | "TIMEOUT" + | "TRANSPORT" + | "ABORTED"; + +export interface LlmFailure { + code: LlmErrorCode; + message: string; + status?: number; + providerRetryAfterMs?: number; + requestId?: string; +} + +// ─── 工具 schema ──────────────────────────────────────────── +// 送往模型的工具描述 (JSON Schema 参数)。与 dsh ToolSchema 对齐。 +export interface ToolSchema { + name: string; + description: string; + parameters: Record; +} + +// ─── 请求选项 ─────────────────────────────────────────────── +// 一次完整组装的模型请求 (参考 dsh GenerateOptions)。 +// messages 是中立结构 (与 Anthropic MessageParam 形状一致: role + content)。 +// 适配器负责映射到 provider wire 格式。 +export interface GenerateOptions { + model: string; + messages: NeutralMessage[]; + system?: string | NeutralSystemBlock[]; + tools?: ToolSchema[]; + temperature?: number; + maxTokens?: number; + stop?: string[]; + thinking?: { type: "enabled"; budgetTokens: number } | { type: "disabled" }; + signal?: AbortSignal; + // 请求来源标记, 透传到适配器做路由/日志 (如 compaction / session-title 辅助调用)。 + purpose?: "compaction" | "session-title" | "main"; +} + +export interface NeutralMessage { + role: "user" | "assistant"; + content: string | NeutralContentBlock[]; +} + +export type NeutralContentBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "tool_use"; id: string; name: string; input: Record | string } + | { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean }; + +export type NeutralSystemBlock = { type: "text"; text: string; cache_control?: unknown }; + +// ─── 适配器接口 ───────────────────────────────────────────── +// 参考 dsh abstract class LlmAdapter: 唯一必需方法 stream()。 +// fusion-code 用静态分派 (registry.ts 按 APIProvider 返回实例), 不引入 Cordis 运行时注册。 +export interface LlmAdapter { + // 唯一必需方法: 把一次模型调用流式吐成中立 chunk。实现须遵守 options.signal。 + stream(options: GenerateOptions): AsyncIterable; + // 可选: provider 显示名 + providerInfo?(): { id: string; name: string }; + // 可选: 列出可宣传的模型 (advisory, 不做请求校验) + listModels?(): Promise; + // 可选: 解析单个模型元数据 (context window / 默认 max_tokens / reasoning) + resolveModel?(model: string, signal?: AbortSignal): Promise<{ + id: string; + name: string; + contextWindow?: number; + defaultMaxTokens?: number; + }>; +} From af809f039cfabf24fff9ad03981970347c511e26 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 11:43:52 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(llm):=20Phase=202=20HTTP/SSE=20?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=20=E2=80=94=20provider=20?= =?UTF-8?q?=E4=B8=AD=E7=AB=8B=E7=9A=84=E6=B5=81=E8=A7=A3=E6=9E=90/?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB/HTTP=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去除 Anthropic SDK 的接缝层基础设施 (暂不接入主循环, 纯新增): - src/services/llm/sseStream.ts: parseSseStream — 按 SSE 帧边界 (空行派发, data:/event:/: 注释) 解析 fetch Response.body, 多行 data 用 \n 连接, 跨 chunk 边界 buffer carryover, AbortSignal 中断抛 AbortError。 替代 SDK 的 Stream/BetaMessageStream (claude.ts 已绕过 BetaMessageStream)。 - src/services/llm/errors.ts: classifyError/isRetryable/LlmRequestError — provider 中立错误码 (AUTH/RATE_LIMIT/INVALID_REQUEST/SERVER/TIMEOUT/ TRANSPORT/ABORTED), 替代 instanceof APIError。AbortError 优先且兼容 DOMException 与任意 .name="AbortError" 的 Error。 - src/services/llm/httpClient.ts: postMessages — 复用现有 client.ts 的 getUserAgent/getProxyFetchOptions/getAnthropicApiKey/computeCch/ CLIENT_REQUEST_ID_HEADER, firstParty 走 cch 签名, 非 2xx/fetch 失败 抛 LlmRequestError (含分类后的 LlmFailure)。 - 单测: sseStream 10 例 (含跨 chunk/心跳/中断), errors 20 例 (status/ message/Abort 优先/重试判定)。types 4 例 (Phase 1) 共 34 例全绿。 checkpoint: typecheck ✓ / 72 tests pass ✓ / build ✓ Co-Authored-By: Claude Fable 5 --- src/__tests__/llm/errors.test.ts | 96 +++++++++++++++++++ src/__tests__/llm/sseStream.test.ts | 94 ++++++++++++++++++ src/services/llm/errors.ts | 98 +++++++++++++++++++ src/services/llm/httpClient.ts | 143 ++++++++++++++++++++++++++++ src/services/llm/sseStream.ts | 108 +++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 src/__tests__/llm/errors.test.ts create mode 100644 src/__tests__/llm/sseStream.test.ts create mode 100644 src/services/llm/errors.ts create mode 100644 src/services/llm/httpClient.ts create mode 100644 src/services/llm/sseStream.ts diff --git a/src/__tests__/llm/errors.test.ts b/src/__tests__/llm/errors.test.ts new file mode 100644 index 0000000..1878008 --- /dev/null +++ b/src/__tests__/llm/errors.test.ts @@ -0,0 +1,96 @@ +// 错误分类单测 — 覆盖 status / message / Abort / 重试判定 + +import { describe, expect, test } from "bun:test"; +import { + classifyError, + isRetryable, + LlmRequestError, +} from "../../services/llm/errors.js"; + +describe("classifyError by status", () => { + test("401 maps to AUTH", () => { + expect(classifyError(new Error("boom"), 401).code).toBe("AUTH"); + }); + test("403 maps to AUTH", () => { + expect(classifyError(new Error("denied"), 403).code).toBe("AUTH"); + }); + test("429 maps to RATE_LIMIT", () => { + expect(classifyError(new Error("slow"), 429).code).toBe("RATE_LIMIT"); + }); + test("529 maps to RATE_LIMIT", () => { + expect(classifyError(new Error("overloaded"), 529).code).toBe("RATE_LIMIT"); + }); + test("400 maps to INVALID_REQUEST", () => { + expect(classifyError(new Error("bad"), 400).code).toBe("INVALID_REQUEST"); + }); + test("500 maps to SERVER", () => { + expect(classifyError(new Error("oops"), 500).code).toBe("SERVER"); + }); + test("503 maps to SERVER", () => { + expect(classifyError(new Error("unavailable"), 503).code).toBe("SERVER"); + }); +}); + +describe("classifyError by message fallback", () => { + test("timeout keyword", () => { + expect(classifyError(new Error("request timed out")).code).toBe("TIMEOUT"); + }); + test("abort keyword", () => { + expect(classifyError(new Error("operation was aborted")).code).toBe("ABORTED"); + }); + test("rate limit keyword", () => { + expect(classifyError(new Error("rate limit exceeded")).code).toBe("RATE_LIMIT"); + }); + test("auth keyword", () => { + expect(classifyError(new Error("invalid api key")).code).toBe("AUTH"); + }); + test("unknown server-ish defaults to TRANSPORT", () => { + expect(classifyError(new Error("ECONNREFUSED some host")).code).toBe("TRANSPORT"); + }); +}); + +describe("classifyError AbortError priority", () => { + test("AbortError overrides status", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(classifyError(err, 500).code).toBe("ABORTED"); + }); +}); + +describe("isRetryable", () => { + test("RATE_LIMIT retryable", () => { + expect(isRetryable(classifyError(new Error("x"), 429))).toBe(true); + }); + test("SERVER retryable", () => { + expect(isRetryable(classifyError(new Error("x"), 500))).toBe(true); + }); + test("TRANSPORT retryable", () => { + expect(isRetryable(classifyError(new Error("ECONNRESET")))).toBe(true); + }); + test("AUTH not retryable", () => { + expect(isRetryable(classifyError(new Error("x"), 401))).toBe(false); + }); + test("INVALID_REQUEST not retryable", () => { + expect(isRetryable(classifyError(new Error("x"), 400))).toBe(false); + }); + test("ABORTED not retryable", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(isRetryable(classifyError(err))).toBe(false); + }); +}); + +describe("LlmRequestError", () => { + test("carries failure with status and message", () => { + try { + throw new LlmRequestError(classifyError(new Error("nope"), 429, "req-1")); + } catch (e) { + expect(e).toBeInstanceOf(LlmRequestError); + const lr = e as LlmRequestError; + expect(lr.failure.code).toBe("RATE_LIMIT"); + expect(lr.failure.status).toBe(429); + expect(lr.failure.requestId).toBe("req-1"); + expect(lr.message).toContain("nope"); + } + }); +}); diff --git a/src/__tests__/llm/sseStream.test.ts b/src/__tests__/llm/sseStream.test.ts new file mode 100644 index 0000000..14ea317 --- /dev/null +++ b/src/__tests__/llm/sseStream.test.ts @@ -0,0 +1,94 @@ +// SSE 解析单测 — 覆盖多行 data / event / 注释 / 跨 chunk 边界 / 中断 + +import { describe, expect, test } from "bun:test"; +import { parseSseStream } from "../../services/llm/sseStream.js"; + +// 从字符串序列构造 ReadableStream (每个字符串模拟一个到达的 chunk)。 +function makeStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const c of chunks) { + controller.enqueue(encoder.encode(c)); + } + controller.close(); + }, + }); +} + +async function collect(body: ReadableStream, signal?: AbortSignal) { + const out: { event: string; data: string }[] = []; + for await (const e of parseSseStream(body, signal)) { + out.push({ event: e.event, data: e.data }); + } + return out; +} + +describe("parseSseStream", () => { + test("single data event", async () => { + const body = makeStream(["data: hello\n\n"]); + expect(await collect(body)).toEqual([{ event: "message", data: "hello" }]); + }); + + test("multi-line data joined with newline", async () => { + const body = makeStream(["data: line1\ndata: line2\n\n"]); + expect(await collect(body)).toEqual([ + { event: "message", data: "line1\nline2" }, + ]); + }); + + test("event field sets type", async () => { + const body = makeStream(["event: content_block_start\ndata: {}\n\n"]); + expect(await collect(body)).toEqual([ + { event: "content_block_start", data: "{}" }, + ]); + }); + + test("comment and heartbeat lines ignored", async () => { + const body = makeStream([": heartbeat\ndata: a\n\n: comment\n\n"]); + expect(await collect(body)).toEqual([{ event: "message", data: "a" }]); + }); + + test("exactly one leading space after colon stripped (SSE spec)", async () => { + // SSE 规范: 冒号后仅剥一个可选空格, 其余保留 + const body = makeStream(["data: spaced\n\n"]); + expect(await collect(body)).toEqual([{ event: "message", data: " spaced" }]); + }); + + test("chunk split across boundary carries buffer", async () => { + const body = makeStream(["data: hel", "lo\n\n"]); + expect(await collect(body)).toEqual([{ event: "message", data: "hello" }]); + }); + + test("multiple events in one chunk", async () => { + const body = makeStream(["data: one\n\ndata: two\n\n"]); + expect(await collect(body)).toEqual([ + { event: "message", data: "one" }, + { event: "message", data: "two" }, + ]); + }); + + test("trailing event without final empty line still dispatched", async () => { + const body = makeStream(["event: finish\ndata: done\n\n"]); + expect(await collect(body)).toEqual([ + { event: "finish", data: "done" }, + ]); + }); + + test("abort signal throws AbortError", async () => { + const ac = new AbortController(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: x")); + ac.abort(); + controller.close(); + }, + }); + await expect(collect(body, ac.signal)).rejects.toBeInstanceOf(DOMException); + }); + + test("empty data field yields empty string", async () => { + const body = makeStream(["data:\n\n"]); + expect(await collect(body)).toEqual([{ event: "message", data: "" }]); + }); +}); diff --git a/src/services/llm/errors.ts b/src/services/llm/errors.ts new file mode 100644 index 0000000..fe54ba5 --- /dev/null +++ b/src/services/llm/errors.ts @@ -0,0 +1,98 @@ +// LLM 接缝 — provider 中立错误分类 (参考 dsh classifyPiAiError) +// +// 替代 src/services/api/errors.ts / withRetry.ts 中的 instanceof APIError 判定。 +// 把 fetch 异常与 HTTP 非 2xx 归为稳定 LlmFailure.code, withRetry 据此判重试。 + +import type { LlmFailure, LlmErrorCode } from "./types.js"; + +// 从 HTTP 状态码 + 错误信息推断稳定错误码。 +export function classifyByStatus(status: number): LlmErrorCode { + if (status === 401 || status === 403) return "AUTH"; + if (status === 429 || status === 529) return "RATE_LIMIT"; + if (status === 400) return "INVALID_REQUEST"; + if (status >= 500) return "SERVER"; + return "INVALID_REQUEST"; +} + +// 从 Error 实例名/信息推断传输层错误码 (无 HTTP 状态时)。 +export function classifyByMessage(message: string): LlmErrorCode { + if (/\b401\b|\b403\b|unauthor|forbidden|invalid.*api.*key/i.test(message)) + return "AUTH"; + if (/\b429\b|rate.?limit|too many requests/i.test(message)) return "RATE_LIMIT"; + if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST"; + if (/\b5\d\d\b|internal server|bad gateway|service unavail/i.test(message)) + return "SERVER"; + if (/timeout|timed?\s*out/i.test(message)) return "TIMEOUT"; + if ( + /(?:network|connection|socket|fetch|econn\w*|terminated|premature close|other side closed)/i.test( + message, + ) + ) + return "TRANSPORT"; + if (/abort/i.test(message)) return "ABORTED"; + return "SERVER"; +} + +// 统一入口: 把任意异常 + 可选 HTTP 状态归为 LlmFailure。 +export function classifyError( + error: unknown, + status?: number, + requestId?: string, +): LlmFailure { + const message = + error instanceof Error ? error.message : String(error ?? "unknown error"); + + // 中断优先 (AbortError 不可重试, 且 status 无意义)。 + // 兼容 DOMException 与任意把 .name 设为 "AbortError" 的 Error (fetch/AbortController 约定)。 + if ( + (error instanceof DOMException && error.name === "AbortError") || + (error as { name?: string })?.name === "AbortError" || + /abort/i.test(message) + ) { + return { code: "ABORTED", message, requestId }; + } + + let code: LlmErrorCode; + if (typeof status === "number" && status >= 400) { + code = classifyByStatus(status); + } else { + code = classifyByMessage(message); + } + + // provider Retry-After 头 (秒) 转 ms, 仅对 RATE_LIMIT 有意义 + let providerRetryAfterMs: number | undefined; + if (code === "RATE_LIMIT") { + providerRetryAfterMs = extractRetryAfterMs(error); + } + + return { code, message, status, providerRetryAfterMs, requestId }; +} + +// 可重试码: 限流 / 服务端错误 / 传输层 / 超时。AUTH/INVALID_REQUEST/ABORTED 不重试。 +export function isRetryable(failure: LlmFailure): boolean { + return ( + failure.code === "RATE_LIMIT" || + failure.code === "SERVER" || + failure.code === "TRANSPORT" || + failure.code === "TIMEOUT" + ); +} + +// 把 LlmFailure 抛出为一个带 code 的 Error, 供 try/catch 处再 classifyError 还原。 +export class LlmRequestError extends Error { + readonly failure: LlmFailure; + constructor(failure: LlmFailure) { + super(failure.message); + this.name = "LlmRequestError"; + this.failure = failure; + } +} + +// 从 Error 上探测 Retry-After (秒)。适配器/httpClient 可在 error 上挂 _retryAfterSec。 +function extractRetryAfterMs(error: unknown): number | undefined { + const sec = (error as { _retryAfterSec?: number })?._retryAfterSec; + if (typeof sec === "number" && sec >= 0) { + return Math.round(sec * 1000); + } + return undefined; +} diff --git a/src/services/llm/httpClient.ts b/src/services/llm/httpClient.ts new file mode 100644 index 0000000..16833b2 --- /dev/null +++ b/src/services/llm/httpClient.ts @@ -0,0 +1,143 @@ +// LLM 接缝 — 极简 HTTP 客户端 (POST /v1/messages -> SSE 流) +// +// 替代 Anthropic SDK 客户端: 直接 fetch + 复用现有 auth/proxy/cch 工具, 不经 SDK。 +// 适配器 (adapter.ts/mlxAdapter.ts) 调此函数拿到 SSE 流后, 用 sseStream.parseSseStream 消费。 +// +// 复用 (不重写) 既有逻辑: +// src/utils/http.ts getUserAgent +// src/utils/proxy.ts getProxyFetchOptions +// src/utils/auth.ts getAnthropicApiKey +// src/utils/cch.ts computeCch/replaceCchPlaceholder/hasCchPlaceholder + +import { randomUUID } from "node:crypto"; +import { + computeCch, + hasCchPlaceholder, + replaceCchPlaceholder, +} from "../../utils/cch.js"; +import { getAnthropicApiKey } from "../../utils/auth.js"; +import { getUserAgent } from "../../utils/http.js"; +import { getProxyFetchOptions } from "../../utils/proxy.js"; +import { logForDebugging } from "../../utils/debug.js"; +import { CLIENT_REQUEST_ID_HEADER } from "../api/client.js"; +import { classifyError, LlmRequestError } from "./errors.js"; + +export interface PostMessagesOptions { + baseUrl: string; + body: string; + apiKey?: string; + authToken?: string; + extraHeaders?: Record; + firstParty?: boolean; + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface PostMessagesResult { + response: Response; + requestId?: string; +} + +// POST 一个 /v1/messages 流式请求, 返回 SSE Response 与 request_id。 +// 非 2xx 或 fetch 异常 -> 抛 LlmRequestError (携带 LlmFailure, withRetry 据此判重试)。 +export async function postMessages( + opts: PostMessagesOptions, +): Promise { + const url = joinUrl(opts.baseUrl, "/v1/messages"); + const headers = buildHeaders(opts); + let body = opts.body; + + // cch 签名: 仅 firstParty 直连时 + if (opts.firstParty && hasCchPlaceholder(body)) { + try { + const cch = await computeCch(body); + body = replaceCchPlaceholder(body, cch); + logForDebugging(`[llm:http] signed request cch=${cch}`); + } catch { + // cch 失败不阻断请求 (与现有 buildFetch 行为一致) + } + } + + const fetchOptions: RequestInit & { dispatcher?: unknown } = { + method: "POST", + headers, + body, + signal: opts.signal, + ...getProxyFetchOptions({ forAnthropicAPI: true }), + }; + if (opts.timeoutMs) { + // @ts-expect-error Bun/Node fetch 接受 timeout + fetchOptions.timeout = opts.timeoutMs; + } + + let response: Response; + try { + response = await fetch(url, fetchOptions as RequestInit); + } catch (error) { + const failure = classifyError(error, undefined, undefined); + logForDebugging(`[llm:http] fetch failed: ${failure.code} ${failure.message}`); + throw new LlmRequestError(failure); + } + + if (!response.ok) { + const requestId = response.headers.get("request-id") ?? undefined; + let statusText = ""; + let retryAfterSec: number | undefined; + try { + statusText = await response.text(); + const ra = response.headers.get("retry-after"); + if (ra) retryAfterSec = Number.parseInt(ra, 10); + } catch { + // 读 body 失败忽略 + } + const wrapped: Error & { _retryAfterSec?: number } = new Error( + `${response.status} ${response.statusText}: ${statusText}`, + ); + if (Number.isFinite(retryAfterSec)) { + wrapped._retryAfterSec = retryAfterSec; + } + const failure = classifyError(wrapped, response.status, requestId); + logForDebugging( + `[llm:http] non-2xx ${response.status} ${failure.code} ${failure.message}`, + ); + throw new LlmRequestError(failure); + } + + const requestId = response.headers.get("request-id") ?? undefined; + return { response, requestId }; +} + +function buildHeaders(opts: PostMessagesOptions): Record { + const h: Record = { + "content-type": "application/json", + "user-agent": getUserAgent(), + "anthropic-version": "2023-06-01", + }; + if (opts.apiKey) { + h["x-api-key"] = opts.apiKey; + } else if (opts.authToken) { + h["authorization"] = `Bearer ${opts.authToken}`; + } else if (opts.firstParty) { + const key = getAnthropicApiKey(); + if (key) h["x-api-key"] = key; + } + if (opts.firstParty) { + h[CLIENT_REQUEST_ID_HEADER] = randomUUID(); + } + if (opts.extraHeaders) { + for (const [k, v] of Object.entries(opts.extraHeaders)) { + h[k.toLowerCase()] = v; + } + } + return h; +} + +function joinUrl(base: string, path: string): string { + if (base.endsWith("/") && path.startsWith("/")) { + return base.slice(0, -1) + path; + } + if (!base.endsWith("/") && !path.startsWith("/")) { + return `${base}/${path}`; + } + return base + path; +} diff --git a/src/services/llm/sseStream.ts b/src/services/llm/sseStream.ts new file mode 100644 index 0000000..63b0a3d --- /dev/null +++ b/src/services/llm/sseStream.ts @@ -0,0 +1,108 @@ +// 通用 SSE 解析 (text/event-stream -> 事件流) +// +// 替代 Anthropic SDK 的 Stream: 把 fetch Response.body 按字节流读入, 按 SSE 帧边界 +// (空行分隔事件, data:/event:/: 注释行) 切成结构化事件。 +// +// 规范依据: MDN Server-sent events +// - 多行 data: 用 "\n" 连接为单个 data 字段 +// - event: 行指定事件类型 (缺省 "message") +// - 以 ":" 开头为注释/心跳, 忽略 +// - 一个空行触发一个事件派发 +// +// 容错: 部分块跨 chunk 边界时, 未完成的行留在 buffer 直到下次读取 (参考 fusion-mlx-stream.ts 现有实现)。 + +export interface SseEvent { + event: string; + data: string; + id?: string; +} + +// 把一个 ReadableStream (fetch Response.body) 解析成 SseEvent 异步迭代器。 +export async function* parseSseStream( + body: ReadableStream, + signal?: AbortSignal, +): AsyncIterable { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let event = "message"; + let dataLines: string[] = []; + let lastId: string | undefined; + + try { + while (true) { + if (signal?.aborted) { + throw new DOMException("SSE stream aborted", "AbortError"); + } + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // 按换行切行; 末尾未完成行留在 buffer + const lines = buffer.split(/\r\n|\r|\n/); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + // 空行: 派发当前事件并重置 + if (line === "") { + if (dataLines.length > 0) { + yield { + event, + data: dataLines.join("\n"), + id: lastId, + }; + } + event = "message"; + dataLines = []; + continue; + } + // 注释/心跳 + if (line.startsWith(":")) continue; + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { + continue; + } + const field = line.slice(0, colonIdx); + let val = line.slice(colonIdx + 1); + if (val.startsWith(" ")) val = val.slice(1); + switch (field) { + case "event": + event = val; + break; + case "data": + dataLines.push(val); + break; + case "id": + lastId = val; + break; + case "retry": + break; + default: + break; + } + } + } + + // 处理尾部残留 buffer (流未以空行结尾的边界) + buffer += decoder.decode(); + if (buffer !== "") { + const trailing = buffer.split(/\r\n|\r|\n/); + for (const line of trailing) { + if (line === "") continue; + if (line.startsWith(":")) continue; + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const field = line.slice(0, colonIdx); + let val = line.slice(colonIdx + 1); + if (val.startsWith(" ")) val = val.slice(1); + if (field === "data") dataLines.push(val); + else if (field === "event") event = val; + } + } + if (dataLines.length > 0) { + yield { event, data: dataLines.join("\n"), id: lastId }; + } + } finally { + reader.releaseLock(); + } +} From 1282cadadf5d651fafb61b53dd5ee2164083378f Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 11:56:52 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(llm):=20Phase=203=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=20=E2=80=94=20AnthropicWireAdapter/MLX=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8/=E6=B3=A8=E5=86=8C=E8=A1=A8=20(feat?= =?UTF-8?q?ure=20flag=20=E5=AE=88=E6=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接缝层适配器实现 (纯新增, LLM_ADAPTER_SEAM feature 守护, 默认关闭走 SDK): - src/services/llm/adapter.ts: AnthropicWireAdapter - buildRequestBody: GenerateOptions -> /v1/messages JSON (model/messages/ system[字符串|块数组]/tools->input_schema/max_tokens/stream/thinking/ temperature/stop_sequences + extraBody 透传 betas/metadata 等扩展字段) 依据 claude.ts:1539 paramsFromContext 实际形状。 - sseToChunk: Anthropic SSE 事件 -> StreamChunk 中立词表 (message_start->message-start, content_block_start->block-start, text_delta/thinking_delta/signature_delta/input_json_delta/ connector_text_delta -> 对应 delta chunk, content_block_stop->block-end, message_delta->usage(记 stop_reason), message_stop->finish)。 依据 claude.ts:1975 switch。signature_delta 归 thinking 块 (零宽+signature)。 - AnthropicWireAdapter.stream: postMessages + parseSseStream + sseToChunk。 - src/services/llm/mlxAdapter.ts: createMlxAdapter - 复用 createFusionMlxFetch (fetch override, 内部 Anthropic<->OpenAI 转译, 响应已 encodeStreamToAnthropicSSE), 故 MLX 路径直接复用 AnthropicWireAdapter 的 SSE 解析。baseUrl 占位 (override 按 url.includes("/v1/messages") 拦截)。 - src/services/llm/registry.ts: getLlmAdapter(provider, model) - 按 APIProvider 静态分发。seam 关闭 (feature 宏 false) 返回 null -> 调用方 回退 SDK, 实现 instant rollback。seam 期 firstParty+fusionMlx 走新适配器, bedrock/vertex/foundry/openai 暂返回 null (Phase 4/5 迁移)。 - feature() 直接用在 if 里 (Bun DCE 宏约束, 不可包在返回它的函数中)。 - src/services/llm/httpClient.ts: PostMessagesOptions 增 fetchFn 可选注入 (MLX 路径用 createFusionMlxFetch 作为 fetch)。 - scripts/build.ts: fullExperimentalFeatures 注册 LLM_ADAPTER_SEAM。 - 单测: adapter 29 例 (buildRequestBody 10 + sseToChunk 19), registry 4 例 (seam 关闭可空契约; on-path 由带 flag 构建的集成测试覆盖)。共 63 llm 例全绿。 checkpoint: typecheck ✓ / 101 tests pass ✓ / build ✓ (含 --feature=LLM_ADAPTER_SEAM 构建 ✓) Co-Authored-By: Claude Fable 5 --- scripts/build.ts | 1 + src/__tests__/llm/adapter.test.ts | 272 ++++++++++++++++++++++++++ src/__tests__/llm/registry.test.ts | 31 +++ src/services/llm/adapter.ts | 302 +++++++++++++++++++++++++++++ src/services/llm/httpClient.ts | 6 +- src/services/llm/mlxAdapter.ts | 28 +++ src/services/llm/registry.ts | 65 +++++++ 7 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/llm/adapter.test.ts create mode 100644 src/__tests__/llm/registry.test.ts create mode 100644 src/services/llm/adapter.ts create mode 100644 src/services/llm/mlxAdapter.ts create mode 100644 src/services/llm/registry.ts diff --git a/scripts/build.ts b/scripts/build.ts index ccaa4d6..d767ef1 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -23,6 +23,7 @@ const fullExperimentalFeatures = [ "EXTRACT_MEMORIES", "HISTORY_PICKER", "HOOK_PROMPTS", + "LLM_ADAPTER_SEAM", "MCP_RICH_OUTPUT", "MESSAGE_ACTIONS", "NATIVE_CLIPBOARD_IMAGE", diff --git a/src/__tests__/llm/adapter.test.ts b/src/__tests__/llm/adapter.test.ts new file mode 100644 index 0000000..c404c05 --- /dev/null +++ b/src/__tests__/llm/adapter.test.ts @@ -0,0 +1,272 @@ +// AnthropicWireAdapter 单测 — 请求体构造 + SSE->StreamChunk 映射 (不经网络) + +import { describe, expect, test } from "bun:test"; +import { + buildRequestBody, + sseToChunk, + type SseState, +} from "../../services/llm/adapter.js"; +import type { GenerateOptions } from "../../services/llm/types.js"; + +function baseOptions(over: Partial = {}): GenerateOptions { + return { + model: "claude-test", + messages: [{ role: "user", content: "hi" }], + ...over, + }; +} + +describe("buildRequestBody", () => { + test("minimal request has model/messages/max_tokens/stream", () => { + const body = buildRequestBody(baseOptions()); + expect(body.model).toBe("claude-test"); + expect(body.stream).toBe(true); + expect(body.max_tokens).toBe(4096); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + }); + + test("respects custom maxTokens", () => { + const body = buildRequestBody(baseOptions({ maxTokens: 100 })); + expect(body.max_tokens).toBe(100); + }); + + test("string system maps to system string", () => { + const body = buildRequestBody(baseOptions({ system: "be brief" })); + expect(body.system).toBe("be brief"); + }); + + test("block system maps to array with cache_control", () => { + const body = buildRequestBody({ + model: "m", + messages: [], + system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }], + }); + expect(body.system).toEqual([ + { type: "text", text: "sys", cache_control: { type: "ephemeral" } }, + ]); + }); + + test("tools map to input_schema", () => { + const body = buildRequestBody( + baseOptions({ + tools: [ + { + name: "get_weather", + description: "weather", + parameters: { type: "object", properties: {} }, + }, + ], + }), + ); + expect(body.tools).toEqual([ + { + name: "get_weather", + description: "weather", + input_schema: { type: "object", properties: {} }, + }, + ]); + }); + + test("tool_use block passes input through", () => { + const body = buildRequestBody({ + model: "m", + messages: [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "t1", name: "f", input: { a: 1 } }, + ], + }, + ], + }); + expect(body.messages).toEqual([ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } }], + }, + ]); + }); + + test("tool_result block maps tool_use_id and is_error", () => { + const body = buildRequestBody({ + model: "m", + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: "ok", + is_error: false, + }, + ], + }, + ], + }); + expect(body.messages).toEqual([ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: "ok", + is_error: false, + }, + ], + }, + ]); + }); + + test("thinking enabled maps budget_tokens", () => { + const body = buildRequestBody( + baseOptions({ thinking: { type: "enabled", budgetTokens: 2048 } }), + ); + expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 2048 }); + }); + + test("temperature and stop_sequences mapped", () => { + const body = buildRequestBody( + baseOptions({ temperature: 0.5, stop: ["END"] }), + ); + expect(body.temperature).toBe(0.5); + expect(body.stop_sequences).toEqual(["END"]); + }); + + test("extraBody merges top-level keys", () => { + const body = buildRequestBody(baseOptions(), { betas: ["b1"], metadata: { k: "v" } }); + expect(body.betas).toEqual(["b1"]); + expect(body.metadata).toEqual({ k: "v" }); + }); +}); + +describe("sseToChunk", () => { + test("message_start yields message-start with usage", () => { + const st: SseState = {}; + const c = sseToChunk( + "message_start", + JSON.stringify({ message: { usage: { input_tokens: 10, output_tokens: 0 } } }), + st, + ); + expect(c).toEqual({ + type: "message-start", + usage: { inputTokens: 10, outputTokens: 0 }, + }); + }); + + test("content_block_start tool_use yields block-start", () => { + const st: SseState = {}; + const c = sseToChunk( + "content_block_start", + JSON.stringify({ + index: 0, + content_block: { type: "tool_use", id: "t1", name: "f", input: {} }, + }), + st, + ); + expect(c).toMatchObject({ + type: "block-start", + index: 0, + block: { type: "tool_use", id: "t1", name: "f" }, + }); + }); + + test("text_delta yields text-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 0, delta: { type: "text_delta", text: "abc" } }), + {}, + ); + expect(c).toEqual({ type: "text-delta", index: 0, text: "abc" }); + }); + + test("thinking_delta yields thinking-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 1, delta: { type: "thinking_delta", thinking: "hmm" } }), + {}, + ); + expect(c).toEqual({ type: "thinking-delta", index: 1, text: "hmm" }); + }); + + test("signature_delta yields thinking-delta with signature", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 1, delta: { type: "signature_delta", signature: "sig" } }), + {}, + ); + expect(c).toEqual({ type: "thinking-delta", index: 1, text: "", signature: "sig" }); + }); + + test("input_json_delta yields tool-call-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 0, delta: { type: "input_json_delta", partial_json: '{"a":' } }), + {}, + ); + expect(c).toEqual({ type: "tool-call-delta", index: 0, argumentsDelta: '{"a":' }); + }); + + test("connector_text_delta yields connector-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 2, delta: { type: "connector_text_delta", connector_text: "x" } }), + {}, + ); + expect(c).toEqual({ type: "connector-delta", index: 2, text: "x" }); + }); + + test("citations_delta ignored (null)", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 0, delta: { type: "citations_delta", citation: {} } }), + {}, + ); + expect(c).toBeNull(); + }); + + test("content_block_stop yields block-end", () => { + const c = sseToChunk("content_block_stop", JSON.stringify({ index: 0 }), {}); + expect(c).toEqual({ type: "block-end", index: 0 }); + }); + + test("message_delta records stop_reason and yields usage", () => { + const st: SseState = {}; + const c = sseToChunk( + "message_delta", + JSON.stringify({ + usage: { input_tokens: 10, output_tokens: 42 }, + delta: { stop_reason: "end_turn" }, + }), + st, + ); + expect(c).toEqual({ type: "usage", usage: { inputTokens: 10, outputTokens: 42 } }); + expect(st.stopReason).toBe("end_turn"); + }); + + test("message_stop yields finish with recorded reason", () => { + const st: SseState = { stopReason: "tool_use" }; + const c = sseToChunk("message_stop", "", st); + expect(c).toEqual({ type: "finish", reason: "tool_use" }); + }); + + test("message_stop defaults to end_turn when no reason", () => { + const c = sseToChunk("message_stop", "", {}); + expect(c).toEqual({ type: "finish", reason: "end_turn" }); + }); + + test("model_context_window_exceeded maps to max_tokens", () => { + const st: SseState = { stopReason: "model_context_window_exceeded" }; + const c = sseToChunk("message_stop", "", st); + expect(c).toEqual({ type: "finish", reason: "max_tokens" }); + }); + + test("ping ignored", () => { + expect(sseToChunk("ping", "", {})).toBeNull(); + }); + + test("malformed JSON ignored", () => { + expect(sseToChunk("message_start", "{not json", {})).toBeNull(); + }); +}); diff --git a/src/__tests__/llm/registry.test.ts b/src/__tests__/llm/registry.test.ts new file mode 100644 index 0000000..138f2a8 --- /dev/null +++ b/src/__tests__/llm/registry.test.ts @@ -0,0 +1,31 @@ +// 适配器注册表单测 — seam 关闭 (默认) 回退 SDK; 仅验证可空契约 +// +// 注: LLM_ADAPTER_SEAM 是 build-time feature 宏, 测试运行时 (未 --feature 构建) 为 false, +// 故 getLlmAdapter 必返回 null。on-path 行为由集成测试 (构建带 flag 的二进制) 覆盖。 + +import { describe, expect, test } from "bun:test"; +import { getLlmAdapter, isLlmAdapterActive } from "../../services/llm/registry.js"; + +describe("getLlmAdapter (seam off by default)", () => { + test("returns null when feature flag disabled (firstParty)", () => { + process.env.FUSION_API_KEY = "test-key"; + expect(getLlmAdapter("firstParty", "claude-test")).toBeNull(); + delete process.env.FUSION_API_KEY; + }); + + test("returns null for fusionMlx when seam off", () => { + expect(getLlmAdapter("fusionMlx", "mlx-test")).toBeNull(); + }); + + test("returns null for bedrock/vertex/foundry/openai", () => { + for (const p of ["bedrock", "vertex", "foundry", "openai"] as const) { + expect(getLlmAdapter(p, "m")).toBeNull(); + } + }); + + test("isLlmAdapterActive false when seam off", () => { + process.env.FUSION_API_KEY = "test-key"; + expect(isLlmAdapterActive("claude-test")).toBe(false); + delete process.env.FUSION_API_KEY; + }); +}); diff --git a/src/services/llm/adapter.ts b/src/services/llm/adapter.ts new file mode 100644 index 0000000..74c6108 --- /dev/null +++ b/src/services/llm/adapter.ts @@ -0,0 +1,302 @@ +// LLM 接缝 — Anthropic Wire Adapter +// +// 把 provider 中立的 GenerateOptions 翻译成 POST /v1/messages 的 JSON body, +// 并把 SSE 事件流 (parseSseStream 产出) 翻译成 provider 中立的 StreamChunk。 +// 替代 @anthropic-ai/sdk 的 beta.messages.create({stream:true}) + BetaMessageStream。 +// +// 本适配器只做协议翻译, 不做领域逻辑 (contentBlocks 累积/usage 合并/stop_reason +// 处理仍在 claude.ts 主循环, Phase 4 把它的 switch 从 SDK part 改为消费 StreamChunk)。 +// +// 请求体字段映射依据 src/services/api/claude.ts:1539 paramsFromContext 的实际形状。 + +import type { + FinishReason, + GenerateOptions, + LlmAdapter, + NeutralContentBlock, + NeutralMessage, + NeutralSystemBlock, + RawContentBlock, + StreamChunk, + ToolSchema, +} from "./types.js"; +import { parseSseStream } from "./sseStream.js"; +import { postMessages, type PostMessagesOptions } from "./httpClient.js"; + +// 把 NeutralMessage[] 翻译成 Anthropic messages 数组 (string content 直传, +// 块数组按类型映射; tool_result 的 content 透传)。 +function toAnthropicMessages( + messages: NeutralMessage[], +): Record[] { + return messages.map((m) => { + if (typeof m.content === "string") { + return { role: m.role, content: m.content }; + } + const blocks = m.content.map(toAnthropicBlock); + return { role: m.role, content: blocks }; + }); +} + +// 单个 NeutralContentBlock -> Anthropic content block。 +function toAnthropicBlock(b: NeutralContentBlock): Record { + switch (b.type) { + case "text": + return { type: "text", text: b.text }; + case "thinking": + return { + type: "thinking", + thinking: b.thinking, + ...(b.signature !== undefined && { signature: b.signature }), + }; + case "tool_use": + // input 可能是 string (部分 JSON) 或对象; 透传给服务端 + return { + type: "tool_use", + id: b.id, + name: b.name, + input: b.input, + }; + case "tool_result": + return { + type: "tool_result", + tool_use_id: b.tool_use_id, + content: b.content, + ...(b.is_error !== undefined && { is_error: b.is_error }), + }; + } +} + +function toAnthropicTool(t: ToolSchema): Record { + return { + name: t.name, + description: t.description, + input_schema: t.parameters, + }; +} + +// 把 GenerateOptions 翻译成 /v1/messages 请求体。 +// 仅映射中立类型能表达的字段; betas/metadata/tool_choice/output_config/speed/ +// context_management 等扩展字段由调用方通过 extraBody 透传 (Phase 4 接入时补)。 +export function buildRequestBody( + options: GenerateOptions, + extraBody?: Record, +): Record { + const body: Record = { + model: options.model, + messages: toAnthropicMessages(options.messages), + max_tokens: options.maxTokens ?? 4096, + stream: true, + }; + if (options.system !== undefined) { + body.system = + typeof options.system === "string" + ? options.system + : options.system.map((b: NeutralSystemBlock) => ({ + type: "text", + text: b.text, + ...(b.cache_control !== undefined && { + cache_control: b.cache_control, + }), + })); + } + if (options.tools && options.tools.length > 0) { + body.tools = options.tools.map(toAnthropicTool); + } + if (options.temperature !== undefined) { + body.temperature = options.temperature; + } + if (options.stop && options.stop.length > 0) { + body.stop_sequences = options.stop; + } + if (options.thinking && options.thinking.type === "enabled") { + body.thinking = { + type: "enabled", + budget_tokens: options.thinking.budgetTokens, + }; + } + if (extraBody) { + Object.assign(body, extraBody); + } + return body; +} + +// SSE 翻译的累加状态 (仅本模块内部用, 持有最近 usage 与 stop_reason)。 +export interface SseState { + lastUsage?: { inputTokens: number; outputTokens: number }; + stopReason?: string; +} + +// content_block_start 的 content_block -> RawContentBlock (中立)。 +function toRawBlock(block: Record): RawContentBlock { + const type = (block.type as string) ?? "text"; + const out: RawContentBlock = { + type: type as RawContentBlock["type"], + }; + if (typeof block.text === "string") out.text = block.text; + if (typeof block.thinking === "string") out.thinking = block.thinking; + if (typeof block.signature === "string") out.signature = block.signature; + if (typeof block.id === "string") out.id = block.id; + if (typeof block.name === "string") out.name = block.name; + // tool_use 的 input 在 block-start 常为 {}, 累积由 input_json_delta 完成 + if (block.input !== undefined) out.input = block.input as string; + return out; +} + +function usageFrom(u: unknown): { inputTokens: number; outputTokens: number } { + const r = (u ?? {}) as { + input_tokens?: number; + output_tokens?: number; + }; + return { + inputTokens: r.input_tokens ?? 0, + outputTokens: r.output_tokens ?? 0, + }; +} + +// 从 stop_reason 推断 FinishReason。 +function toFinishReason(stopReason: string | undefined): FinishReason { + switch (stopReason) { + case "end_turn": + case "tool_use": + case "max_tokens": + case "stop_sequence": + return stopReason; + case "model_context_window_exceeded": + return "max_tokens"; + default: + return "end_turn"; + } +} + +// SSE 事件 data (JSON 字符串) -> StreamChunk。返回 null 表示忽略 (ping/citations 等)。 +// 事件类型与字段依据 claude.ts:1975 switch。 +// 导出供单测直接验证 SSE->chunk 映射 (不经过网络)。 +export function sseToChunk( + event: string, + data: string, + state: SseState, +): StreamChunk | null { + let p: Record = {}; + if (data !== "") { + try { + p = JSON.parse(data) as Record; + } catch { + return null; + } + } + + switch (event) { + case "message_start": { + const message = (p.message ?? {}) as Record; + if (message.usage) state.lastUsage = usageFrom(message.usage); + return { + type: "message-start", + usage: state.lastUsage, + }; + } + case "content_block_start": { + const index = (p.index as number) ?? 0; + const block = (p.content_block ?? {}) as Record; + return { + type: "block-start", + index, + block: toRawBlock(block), + }; + } + case "content_block_delta": { + const index = (p.index as number) ?? 0; + const delta = (p.delta ?? {}) as Record; + switch (delta.type as string) { + case "text_delta": + return { + type: "text-delta", + index, + text: (delta.text as string) ?? "", + }; + case "thinking_delta": + return { + type: "thinking-delta", + index, + text: (delta.thinking as string) ?? "", + }; + case "signature_delta": + // signature 归到 thinking 块; 零宽 thinking-delta 携带 signature + return { + type: "thinking-delta", + index, + text: "", + signature: (delta.signature as string) ?? "", + }; + case "input_json_delta": + return { + type: "tool-call-delta", + index, + argumentsDelta: (delta.partial_json as string) ?? "", + }; + case "connector_text_delta": + return { + type: "connector-delta", + index, + text: (delta.connector_text as string) ?? "", + }; + case "citations_delta": + default: + return null; + } + } + case "content_block_stop": + return { type: "block-end", index: (p.index as number) ?? 0 }; + case "message_delta": { + const usage = p.usage + ? usageFrom(p.usage) + : state.lastUsage ?? { inputTokens: 0, outputTokens: 0 }; + state.lastUsage = usage; + const delta = (p.delta ?? {}) as Record; + if (typeof delta.stop_reason === "string") { + state.stopReason = delta.stop_reason; + } + return { type: "usage", usage }; + } + case "message_stop": + return { type: "finish", reason: toFinishReason(state.stopReason) }; + case "ping": + case "error": + default: + return null; + } +} + +// Anthropic Wire Adapter — 通过 httpClient POST /v1/messages, SSE -> StreamChunk。 +export class AnthropicWireAdapter implements LlmAdapter { + private readonly postOpts: Omit; + + constructor(postOpts: Omit) { + this.postOpts = postOpts; + } + + providerInfo(): { id: string; name: string } { + return { id: "anthropic", name: "Anthropic" }; + } + + async *stream(options: GenerateOptions): AsyncIterable { + const body = buildRequestBody(options); + const { response } = await postMessages({ + ...this.postOpts, + body: JSON.stringify(body), + signal: options.signal, + }); + if (!response.body) { + throw new Error("AnthropicWireAdapter: response body missing"); + } + const state: SseState = {}; + for await (const evt of parseSseStream(response.body, options.signal)) { + const chunk = sseToChunk(evt.event, evt.data, state); + if (chunk) yield chunk; + } + } +} + +// 接缝开关: 仅在 LLM_ADAPTER_SEAM feature 开启时启用新适配器路径。 +// 关闭时 (默认) 走 SDK, 相关代码被 DCE 消除。 +// 注意: feature() 是 Bun bundle DCE 宏, 只能直接用在 if/三元里, 不能包在返回它的 +// 函数中 (否则运行时抛错)。调用方需直接写 feature("LLM_ADAPTER_SEAM") 判定。 diff --git a/src/services/llm/httpClient.ts b/src/services/llm/httpClient.ts index 16833b2..6737767 100644 --- a/src/services/llm/httpClient.ts +++ b/src/services/llm/httpClient.ts @@ -31,6 +31,9 @@ export interface PostMessagesOptions { firstParty?: boolean; signal?: AbortSignal; timeoutMs?: number; + // 可选 fetch 注入 (fusion-mlx 路径用 createFusionMlxFetch 拦截并转译)。 + // 缺省走 globalThis.fetch。 + fetchFn?: typeof fetch; } export interface PostMessagesResult { @@ -72,7 +75,8 @@ export async function postMessages( let response: Response; try { - response = await fetch(url, fetchOptions as RequestInit); + const doFetch = opts.fetchFn ?? fetch; + response = await doFetch(url, fetchOptions as RequestInit); } catch (error) { const failure = classifyError(error, undefined, undefined); logForDebugging(`[llm:http] fetch failed: ${failure.code} ${failure.message}`); diff --git a/src/services/llm/mlxAdapter.ts b/src/services/llm/mlxAdapter.ts new file mode 100644 index 0000000..cec60ff --- /dev/null +++ b/src/services/llm/mlxAdapter.ts @@ -0,0 +1,28 @@ +// LLM 接缝 — Fusion-MLX 适配器 +// +// fusion-mlx 网关 (127.0.0.1:11432) 原生用 OpenAI /v1/chat/completions 格式, +// 但 createFusionMlxFetch 是一个 fetch override: 拦截对 /v1/messages 的请求, +// 内部做 Anthropic->OpenAI 翻译, 并把 OpenAI 流式响应 (transformMLXStreamToAnthropic +// + encodeStreamToAnthropicSSE) 转回 Anthropic SSE 事件。 +// +// 因此 MLX 路径在接缝层直接复用 AnthropicWireAdapter 的 SSE->StreamChunk 解析: +// 只需把 postMessages 的 fetch 换成 createFusionMlxFetch (它内部已处理 base_url 与鉴权), +// baseUrl 设为占位 (override 按 url.includes("/v1/messages") 拦截, 不实际连接该地址)。 + +import type { LlmAdapter } from "./types.js"; +import { AnthropicWireAdapter } from "./adapter.js"; +import { createFusionMlxFetch } from "../api/fusion-mlx-adapter.js"; + +// 占位 baseUrl: postMessages 会拼成 /v1/messages, override 按 url.includes +// ("/v1/messages") 拦截, 真正的 MLX base_url 由 createFusionMlxFetch 内部决定。 +const MLX_PLACEHOLDER_BASE = "http://fusion-mlx.local"; + +export function createMlxAdapter(model: string): LlmAdapter { + const mlxFetch = createFusionMlxFetch(model); + return new AnthropicWireAdapter({ + baseUrl: MLX_PLACEHOLDER_BASE, + // MLX override 内部处理鉴权 (settings.json / 环境变量), 此处不传 apiKey + firstParty: false, + fetchFn: mlxFetch, + }); +} diff --git a/src/services/llm/registry.ts b/src/services/llm/registry.ts new file mode 100644 index 0000000..b09c15a --- /dev/null +++ b/src/services/llm/registry.ts @@ -0,0 +1,65 @@ +// LLM 接缝 — 适配器注册表 +// +// 按 APIProvider 静态分发到对应 LlmAdapter (非 Cordis 运行时注册)。 +// 仅在 LLM_ADAPTER_SEAM feature 开启时返回适配器; 关闭 (默认) 返回 null, +// 调用方 (claude.ts, Phase 4) 据此回退到现有 SDK 路径, 实现 instant rollback。 + +import { AnthropicWireAdapter } from "./adapter.js"; +import { createMlxAdapter } from "./mlxAdapter.js"; +import { feature } from "bun:bundle"; +import type { LlmAdapter } from "./types.js"; +import type { APIProvider } from "../../utils/model/providers.js"; +import { + getAPIProvider, + isFirstPartyAnthropicBaseUrl, +} from "../../utils/model/providers.js"; +import { getAnthropicApiKey } from "../../utils/auth.js"; + +// firstParty 直连 Anthropic 的 base URL (与 claude.ts:540 一致: 优先 FUSION/ANTHROPIC env)。 +function resolveFirstPartyBaseUrl(): string { + return ( + process.env.FUSION_BASE_URL || + process.env.ANTHROPIC_BASE_URL || + "https://api.anthropic.com" + ); +} + +// 按 provider + model 解析适配器。seam 关闭时返回 null (走 SDK)。 +// feature() 必须直接用在 if 里 (Bun DCE 宏约束), 不可提取为辅助函数。 +export function getLlmAdapter( + provider?: APIProvider, + model?: string, +): LlmAdapter | null { + if (!feature("LLM_ADAPTER_SEAM")) { + return null; + } + const p = provider ?? getAPIProvider(model); + switch (p) { + case "fusionMlx": + // MLX: 用 fetch override, 响应已转成 Anthropic SSE, 复用解析 + return createMlxAdapter(model ?? ""); + case "firstParty": + return new AnthropicWireAdapter({ + baseUrl: resolveFirstPartyBaseUrl(), + apiKey: getAnthropicApiKey() ?? undefined, + firstParty: isFirstPartyAnthropicBaseUrl(), + }); + case "bedrock": + case "vertex": + case "foundry": + case "openai": + // 这些 provider 暂仍走 SDK (Phase 4/5 逐步迁移); seam 期返回 null 回退 SDK + return null; + default: + return null; + } +} + +// 便捷封装: 直接按当前 provider 判定是否启用接缝适配器。 +export function isLlmAdapterActive(model?: string): boolean { + return getLlmAdapter(undefined, model) !== null; +} + +// 重新导出类型, 方便调用方单点 import。 +export type { LlmAdapter } from "./types.js"; +export type { APIProvider } from "../../utils/model/providers.js"; From 3584a3ea3ab129035ff731cbc142f82ece5e9336 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 13:47:31 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(llm):=20Phase=204=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=B8=BB=E5=BE=AA=E7=8E=AF=20=E2=80=94=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=99=A8+flag=E5=85=B1=E5=AD=98=E6=8E=A5=E7=BC=9D?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude.ts 在 SDK messages.create 前注入 LLM_ADAPTER_SEAM 接缝分支: flag 开 + provider∈{firstParty,fusionMlx} 时, 调 streamViaSeam 直接 POST /v1/messages 并把 SSE→StreamChunk→SdkPart, 喂给下方既有 switch, 零 switch 改动。flag 关走 SDK 原路径, 回滚=关 flag。 - seam.ts: isSeamActive (ternary, DCE 兼容) + streamViaSeam 核心流式 - chunkToPart.ts: StreamChunk→SdkPart 翻译器 (sseToChunk 的逆映射), usage chunk 携带 stopReason 于 message_delta 时 emit - types.ts: usage chunk 扩展 stopReason 字段 - adapter.ts: sseToChunk message_delta emit stopReason - claude.ts: import seam + 接缝分支 (queryCheckpoint + return) - 测试: chunkToPart 12 项, adapter message_delta stopReason 断言更新 验证: typecheck ✓ / 113 tests ✓ / build(默认+flag) ✓ / 本地 MLX 真实模型 smoke (Llama-3.1-8B, seam 激活, status 200, 正确流式应答) ✓ Co-Authored-By: Claude Fable 5 --- src/__tests__/llm/adapter.test.ts | 505 ++++++++++++++------------ src/__tests__/llm/chunkToPart.test.ts | 193 ++++++++++ src/services/api/claude.ts | 14 + src/services/llm/adapter.ts | 470 ++++++++++++------------ src/services/llm/chunkToPart.ts | 144 ++++++++ src/services/llm/seam.ts | 88 +++++ src/services/llm/types.ts | 191 +++++----- 7 files changed, 1050 insertions(+), 555 deletions(-) create mode 100644 src/__tests__/llm/chunkToPart.test.ts create mode 100644 src/services/llm/chunkToPart.ts create mode 100644 src/services/llm/seam.ts diff --git a/src/__tests__/llm/adapter.test.ts b/src/__tests__/llm/adapter.test.ts index c404c05..65d312a 100644 --- a/src/__tests__/llm/adapter.test.ts +++ b/src/__tests__/llm/adapter.test.ts @@ -2,271 +2,308 @@ import { describe, expect, test } from "bun:test"; import { - buildRequestBody, - sseToChunk, - type SseState, + buildRequestBody, + type SseState, + sseToChunk, } from "../../services/llm/adapter.js"; import type { GenerateOptions } from "../../services/llm/types.js"; function baseOptions(over: Partial = {}): GenerateOptions { - return { - model: "claude-test", - messages: [{ role: "user", content: "hi" }], - ...over, - }; + return { + model: "claude-test", + messages: [{ role: "user", content: "hi" }], + ...over, + }; } describe("buildRequestBody", () => { - test("minimal request has model/messages/max_tokens/stream", () => { - const body = buildRequestBody(baseOptions()); - expect(body.model).toBe("claude-test"); - expect(body.stream).toBe(true); - expect(body.max_tokens).toBe(4096); - expect(body.messages).toEqual([{ role: "user", content: "hi" }]); - }); + test("minimal request has model/messages/max_tokens/stream", () => { + const body = buildRequestBody(baseOptions()); + expect(body.model).toBe("claude-test"); + expect(body.stream).toBe(true); + expect(body.max_tokens).toBe(4096); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + }); - test("respects custom maxTokens", () => { - const body = buildRequestBody(baseOptions({ maxTokens: 100 })); - expect(body.max_tokens).toBe(100); - }); + test("respects custom maxTokens", () => { + const body = buildRequestBody(baseOptions({ maxTokens: 100 })); + expect(body.max_tokens).toBe(100); + }); - test("string system maps to system string", () => { - const body = buildRequestBody(baseOptions({ system: "be brief" })); - expect(body.system).toBe("be brief"); - }); + test("string system maps to system string", () => { + const body = buildRequestBody(baseOptions({ system: "be brief" })); + expect(body.system).toBe("be brief"); + }); - test("block system maps to array with cache_control", () => { - const body = buildRequestBody({ - model: "m", - messages: [], - system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }], - }); - expect(body.system).toEqual([ - { type: "text", text: "sys", cache_control: { type: "ephemeral" } }, - ]); - }); + test("block system maps to array with cache_control", () => { + const body = buildRequestBody({ + model: "m", + messages: [], + system: [ + { type: "text", text: "sys", cache_control: { type: "ephemeral" } }, + ], + }); + expect(body.system).toEqual([ + { type: "text", text: "sys", cache_control: { type: "ephemeral" } }, + ]); + }); - test("tools map to input_schema", () => { - const body = buildRequestBody( - baseOptions({ - tools: [ - { - name: "get_weather", - description: "weather", - parameters: { type: "object", properties: {} }, - }, - ], - }), - ); - expect(body.tools).toEqual([ - { - name: "get_weather", - description: "weather", - input_schema: { type: "object", properties: {} }, - }, - ]); - }); + test("tools map to input_schema", () => { + const body = buildRequestBody( + baseOptions({ + tools: [ + { + name: "get_weather", + description: "weather", + parameters: { type: "object", properties: {} }, + }, + ], + }), + ); + expect(body.tools).toEqual([ + { + name: "get_weather", + description: "weather", + input_schema: { type: "object", properties: {} }, + }, + ]); + }); - test("tool_use block passes input through", () => { - const body = buildRequestBody({ - model: "m", - messages: [ - { - role: "assistant", - content: [ - { type: "tool_use", id: "t1", name: "f", input: { a: 1 } }, - ], - }, - ], - }); - expect(body.messages).toEqual([ - { - role: "assistant", - content: [{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } }], - }, - ]); - }); + test("tool_use block passes input through", () => { + const body = buildRequestBody({ + model: "m", + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } }], + }, + ], + }); + expect(body.messages).toEqual([ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } }], + }, + ]); + }); - test("tool_result block maps tool_use_id and is_error", () => { - const body = buildRequestBody({ - model: "m", - messages: [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "t1", - content: "ok", - is_error: false, - }, - ], - }, - ], - }); - expect(body.messages).toEqual([ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "t1", - content: "ok", - is_error: false, - }, - ], - }, - ]); - }); + test("tool_result block maps tool_use_id and is_error", () => { + const body = buildRequestBody({ + model: "m", + messages: [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: "ok", + is_error: false, + }, + ], + }, + ], + }); + expect(body.messages).toEqual([ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: "ok", + is_error: false, + }, + ], + }, + ]); + }); - test("thinking enabled maps budget_tokens", () => { - const body = buildRequestBody( - baseOptions({ thinking: { type: "enabled", budgetTokens: 2048 } }), - ); - expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 2048 }); - }); + test("thinking enabled maps budget_tokens", () => { + const body = buildRequestBody( + baseOptions({ thinking: { type: "enabled", budgetTokens: 2048 } }), + ); + expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 2048 }); + }); - test("temperature and stop_sequences mapped", () => { - const body = buildRequestBody( - baseOptions({ temperature: 0.5, stop: ["END"] }), - ); - expect(body.temperature).toBe(0.5); - expect(body.stop_sequences).toEqual(["END"]); - }); + test("temperature and stop_sequences mapped", () => { + const body = buildRequestBody( + baseOptions({ temperature: 0.5, stop: ["END"] }), + ); + expect(body.temperature).toBe(0.5); + expect(body.stop_sequences).toEqual(["END"]); + }); - test("extraBody merges top-level keys", () => { - const body = buildRequestBody(baseOptions(), { betas: ["b1"], metadata: { k: "v" } }); - expect(body.betas).toEqual(["b1"]); - expect(body.metadata).toEqual({ k: "v" }); - }); + test("extraBody merges top-level keys", () => { + const body = buildRequestBody(baseOptions(), { + betas: ["b1"], + metadata: { k: "v" }, + }); + expect(body.betas).toEqual(["b1"]); + expect(body.metadata).toEqual({ k: "v" }); + }); }); describe("sseToChunk", () => { - test("message_start yields message-start with usage", () => { - const st: SseState = {}; - const c = sseToChunk( - "message_start", - JSON.stringify({ message: { usage: { input_tokens: 10, output_tokens: 0 } } }), - st, - ); - expect(c).toEqual({ - type: "message-start", - usage: { inputTokens: 10, outputTokens: 0 }, - }); - }); + test("message_start yields message-start with usage", () => { + const st: SseState = {}; + const c = sseToChunk( + "message_start", + JSON.stringify({ + message: { usage: { input_tokens: 10, output_tokens: 0 } }, + }), + st, + ); + expect(c).toEqual({ + type: "message-start", + usage: { inputTokens: 10, outputTokens: 0 }, + }); + }); - test("content_block_start tool_use yields block-start", () => { - const st: SseState = {}; - const c = sseToChunk( - "content_block_start", - JSON.stringify({ - index: 0, - content_block: { type: "tool_use", id: "t1", name: "f", input: {} }, - }), - st, - ); - expect(c).toMatchObject({ - type: "block-start", - index: 0, - block: { type: "tool_use", id: "t1", name: "f" }, - }); - }); + test("content_block_start tool_use yields block-start", () => { + const st: SseState = {}; + const c = sseToChunk( + "content_block_start", + JSON.stringify({ + index: 0, + content_block: { type: "tool_use", id: "t1", name: "f", input: {} }, + }), + st, + ); + expect(c).toMatchObject({ + type: "block-start", + index: 0, + block: { type: "tool_use", id: "t1", name: "f" }, + }); + }); - test("text_delta yields text-delta", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 0, delta: { type: "text_delta", text: "abc" } }), - {}, - ); - expect(c).toEqual({ type: "text-delta", index: 0, text: "abc" }); - }); + test("text_delta yields text-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ index: 0, delta: { type: "text_delta", text: "abc" } }), + {}, + ); + expect(c).toEqual({ type: "text-delta", index: 0, text: "abc" }); + }); - test("thinking_delta yields thinking-delta", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 1, delta: { type: "thinking_delta", thinking: "hmm" } }), - {}, - ); - expect(c).toEqual({ type: "thinking-delta", index: 1, text: "hmm" }); - }); + test("thinking_delta yields thinking-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ + index: 1, + delta: { type: "thinking_delta", thinking: "hmm" }, + }), + {}, + ); + expect(c).toEqual({ type: "thinking-delta", index: 1, text: "hmm" }); + }); - test("signature_delta yields thinking-delta with signature", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 1, delta: { type: "signature_delta", signature: "sig" } }), - {}, - ); - expect(c).toEqual({ type: "thinking-delta", index: 1, text: "", signature: "sig" }); - }); + test("signature_delta yields thinking-delta with signature", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ + index: 1, + delta: { type: "signature_delta", signature: "sig" }, + }), + {}, + ); + expect(c).toEqual({ + type: "thinking-delta", + index: 1, + text: "", + signature: "sig", + }); + }); - test("input_json_delta yields tool-call-delta", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 0, delta: { type: "input_json_delta", partial_json: '{"a":' } }), - {}, - ); - expect(c).toEqual({ type: "tool-call-delta", index: 0, argumentsDelta: '{"a":' }); - }); + test("input_json_delta yields tool-call-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ + index: 0, + delta: { type: "input_json_delta", partial_json: '{"a":' }, + }), + {}, + ); + expect(c).toEqual({ + type: "tool-call-delta", + index: 0, + argumentsDelta: '{"a":', + }); + }); - test("connector_text_delta yields connector-delta", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 2, delta: { type: "connector_text_delta", connector_text: "x" } }), - {}, - ); - expect(c).toEqual({ type: "connector-delta", index: 2, text: "x" }); - }); + test("connector_text_delta yields connector-delta", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ + index: 2, + delta: { type: "connector_text_delta", connector_text: "x" }, + }), + {}, + ); + expect(c).toEqual({ type: "connector-delta", index: 2, text: "x" }); + }); - test("citations_delta ignored (null)", () => { - const c = sseToChunk( - "content_block_delta", - JSON.stringify({ index: 0, delta: { type: "citations_delta", citation: {} } }), - {}, - ); - expect(c).toBeNull(); - }); + test("citations_delta ignored (null)", () => { + const c = sseToChunk( + "content_block_delta", + JSON.stringify({ + index: 0, + delta: { type: "citations_delta", citation: {} }, + }), + {}, + ); + expect(c).toBeNull(); + }); - test("content_block_stop yields block-end", () => { - const c = sseToChunk("content_block_stop", JSON.stringify({ index: 0 }), {}); - expect(c).toEqual({ type: "block-end", index: 0 }); - }); + test("content_block_stop yields block-end", () => { + const c = sseToChunk( + "content_block_stop", + JSON.stringify({ index: 0 }), + {}, + ); + expect(c).toEqual({ type: "block-end", index: 0 }); + }); - test("message_delta records stop_reason and yields usage", () => { - const st: SseState = {}; - const c = sseToChunk( - "message_delta", - JSON.stringify({ - usage: { input_tokens: 10, output_tokens: 42 }, - delta: { stop_reason: "end_turn" }, - }), - st, - ); - expect(c).toEqual({ type: "usage", usage: { inputTokens: 10, outputTokens: 42 } }); - expect(st.stopReason).toBe("end_turn"); - }); + test("message_delta records stop_reason and yields usage with stopReason", () => { + const st: SseState = {}; + const c = sseToChunk( + "message_delta", + JSON.stringify({ + usage: { input_tokens: 10, output_tokens: 42 }, + delta: { stop_reason: "end_turn" }, + }), + st, + ); + expect(c).toEqual({ + type: "usage", + usage: { inputTokens: 10, outputTokens: 42 }, + stopReason: "end_turn", + }); + expect(st.stopReason).toBe("end_turn"); + }); - test("message_stop yields finish with recorded reason", () => { - const st: SseState = { stopReason: "tool_use" }; - const c = sseToChunk("message_stop", "", st); - expect(c).toEqual({ type: "finish", reason: "tool_use" }); - }); + test("message_stop yields finish with recorded reason", () => { + const st: SseState = { stopReason: "tool_use" }; + const c = sseToChunk("message_stop", "", st); + expect(c).toEqual({ type: "finish", reason: "tool_use" }); + }); - test("message_stop defaults to end_turn when no reason", () => { - const c = sseToChunk("message_stop", "", {}); - expect(c).toEqual({ type: "finish", reason: "end_turn" }); - }); + test("message_stop defaults to end_turn when no reason", () => { + const c = sseToChunk("message_stop", "", {}); + expect(c).toEqual({ type: "finish", reason: "end_turn" }); + }); - test("model_context_window_exceeded maps to max_tokens", () => { - const st: SseState = { stopReason: "model_context_window_exceeded" }; - const c = sseToChunk("message_stop", "", st); - expect(c).toEqual({ type: "finish", reason: "max_tokens" }); - }); + test("model_context_window_exceeded maps to max_tokens", () => { + const st: SseState = { stopReason: "model_context_window_exceeded" }; + const c = sseToChunk("message_stop", "", st); + expect(c).toEqual({ type: "finish", reason: "max_tokens" }); + }); - test("ping ignored", () => { - expect(sseToChunk("ping", "", {})).toBeNull(); - }); + test("ping ignored", () => { + expect(sseToChunk("ping", "", {})).toBeNull(); + }); - test("malformed JSON ignored", () => { - expect(sseToChunk("message_start", "{not json", {})).toBeNull(); - }); + test("malformed JSON ignored", () => { + expect(sseToChunk("message_start", "{not json", {})).toBeNull(); + }); }); diff --git a/src/__tests__/llm/chunkToPart.test.ts b/src/__tests__/llm/chunkToPart.test.ts new file mode 100644 index 0000000..848a857 --- /dev/null +++ b/src/__tests__/llm/chunkToPart.test.ts @@ -0,0 +1,193 @@ +// StreamChunk -> SDK part 翻译器单测 — 验证逆映射 + usage snake_case + stop_reason 时序 + +import { describe, expect, test } from "bun:test"; +import { + chunkStreamToSdkParts, + chunkToSdkPart, +} from "../../services/llm/chunkToPart.js"; +import type { StreamChunk } from "../../services/llm/types.js"; + +async function collect(chunks: StreamChunk[]) { + const out: { type: string; [k: string]: unknown }[] = []; + for await (const p of chunkStreamToSdkParts( + (async function* () { + for (const c of chunks) yield c; + })(), + )) { + out.push(p); + } + return out; +} + +describe("chunkToSdkPart", () => { + test("message-start -> message_start with snake_case usage", () => { + const p = chunkToSdkPart( + { type: "message-start", usage: { inputTokens: 10, outputTokens: 0 } }, + {}, + ); + expect(p).toMatchObject({ + type: "message_start", + message: { usage: { input_tokens: 10, output_tokens: 0 } }, + }); + }); + + test("block-start tool_use -> content_block_start", () => { + const p = chunkToSdkPart( + { + type: "block-start", + index: 0, + block: { type: "tool_use", id: "t1", name: "f" }, + }, + {}, + ); + expect(p).toEqual({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "t1", name: "f" }, + }); + }); + + test("text-delta -> text_delta", () => { + const p = chunkToSdkPart({ type: "text-delta", index: 0, text: "abc" }, {}); + expect(p).toEqual({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "abc" }, + }); + }); + + test("thinking-delta (no signature) -> thinking_delta", () => { + const p = chunkToSdkPart( + { type: "thinking-delta", index: 1, text: "hmm" }, + {}, + ); + expect(p).toEqual({ + type: "content_block_delta", + index: 1, + delta: { type: "thinking_delta", thinking: "hmm" }, + }); + }); + + test("thinking-delta (with signature) -> signature_delta", () => { + const p = chunkToSdkPart( + { type: "thinking-delta", index: 1, text: "", signature: "sig" }, + {}, + ); + expect(p).toEqual({ + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: "sig" }, + }); + }); + + test("tool-call-delta -> input_json_delta", () => { + const p = chunkToSdkPart( + { type: "tool-call-delta", index: 0, argumentsDelta: '{"a":' }, + {}, + ); + expect(p).toEqual({ + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"a":' }, + }); + }); + + test("connector-delta -> connector_text_delta", () => { + const p = chunkToSdkPart( + { type: "connector-delta", index: 2, text: "x" }, + {}, + ); + expect(p).toEqual({ + type: "content_block_delta", + index: 2, + delta: { type: "connector_text_delta", connector_text: "x" }, + }); + }); + + test("block-end -> content_block_stop", () => { + const p = chunkToSdkPart({ type: "block-end", index: 0 }, {}); + expect(p).toEqual({ type: "content_block_stop", index: 0 }); + }); + + test("usage with stopReason -> message_delta with delta.stop_reason", () => { + const st: { stopReason?: string } = {}; + const p = chunkToSdkPart( + { + type: "usage", + usage: { inputTokens: 10, outputTokens: 42 }, + stopReason: "tool_use", + }, + st, + ); + expect(p).toEqual({ + type: "message_delta", + usage: { input_tokens: 10, output_tokens: 42 }, + delta: { stop_reason: "tool_use" }, + }); + expect(st.stopReason).toBe("tool_use"); + }); + + test("finish -> message_stop", () => { + const p = chunkToSdkPart({ type: "finish", reason: "end_turn" }, {}); + expect(p).toEqual({ type: "message_stop" }); + }); +}); + +describe("chunkStreamToSdkParts (full sequence)", () => { + test("emits parts in order matching SDK event sequence", async () => { + const chunks: StreamChunk[] = [ + { type: "message-start", usage: { inputTokens: 5, outputTokens: 0 } }, + { type: "block-start", index: 0, block: { type: "text" } }, + { type: "text-delta", index: 0, text: "Hello" }, + { type: "block-end", index: 0 }, + { + type: "usage", + usage: { inputTokens: 5, outputTokens: 3 }, + stopReason: "end_turn", + }, + { type: "finish", reason: "end_turn" }, + ]; + const parts = await collect(chunks); + expect(parts.map((p) => p.type)).toEqual([ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + ]); + // message_delta 携带 stop_reason (switch 在 message_delta case 读取 part.delta.stop_reason) + const md = parts[4]; + expect(md.delta).toEqual({ stop_reason: "end_turn" }); + }); + + test("tool_use sequence: block-start + json deltas + block-end", async () => { + const chunks: StreamChunk[] = [ + { + type: "block-start", + index: 0, + block: { type: "tool_use", id: "t1", name: "get_weather" }, + }, + { type: "tool-call-delta", index: 0, argumentsDelta: '{"city":"SF"}' }, + { type: "block-end", index: 0 }, + { + type: "usage", + usage: { inputTokens: 0, outputTokens: 0 }, + stopReason: "tool_use", + }, + { type: "finish", reason: "tool_use" }, + ]; + const parts = await collect(chunks); + expect(parts.map((p) => p.type)).toEqual([ + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + ]); + expect(parts[1].delta).toEqual({ + type: "input_json_delta", + partial_json: '{"city":"SF"}', + }); + }); +}); diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index fdef1ce..e1f332f 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -255,6 +255,8 @@ import { type RetryContext, withRetry, } from "./withRetry.js"; +// LLM 接缝 (Phase 4): flag 开时用 streamViaSeam 替代 SDK 流式; 关时 DCE 消除 +import { isSeamActive, streamViaSeam } from "../llm/seam.js"; // Define a type that represents valid JSON values type JsonValue = string | number | boolean | null | JsonObject | JsonArray; @@ -1815,6 +1817,18 @@ async function* queryModel( // BetaMessageStream calls partialParse() on every input_json_delta, which we don't need // since we handle tool input accumulation ourselves // biome-ignore lint/plugin: main conversation loop handles attribution separately + // + // LLM 接缝 (Phase 4): flag 开时走 streamViaSeam (直接 HTTP+SSE,不经 SDK), + // 复用下方 switch (streamViaSeam 产出结构兼容的 SDK part)。 + // flag 关时走 SDK 原路径, 行为不变; 回滚=关 LLM_ADAPTER_SEAM。 + if (isSeamActive(options.model)) { + queryCheckpoint("query_response_headers_received"); + return streamViaSeam( + params, + signal, + options.model, + ) as unknown as typeof result.data; + } const result = await anthropic.beta.messages .create( { ...params, stream: true }, diff --git a/src/services/llm/adapter.ts b/src/services/llm/adapter.ts index 74c6108..7ec020e 100644 --- a/src/services/llm/adapter.ts +++ b/src/services/llm/adapter.ts @@ -9,291 +9,293 @@ // // 请求体字段映射依据 src/services/api/claude.ts:1539 paramsFromContext 的实际形状。 +import { type PostMessagesOptions, postMessages } from "./httpClient.js"; +import { parseSseStream } from "./sseStream.js"; import type { - FinishReason, - GenerateOptions, - LlmAdapter, - NeutralContentBlock, - NeutralMessage, - NeutralSystemBlock, - RawContentBlock, - StreamChunk, - ToolSchema, + FinishReason, + GenerateOptions, + LlmAdapter, + NeutralContentBlock, + NeutralMessage, + NeutralSystemBlock, + RawContentBlock, + StreamChunk, + ToolSchema, } from "./types.js"; -import { parseSseStream } from "./sseStream.js"; -import { postMessages, type PostMessagesOptions } from "./httpClient.js"; // 把 NeutralMessage[] 翻译成 Anthropic messages 数组 (string content 直传, // 块数组按类型映射; tool_result 的 content 透传)。 function toAnthropicMessages( - messages: NeutralMessage[], + messages: NeutralMessage[], ): Record[] { - return messages.map((m) => { - if (typeof m.content === "string") { - return { role: m.role, content: m.content }; - } - const blocks = m.content.map(toAnthropicBlock); - return { role: m.role, content: blocks }; - }); + return messages.map((m) => { + if (typeof m.content === "string") { + return { role: m.role, content: m.content }; + } + const blocks = m.content.map(toAnthropicBlock); + return { role: m.role, content: blocks }; + }); } // 单个 NeutralContentBlock -> Anthropic content block。 function toAnthropicBlock(b: NeutralContentBlock): Record { - switch (b.type) { - case "text": - return { type: "text", text: b.text }; - case "thinking": - return { - type: "thinking", - thinking: b.thinking, - ...(b.signature !== undefined && { signature: b.signature }), - }; - case "tool_use": - // input 可能是 string (部分 JSON) 或对象; 透传给服务端 - return { - type: "tool_use", - id: b.id, - name: b.name, - input: b.input, - }; - case "tool_result": - return { - type: "tool_result", - tool_use_id: b.tool_use_id, - content: b.content, - ...(b.is_error !== undefined && { is_error: b.is_error }), - }; - } + switch (b.type) { + case "text": + return { type: "text", text: b.text }; + case "thinking": + return { + type: "thinking", + thinking: b.thinking, + ...(b.signature !== undefined && { signature: b.signature }), + }; + case "tool_use": + // input 可能是 string (部分 JSON) 或对象; 透传给服务端 + return { + type: "tool_use", + id: b.id, + name: b.name, + input: b.input, + }; + case "tool_result": + return { + type: "tool_result", + tool_use_id: b.tool_use_id, + content: b.content, + ...(b.is_error !== undefined && { is_error: b.is_error }), + }; + } } function toAnthropicTool(t: ToolSchema): Record { - return { - name: t.name, - description: t.description, - input_schema: t.parameters, - }; + return { + name: t.name, + description: t.description, + input_schema: t.parameters, + }; } // 把 GenerateOptions 翻译成 /v1/messages 请求体。 // 仅映射中立类型能表达的字段; betas/metadata/tool_choice/output_config/speed/ // context_management 等扩展字段由调用方通过 extraBody 透传 (Phase 4 接入时补)。 export function buildRequestBody( - options: GenerateOptions, - extraBody?: Record, + options: GenerateOptions, + extraBody?: Record, ): Record { - const body: Record = { - model: options.model, - messages: toAnthropicMessages(options.messages), - max_tokens: options.maxTokens ?? 4096, - stream: true, - }; - if (options.system !== undefined) { - body.system = - typeof options.system === "string" - ? options.system - : options.system.map((b: NeutralSystemBlock) => ({ - type: "text", - text: b.text, - ...(b.cache_control !== undefined && { - cache_control: b.cache_control, - }), - })); - } - if (options.tools && options.tools.length > 0) { - body.tools = options.tools.map(toAnthropicTool); - } - if (options.temperature !== undefined) { - body.temperature = options.temperature; - } - if (options.stop && options.stop.length > 0) { - body.stop_sequences = options.stop; - } - if (options.thinking && options.thinking.type === "enabled") { - body.thinking = { - type: "enabled", - budget_tokens: options.thinking.budgetTokens, - }; - } - if (extraBody) { - Object.assign(body, extraBody); - } - return body; + const body: Record = { + model: options.model, + messages: toAnthropicMessages(options.messages), + max_tokens: options.maxTokens ?? 4096, + stream: true, + }; + if (options.system !== undefined) { + body.system = + typeof options.system === "string" + ? options.system + : options.system.map((b: NeutralSystemBlock) => ({ + type: "text", + text: b.text, + ...(b.cache_control !== undefined && { + cache_control: b.cache_control, + }), + })); + } + if (options.tools && options.tools.length > 0) { + body.tools = options.tools.map(toAnthropicTool); + } + if (options.temperature !== undefined) { + body.temperature = options.temperature; + } + if (options.stop && options.stop.length > 0) { + body.stop_sequences = options.stop; + } + if (options.thinking && options.thinking.type === "enabled") { + body.thinking = { + type: "enabled", + budget_tokens: options.thinking.budgetTokens, + }; + } + if (extraBody) { + Object.assign(body, extraBody); + } + return body; } // SSE 翻译的累加状态 (仅本模块内部用, 持有最近 usage 与 stop_reason)。 export interface SseState { - lastUsage?: { inputTokens: number; outputTokens: number }; - stopReason?: string; + lastUsage?: { inputTokens: number; outputTokens: number }; + stopReason?: string; } // content_block_start 的 content_block -> RawContentBlock (中立)。 function toRawBlock(block: Record): RawContentBlock { - const type = (block.type as string) ?? "text"; - const out: RawContentBlock = { - type: type as RawContentBlock["type"], - }; - if (typeof block.text === "string") out.text = block.text; - if (typeof block.thinking === "string") out.thinking = block.thinking; - if (typeof block.signature === "string") out.signature = block.signature; - if (typeof block.id === "string") out.id = block.id; - if (typeof block.name === "string") out.name = block.name; - // tool_use 的 input 在 block-start 常为 {}, 累积由 input_json_delta 完成 - if (block.input !== undefined) out.input = block.input as string; - return out; + const type = (block.type as string) ?? "text"; + const out: RawContentBlock = { + type: type as RawContentBlock["type"], + }; + if (typeof block.text === "string") out.text = block.text; + if (typeof block.thinking === "string") out.thinking = block.thinking; + if (typeof block.signature === "string") out.signature = block.signature; + if (typeof block.id === "string") out.id = block.id; + if (typeof block.name === "string") out.name = block.name; + // tool_use 的 input 在 block-start 常为 {}, 累积由 input_json_delta 完成 + if (block.input !== undefined) out.input = block.input as string; + return out; } function usageFrom(u: unknown): { inputTokens: number; outputTokens: number } { - const r = (u ?? {}) as { - input_tokens?: number; - output_tokens?: number; - }; - return { - inputTokens: r.input_tokens ?? 0, - outputTokens: r.output_tokens ?? 0, - }; + const r = (u ?? {}) as { + input_tokens?: number; + output_tokens?: number; + }; + return { + inputTokens: r.input_tokens ?? 0, + outputTokens: r.output_tokens ?? 0, + }; } // 从 stop_reason 推断 FinishReason。 function toFinishReason(stopReason: string | undefined): FinishReason { - switch (stopReason) { - case "end_turn": - case "tool_use": - case "max_tokens": - case "stop_sequence": - return stopReason; - case "model_context_window_exceeded": - return "max_tokens"; - default: - return "end_turn"; - } + switch (stopReason) { + case "end_turn": + case "tool_use": + case "max_tokens": + case "stop_sequence": + return stopReason; + case "model_context_window_exceeded": + return "max_tokens"; + default: + return "end_turn"; + } } // SSE 事件 data (JSON 字符串) -> StreamChunk。返回 null 表示忽略 (ping/citations 等)。 // 事件类型与字段依据 claude.ts:1975 switch。 // 导出供单测直接验证 SSE->chunk 映射 (不经过网络)。 export function sseToChunk( - event: string, - data: string, - state: SseState, + event: string, + data: string, + state: SseState, ): StreamChunk | null { - let p: Record = {}; - if (data !== "") { - try { - p = JSON.parse(data) as Record; - } catch { - return null; - } - } + let p: Record = {}; + if (data !== "") { + try { + p = JSON.parse(data) as Record; + } catch { + return null; + } + } - switch (event) { - case "message_start": { - const message = (p.message ?? {}) as Record; - if (message.usage) state.lastUsage = usageFrom(message.usage); - return { - type: "message-start", - usage: state.lastUsage, - }; - } - case "content_block_start": { - const index = (p.index as number) ?? 0; - const block = (p.content_block ?? {}) as Record; - return { - type: "block-start", - index, - block: toRawBlock(block), - }; - } - case "content_block_delta": { - const index = (p.index as number) ?? 0; - const delta = (p.delta ?? {}) as Record; - switch (delta.type as string) { - case "text_delta": - return { - type: "text-delta", - index, - text: (delta.text as string) ?? "", - }; - case "thinking_delta": - return { - type: "thinking-delta", - index, - text: (delta.thinking as string) ?? "", - }; - case "signature_delta": - // signature 归到 thinking 块; 零宽 thinking-delta 携带 signature - return { - type: "thinking-delta", - index, - text: "", - signature: (delta.signature as string) ?? "", - }; - case "input_json_delta": - return { - type: "tool-call-delta", - index, - argumentsDelta: (delta.partial_json as string) ?? "", - }; - case "connector_text_delta": - return { - type: "connector-delta", - index, - text: (delta.connector_text as string) ?? "", - }; - case "citations_delta": - default: - return null; - } - } - case "content_block_stop": - return { type: "block-end", index: (p.index as number) ?? 0 }; - case "message_delta": { - const usage = p.usage - ? usageFrom(p.usage) - : state.lastUsage ?? { inputTokens: 0, outputTokens: 0 }; - state.lastUsage = usage; - const delta = (p.delta ?? {}) as Record; - if (typeof delta.stop_reason === "string") { - state.stopReason = delta.stop_reason; - } - return { type: "usage", usage }; - } - case "message_stop": - return { type: "finish", reason: toFinishReason(state.stopReason) }; - case "ping": - case "error": - default: - return null; - } + switch (event) { + case "message_start": { + const message = (p.message ?? {}) as Record; + if (message.usage) state.lastUsage = usageFrom(message.usage); + return { + type: "message-start", + usage: state.lastUsage, + }; + } + case "content_block_start": { + const index = (p.index as number) ?? 0; + const block = (p.content_block ?? {}) as Record; + return { + type: "block-start", + index, + block: toRawBlock(block), + }; + } + case "content_block_delta": { + const index = (p.index as number) ?? 0; + const delta = (p.delta ?? {}) as Record; + switch (delta.type as string) { + case "text_delta": + return { + type: "text-delta", + index, + text: (delta.text as string) ?? "", + }; + case "thinking_delta": + return { + type: "thinking-delta", + index, + text: (delta.thinking as string) ?? "", + }; + case "signature_delta": + // signature 归到 thinking 块; 零宽 thinking-delta 携带 signature + return { + type: "thinking-delta", + index, + text: "", + signature: (delta.signature as string) ?? "", + }; + case "input_json_delta": + return { + type: "tool-call-delta", + index, + argumentsDelta: (delta.partial_json as string) ?? "", + }; + case "connector_text_delta": + return { + type: "connector-delta", + index, + text: (delta.connector_text as string) ?? "", + }; + case "citations_delta": + default: + return null; + } + } + case "content_block_stop": + return { type: "block-end", index: (p.index as number) ?? 0 }; + case "message_delta": { + const usage = p.usage + ? usageFrom(p.usage) + : (state.lastUsage ?? { inputTokens: 0, outputTokens: 0 }); + state.lastUsage = usage; + const delta = (p.delta ?? {}) as Record; + let stopReason: string | undefined; + if (typeof delta.stop_reason === "string") { + state.stopReason = delta.stop_reason; + stopReason = delta.stop_reason; + } + return { type: "usage", usage, stopReason }; + } + case "message_stop": + return { type: "finish", reason: toFinishReason(state.stopReason) }; + case "ping": + case "error": + default: + return null; + } } // Anthropic Wire Adapter — 通过 httpClient POST /v1/messages, SSE -> StreamChunk。 export class AnthropicWireAdapter implements LlmAdapter { - private readonly postOpts: Omit; + private readonly postOpts: Omit; - constructor(postOpts: Omit) { - this.postOpts = postOpts; - } + constructor(postOpts: Omit) { + this.postOpts = postOpts; + } - providerInfo(): { id: string; name: string } { - return { id: "anthropic", name: "Anthropic" }; - } + providerInfo(): { id: string; name: string } { + return { id: "anthropic", name: "Anthropic" }; + } - async *stream(options: GenerateOptions): AsyncIterable { - const body = buildRequestBody(options); - const { response } = await postMessages({ - ...this.postOpts, - body: JSON.stringify(body), - signal: options.signal, - }); - if (!response.body) { - throw new Error("AnthropicWireAdapter: response body missing"); - } - const state: SseState = {}; - for await (const evt of parseSseStream(response.body, options.signal)) { - const chunk = sseToChunk(evt.event, evt.data, state); - if (chunk) yield chunk; - } - } + async *stream(options: GenerateOptions): AsyncIterable { + const body = buildRequestBody(options); + const { response } = await postMessages({ + ...this.postOpts, + body: JSON.stringify(body), + signal: options.signal, + }); + if (!response.body) { + throw new Error("AnthropicWireAdapter: response body missing"); + } + const state: SseState = {}; + for await (const evt of parseSseStream(response.body, options.signal)) { + const chunk = sseToChunk(evt.event, evt.data, state); + if (chunk) yield chunk; + } + } } // 接缝开关: 仅在 LLM_ADAPTER_SEAM feature 开启时启用新适配器路径。 diff --git a/src/services/llm/chunkToPart.ts b/src/services/llm/chunkToPart.ts new file mode 100644 index 0000000..b9185d9 --- /dev/null +++ b/src/services/llm/chunkToPart.ts @@ -0,0 +1,144 @@ +// LLM 接缝 — StreamChunk -> SDK part 翻译器 +// +// Phase 4 接入策略: flag 开时用 adapter.stream() 产出中立 StreamChunk, 再经本翻译器 +// 转回 SDK part 形状 (BetaRawMessageStreamEvent 的结构), 喂给 claude.ts 现有 switch +// (零改 switch)。flag 关时走 SDK, 回滚=关 flag。 +// +// 翻译是 sseToChunk 的逆映射, 但 usage 用 Anthropic snake_case (updateUsage 需要)。 +// 只构造 switch 实际读取的字段, 不复刻完整 SDK 类型 (结构兼容即可)。 + +import type { RawContentBlock, StreamChunk, TokenUsage } from "./types.js"; + +// SDK part 形状 (结构子集, claude.ts switch 实际读取的字段)。 +// 用宽化字段避免引入 SDK 类型依赖; switch 只做 part.type 判别 + 字段读取。 +export type SdkPart = { + type: string; + [k: string]: unknown; +}; + +// camelCase TokenUsage -> Anthropic snake_case usage (供 updateUsage)。 +function toAnthropicUsage(u: TokenUsage): Record { + const out: Record = { + input_tokens: u.inputTokens ?? 0, + output_tokens: u.outputTokens ?? 0, + }; + if (typeof u.cacheReadTokens === "number") + out.cache_read_input_tokens = u.cacheReadTokens; + if (typeof u.cacheWriteTokens === "number") + out.cache_creation_input_tokens = u.cacheWriteTokens; + return out; +} + +// content_block_start 的中立 RawContentBlock -> Anthropic content_block。 +function toAnthropicBlock(b: RawContentBlock): Record { + const out: Record = { type: b.type }; + if (typeof b.text === "string") out.text = b.text; + if (typeof b.thinking === "string") out.thinking = b.thinking; + if (typeof b.signature === "string") out.signature = b.signature; + if (typeof b.id === "string") out.id = b.id; + if (typeof b.name === "string") out.name = b.name; + if (b.input !== undefined) out.input = b.input; + return out; +} + +// 单个 StreamChunk -> SDK part。返回 null 表示该 chunk 无对应 part (理论上不发生)。 +export function chunkToSdkPart( + chunk: StreamChunk, + state: { lastUsage?: Record; stopReason?: string }, +): SdkPart | null { + switch (chunk.type) { + case "message-start": { + const usage = chunk.usage + ? toAnthropicUsage(chunk.usage) + : { input_tokens: 0, output_tokens: 0 }; + state.lastUsage = usage; + return { + type: "message_start", + message: { + id: "msg_seam", + type: "message", + role: "assistant", + content: [], + model: "", + stop_reason: null, + usage, + }, + }; + } + case "block-start": { + return { + type: "content_block_start", + index: chunk.index, + content_block: toAnthropicBlock(chunk.block), + }; + } + case "text-delta": { + return { + type: "content_block_delta", + index: chunk.index, + delta: { type: "text_delta", text: chunk.text }, + }; + } + case "thinking-delta": { + // signature_delta 与 thinking_delta 在中立层合并为 thinking-delta; + // 若带 signature, 还原成单独的 signature_delta part (switch 期望如此)。 + if (chunk.signature !== undefined && chunk.signature !== "") { + return { + type: "content_block_delta", + index: chunk.index, + delta: { type: "signature_delta", signature: chunk.signature }, + }; + } + return { + type: "content_block_delta", + index: chunk.index, + delta: { type: "thinking_delta", thinking: chunk.text }, + }; + } + case "tool-call-delta": { + return { + type: "content_block_delta", + index: chunk.index, + delta: { type: "input_json_delta", partial_json: chunk.argumentsDelta }, + }; + } + case "connector-delta": { + return { + type: "content_block_delta", + index: chunk.index, + delta: { type: "connector_text_delta", connector_text: chunk.text }, + }; + } + case "block-end": { + return { type: "content_block_stop", index: chunk.index }; + } + case "usage": { + const usage = toAnthropicUsage(chunk.usage); + state.lastUsage = usage; + if (chunk.stopReason) state.stopReason = chunk.stopReason; + return { + type: "message_delta", + usage, + delta: { + stop_reason: chunk.stopReason ?? state.stopReason ?? "end_turn", + }, + }; + } + case "finish": { + return { type: "message_stop" }; + } + default: + return null; + } +} + +// 把 adapter 的 StreamChunk 异步流整体转成 SDK part 异步流 (claude.ts for-await 消费)。 +export async function* chunkStreamToSdkParts( + chunks: AsyncIterable, +): AsyncIterable { + const state: { lastUsage?: Record; stopReason?: string } = {}; + for await (const chunk of chunks) { + const part = chunkToSdkPart(chunk, state); + if (part) yield part; + } +} diff --git a/src/services/llm/seam.ts b/src/services/llm/seam.ts new file mode 100644 index 0000000..2625968 --- /dev/null +++ b/src/services/llm/seam.ts @@ -0,0 +1,88 @@ +// LLM 接缝 — 主循环接入函数 +// +// Phase 4: claude.ts 在 withRetry 回调里, 若 LLM_ADAPTER_SEAM 开启, 调用本函数替代 +// anthropic.beta.messages.create({stream:true})。本函数接收已构造好的 Anthropic +// params (paramsFromContext 产出), 直接 POST /v1/messages 并把 SSE -> StreamChunk +// -> SDK part, 返回与 Stream 结构兼容的异步迭代器。 +// +// flag 关时 claude.ts 不调用本函数, 走 SDK; 回滚=关 flag。 + +import { feature } from "bun:bundle"; +import { getAnthropicApiKey } from "../../utils/auth.js"; +import { + getAPIProvider, + isFirstPartyAnthropicBaseUrl, +} from "../../utils/model/providers.js"; +import { createFusionMlxFetch } from "../api/fusion-mlx-adapter.js"; +import { type SseState, sseToChunk } from "./adapter.js"; +import { chunkStreamToSdkParts, type SdkPart } from "./chunkToPart.js"; +import { postMessages } from "./httpClient.js"; +import { parseSseStream } from "./sseStream.js"; +import type { StreamChunk } from "./types.js"; + +// 占位 baseUrl: MLX override 按 url.includes("/v1/messages") 拦截。 +const MLX_PLACEHOLDER_BASE = "http://fusion-mlx.local"; + +function resolveFirstPartyBaseUrl(): string { + return ( + process.env.FUSION_BASE_URL || + process.env.ANTHROPIC_BASE_URL || + "https://api.anthropic.com" + ); +} + +// 接缝是否激活 (feature 宏用三元判定, Bun DCE 允许)。 +export function isSeamActive(model?: string): boolean { + return feature("LLM_ADAPTER_SEAM") + ? getAPIProvider(model) === "firstParty" || + getAPIProvider(model) === "fusionMlx" + : false; +} + +// 核心: 用接缝层流式请求, 返回 SDK part 异步流。 +// params 为 Anthropic /v1/messages 请求体 (已含 stream:true 或不要求; 本函数强制 stream)。 +export async function* streamViaSeam( + params: Record, + signal: AbortSignal, + model: string, +): AsyncIterable { + const provider = getAPIProvider(model); + const body = JSON.stringify({ ...params, stream: true }); + + let baseUrl: string; + let apiKey: string | undefined; + let firstParty = false; + let fetchFn: typeof fetch | undefined; + + if (provider === "fusionMlx") { + baseUrl = MLX_PLACEHOLDER_BASE; + fetchFn = createFusionMlxFetch(model); + } else { + baseUrl = resolveFirstPartyBaseUrl(); + apiKey = getAnthropicApiKey() ?? undefined; + firstParty = isFirstPartyAnthropicBaseUrl(); + } + + const { response } = await postMessages({ + baseUrl, + body, + apiKey, + firstParty, + signal, + fetchFn, + }); + if (!response.body) { + throw new Error("streamViaSeam: response body missing"); + } + + // SSE -> StreamChunk -> SdkPart + const state: SseState = {}; + const chunks: AsyncIterable = (async function* () { + for await (const evt of parseSseStream(response.body, signal)) { + const chunk = sseToChunk(evt.event, evt.data, state); + if (chunk) yield chunk; + } + })(); + + yield* chunkStreamToSdkParts(chunks); +} diff --git a/src/services/llm/types.ts b/src/services/llm/types.ts index 3c55f0d..dd42090 100644 --- a/src/services/llm/types.ts +++ b/src/services/llm/types.ts @@ -9,21 +9,21 @@ // ─── 内容块类型 ───────────────────────────────────────────── // 中立内容块标签, 与 Anthropic content_block.type 对齐, 便于适配器零损耗映射。 export type ContentBlockType = - | "text" - | "thinking" - | "tool_use" - | "server_tool_use" - | "tool_result" - | "connector_text"; + | "text" + | "thinking" + | "tool_use" + | "server_tool_use" + | "tool_result" + | "connector_text"; // ─── token 计费 ───────────────────────────────────────────── // cache 字段可选: 仅 provider 上报非零时出现 (参考 dsh TokenUsage)。 export interface TokenUsage { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - reasoningTokens?: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; } // ─── 流式 chunk ───────────────────────────────────────────── @@ -39,72 +39,72 @@ export interface TokenUsage { // usage ← message_delta.usage // finish ← message_stop (+ stop_reason → FinishReason) export type StreamChunk = - | { type: "message-start"; usage?: TokenUsage } - | { type: "block-start"; index: number; block: RawContentBlock } - | { type: "text-delta"; index: number; text: string } - | { type: "thinking-delta"; index: number; text: string; signature?: string } - | { type: "tool-call-delta"; index: number; argumentsDelta: string } - | { type: "connector-delta"; index: number; text: string } - | { type: "block-end"; index: number } - | { type: "usage"; usage: TokenUsage } - | { type: "finish"; reason: FinishReason }; + | { type: "message-start"; usage?: TokenUsage } + | { type: "block-start"; index: number; block: RawContentBlock } + | { type: "text-delta"; index: number; text: string } + | { type: "thinking-delta"; index: number; text: string; signature?: string } + | { type: "tool-call-delta"; index: number; argumentsDelta: string } + | { type: "connector-delta"; index: number; text: string } + | { type: "block-end"; index: number } + | { type: "usage"; usage: TokenUsage; stopReason?: string } + | { type: "finish"; reason: FinishReason }; // block-start 携带的原始内容块 (展开字段, 非 SDK 类型)。 // 适配器从 provider wire 原样搬运, 主循环按 type 分派累积。 export interface RawContentBlock { - type: ContentBlockType; - // text/thinking 块 - text?: string; - thinking?: string; - signature?: string; - // tool_use / server_tool_use 块 - id?: string; - name?: string; - input?: string | Record; - // tool_result 块 - toolUseId?: string; - content?: unknown; - isError?: boolean; - // 透传未识别字段 (advisor_tool_result 等 server 扩展) - [extra: string]: unknown; + type: ContentBlockType; + // text/thinking 块 + text?: string; + thinking?: string; + signature?: string; + // tool_use / server_tool_use 块 + id?: string; + name?: string; + input?: string | Record; + // tool_result 块 + toolUseId?: string; + content?: unknown; + isError?: boolean; + // 透传未识别字段 (advisor_tool_result 等 server 扩展) + [extra: string]: unknown; } // ─── 结束原因 ─────────────────────────────────────────────── // 参考 dsh FinishReasonMap: 稳定中立码, 非 SDK 的 stop_reason 字符串。 export type FinishReason = - | "end_turn" - | "tool_use" - | "max_tokens" - | "stop_sequence" - | "aborted" - | "error"; + | "end_turn" + | "tool_use" + | "max_tokens" + | "stop_sequence" + | "aborted" + | "error"; // ─── 失败 ─────────────────────────────────────────────────── // provider 中立失败事实 (参考 dsh LlmFailure)。替代 instanceof APIError 判定。 // code 是稳定机器路由码; withRetry/errors 据此判重试/分类。 export type LlmErrorCode = - | "AUTH" - | "RATE_LIMIT" - | "INVALID_REQUEST" - | "SERVER" - | "TIMEOUT" - | "TRANSPORT" - | "ABORTED"; + | "AUTH" + | "RATE_LIMIT" + | "INVALID_REQUEST" + | "SERVER" + | "TIMEOUT" + | "TRANSPORT" + | "ABORTED"; export interface LlmFailure { - code: LlmErrorCode; - message: string; - status?: number; - providerRetryAfterMs?: number; - requestId?: string; + code: LlmErrorCode; + message: string; + status?: number; + providerRetryAfterMs?: number; + requestId?: string; } // ─── 工具 schema ──────────────────────────────────────────── // 送往模型的工具描述 (JSON Schema 参数)。与 dsh ToolSchema 对齐。 export interface ToolSchema { - name: string; - description: string; - parameters: Record; + name: string; + description: string; + parameters: Record; } // ─── 请求选项 ─────────────────────────────────────────────── @@ -112,47 +112,64 @@ export interface ToolSchema { // messages 是中立结构 (与 Anthropic MessageParam 形状一致: role + content)。 // 适配器负责映射到 provider wire 格式。 export interface GenerateOptions { - model: string; - messages: NeutralMessage[]; - system?: string | NeutralSystemBlock[]; - tools?: ToolSchema[]; - temperature?: number; - maxTokens?: number; - stop?: string[]; - thinking?: { type: "enabled"; budgetTokens: number } | { type: "disabled" }; - signal?: AbortSignal; - // 请求来源标记, 透传到适配器做路由/日志 (如 compaction / session-title 辅助调用)。 - purpose?: "compaction" | "session-title" | "main"; + model: string; + messages: NeutralMessage[]; + system?: string | NeutralSystemBlock[]; + tools?: ToolSchema[]; + temperature?: number; + maxTokens?: number; + stop?: string[]; + thinking?: { type: "enabled"; budgetTokens: number } | { type: "disabled" }; + signal?: AbortSignal; + // 请求来源标记, 透传到适配器做路由/日志 (如 compaction / session-title 辅助调用)。 + purpose?: "compaction" | "session-title" | "main"; } export interface NeutralMessage { - role: "user" | "assistant"; - content: string | NeutralContentBlock[]; + role: "user" | "assistant"; + content: string | NeutralContentBlock[]; } export type NeutralContentBlock = - | { type: "text"; text: string } - | { type: "thinking"; thinking: string; signature?: string } - | { type: "tool_use"; id: string; name: string; input: Record | string } - | { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean }; + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { + type: "tool_use"; + id: string; + name: string; + input: Record | string; + } + | { + type: "tool_result"; + tool_use_id: string; + content: unknown; + is_error?: boolean; + }; -export type NeutralSystemBlock = { type: "text"; text: string; cache_control?: unknown }; +export type NeutralSystemBlock = { + type: "text"; + text: string; + cache_control?: unknown; +}; // ─── 适配器接口 ───────────────────────────────────────────── // 参考 dsh abstract class LlmAdapter: 唯一必需方法 stream()。 // fusion-code 用静态分派 (registry.ts 按 APIProvider 返回实例), 不引入 Cordis 运行时注册。 export interface LlmAdapter { - // 唯一必需方法: 把一次模型调用流式吐成中立 chunk。实现须遵守 options.signal。 - stream(options: GenerateOptions): AsyncIterable; - // 可选: provider 显示名 - providerInfo?(): { id: string; name: string }; - // 可选: 列出可宣传的模型 (advisory, 不做请求校验) - listModels?(): Promise; - // 可选: 解析单个模型元数据 (context window / 默认 max_tokens / reasoning) - resolveModel?(model: string, signal?: AbortSignal): Promise<{ - id: string; - name: string; - contextWindow?: number; - defaultMaxTokens?: number; - }>; + // 唯一必需方法: 把一次模型调用流式吐成中立 chunk。实现须遵守 options.signal。 + stream(options: GenerateOptions): AsyncIterable; + // 可选: provider 显示名 + providerInfo?(): { id: string; name: string }; + // 可选: 列出可宣传的模型 (advisory, 不做请求校验) + listModels?(): Promise; + // 可选: 解析单个模型元数据 (context window / 默认 max_tokens / reasoning) + resolveModel?( + model: string, + signal?: AbortSignal, + ): Promise<{ + id: string; + name: string; + contextWindow?: number; + defaultMaxTokens?: number; + }>; } From 93bf7832f6f734960ed0752bc622e5cba1911970 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 14:51:12 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(llm):=20Phase=205=20step=202=20?= =?UTF-8?q?=E2=80=94=20withRetry=20=E8=84=B1=E7=A6=BB=20SDK=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=B1=BB=20(=E5=BD=A2=E6=80=81=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=A1=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withRetry.ts 不再 import @anthropic-ai/sdk 运行时类: - instanceof APIError → isApiErrorLike (接纳 SDK APIError 与 seam LlmRequestError) - instanceof APIConnectionError → isConnectionErrorLike - new APIUserAbortError() → abortError() (name="AbortError") - APIError 形参 → ApiErrorLike 形态类型 前置 (step 1): - LlmFailure/LlmRequestError 暴露 SDK 兼容 .status/.headers/.requestID - classifyError 接收 headers; httpClient 捕获响应头到 LlmRequestError - 新增 isApiErrorLike/isConnectionErrorLike/isTimeoutErrorLike/isAbortErrorLike 修复 seam 路径正确性缺口: 之前 seam 抛的 LlmRequestError 无 .status/.headers, 会穿透 withRetry 所有 instanceof APIError 分支 → seam 错误不重试/不分类。 现形态桥同时覆盖 SDK 路径 (flag 关) 与 seam 路径 (flag 开)。 验证: typecheck 0 错; bun test src/__tests__/llm 75 pass; tests/services 146 pass; build:dev --feature=LLM_ADAPTER_SEAM 绿。 Co-Authored-By: Claude Fable 5 --- src/services/api/withRetry.ts | 98 +++++++++------ src/services/llm/errors.ts | 203 ++++++++++++++++++++---------- src/services/llm/httpClient.ts | 220 +++++++++++++++++---------------- src/services/llm/types.ts | 10 ++ 4 files changed, 319 insertions(+), 212 deletions(-) diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 0eb060d..719ff64 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -1,10 +1,6 @@ import { feature } from "bun:bundle"; import type { AnthropicDefault as Anthropic } from "src/types/anthropic-protocol.js"; -import { - APIConnectionError, - APIError, - APIUserAbortError, -} from "@anthropic-ai/sdk"; +import type { APIError } from "src/types/anthropic-protocol.js"; import type { QuerySource } from "src/constants/querySource.js"; import type { SystemAPIErrorMessage } from "src/types/message.js"; import { logForDebugging } from "src/utils/debug.js"; @@ -45,8 +41,30 @@ import { } from "../rateLimitMocking.js"; import { REPEATED_529_ERROR_MESSAGE } from "./errors.js"; import { extractConnectionErrorDetails } from "./errorUtils.js"; - -const abortError = () => new APIUserAbortError(); +// LLM 接缝 (Phase 5): 用形态判定替代 instanceof APIError/APIConnectionError/APIUserAbortError, +// 同时接纳 SDK 抛出的 APIError (flag 关) 与 seam 抛出的 LlmRequestError (flag 开)。 +import { + isAbortErrorLike, + isApiErrorLike, + isConnectionErrorLike, +} from "../llm/errors.js"; + +// 中断错误工厂: 返回 name="AbortError" 的 Error, isAbortErrorLike 可识别。 +// 替代 SDK 的 new APIUserAbortError(); sleep 的 abortError 选项要求 () => Error。 +const abortError = (): Error => { + const err = new Error("Request was aborted."); + err.name = "AbortError"; + return err; +}; + +// 形态化 API 错误类型 (替代 SDK APIError 形参): 同时描述 SDK APIError 与 seam LlmRequestError。 +// isApiErrorLike 是其类型守卫; .status/.message/.headers/.requestID 字段与 SDK 一致。 +type ApiErrorLike = { + status?: number; + message: string; + headers?: { get?(name: string): string | null }; + requestID?: string; +}; const DEFAULT_MAX_RETRIES = 10; const FLOOR_OUTPUT_TOKENS = 3000; @@ -104,12 +122,12 @@ function isPersistentRetryEnabled(): boolean { function isTransientCapacityError(error: unknown): boolean { return ( - is529Error(error) || (error instanceof APIError && error.status === 429) + is529Error(error) || (isApiErrorLike(error) && error.status === 429) ); } function isStaleConnectionError(error: unknown): boolean { - if (!(error instanceof APIConnectionError)) { + if (!isConnectionErrorLike(error)) { return false; } const details = extractConnectionErrorDetails(error); @@ -187,7 +205,7 @@ export async function* withRetry( let persistentAttempt = 0; for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { if (options.signal?.aborted) { - throw new APIUserAbortError(); + throw abortError(); } // Capture whether fast mode is active before this attempt @@ -230,7 +248,7 @@ export async function* withRetry( if ( client === null || - (lastError instanceof APIError && lastError.status === 401) || + (isApiErrorLike(lastError) && lastError.status === 401) || isOAuthTokenRevokedError(lastError) || isBedrockAuthError(lastError) || isVertexAuthError(lastError) || @@ -238,7 +256,7 @@ export async function* withRetry( ) { // On 401 "token expired" or 403 "token revoked", force a token refresh if ( - (lastError instanceof APIError && lastError.status === 401) || + (isApiErrorLike(lastError) && lastError.status === 401) || isOAuthTokenRevokedError(lastError) ) { const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken; @@ -253,7 +271,7 @@ export async function* withRetry( } catch (error) { lastError = error; logForDebugging( - `API error (attempt ${attempt}/${maxRetries + 1}): ${error instanceof APIError ? `${error.status} ${error.message}` : errorMessage(error)}`, + `API error (attempt ${attempt}/${maxRetries + 1}): ${isApiErrorLike(error) ? `${error.status} ${error.message}` : errorMessage(error)}`, { level: "error" }, ); @@ -266,7 +284,7 @@ export async function* withRetry( if ( wasFastModeActive && !isPersistentRetryEnabled() && - error instanceof APIError && + isApiErrorLike(error) && (error.status === 429 || is529Error(error)) ) { // If the 429 is specifically because extra usage (overage) is not @@ -325,7 +343,7 @@ export async function* withRetry( // Track consecutive 529/429 errors — trigger fallback for all models with a fallback configured const isOverloadOrRateLimit = is529Error(error) || - (error instanceof APIError && error.status === 429); + (isApiErrorLike(error) && error.status === 429); if (isOverloadOrRateLimit) { consecutive529Errors++; if (consecutive529Errors >= MAX_529_RETRIES) { @@ -337,7 +355,7 @@ export async function* withRetry( fallback_model: options.fallbackModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, provider: getAPIProviderForStatsig(), - error_status: (error instanceof APIError + error_status: (isApiErrorLike(error) ? error.status : 529) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }); @@ -375,7 +393,7 @@ export async function* withRetry( handleAwsCredentialError(error) || handleGcpCredentialError(error); if ( !handledCloudAuthError && - (!(error instanceof APIError) || !shouldRetry(error)) + (!isApiErrorLike(error) || !shouldRetry(error)) ) { throw new CannotRetryError(error, retryContext); } @@ -384,7 +402,7 @@ export async function* withRetry( // NOTE: With extended-context-window beta, this 400 error should not occur. // The API now returns 'model_context_window_exceeded' stop_reason instead. // Keeping for backward compatibility. - if (error instanceof APIError) { + if (isApiErrorLike(error)) { const overflowData = parseMaxTokensContextOverflowError(error); if (overflowData) { const { inputTokens, contextLimit } = overflowData; @@ -429,7 +447,7 @@ export async function* withRetry( // Get retry-after header if available const retryAfter = getRetryAfter(error); let delayMs: number; - if (persistent && error instanceof APIError && error.status === 429) { + if (persistent && isApiErrorLike(error) && error.status === 429) { persistentAttempt++; // Window-based limits (e.g. 5hr Max/Pro) include a reset timestamp. // Wait until reset rather than polling every 5 min uselessly. @@ -467,16 +485,17 @@ export async function* withRetry( logEvent("tengu_api_retry", { attempt: reportedAttempt, delayMs: delayMs, - error: (error as APIError) - .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - status: (error as APIError).status, + error: (isApiErrorLike(error) + ? error.message + : errorMessage(error)) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + status: isApiErrorLike(error) ? error.status : undefined, provider: getAPIProviderForStatsig(), }); if (persistent) { if (delayMs > 60_000) { logEvent("tengu_api_persistent_retry_wait", { - status: (error as APIError).status, + status: isApiErrorLike(error) ? error.status : undefined, delayMs, attempt: reportedAttempt, provider: getAPIProviderForStatsig(), @@ -487,10 +506,10 @@ export async function* withRetry( // {type:'system', subtype:'api_retry'} on stdout via QueryEngine. let remaining = delayMs; while (remaining > 0) { - if (options.signal?.aborted) throw new APIUserAbortError(); - if (error instanceof APIError) { + if (options.signal?.aborted) throw abortError(); + if (isApiErrorLike(error)) { yield createSystemAPIErrorMessage( - error, + error as unknown as APIError, remaining, reportedAttempt, maxRetries, @@ -504,9 +523,9 @@ export async function* withRetry( // persistentAttempt counter which keeps growing to the 5-min cap. if (attempt >= maxRetries) attempt = maxRetries; } else { - if (error instanceof APIError) { + if (isApiErrorLike(error)) { yield createSystemAPIErrorMessage( - error, + error as unknown as APIError, delayMs, attempt, maxRetries, @@ -526,7 +545,7 @@ function getRetryAfter(error: unknown): string | null { "retry-after" ] || // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins - ((error as APIError).headers as Headers)?.get?.("retry-after")) ?? + (error as { headers?: Headers })?.headers?.get?.("retry-after")) ?? null ); } @@ -548,7 +567,9 @@ export function getRetryDelay( return baseDelay + jitter; } -export function parseMaxTokensContextOverflowError(error: APIError): +export function parseMaxTokensContextOverflowError( + error: ApiErrorLike, +): | { inputTokens: number; maxTokens: number; @@ -599,7 +620,7 @@ export function parseMaxTokensContextOverflowError(error: APIError): // header for fast-mode rejection (e.g., x-fast-mode-rejected). String-matching // the error message is fragile and will break if the API wording changes. function isFastModeNotEnabledError(error: unknown): boolean { - if (!(error instanceof APIError)) { + if (!isApiErrorLike(error)) { return false; } return ( @@ -609,7 +630,7 @@ function isFastModeNotEnabledError(error: unknown): boolean { } export function is529Error(error: unknown): boolean { - if (!(error instanceof APIError)) { + if (!isApiErrorLike(error)) { return false; } @@ -623,7 +644,7 @@ export function is529Error(error: unknown): boolean { function isOAuthTokenRevokedError(error: unknown): boolean { return ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 403 && (error.message?.includes("OAuth token has been revoked") ?? false) ); @@ -658,9 +679,10 @@ function handleGcpCredentialError(_error: unknown): boolean { return false; } -function shouldRetry(error: APIError): boolean { +function shouldRetry(error: ApiErrorLike): boolean { // Never retry mock errors - they're from /mock-limits command for testing - if (isMockRateLimitError(error)) { + // rateLimitMocking 仍用 SDK APIError 形参 (ant-only mock), 此处形态兼容, 安全转换。 + if (isMockRateLimitError(error as unknown as APIError)) { return false; } @@ -715,7 +737,7 @@ function shouldRetry(error: APIError): boolean { } } - if (error instanceof APIConnectionError) { + if (isConnectionErrorLike(error)) { return true; } @@ -767,7 +789,7 @@ const DEFAULT_FAST_MODE_FALLBACK_HOLD_MS = 30 * 60 * 1000; // 30 minutes const SHORT_RETRY_THRESHOLD_MS = 20 * 1000; // 20 seconds const MIN_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes -function getRetryAfterMs(error: APIError): number | null { +function getRetryAfterMs(error: ApiErrorLike): number | null { const retryAfter = getRetryAfter(error); if (retryAfter) { const seconds = parseInt(retryAfter, 10); @@ -778,7 +800,7 @@ function getRetryAfterMs(error: APIError): number | null { return null; } -function getRateLimitResetDelayMs(error: APIError): number | null { +function getRateLimitResetDelayMs(error: ApiErrorLike): number | null { const resetHeader = error.headers?.get?.("anthropic-ratelimit-unified-reset"); if (!resetHeader) return null; const resetUnixSec = Number(resetHeader); diff --git a/src/services/llm/errors.ts b/src/services/llm/errors.ts index fe54ba5..7ccc9c8 100644 --- a/src/services/llm/errors.ts +++ b/src/services/llm/errors.ts @@ -3,96 +3,163 @@ // 替代 src/services/api/errors.ts / withRetry.ts 中的 instanceof APIError 判定。 // 把 fetch 异常与 HTTP 非 2xx 归为稳定 LlmFailure.code, withRetry 据此判重试。 -import type { LlmFailure, LlmErrorCode } from "./types.js"; +import type { LlmErrorCode, LlmFailure } from "./types.js"; // 从 HTTP 状态码 + 错误信息推断稳定错误码。 export function classifyByStatus(status: number): LlmErrorCode { - if (status === 401 || status === 403) return "AUTH"; - if (status === 429 || status === 529) return "RATE_LIMIT"; - if (status === 400) return "INVALID_REQUEST"; - if (status >= 500) return "SERVER"; - return "INVALID_REQUEST"; + if (status === 401 || status === 403) return "AUTH"; + if (status === 429 || status === 529) return "RATE_LIMIT"; + if (status === 400) return "INVALID_REQUEST"; + if (status >= 500) return "SERVER"; + return "INVALID_REQUEST"; } // 从 Error 实例名/信息推断传输层错误码 (无 HTTP 状态时)。 export function classifyByMessage(message: string): LlmErrorCode { - if (/\b401\b|\b403\b|unauthor|forbidden|invalid.*api.*key/i.test(message)) - return "AUTH"; - if (/\b429\b|rate.?limit|too many requests/i.test(message)) return "RATE_LIMIT"; - if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST"; - if (/\b5\d\d\b|internal server|bad gateway|service unavail/i.test(message)) - return "SERVER"; - if (/timeout|timed?\s*out/i.test(message)) return "TIMEOUT"; - if ( - /(?:network|connection|socket|fetch|econn\w*|terminated|premature close|other side closed)/i.test( - message, - ) - ) - return "TRANSPORT"; - if (/abort/i.test(message)) return "ABORTED"; - return "SERVER"; + if (/\b401\b|\b403\b|unauthor|forbidden|invalid.*api.*key/i.test(message)) + return "AUTH"; + if (/\b429\b|rate.?limit|too many requests/i.test(message)) + return "RATE_LIMIT"; + if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST"; + if (/\b5\d\d\b|internal server|bad gateway|service unavail/i.test(message)) + return "SERVER"; + if (/timeout|timed?\s*out/i.test(message)) return "TIMEOUT"; + if ( + /(?:network|connection|socket|fetch|econn\w*|terminated|premature close|other side closed)/i.test( + message, + ) + ) + return "TRANSPORT"; + if (/abort/i.test(message)) return "ABORTED"; + return "SERVER"; } // 统一入口: 把任意异常 + 可选 HTTP 状态归为 LlmFailure。 export function classifyError( - error: unknown, - status?: number, - requestId?: string, + error: unknown, + status?: number, + requestId?: string, + headers?: LlmFailure["headers"], ): LlmFailure { - const message = - error instanceof Error ? error.message : String(error ?? "unknown error"); - - // 中断优先 (AbortError 不可重试, 且 status 无意义)。 - // 兼容 DOMException 与任意把 .name 设为 "AbortError" 的 Error (fetch/AbortController 约定)。 - if ( - (error instanceof DOMException && error.name === "AbortError") || - (error as { name?: string })?.name === "AbortError" || - /abort/i.test(message) - ) { - return { code: "ABORTED", message, requestId }; - } - - let code: LlmErrorCode; - if (typeof status === "number" && status >= 400) { - code = classifyByStatus(status); - } else { - code = classifyByMessage(message); - } - - // provider Retry-After 头 (秒) 转 ms, 仅对 RATE_LIMIT 有意义 - let providerRetryAfterMs: number | undefined; - if (code === "RATE_LIMIT") { - providerRetryAfterMs = extractRetryAfterMs(error); - } - - return { code, message, status, providerRetryAfterMs, requestId }; + const message = + error instanceof Error ? error.message : String(error ?? "unknown error"); + + // 中断优先 (AbortError 不可重试, 且 status 无意义)。 + // 兼容 DOMException 与任意把 .name 设为 "AbortError" 的 Error (fetch/AbortController 约定)。 + if ( + (error instanceof DOMException && error.name === "AbortError") || + (error as { name?: string })?.name === "AbortError" || + /abort/i.test(message) + ) { + return { code: "ABORTED", message, requestId }; + } + + let code: LlmErrorCode; + if (typeof status === "number" && status >= 400) { + code = classifyByStatus(status); + } else { + code = classifyByMessage(message); + } + + // provider Retry-After 头 (秒) 转 ms, 仅对 RATE_LIMIT 有意义 + let providerRetryAfterMs: number | undefined; + if (code === "RATE_LIMIT") { + providerRetryAfterMs = extractRetryAfterMs(error); + } + + return { code, message, status, providerRetryAfterMs, requestId, headers }; } // 可重试码: 限流 / 服务端错误 / 传输层 / 超时。AUTH/INVALID_REQUEST/ABORTED 不重试。 export function isRetryable(failure: LlmFailure): boolean { - return ( - failure.code === "RATE_LIMIT" || - failure.code === "SERVER" || - failure.code === "TRANSPORT" || - failure.code === "TIMEOUT" - ); + return ( + failure.code === "RATE_LIMIT" || + failure.code === "SERVER" || + failure.code === "TRANSPORT" || + failure.code === "TIMEOUT" + ); } // 把 LlmFailure 抛出为一个带 code 的 Error, 供 try/catch 处再 classifyError 还原。 +// 暴露 SDK 兼容形态 (.status/.headers/.requestID), 使 withRetry/errors 既有的 +// error.status / error.headers?.get(...) 读取对 seam 路径同样生效 (无需改那些读取点)。 export class LlmRequestError extends Error { - readonly failure: LlmFailure; - constructor(failure: LlmFailure) { - super(failure.message); - this.name = "LlmRequestError"; - this.failure = failure; - } + readonly failure: LlmFailure; + readonly status?: number; + readonly headers?: LlmFailure["headers"]; + readonly requestID?: string; + constructor(failure: LlmFailure) { + super(failure.message); + this.name = "LlmRequestError"; + this.failure = failure; + this.status = failure.status; + this.headers = failure.headers; + this.requestID = failure.requestId; + } +} + +// ── duck-typing 桥 (替代 instanceof APIError / APIConnectionError / APIUserAbortError) ── +// 同时接纳 SDK 抛出的 APIError (flag 关时客户端路径) 与 seam 抛出的 LlmRequestError +// (flag 开时 HTTP 路径)。判定靠形态而非原型链, 因两类错误无共同基类。 + +// 任意带 .status (number) 的错误 — 覆盖 SDK APIError 与 LlmRequestError。 +export function isApiErrorLike(error: unknown): error is { + status?: number; + message: string; + headers?: { get?(name: string): string | null }; + requestID?: string; +} { + if (!(error instanceof Error)) return false; + return ( + typeof (error as { status?: unknown }).status === "number" || + (error as { headers?: unknown }).headers !== undefined || + (error as { requestID?: unknown }).requestID !== undefined + ); +} + +// 传输层错误: SDK APIConnectionError (name 含 "Connection") 或 LlmRequestError(code=TRANSPORT/TIMEOUT)。 +export function isConnectionErrorLike(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error instanceof LlmRequestError) { + return ( + error.failure.code === "TRANSPORT" || + error.failure.code === "TIMEOUT" + ); + } + const name = (error as { name?: string }).name ?? ""; + return /Connection/.test(name); +} + +// 传输层 + 超时: SDK APIConnectionTimeoutError (name 含 "Timeout") 或 message 含 timeout。 +export function isTimeoutErrorLike(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error instanceof LlmRequestError) { + return error.failure.code === "TIMEOUT"; + } + const name = (error as { name?: string }).name ?? ""; + return /Timeout/.test(name) || /timeout/i.test(error.message); +} + +// 中断: SDK APIUserAbortError (name "APIUserAbortError") 或任意 .name === "AbortError", +// 或 LlmRequestError(code=ABORTED), 或 fetch AbortSignal 抛出的 DOMException。 +export function isAbortErrorLike(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error instanceof LlmRequestError) { + return error.failure.code === "ABORTED"; + } + const name = (error as { name?: string }).name ?? ""; + return ( + name === "APIUserAbortError" || + name === "AbortError" || + error.message === "Request was aborted." + ); } // 从 Error 上探测 Retry-After (秒)。适配器/httpClient 可在 error 上挂 _retryAfterSec。 function extractRetryAfterMs(error: unknown): number | undefined { - const sec = (error as { _retryAfterSec?: number })?._retryAfterSec; - if (typeof sec === "number" && sec >= 0) { - return Math.round(sec * 1000); - } - return undefined; + const sec = (error as { _retryAfterSec?: number })?._retryAfterSec; + if (typeof sec === "number" && sec >= 0) { + return Math.round(sec * 1000); + } + return undefined; } diff --git a/src/services/llm/httpClient.ts b/src/services/llm/httpClient.ts index 6737767..fb948d4 100644 --- a/src/services/llm/httpClient.ts +++ b/src/services/llm/httpClient.ts @@ -10,138 +10,146 @@ // src/utils/cch.ts computeCch/replaceCchPlaceholder/hasCchPlaceholder import { randomUUID } from "node:crypto"; +import { getAnthropicApiKey } from "../../utils/auth.js"; import { - computeCch, - hasCchPlaceholder, - replaceCchPlaceholder, + computeCch, + hasCchPlaceholder, + replaceCchPlaceholder, } from "../../utils/cch.js"; -import { getAnthropicApiKey } from "../../utils/auth.js"; +import { logForDebugging } from "../../utils/debug.js"; import { getUserAgent } from "../../utils/http.js"; import { getProxyFetchOptions } from "../../utils/proxy.js"; -import { logForDebugging } from "../../utils/debug.js"; import { CLIENT_REQUEST_ID_HEADER } from "../api/client.js"; import { classifyError, LlmRequestError } from "./errors.js"; export interface PostMessagesOptions { - baseUrl: string; - body: string; - apiKey?: string; - authToken?: string; - extraHeaders?: Record; - firstParty?: boolean; - signal?: AbortSignal; - timeoutMs?: number; - // 可选 fetch 注入 (fusion-mlx 路径用 createFusionMlxFetch 拦截并转译)。 - // 缺省走 globalThis.fetch。 - fetchFn?: typeof fetch; + baseUrl: string; + body: string; + apiKey?: string; + authToken?: string; + extraHeaders?: Record; + firstParty?: boolean; + signal?: AbortSignal; + timeoutMs?: number; + // 可选 fetch 注入 (fusion-mlx 路径用 createFusionMlxFetch 拦截并转译)。 + // 缺省走 globalThis.fetch。 + fetchFn?: typeof fetch; } export interface PostMessagesResult { - response: Response; - requestId?: string; + response: Response; + requestId?: string; } // POST 一个 /v1/messages 流式请求, 返回 SSE Response 与 request_id。 // 非 2xx 或 fetch 异常 -> 抛 LlmRequestError (携带 LlmFailure, withRetry 据此判重试)。 export async function postMessages( - opts: PostMessagesOptions, + opts: PostMessagesOptions, ): Promise { - const url = joinUrl(opts.baseUrl, "/v1/messages"); - const headers = buildHeaders(opts); - let body = opts.body; + const url = joinUrl(opts.baseUrl, "/v1/messages"); + const headers = buildHeaders(opts); + let body = opts.body; - // cch 签名: 仅 firstParty 直连时 - if (opts.firstParty && hasCchPlaceholder(body)) { - try { - const cch = await computeCch(body); - body = replaceCchPlaceholder(body, cch); - logForDebugging(`[llm:http] signed request cch=${cch}`); - } catch { - // cch 失败不阻断请求 (与现有 buildFetch 行为一致) - } - } + // cch 签名: 仅 firstParty 直连时 + if (opts.firstParty && hasCchPlaceholder(body)) { + try { + const cch = await computeCch(body); + body = replaceCchPlaceholder(body, cch); + logForDebugging(`[llm:http] signed request cch=${cch}`); + } catch { + // cch 失败不阻断请求 (与现有 buildFetch 行为一致) + } + } - const fetchOptions: RequestInit & { dispatcher?: unknown } = { - method: "POST", - headers, - body, - signal: opts.signal, - ...getProxyFetchOptions({ forAnthropicAPI: true }), - }; - if (opts.timeoutMs) { - // @ts-expect-error Bun/Node fetch 接受 timeout - fetchOptions.timeout = opts.timeoutMs; - } + const fetchOptions: RequestInit & { dispatcher?: unknown } = { + method: "POST", + headers, + body, + signal: opts.signal, + ...getProxyFetchOptions({ forAnthropicAPI: true }), + }; + if (opts.timeoutMs) { + // @ts-expect-error Bun/Node fetch 接受 timeout + fetchOptions.timeout = opts.timeoutMs; + } - let response: Response; - try { - const doFetch = opts.fetchFn ?? fetch; - response = await doFetch(url, fetchOptions as RequestInit); - } catch (error) { - const failure = classifyError(error, undefined, undefined); - logForDebugging(`[llm:http] fetch failed: ${failure.code} ${failure.message}`); - throw new LlmRequestError(failure); - } + let response: Response; + try { + const doFetch = opts.fetchFn ?? fetch; + response = await doFetch(url, fetchOptions as RequestInit); + } catch (error) { + const failure = classifyError(error, undefined, undefined); + logForDebugging( + `[llm:http] fetch failed: ${failure.code} ${failure.message}`, + ); + throw new LlmRequestError(failure); + } - if (!response.ok) { - const requestId = response.headers.get("request-id") ?? undefined; - let statusText = ""; - let retryAfterSec: number | undefined; - try { - statusText = await response.text(); - const ra = response.headers.get("retry-after"); - if (ra) retryAfterSec = Number.parseInt(ra, 10); - } catch { - // 读 body 失败忽略 - } - const wrapped: Error & { _retryAfterSec?: number } = new Error( - `${response.status} ${response.statusText}: ${statusText}`, - ); - if (Number.isFinite(retryAfterSec)) { - wrapped._retryAfterSec = retryAfterSec; - } - const failure = classifyError(wrapped, response.status, requestId); - logForDebugging( - `[llm:http] non-2xx ${response.status} ${failure.code} ${failure.message}`, - ); - throw new LlmRequestError(failure); - } + if (!response.ok) { + const requestId = response.headers.get("request-id") ?? undefined; + let statusText = ""; + let retryAfterSec: number | undefined; + try { + statusText = await response.text(); + const ra = response.headers.get("retry-after"); + if (ra) retryAfterSec = Number.parseInt(ra, 10); + } catch { + // 读 body 失败忽略 + } + const wrapped: Error & { _retryAfterSec?: number } = new Error( + `${response.status} ${response.statusText}: ${statusText}`, + ); + if (Number.isFinite(retryAfterSec)) { + wrapped._retryAfterSec = retryAfterSec; + } + // 保留响应头 (限流/重试头来源), 使 withRetry/errors 的 error.headers?.get(...) 生效。 + const failure = classifyError( + wrapped, + response.status, + requestId, + response.headers as unknown as Parameters[3], + ); + logForDebugging( + `[llm:http] non-2xx ${response.status} ${failure.code} ${failure.message}`, + ); + throw new LlmRequestError(failure); + } - const requestId = response.headers.get("request-id") ?? undefined; - return { response, requestId }; + const requestId = response.headers.get("request-id") ?? undefined; + return { response, requestId }; } function buildHeaders(opts: PostMessagesOptions): Record { - const h: Record = { - "content-type": "application/json", - "user-agent": getUserAgent(), - "anthropic-version": "2023-06-01", - }; - if (opts.apiKey) { - h["x-api-key"] = opts.apiKey; - } else if (opts.authToken) { - h["authorization"] = `Bearer ${opts.authToken}`; - } else if (opts.firstParty) { - const key = getAnthropicApiKey(); - if (key) h["x-api-key"] = key; - } - if (opts.firstParty) { - h[CLIENT_REQUEST_ID_HEADER] = randomUUID(); - } - if (opts.extraHeaders) { - for (const [k, v] of Object.entries(opts.extraHeaders)) { - h[k.toLowerCase()] = v; - } - } - return h; + const h: Record = { + "content-type": "application/json", + "user-agent": getUserAgent(), + "anthropic-version": "2023-06-01", + }; + if (opts.apiKey) { + h["x-api-key"] = opts.apiKey; + } else if (opts.authToken) { + h["authorization"] = `Bearer ${opts.authToken}`; + } else if (opts.firstParty) { + const key = getAnthropicApiKey(); + if (key) h["x-api-key"] = key; + } + if (opts.firstParty) { + h[CLIENT_REQUEST_ID_HEADER] = randomUUID(); + } + if (opts.extraHeaders) { + for (const [k, v] of Object.entries(opts.extraHeaders)) { + h[k.toLowerCase()] = v; + } + } + return h; } function joinUrl(base: string, path: string): string { - if (base.endsWith("/") && path.startsWith("/")) { - return base.slice(0, -1) + path; - } - if (!base.endsWith("/") && !path.startsWith("/")) { - return `${base}/${path}`; - } - return base + path; + if (base.endsWith("/") && path.startsWith("/")) { + return base.slice(0, -1) + path; + } + if (!base.endsWith("/") && !path.startsWith("/")) { + return `${base}/${path}`; + } + return base + path; } diff --git a/src/services/llm/types.ts b/src/services/llm/types.ts index dd42090..01c45a3 100644 --- a/src/services/llm/types.ts +++ b/src/services/llm/types.ts @@ -91,12 +91,22 @@ export type LlmErrorCode = | "TRANSPORT" | "ABORTED"; +// Headers 形态兼容 SDK APIError.headers (支持 .get(name)) 与裸对象 (支持 ["retry-after"])。 +// httpClient 把 fetch Response.headers (原生 Headers, 自带 .get) 直接挂上即可。 +export type LlmFailureHeaders = { + get?(name: string): string | null; +} & { + [header: string]: string | undefined; +}; + export interface LlmFailure { code: LlmErrorCode; message: string; status?: number; providerRetryAfterMs?: number; requestId?: string; + // 非 2xx 响应头 (限流/重试头来源)。fetch 异常路径无 response, 留 undefined。 + headers?: LlmFailureHeaders; } // ─── 工具 schema ──────────────────────────────────────────── From e7b50f2beaf82699a5823eb0d447edbc4501f6a7 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 14:55:00 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(llm):=20Phase=205=20step=203=20?= =?UTF-8?q?=E2=80=94=20api/errors=20=E8=84=B1=E7=A6=BB=20SDK=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit errors.ts 不再 runtime import @anthropic-ai/sdk: - instanceof APIError (×22) → isApiErrorLike - instanceof APIConnectionError/TimeoutError → isConnectionErrorLike/isTimeoutErrorLike - APIError 保留为 type-only (anthropic-protocol.ts, 构建期擦除), 用于 categorizeRetryableAPIError 形参与 formatAPIError 调用点 cast isConnectionErrorLike/isTimeoutErrorLike 升级为类型守卫 (error is Error), 使 errors.ts 在守卫后可读 error.message (原 instanceof 自动收窄的等价能力)。 验证: typecheck 0 错; bun test 259 pass (含 llm 75 + services 146); build:dev --feature=LLM_ADAPTER_SEAM 绿。 Co-Authored-By: Claude Fable 5 --- src/services/api/errors.ts | 81 ++++++++++++++++++++------------------ src/services/llm/errors.ts | 4 +- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 0949035..8f479ce 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -1,9 +1,12 @@ +// LLM 接缝 (Phase 5): 用形态判定替代 instanceof APIError/APIConnectionError, +// 同时接纳 SDK 抛出的 APIError (flag 关) 与 seam 抛出的 LlmRequestError (flag 开)。 import { - APIConnectionError, - APIConnectionTimeoutError, - APIError, -} from "@anthropic-ai/sdk"; + isApiErrorLike, + isConnectionErrorLike, + isTimeoutErrorLike, +} from "../llm/errors.js"; import type { + APIError, BetaMessage, BetaStopReason, } from "src/types/anthropic-protocol.js"; @@ -435,8 +438,8 @@ export function getAssistantMessageFromError( ): AssistantMessage { // Check for SDK timeout errors if ( - error instanceof APIConnectionTimeoutError || - (error instanceof APIConnectionError && + isTimeoutErrorLike(error) || + (isConnectionErrorLike(error) && error.message.toLowerCase().includes("timeout")) ) { return createAssistantAPIErrorMessage({ @@ -466,7 +469,7 @@ export function getAssistantMessageFromError( } if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 429 && shouldProcessRateLimits(isClaudeAISubscriber()) ) { @@ -619,7 +622,7 @@ export function getAssistantMessageFromError( // Check for image size errors (e.g., "image exceeds 5 MB maximum: 5316852 bytes > 5242880 bytes") if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("image exceeds") && error.message.includes("maximum") @@ -632,7 +635,7 @@ export function getAssistantMessageFromError( // Check for many-image dimension errors (API enforces stricter 2000px limit for many-image requests) if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("image dimensions exceed") && error.message.includes("many-image") @@ -651,7 +654,7 @@ export function getAssistantMessageFromError( // so the truthy guard keeps this inert there. if ( AFK_MODE_BETA_HEADER && - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes(AFK_MODE_BETA_HEADER) && error.message.includes("anthropic-beta") @@ -664,7 +667,7 @@ export function getAssistantMessageFromError( // Check for request too large errors (413 status) // This typically happens when a large PDF + conversation context exceeds the 32MB API limit - if (error instanceof APIError && error.status === 413) { + if (isApiErrorLike(error) && error.status === 413) { return createAssistantAPIErrorMessage({ content: getRequestTooLargeErrorMessage(), error: "invalid_request" as unknown as SDKAssistantMessageError, @@ -673,7 +676,7 @@ export function getAssistantMessageFromError( // Check for tool_use/tool_result concurrency error if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes( "`tool_use` ids were found without `tool_result` blocks immediately after", @@ -714,7 +717,7 @@ export function getAssistantMessageFromError( } if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("unexpected `tool_use_id` found in `tool_result`") ) { @@ -725,7 +728,7 @@ export function getAssistantMessageFromError( // before send, so hitting this means a new corruption path slipped through. // Log for root-causing, and give users a recovery path instead of deadlock. if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("`tool_use` ids must be unique") ) { @@ -743,7 +746,7 @@ export function getAssistantMessageFromError( // Check for invalid model name error for subscription users trying to use Opus if ( isClaudeAISubscriber() && - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.toLowerCase().includes("invalid model name") && (isNonCustomOpusModel(model) || model === "opus") @@ -791,7 +794,7 @@ export function getAssistantMessageFromError( // the env-var case; apiKeyHelper and /login-managed keys mean the active // auth's org is genuinely disabled with no dormant fallback to point at. if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.toLowerCase().includes("organization has been disabled") ) { @@ -856,7 +859,7 @@ export function getAssistantMessageFromError( // Check for OAuth token revocation error if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 403 && error.message.includes("OAuth token has been revoked") ) { @@ -868,7 +871,7 @@ export function getAssistantMessageFromError( // Check for OAuth organization not allowed error if ( - error instanceof APIError && + isApiErrorLike(error) && (error.status === 401 || error.status === 403) && error.message.includes( "OAuth authentication is currently not allowed for this organization", @@ -882,7 +885,7 @@ export function getAssistantMessageFromError( // Generic handler for other 401/403 authentication errors if ( - error instanceof APIError && + isApiErrorLike(error) && (error.status === 401 || error.status === 403) ) { // In CCR mode, auth is via JWTs - this is likely a transient network issue @@ -922,7 +925,7 @@ export function getAssistantMessageFromError( // 404 Not Found — usually means the selected model doesn't exist or isn't // available. Guide the user to /model so they can pick a valid one. // For 3P users, suggest a specific fallback model they can try. - if (error instanceof APIError && error.status === 404) { + if (isApiErrorLike(error) && error.status === 404) { const switchCmd = getIsNonInteractiveSession() ? "--model" : "/model"; const fallbackSuggestion = get3PModelFallbackSuggestion(model); return createAssistantAPIErrorMessage({ @@ -934,9 +937,9 @@ export function getAssistantMessageFromError( } // Connection errors (non-timeout) — use formatAPIError for detailed messages - if (error instanceof APIConnectionError) { + if (isConnectionErrorLike(error)) { return createAssistantAPIErrorMessage({ - content: `${API_ERROR_MESSAGE_PREFIX}: ${formatAPIError(error)}`, + content: `${API_ERROR_MESSAGE_PREFIX}: ${formatAPIError(error as unknown as APIError)}`, error: "unknown" as unknown as SDKAssistantMessageError, }); } @@ -990,8 +993,8 @@ export function classifyAPIError(error: unknown): string { // Timeout errors if ( - error instanceof APIConnectionTimeoutError || - (error instanceof APIConnectionError && + isTimeoutErrorLike(error) || + (isConnectionErrorLike(error) && error.message.toLowerCase().includes("timeout")) ) { return "api_timeout"; @@ -1014,13 +1017,13 @@ export function classifyAPIError(error: unknown): string { } // Rate limiting - if (error instanceof APIError && error.status === 429) { + if (isApiErrorLike(error) && error.status === 429) { return "rate_limit"; } // Server overload (529) if ( - error instanceof APIError && + isApiErrorLike(error) && (error.status === 529 || error.message?.includes('"type":"overloaded_error"')) ) { @@ -1054,7 +1057,7 @@ export function classifyAPIError(error: unknown): string { // Image size errors if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("image exceeds") && error.message.includes("maximum") @@ -1064,7 +1067,7 @@ export function classifyAPIError(error: unknown): string { // Many-image dimension errors if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("image dimensions exceed") && error.message.includes("many-image") @@ -1074,7 +1077,7 @@ export function classifyAPIError(error: unknown): string { // Tool use errors (400) if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes( "`tool_use` ids were found without `tool_result` blocks immediately after", @@ -1084,7 +1087,7 @@ export function classifyAPIError(error: unknown): string { } if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("unexpected `tool_use_id` found in `tool_result`") ) { @@ -1092,7 +1095,7 @@ export function classifyAPIError(error: unknown): string { } if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.includes("`tool_use` ids must be unique") ) { @@ -1101,7 +1104,7 @@ export function classifyAPIError(error: unknown): string { // Invalid model errors (400) if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 400 && error.message.toLowerCase().includes("invalid model name") ) { @@ -1127,7 +1130,7 @@ export function classifyAPIError(error: unknown): string { } if ( - error instanceof APIError && + isApiErrorLike(error) && error.status === 403 && error.message.includes("OAuth token has been revoked") ) { @@ -1135,7 +1138,7 @@ export function classifyAPIError(error: unknown): string { } if ( - error instanceof APIError && + isApiErrorLike(error) && (error.status === 401 || error.status === 403) && error.message.includes( "OAuth authentication is currently not allowed for this organization", @@ -1146,21 +1149,21 @@ export function classifyAPIError(error: unknown): string { // Generic auth errors if ( - error instanceof APIError && + isApiErrorLike(error) && (error.status === 401 || error.status === 403) ) { return "auth_error"; } // Status code based fallbacks - if (error instanceof APIError) { + if (isApiErrorLike(error)) { const status = error.status; - if (status >= 500) return "server_error"; - if (status >= 400) return "client_error"; + if (status !== undefined && status >= 500) return "server_error"; + if (status !== undefined && status >= 400) return "client_error"; } // Connection errors - check for SSL/TLS issues first - if (error instanceof APIConnectionError) { + if (isConnectionErrorLike(error)) { const connectionDetails = extractConnectionErrorDetails(error); if (connectionDetails?.isSSLError) { return "ssl_cert_error"; diff --git a/src/services/llm/errors.ts b/src/services/llm/errors.ts index 7ccc9c8..ffda5c3 100644 --- a/src/services/llm/errors.ts +++ b/src/services/llm/errors.ts @@ -118,7 +118,7 @@ export function isApiErrorLike(error: unknown): error is { } // 传输层错误: SDK APIConnectionError (name 含 "Connection") 或 LlmRequestError(code=TRANSPORT/TIMEOUT)。 -export function isConnectionErrorLike(error: unknown): boolean { +export function isConnectionErrorLike(error: unknown): error is Error { if (!(error instanceof Error)) return false; if (error instanceof LlmRequestError) { return ( @@ -131,7 +131,7 @@ export function isConnectionErrorLike(error: unknown): boolean { } // 传输层 + 超时: SDK APIConnectionTimeoutError (name 含 "Timeout") 或 message 含 timeout。 -export function isTimeoutErrorLike(error: unknown): boolean { +export function isTimeoutErrorLike(error: unknown): error is Error { if (!(error instanceof Error)) return false; if (error instanceof LlmRequestError) { return error.failure.code === "TIMEOUT"; From f9ef7843ac352ef934d78e829a72c0f9df92dce7 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 15:14:28 +0800 Subject: [PATCH 08/11] =?UTF-8?q?refactor(div-anthropic):=20=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=207=20=E5=A4=84=20APIUserAbortError=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E4=BE=9D=E8=B5=96=20(Phase=205=20Step=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/errors.ts: isAbortError 改用 .name/.message 鸭子判断, 不再 instanceof SDK bashPermissions / permissions / useCanUseTool / GenerateStep / awaySummary: instanceof APIUserAbortError → isAbortError() compact.ts: abortError 工厂改为 () => Error{name:'AbortError'}, 不再 new APIUserAbortError checkpoint: typecheck 0 / 113 tests pass / build green Co-Authored-By: Claude Fable 5 --- .../wizard-steps/GenerateStep.tsx | 4 +- src/hooks/useCanUseTool.tsx | 8 +- src/services/awaySummary.ts | 4 +- src/services/compact/compact.ts | 8 +- src/tools/BashTool/bashPermissions.ts | 7 +- src/utils/errors.ts | 214 +++++++++--------- src/utils/permissions/permissions.ts | 9 +- 7 files changed, 129 insertions(+), 125 deletions(-) diff --git a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx index 9d85237..6255e13 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx @@ -1,9 +1,9 @@ -import { APIUserAbortError } from "@anthropic-ai/sdk"; import { type ReactNode, useCallback, useRef, useState } from "react"; import { useMainLoopModel } from "../../../../hooks/useMainLoopModel.js"; import { Box, Text } from "../../../../ink.js"; import { useKeybinding } from "../../../../keybindings/useKeybinding.js"; import { createAbortController } from "../../../../utils/abortController.js"; +import { isAbortError } from "../../../../utils/errors.js"; import { editPromptInEditor } from "../../../../utils/promptEditor.js"; import { ConfigurableShortcutHint } from "../../../ConfigurableShortcutHint.js"; import { Byline } from "../../../design-system/Byline.js"; @@ -106,7 +106,7 @@ export function GenerateStep(): ReactNode { goToStep(6); } catch (err) { // Don't show error if it was cancelled (already set in escape handler) - if (err instanceof APIUserAbortError) { + if (isAbortError(err)) { // User cancelled - no error to show } else if ( err instanceof Error && diff --git a/src/hooks/useCanUseTool.tsx b/src/hooks/useCanUseTool.tsx index c03a6ed..dc34059 100644 --- a/src/hooks/useCanUseTool.tsx +++ b/src/hooks/useCanUseTool.tsx @@ -1,5 +1,4 @@ import { feature } from "bun:bundle"; -import { APIUserAbortError } from "@anthropic-ai/sdk"; import { c as _c } from "react/compiler-runtime"; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -24,7 +23,7 @@ import { setYoloClassifierApproval, } from "../utils/classifierApprovals.js"; import { logForDebugging } from "../utils/debug.js"; -import { AbortError } from "../utils/errors.js"; +import { isAbortError } from "../utils/errors.js"; import { logError } from "../utils/log.js"; import type { ClassifierResult } from "../utils/permissions/bashClassifier.js"; import type { PermissionDecision } from "../utils/permissions/PermissionResult.js"; @@ -292,10 +291,7 @@ function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) { } }) .catch((error) => { - if ( - error instanceof AbortError || - error instanceof APIUserAbortError - ) { + if (isAbortError(error)) { logForDebugging( `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, ); diff --git a/src/services/awaySummary.ts b/src/services/awaySummary.ts index 2f5eddf..1c9f95d 100644 --- a/src/services/awaySummary.ts +++ b/src/services/awaySummary.ts @@ -1,7 +1,7 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk' import { getEmptyToolPermissionContext } from '../Tool.js' import type { Message } from '../types/message.js' import { logForDebugging } from '../utils/debug.js' +import { isAbortError } from '../utils/errors.js' import { createUserMessage, getAssistantMessageText, @@ -65,7 +65,7 @@ export async function generateAwaySummary( } return getAssistantMessageText(response) } catch (err) { - if (err instanceof APIUserAbortError || signal.aborted) { + if (isAbortError(err) || signal.aborted) { return null } logForDebugging(`[awaySummary] generation failed: ${err}`) diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index df69fbb..4b2d331 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -15,7 +15,6 @@ const sessionTranscriptModule = feature("KAIROS") })() : null; -import { APIUserAbortError } from "@anthropic-ai/sdk"; import { markPostCompaction } from "src/bootstrap/state.js"; import { getInvokedSkillsForAgent } from "../../bootstrap/state.js"; import type { QuerySource } from "../../constants/querySource.js"; @@ -1776,7 +1775,12 @@ async function streamCompactSummary({ hasStartedStreaming, }); await sleep(getRetryDelay(attempt), context.abortController.signal, { - abortError: () => new APIUserAbortError(), + abortError: () => { + // log: 不依赖 SDK 的 AbortError 工厂 — sleep 仅要求 () => Error + const err = new Error("Request was aborted."); + err.name = "AbortError"; + return err; + }, }); continue; } diff --git a/src/tools/BashTool/bashPermissions.ts b/src/tools/BashTool/bashPermissions.ts index bfb9de9..28d405b 100644 --- a/src/tools/BashTool/bashPermissions.ts +++ b/src/tools/BashTool/bashPermissions.ts @@ -1,5 +1,4 @@ import { feature } from "bun:bundle"; -import { APIUserAbortError } from "@anthropic-ai/sdk"; import type { z } from "zod/v4"; import { getFeatureValue_CACHED_MAY_BE_STALE } from "../../services/analytics/growthbook.js"; import { @@ -28,7 +27,7 @@ import { tryParseShellCommand } from "../../utils/bash/shellQuote.js"; import { getCwd } from "../../utils/cwd.js"; import { logForDebugging } from "../../utils/debug.js"; import { isEnvTruthy } from "../../utils/envUtils.js"; -import { AbortError } from "../../utils/errors.js"; +import { AbortError, isAbortError } from "../../utils/errors.js"; import type { ClassifierBehavior, ClassifierResult, @@ -1634,9 +1633,9 @@ export async function executeAsyncClassifierCheck( ); } catch (error: unknown) { // When the coordinator session is cancelled, the abort signal fires and the - // classifier API call rejects with APIUserAbortError. This is expected and + // classifier API call rejects with an abort-shaped error. This is expected and // should not surface as an unhandled promise rejection. - if (error instanceof APIUserAbortError || error instanceof AbortError) { + if (isAbortError(error)) { callbacks.onComplete?.(); return; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 6a7f46e..a60b698 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,35 +1,40 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk' +// LLM 接缝 (Phase 5): 不再 import SDK 的 APIUserAbortError。 +// SDK 类名在 minified 构建里会被混淆 (constructor.name → 'nJT'), 且 SDK 不设 this.name, +// 故用 .name / .message 兜底: "APIUserAbortError" (未混淆时) / "AbortError" (DOMException 与 +// 我们的 AbortError) / "Request was aborted." (SDK 抛出的标准文案) 三者覆盖所有形态。 export class ClaudeError extends Error { - constructor(message: string) { - super(message) - this.name = this.constructor.name - } + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } } export class MalformedCommandError extends Error {} export class AbortError extends Error { - constructor(message?: string) { - super(message) - this.name = 'AbortError' - } + constructor(message?: string) { + super(message); + this.name = "AbortError"; + } } /** * True iff `e` is any of the abort-shaped errors the codebase encounters: * our AbortError class, a DOMException from AbortController.abort() * (.name === 'AbortError'), or the SDK's APIUserAbortError. The SDK class - * is checked via instanceof because minified builds mangle class names — - * constructor.name becomes something like 'nJT' and the SDK never sets - * this.name, so string matching silently fails in production. + * name is mangled in minified builds (constructor.name → 'nJT') and the SDK + * never sets this.name, so we match by .name (unmangled) and by the SDK's + * standard message "Request was aborted." as a fallback. */ export function isAbortError(e: unknown): boolean { - return ( - e instanceof AbortError || - e instanceof APIUserAbortError || - (e instanceof Error && e.name === 'AbortError') - ) + if (e instanceof AbortError) return true; + if (!(e instanceof Error)) return false; + return ( + e.name === "APIUserAbortError" || + e.name === "AbortError" || + e.message === "Request was aborted." + ); } /** @@ -37,37 +42,37 @@ export function isAbortError(e: unknown): boolean { * Includes the file path and the default configuration that should be used */ export class ConfigParseError extends Error { - filePath: string - defaultConfig: unknown + filePath: string; + defaultConfig: unknown; - constructor(message: string, filePath: string, defaultConfig: unknown) { - super(message) - this.name = 'ConfigParseError' - this.filePath = filePath - this.defaultConfig = defaultConfig - } + constructor(message: string, filePath: string, defaultConfig: unknown) { + super(message); + this.name = "ConfigParseError"; + this.filePath = filePath; + this.defaultConfig = defaultConfig; + } } export class ShellError extends Error { - constructor( - public readonly stdout: string, - public readonly stderr: string, - public readonly code: number, - public readonly interrupted: boolean, - ) { - super('Shell command failed') - this.name = 'ShellError' - } + constructor( + public readonly stdout: string, + public readonly stderr: string, + public readonly code: number, + public readonly interrupted: boolean, + ) { + super("Shell command failed"); + this.name = "ShellError"; + } } export class TeleportOperationError extends Error { - constructor( - message: string, - public readonly formattedMessage: string, - ) { - super(message) - this.name = 'TeleportOperationError' - } + constructor( + message: string, + public readonly formattedMessage: string, + ) { + super(message); + this.name = "TeleportOperationError"; + } } /** @@ -91,17 +96,17 @@ export class TeleportOperationError extends Error { * ) */ export class TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS extends Error { - readonly telemetryMessage: string + readonly telemetryMessage: string; - constructor(message: string, telemetryMessage?: string) { - super(message) - this.name = 'TelemetrySafeError' - this.telemetryMessage = telemetryMessage ?? message - } + constructor(message: string, telemetryMessage?: string) { + super(message); + this.name = "TelemetrySafeError"; + this.telemetryMessage = telemetryMessage ?? message; + } } export function hasExactErrorMessage(error: unknown, message: string): boolean { - return error instanceof Error && error.message === message + return error instanceof Error && error.message === message; } /** @@ -109,7 +114,7 @@ export function hasExactErrorMessage(error: unknown, message: string): boolean { * Use at catch-site boundaries when you need an Error instance. */ export function toError(e: unknown): Error { - return e instanceof Error ? e : new Error(String(e)) + return e instanceof Error ? e : new Error(String(e)); } /** @@ -117,7 +122,7 @@ export function toError(e: unknown): Error { * Use when you only need the message (e.g., for logging or display). */ export function errorMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e) + return e instanceof Error ? e.message : String(e); } /** @@ -126,10 +131,10 @@ export function errorMessage(e: unknown): string { * Replaces the `(e as NodeJS.ErrnoException).code` cast pattern. */ export function getErrnoCode(e: unknown): string | undefined { - if (e && typeof e === 'object' && 'code' in e && typeof e.code === 'string') { - return e.code - } - return undefined + if (e && typeof e === "object" && "code" in e && typeof e.code === "string") { + return e.code; + } + return undefined; } /** @@ -137,7 +142,7 @@ export function getErrnoCode(e: unknown): string | undefined { * Replaces `(e as NodeJS.ErrnoException).code === 'ENOENT'`. */ export function isENOENT(e: unknown): boolean { - return getErrnoCode(e) === 'ENOENT' + return getErrnoCode(e) === "ENOENT"; } /** @@ -146,10 +151,10 @@ export function isENOENT(e: unknown): boolean { * Replaces the `(e as NodeJS.ErrnoException).path` cast pattern. */ export function getErrnoPath(e: unknown): string | undefined { - if (e && typeof e === 'object' && 'path' in e && typeof e.path === 'string') { - return e.path - } - return undefined + if (e && typeof e === "object" && "path" in e && typeof e.path === "string") { + return e.path; + } + return undefined; } /** @@ -159,15 +164,15 @@ export function getErrnoPath(e: unknown): string | undefined { * waste context tokens. Keep the full stack in debug logs instead. */ export function shortErrorStack(e: unknown, maxFrames = 5): string { - if (!(e instanceof Error)) return String(e) - if (!e.stack) return e.message - // V8/Bun stack format: "Name: message\n at frame1\n at frame2..." - // First line is the message; subsequent " at " lines are frames. - const lines = e.stack.split('\n') - const header = lines[0] ?? e.message - const frames = lines.slice(1).filter(l => l.trim().startsWith('at ')) - if (frames.length <= maxFrames) return e.stack - return [header, ...frames.slice(0, maxFrames)].join('\n') + if (!(e instanceof Error)) return String(e); + if (!e.stack) return e.message; + // V8/Bun stack format: "Name: message\n at frame1\n at frame2..." + // First line is the message; subsequent " at " lines are frames. + const lines = e.stack.split("\n"); + const header = lines[0] ?? e.message; + const frames = lines.slice(1).filter((l) => l.trim().startsWith("at ")); + if (frames.length <= maxFrames) return e.stack; + return [header, ...frames.slice(0, maxFrames)].join("\n"); } /** @@ -184,22 +189,22 @@ export function shortErrorStack(e: unknown, maxFrames = 5): string { * ELOOP — too many symlink levels (circular symlinks) */ export function isFsInaccessible(e: unknown): e is NodeJS.ErrnoException { - const code = getErrnoCode(e) - return ( - code === 'ENOENT' || - code === 'EACCES' || - code === 'EPERM' || - code === 'ENOTDIR' || - code === 'ELOOP' - ) + const code = getErrnoCode(e); + return ( + code === "ENOENT" || + code === "EACCES" || + code === "EPERM" || + code === "ENOTDIR" || + code === "ELOOP" + ); } export type AxiosErrorKind = - | 'auth' // 401/403 — caller typically sets skipRetry - | 'timeout' // ECONNABORTED - | 'network' // ECONNREFUSED/ENOTFOUND - | 'http' // other axios error (may have status) - | 'other' // not an axios error + | "auth" // 401/403 — caller typically sets skipRetry + | "timeout" // ECONNABORTED + | "network" // ECONNREFUSED/ENOTFOUND + | "http" // other axios error (may have status) + | "other"; // not an axios error /** * Classify a caught error from an axios request into one of a few buckets. @@ -211,28 +216,29 @@ export type AxiosErrorKind = * axios.isAxiosError()) to keep this module dependency-free. */ export function classifyAxiosError(e: unknown): { - kind: AxiosErrorKind - status?: number - message: string + kind: AxiosErrorKind; + status?: number; + message: string; } { - const message = errorMessage(e) - if ( - !e || - typeof e !== 'object' || - !('isAxiosError' in e) || - !e.isAxiosError - ) { - return { kind: 'other', message } - } - const err = e as { - response?: { status?: number } - code?: string - } - const status = err.response?.status - if (status === 401 || status === 403) return { kind: 'auth', status, message } - if (err.code === 'ECONNABORTED') return { kind: 'timeout', status, message } - if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') { - return { kind: 'network', status, message } - } - return { kind: 'http', status, message } + const message = errorMessage(e); + if ( + !e || + typeof e !== "object" || + !("isAxiosError" in e) || + !e.isAxiosError + ) { + return { kind: "other", message }; + } + const err = e as { + response?: { status?: number }; + code?: string; + }; + const status = err.response?.status; + if (status === 401 || status === 403) + return { kind: "auth", status, message }; + if (err.code === "ECONNABORTED") return { kind: "timeout", status, message }; + if (err.code === "ECONNREFUSED" || err.code === "ENOTFOUND") { + return { kind: "network", status, message }; + } + return { kind: "http", status, message }; } diff --git a/src/utils/permissions/permissions.ts b/src/utils/permissions/permissions.ts index a868036..0613467 100644 --- a/src/utils/permissions/permissions.ts +++ b/src/utils/permissions/permissions.ts @@ -1,5 +1,4 @@ import { feature } from "bun:bundle"; -import { APIUserAbortError } from "@anthropic-ai/sdk"; import type { CanUseToolFn } from "../../hooks/useCanUseTool.js"; import { getToolNameForPermissionCheck, @@ -18,7 +17,7 @@ import { REPL_TOOL_NAME } from "../../tools/REPLTool/constants.js"; import type { AssistantMessage } from "../../types/message.js"; import { extractOutputRedirections } from "../bash/commands.js"; import { logForDebugging } from "../debug.js"; -import { AbortError, toError } from "../errors.js"; +import { AbortError, isAbortError, toError } from "../errors.js"; import { logError } from "../log.js"; import { SandboxManager } from "../sandbox/sandbox-adapter.js"; import { @@ -747,7 +746,7 @@ export const hasPermissionsToUseTool: CanUseToolFn = async ( }; } } catch (e) { - if (e instanceof AbortError || e instanceof APIUserAbortError) { + if (isAbortError(e)) { throw e; } // If the acceptEdits check fails, fall through to the classifier @@ -1219,7 +1218,7 @@ export async function checkRuleBasedPermissions( const parsedInput = tool.inputSchema.parse(input); toolPermissionResult = await tool.checkPermissions(parsedInput, context); } catch (e) { - if (e instanceof AbortError || e instanceof APIUserAbortError) { + if (isAbortError(e)) { throw e; } logError(e); @@ -1316,7 +1315,7 @@ async function hasPermissionsToUseToolInner( toolPermissionResult = await tool.checkPermissions(parsedInput, context); } catch (e) { // Rethrow abort errors so they propagate properly - if (e instanceof AbortError || e instanceof APIUserAbortError) { + if (isAbortError(e)) { throw e; } logError(e); From 8a31408460b279c67a47432b9080eb0afe64c104 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 15:25:27 +0800 Subject: [PATCH 09/11] =?UTF-8?q?refactor(div-anthropic):=20=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=205=20=E5=A4=84=20APIError=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E4=BE=9D=E8=B5=96=20(Phase=205=20Step=204=20cont)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logging.ts / claudeAiLimits.ts / validateModel.ts / claude.ts / rateLimitMocking.ts: instanceof APIError → isApiErrorLike (按形状: status/headers/requestID) instanceof NotFoundError → isApiErrorLike && status===404 instanceof AuthenticationError → status===401 instanceof APIConnectionError → isConnectionErrorLike new APIUserAbortError() / new APIConnectionTimeoutError() → 具名 Error 工厂 rateLimitMocking: 本地 MockAPIError extends Error 替代 SDK APIError 类 (ant-only) checkpoint: typecheck 0 / 113 tests pass / build green Co-Authored-By: Claude Fable 5 --- src/services/api/claude.ts | 68 +++++++++++++++++--------------- src/services/api/logging.ts | 13 +++--- src/services/claudeAiLimits.ts | 12 ++++-- src/services/rateLimitMocking.ts | 35 +++++++++++++--- src/utils/model/validateModel.ts | 18 ++++----- 5 files changed, 90 insertions(+), 56 deletions(-) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index e1f332f..9238b0a 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import type { BetaContentBlock, BetaContentBlockParam, @@ -16,10 +17,9 @@ import type { BetaToolUnion, BetaUsage, BetaMessageParam as MessageParam, + Stream, + TextBlockParam, } from "src/types/anthropic-protocol.js"; -import type { TextBlockParam } from "src/types/anthropic-protocol.js"; -import type { Stream } from "src/types/anthropic-protocol.js"; -import { randomUUID } from "crypto"; import { getAPIProvider, isFirstPartyAnthropicBaseUrl, @@ -66,7 +66,7 @@ import { } from "../../utils/context.js"; import { resolveAppliedEffort } from "../../utils/effort.js"; import { isEnvTruthy } from "../../utils/envUtils.js"; -import { errorMessage } from "../../utils/errors.js"; +import { errorMessage, isAbortError } from "../../utils/errors.js"; import { computeFingerprintFromMessages } from "../../utils/fingerprint.js"; import { captureAPIRequest, logError } from "../../utils/log.js"; import { @@ -103,12 +103,7 @@ const autoModeStateModule = require("../../utils/permissions/autoModeState.js") as typeof import("../../utils/permissions/autoModeState.js"); import { feature } from "bun:bundle"; -import type { ClientOptions } from "src/types/anthropic-protocol.js"; -import { - APIConnectionTimeoutError, - APIError, - APIUserAbortError, -} from "@anthropic-ai/sdk/error"; +import { isApiErrorLike } from "../llm/errors.js"; import { getAfkModeHeaderLatched, getCacheEditingHeaderLatched, @@ -141,6 +136,7 @@ import type { QuerySource } from "src/constants/querySource.js"; import type { Notification } from "src/context/notifications.js"; import { addToTotalSessionCost } from "src/cost-tracker.js"; import { getFeatureValue_CACHED_MAY_BE_STALE } from "src/services/analytics/growthbook.js"; +import type { ClientOptions } from "src/types/anthropic-protocol.js"; import type { AgentId } from "src/types/ids.js"; import { ADVISOR_TOOL_INSTRUCTIONS, @@ -225,6 +221,8 @@ import { markToolsSentToAPIState, pinCacheEdits, } from "../compact/microCompact.js"; +// LLM 接缝 (Phase 4): flag 开时用 streamViaSeam 替代 SDK 流式; 关时 DCE 消除 +import { isSeamActive, streamViaSeam } from "../llm/seam.js"; import { getInitializationStatus } from "../lsp/manager.js"; import { isToolFromMcpServer } from "../mcp/utils.js"; import { withStreamingVCR, withVCR } from "../vcr.js"; @@ -255,8 +253,6 @@ import { type RetryContext, withRetry, } from "./withRetry.js"; -// LLM 接缝 (Phase 4): flag 开时用 streamViaSeam 替代 SDK 流式; 关时 DCE 消除 -import { isSeamActive, streamViaSeam } from "../llm/seam.js"; // Define a type that represents valid JSON values type JsonValue = string | number | boolean | null | JsonObject | JsonArray; @@ -746,10 +742,13 @@ export async function queryModelWithoutStreaming({ } } if (!assistantMessage) { - // If the signal was aborted, throw APIUserAbortError instead of a generic error + // If the signal was aborted, throw AbortError instead of a generic error // This allows callers to handle abort scenarios gracefully if (signal.aborted) { - throw new APIUserAbortError(); + // log: 不依赖 SDK 的 APIUserAbortError — 构造具名 AbortError, isAbortError 可识别 + const abortErr = new Error("Request was aborted."); + abortErr.name = "AbortError"; + throw abortErr; } throw new Error("No assistant message found"); } @@ -880,7 +879,7 @@ export async function* executeNonStreamingRequest( ); } catch (err) { // User aborts are not errors — re-throw immediately without logging - if (err instanceof APIUserAbortError) throw err; + if (isAbortError(err)) throw err; // Instrumentation: record when the non-streaming request errors (including // timeouts). Lets us distinguish "fallback hung past container kill" @@ -2450,7 +2449,7 @@ async function* queryModel( }); } - if (streamingError instanceof APIUserAbortError) { + if (isAbortError(streamingError)) { // Check if the abort signal was triggered by the user (ESC key) // If the signal is aborted, it's a user-initiated abort // If not, it's likely a timeout from the SDK @@ -2469,14 +2468,16 @@ async function* queryModel( } throw streamingError; } else { - // The SDK threw APIUserAbortError but our signal wasn't aborted + // The SDK threw an abort but our signal wasn't aborted // This means it's a timeout from the SDK's internal timeout logForDebugging( `Streaming timeout (SDK abort): ${streamingError.message}`, { level: "error" }, ); - // Throw a more specific error for timeout - throw new APIConnectionTimeoutError({ message: "Request timed out" }); + // log: 不依赖 SDK 的 APIConnectionTimeoutError — 构造具名 timeout 错误 + const timeoutErr = new Error("Request timed out"); + timeoutErr.name = "APIConnectionTimeoutError"; + throw timeoutErr; } } @@ -2631,7 +2632,7 @@ async function* queryModel( const is404StreamCreationError = !didFallBackToNonStreaming && errorFromRetry instanceof CannotRetryError && - errorFromRetry.originalError instanceof APIError && + isApiErrorLike(errorFromRetry.originalError) && errorFromRetry.originalError.status === 404; if (is404StreamCreationError) { @@ -2639,7 +2640,8 @@ async function* queryModel( // and CannotRetryError means every retry failed — so grab the failed // request's ID from the error header instead. const failedRequestId = - (errorFromRetry.originalError as APIError).requestID ?? "unknown"; + (errorFromRetry.originalError as { requestID?: string }).requestID ?? + "unknown"; logForDebugging( "Streaming endpoint returned 404, falling back to non-streaming mode", { level: "warn" }, @@ -2725,15 +2727,17 @@ async function* queryModel( errorModel = fallbackError.retryContext.model; } - if (error instanceof APIError) { + if (isApiErrorLike(error)) { extractQuotaStatusFromError(error); } const requestId = streamRequestId || - (error instanceof APIError ? error.requestID : undefined) || - (error instanceof APIError - ? (error.error as { request_id?: string })?.request_id + (isApiErrorLike(error) + ? (error as { requestID?: string }).requestID + : undefined) || + (isApiErrorLike(error) + ? ((error as { error?: { request_id?: string } }).error)?.request_id : undefined); logAPIError({ @@ -2754,7 +2758,7 @@ async function* queryModel( previousRequestId, }); - if (error instanceof APIUserAbortError) { + if (isAbortError(error)) { releaseStreamResources(); return; } @@ -2780,16 +2784,18 @@ async function* queryModel( } // Extract quota status from error headers if it's a rate limit error - if (error instanceof APIError) { + if (isApiErrorLike(error)) { extractQuotaStatusFromError(error); } // Extract requestId from stream, error header, or error body const requestId = streamRequestId || - (error instanceof APIError ? error.requestID : undefined) || - (error instanceof APIError - ? (error.error as { request_id?: string })?.request_id + (isApiErrorLike(error) + ? (error as { requestID?: string }).requestID + : undefined) || + (isApiErrorLike(error) + ? ((error as { error?: { request_id?: string } }).error)?.request_id : undefined); logAPIError({ @@ -2812,7 +2818,7 @@ async function* queryModel( // Don't yield an assistant error message for user aborts // The interruption message is handled in query.ts - if (error instanceof APIUserAbortError) { + if (isAbortError(error)) { releaseStreamResources(); return; } diff --git a/src/services/api/logging.ts b/src/services/api/logging.ts index 8001ae8..832556c 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -1,5 +1,4 @@ import { feature } from "bun:bundle"; -import { APIError } from "@anthropic-ai/sdk"; import type { BetaStopReason, BetaUsage as Usage, @@ -38,6 +37,7 @@ import { sanitizeToolNameForAnalytics } from "../analytics/metadata.js"; import { EMPTY_USAGE } from "./emptyUsage.js"; import { classifyAPIError } from "./errors.js"; import { extractConnectionErrorDetails } from "./errorUtils.js"; +import { isApiErrorLike } from "../llm/errors.js"; export type { NonNullableUsage }; export { EMPTY_USAGE }; @@ -46,8 +46,9 @@ export { EMPTY_USAGE }; export type GlobalCacheStrategy = "tool_based" | "system_prompt" | "none"; function getErrorMessage(error: unknown): string { - if (error instanceof APIError) { - const body = error.error as { error?: { message?: string } } | undefined; + if (isApiErrorLike(error)) { + // log: SDK 错误体嵌在 .error 字段; 接缝错误无此字段, 走通用 message + const body = (error as { error?: { error?: { message?: string } } }).error; if (body?.error?.message) return body.error.message; } return error instanceof Error ? error.message : String(error); @@ -273,12 +274,14 @@ export function logAPIError({ }): void { const gateway = detectGateway({ headers: - error instanceof APIError && error.headers ? error.headers : headers, + isApiErrorLike(error) && error.headers + ? (error.headers as unknown as globalThis.Headers) + : headers, baseUrl: process.env.FUSION_BASE_URL, }); const errStr = getErrorMessage(error); - const status = error instanceof APIError ? String(error.status) : undefined; + const status = isApiErrorLike(error) ? String(error.status) : undefined; const errorType = classifyAPIError(error); // Log detailed connection error info to debug logs (visible via --debug) diff --git a/src/services/claudeAiLimits.ts b/src/services/claudeAiLimits.ts index 1b28076..1f103f6 100644 --- a/src/services/claudeAiLimits.ts +++ b/src/services/claudeAiLimits.ts @@ -1,4 +1,3 @@ -import { APIError } from '@anthropic-ai/sdk' import type { MessageParam } from 'src/types/anthropic-protocol.js' import isEqual from 'lodash-es/isEqual.js' import { getIsNonInteractiveSession } from '../bootstrap/state.js' @@ -8,6 +7,7 @@ import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' import { logError } from '../utils/log.js' import { getSmallFastModel } from '../utils/model/model.js' import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +import { isApiErrorLike } from './llm/errors.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './analytics/index.js' import { logEvent } from './analytics/index.js' import { getAPIMetadata } from './api/claude.js' @@ -242,7 +242,7 @@ export async function checkQuotaStatus(): Promise { // Update limits based on the response extractQuotaStatusFromHeaders(raw.headers) } catch (error) { - if (error instanceof APIError) { + if (isApiErrorLike(error)) { extractQuotaStatusFromError(error) } } @@ -484,7 +484,9 @@ export function extractQuotaStatusFromHeaders( } } -export function extractQuotaStatusFromError(error: APIError): void { +export function extractQuotaStatusFromError( + error: { status?: number; message: string; headers?: unknown }, +): void { if ( !shouldProcessRateLimits(isClaudeAISubscriber()) || error.status !== 429 @@ -496,7 +498,9 @@ export function extractQuotaStatusFromError(error: APIError): void { let newLimits = { ...currentLimits } if (error.headers) { // Process headers (applies mocks from /mock-limits command if active) - const headersToUse = processRateLimitHeaders(error.headers) + const headersToUse = processRateLimitHeaders( + error.headers as globalThis.Headers, + ) rawUtilization = extractRawUtilization(headersToUse) newLimits = computeNewLimitsFromHeaders(headersToUse) diff --git a/src/services/rateLimitMocking.ts b/src/services/rateLimitMocking.ts index e2b9756..80ef079 100644 --- a/src/services/rateLimitMocking.ts +++ b/src/services/rateLimitMocking.ts @@ -3,7 +3,6 @@ * This isolates mock logic from production code */ -import { APIError } from '@anthropic-ai/sdk' import { applyMockHeaders, checkMockFastModeRateLimit, @@ -13,6 +12,28 @@ import { shouldProcessMockLimits, } from './mockRateLimits.js' +// log: ant-only mock — 不依赖 SDK 的 APIError 类。构造一个结构与 APIError 兼容的 +// Error 子类 (status/headers/error/requestID), 下游 isApiErrorLike 按形状识别。 +class MockAPIError extends Error { + readonly status: number + readonly headers: globalThis.Headers + readonly error: unknown + readonly requestID: string | undefined + constructor( + status: number, + error: unknown, + message: string, + headers: globalThis.Headers, + ) { + super(message) + this.name = 'APIError' + this.status = status + this.error = error + this.headers = headers + this.requestID = undefined + } +} + /** * Process headers, applying mocks if /mock-limits command is active */ @@ -42,14 +63,14 @@ export function shouldProcessRateLimits(isSubscriber: boolean): boolean { export function checkMockRateLimitError( currentModel: string, isFastModeActive?: boolean, -): APIError | null { +): MockAPIError | null { if (!shouldProcessMockLimits()) { return null } const headerlessMessage = getMockHeaderless429Message() if (headerlessMessage) { - return new APIError( + return new MockAPIError( 429, { error: { type: 'rate_limit_error', message: headerlessMessage } }, headerlessMessage, @@ -93,7 +114,7 @@ export function checkMockRateLimitError( return null } // Create a mock 429 error with the fast mode headers - const error = new APIError( + const error = new MockAPIError( 429, { error: { type: 'rate_limit_error', message: 'Rate limit exceeded' } }, 'Rate limit exceeded', @@ -113,7 +134,7 @@ export function checkMockRateLimitError( if (shouldThrow429) { // Create a mock 429 error with the appropriate headers - const error = new APIError( + const error = new MockAPIError( 429, { error: { type: 'rate_limit_error', message: 'Rate limit exceeded' } }, 'Rate limit exceeded', @@ -134,7 +155,9 @@ export function checkMockRateLimitError( /** * Check if this is a mock 429 error that shouldn't be retried */ -export function isMockRateLimitError(error: APIError): boolean { +export function isMockRateLimitError( + error: { status?: number }, +): boolean { return shouldProcessMockLimits() && error.status === 429 } diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index 7cfe0af..fb03dda 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -4,11 +4,9 @@ import { isModelAllowed } from './modelAllowlist.js' import { getAPIProvider } from './providers.js' import { sideQuery } from '../sideQuery.js' import { - NotFoundError, - APIError, - APIConnectionError, - AuthenticationError, -} from '@anthropic-ai/sdk' + isApiErrorLike, + isConnectionErrorLike, +} from '../../services/llm/errors.js' import { getModelStrings } from './modelStrings.js' // Cache valid models to avoid repeated API calls @@ -103,7 +101,7 @@ function handleValidationError( modelName: string, ): { valid: boolean; error: string } { // NotFoundError (404) means the model doesn't exist - if (error instanceof NotFoundError) { + if (isApiErrorLike(error) && error.status === 404) { const fallback = get3PFallbackSuggestion(modelName) const suggestion = fallback ? `. Try '${fallback}' instead` : '' return { @@ -113,15 +111,15 @@ function handleValidationError( } // For other API errors, provide context-specific messages - if (error instanceof APIError) { - if (error instanceof AuthenticationError) { + if (isApiErrorLike(error)) { + if (error.status === 401) { return { valid: false, error: 'Authentication failed. Please check your API credentials.', } } - if (error instanceof APIConnectionError) { + if (isConnectionErrorLike(error)) { return { valid: false, error: 'Network error. Please check your internet connection.', @@ -129,7 +127,7 @@ function handleValidationError( } // Check error body for model-specific errors - const errorBody = error.error as unknown + const errorBody = (error as { error?: unknown }).error if ( errorBody && typeof errorBody === 'object' && From 451d532ac8bcb8bc60ca39bb1e58b80dc7ad8c14 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 16:00:40 +0800 Subject: [PATCH 10/11] =?UTF-8?q?test(startup):=20=E4=BF=AE=E5=A4=8D=20pro?= =?UTF-8?q?vider=20=E6=A3=80=E6=B5=8B=E7=94=A8=E4=BE=8B=E7=9A=84=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beforeEach 未清理 FUSION_BASE_URL/FUSION_GATEWAY_ENABLED/FUSION_API_KEY, shell 中 FUSION_BASE_URL=http://127.0.0.1 使 shouldAutoUseFusionMlx() 误判为 true, 导致 "有 FUSION_API_KEY 时不应自动启用" 用例失败。补齐清理使用例 hermetic。 (报错用例定位修复 — 与 div-anthropic 无关, 但按规则一并修复) Co-Authored-By: Claude Fable 5 --- tests/cli/startup.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/cli/startup.test.ts b/tests/cli/startup.test.ts index d8b5fd3..dff1b3a 100644 --- a/tests/cli/startup.test.ts +++ b/tests/cli/startup.test.ts @@ -26,6 +26,10 @@ describe("CLI 启动环境变量", () => { delete process.env.FUSION_MLX_ENABLED; delete process.env.FUSION_MLX_MODEL; delete process.env.FUSION_MLX_BASE_URL; + delete process.env.FUSION_MLX_AUTO; + delete process.env.FUSION_GATEWAY_ENABLED; + delete process.env.FUSION_BASE_URL; + delete process.env.FUSION_API_KEY; delete process.env.FORCE_COLOR; delete process.env.CLAUDE_CONFIG_DIR; delete process.env.ANTHROPIC_API_KEY; From e62e2d992266a3f23893105d33e8e78d441f4e05 Mon Sep 17 00:00:00 2001 From: dahai80 <121743945@qq.com> Date: Sat, 15 Aug 2026 19:57:42 +0800 Subject: [PATCH 11/11] =?UTF-8?q?docs(div-anthropic):=20Phase=206=20?= =?UTF-8?q?=E2=80=94=20=E8=AE=B0=E5=BD=95=20LLM=20Adapter=20=E6=8E=A5?= =?UTF-8?q?=E7=BC=9D=E4=B8=8E=E9=94=99=E8=AF=AF=E5=B1=82=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model-providers.md: 新增 "LLM Adapter 接缝" 节 — 架构/错误层鸭子桥/范围与遗留(#63-65) README.md: Build 节新增 LLM_ADAPTER_SEAM flag 说明 + 链接 docs CLAUDE.md: 本地 (gitignored) 新增 seam 子系统指引 关联 issue: #63 (client.ts 云 provider) #64 (package.json) #65 (sdk/runtime/mcpb 评估) Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++++ docs/model-providers.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/README.md b/README.md index f9efb41..aba9d1e 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,10 @@ bun run ./scripts/build.ts --feature=ULTRAPLAN --feature=ULTRATHINK bun run ./scripts/build.ts --dev --feature-set=dev-full --feature=BRIDGE_MODE ``` +### LLM Adapter Seam (`LLM_ADAPTER_SEAM`) + +A provider-neutral seam (`src/services/llm/`) decouples the runtime from `@anthropic-ai/sdk`'s error layer. When this flag is on and the provider is `firstParty` or `fusionMlx`, requests bypass `anthropic.beta.messages.create` and POST `/v1/messages` directly via the seam (SSE→StreamChunk→SDK parts). Error handling uses duck-typing guards (`isApiErrorLike`/`isAbortError`) instead of `instanceof` SDK classes. Default off; in the `dev-full` set. See `docs/model-providers.md` § "LLM Adapter 接缝". Cloud providers (bedrock/vertex/foundry) still use the SDK client — full decoupling tracked in issues #63/#64/#65. + --- ## Usage @@ -433,6 +437,12 @@ Collect session trajectories and export training datasets (SFT/DPO/GRPO) for fus See [docs/trajectory-pipeline.md](docs/trajectory-pipeline.md) for details. +### Production Acceptance + +v0.4.18 核心特性生产验收 (13 项: 多供应商路由 / Feature Flags / MLX Tiering / 权限模式 / Slash Commands / Plugins / Context Management / Workflows / FUSION.rules / Context Hub / Agent Tools / Safe Mode / Telemetry): + +See [docs/acceptance-report.md](docs/acceptance-report.md) for full report. + ### Permission Modes Press **Shift+Tab** to cycle modes: diff --git a/docs/model-providers.md b/docs/model-providers.md index c1a0020..afd3c3f 100644 --- a/docs/model-providers.md +++ b/docs/model-providers.md @@ -179,6 +179,43 @@ ANTHROPIC_VERTEX_PROJECT_ID=xxx \ 注:当前 fork 中 vertex 分支已禁用,需恢复源码中 `if (false)` 分支才能使用。 +## LLM Adapter 接缝 (div-anthropic Phase 1-5) + +为去除对 `@anthropic-ai/sdk` 的运行时耦合,fusion-code 引入 provider 中立的 **LLM 接缝 (seam)**。分支 `feat/div-anthropic`,feature flag `LLM_ADAPTER_SEAM` 守护(默认关,`dev-full` feature-set 开启)。 + +### 架构 + +``` +claude.ts queryModel() + └─ if (isSeamActive(model)) ← flag + provider∈{firstParty, fusionMlx} + streamViaSeam(params, signal, model) ← 接缝路径 + ├─ httpClient.postMessages() ← 裸 HTTP POST /v1/messages + ├─ parseSseStream() → sseToChunk() ← SSE → StreamChunk (provider 中立) + └─ chunkStreamToSdkParts() ← StreamChunk → SDK part (喂下方既有 switch, 零改动) + else + anthropic.beta.messages.create({stream}) ← SDK 路径 (flag 关 / 云 provider) +``` + +flag 关时 `streamViaSeam` 经 Bun DCE 消除,回滚=关 flag。 + +### 错误层解耦 (Phase 5) + +错误处理不再 `instanceof` SDK 错误类,改用鸭子类型桥 (`src/services/llm/errors.ts`),同时接受 SDK `APIError`(flag-off)与接缝 `LlmRequestError`(flag-on): + +| 鸭子守卫 | 替代的 SDK 判定 | 判定依据 | +|----------|----------------|----------| +| `isApiErrorLike(e)` | `instanceof APIError` | `Error` + 有 `status`/`headers`/`requestID` 之一 | +| `isConnectionErrorLike(e)` | `instanceof APIConnectionError` | `LlmRequestError` code=TRANSPORT/TIMEOUT,或 `.name` 含 "Connection" | +| `isTimeoutErrorLike(e)` | `instanceof APIConnectionTimeoutError` | code=TIMEOUT,或 `.name` 含 "Timeout",或 message 含 "timeout" | +| `isAbortError(e)` | `instanceof APIUserAbortError` | `.name`∈{APIUserAbortError, AbortError},或 message="Request was aborted." | + +`utils/errors.ts` 的 `isAbortError` 用 `.name`/`.message` 而非 `instanceof`,因 minified build 中 SDK 类名混淆为 `nJT` 且 SDK 不设 `.name`。 + +### 当前范围与遗留 + +- **已完成**:错误层 12 个文件脱 SDK 运行时;编译后二进制 0 处 `@anthropic-ai/sdk` 错误类引用;firstParty+fusionMlx 经接缝跑通(MLX smoke 200 OK)。 +- **未完成(见 issue #63/#64/#65)**:`client.ts` 的 `new Anthropic(...)` 仍为云 provider(bedrock/vertex/foundry 签名)必需;`package.json` 的 SDK 依赖未移除。接缝当前只覆盖 firstParty+fusionMlx。 + ## 辅助函数 | 函数 | 文件 | 说明 |