From 581237035441047d620887445bdbaa431890388d Mon Sep 17 00:00:00 2001 From: H2Rhan <2373723338@qq.com> Date: Wed, 9 Sep 2026 22:47:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v2.0=20TypeScript=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D=EF=BC=88ts/=EF=BC=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20Node=2022=20+=20TS=20=E6=8A=80=E6=9C=AF?= =?UTF-8?q?=E6=A0=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ts/ 全量移植:IR 契约 / 三对 adapter / 状态层(node:sqlite)/ 网关 / SSE 流式(转换+透传+501)/ 埋点 / 预热 / mock / 冒烟,功能对齐 Python 版 v1.7 - 测试镜像:56 项离线单测(node:test)+ 25 项端到端冒烟全过,用例与 Python 版逐条对应;strict + erasableSyntaxOnly 类型检查全过 - 运行零第三方依赖(Node 22 类型擦除);开发依赖仅 typescript(含 package-lock) - 差异清单见 ts/README.md(恒直连 / node:sqlite / PRNG 更换);webui、verify_cache、experiments 未移植(后续 PR) - CI 加 unittest-ts job(typecheck + 单测 + 冒烟);README/CHANGELOG/.gitignore 同步 --- .github/workflows/test.yml | 33 +- .gitignore | 3 + CHANGELOG.md | 8 + README.md | 2 + ts/README.md | 46 ++ ts/config/session.json | 12 + ts/package-lock.json | 50 ++ ts/package.json | 21 + ts/src/adapters/anthropic.ts | 191 ++++++ ts/src/adapters/base.ts | 139 ++++ ts/src/adapters/chat.ts | 150 +++++ ts/src/adapters/response.ts | 129 ++++ ts/src/gateway/server.ts | 666 +++++++++++++++++++ ts/src/gateway/sse.ts | 262 ++++++++ ts/src/ir/model.ts | 179 ++++++ ts/src/observability/metrics.ts | 100 +++ ts/src/state/idmap.ts | 95 +++ ts/src/state/session_config.ts | 103 +++ ts/src/state/store.ts | 258 ++++++++ ts/src/warmup/prewarm.ts | 75 +++ ts/tests/offline.test.ts | 1071 +++++++++++++++++++++++++++++++ ts/tools/mock_backend.ts | 252 ++++++++ ts/tools/smoke_e2e.ts | 254 ++++++++ ts/tsconfig.json | 15 + 24 files changed, 4111 insertions(+), 3 deletions(-) create mode 100644 ts/README.md create mode 100644 ts/config/session.json create mode 100644 ts/package-lock.json create mode 100644 ts/package.json create mode 100644 ts/src/adapters/anthropic.ts create mode 100644 ts/src/adapters/base.ts create mode 100644 ts/src/adapters/chat.ts create mode 100644 ts/src/adapters/response.ts create mode 100644 ts/src/gateway/server.ts create mode 100644 ts/src/gateway/sse.ts create mode 100644 ts/src/ir/model.ts create mode 100644 ts/src/observability/metrics.ts create mode 100644 ts/src/state/idmap.ts create mode 100644 ts/src/state/session_config.ts create mode 100644 ts/src/state/store.ts create mode 100644 ts/src/warmup/prewarm.ts create mode 100644 ts/tests/offline.test.ts create mode 100644 ts/tools/mock_backend.ts create mode 100644 ts/tools/smoke_e2e.ts create mode 100644 ts/tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5fbad91..1b5e000 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - unittest: + unittest-python: runs-on: ubuntu-latest strategy: fail-fast: false @@ -18,9 +18,36 @@ jobs: with: python-version: ${{ matrix.python-version }} # 零第三方依赖(纯标准库),无需 pip install - - name: 离线单测(44 项) + - name: 离线单测(56 项) run: python -m unittest tests.test_offline - - name: 端到端冒烟(11 项,子进程级) + - name: 端到端冒烟(25 项,子进程级) run: python tools/smoke_e2e.py - name: 实验框架 dry-run run: python experiments/run_experiments.py --dry-run + + unittest-ts: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ["22"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: ts/package-lock.json + # TS 移植版:运行零第三方依赖(Node 22 类型擦除 + node:sqlite) + - name: 安装开发依赖(typescript) + run: npm ci + working-directory: ts + - name: 类型检查(strict + erasableSyntaxOnly) + run: npm run typecheck + working-directory: ts + - name: 离线单测(56 项) + run: npm test + working-directory: ts + - name: 端到端冒烟(25 项,子进程级) + run: npm run smoke + working-directory: ts diff --git a/.gitignore b/.gitignore index 1b314b1..ead6160 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ data/* metrics.jsonl experiments/report.json +# Node(ts/ 移植版) +node_modules/ + # 系统 Thumbs.db .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index c82f7f8..48858c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,3 +76,11 @@ - 客户端中途断开:已收到的部分照常落账(费用已实际发生) - mock:`/chat/completions` 路径发 Chat 形状 chunk(role 首帧 + finish + usage + [DONE]) - 测试:`TestChatStreamCollector` 3 项单测;冒烟第 7 组 7 项(两个透传方向文本拼合 / 逐块到达 ~31ms / [DONE] / 501)。56 项单测 + 25 项冒烟全过 + +## 2026-09-09 · v2.0 TypeScript 全量移植(本提交) + +- **`ts/` 目录:对齐上游 TencentDB-Agent-Memory 技术栈(Node 22 + TypeScript)的全量移植**,功能对齐 Python 版 v1.7。IR 契约 / 三对 adapter / 状态层(node:sqlite)/ 网关 / SSE 流式(转换 + 透传 + 501)/ 埋点 / 预热 / mock / 冒烟,模块与 Python 版一一对应 +- **测试镜像**:56 项离线单测(node:test)+ 25 项端到端冒烟,用例与 Python 版逐条对应;strict + erasableSyntaxOnly 类型检查全过;运行零第三方依赖(Node 22 类型擦除,无需编译),开发依赖仅 typescript +- 差异清单(有意为之,见 ts/README.md):不读系统代理(恒直连 ≡ Python 版 PB_DIRECT=1)、node:sqlite 实验警告、property 测试 PRNG 换 mulberry32(性质不变);webui / verify_cache / experiments 未移植(后续 PR) +- CI 加 `unittest-ts` job(typecheck + 单测 + 冒烟);Python job 测试数口径同步 56/25 +- Python 版保留为参考实现;docs/ 契约与实验文档对两个实现同样适用 diff --git a/README.md b/README.md index 0f2d431..49b6585 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + | TRACK04 边界参数对齐稿 | ✅ 已对齐 | `docs/TRACK04_签字确认稿.md`(single_task 等三参数已定稿) | | 弹网页(本地仪表盘) | ✅ 记忆编排界面 | `src/webui/` 安全骨架 + 记忆勾选/裁剪 + 缓存前缀可视化 | | 已知限制(边界自觉) | 📋 12 项四要素(3 项已闭环留档) | `docs/LIMITATIONS.md`(含结论隔离证明) | +| **TypeScript 移植版** | ✅ **v2.0**(对齐上游技术栈) | `ts/`:56 项单测 + 25 项冒烟 + strict 类型检查全过(用例与 Python 版一一镜像),差异清单见 `ts/README.md` | ## 快速开始(离线) @@ -104,6 +105,7 @@ experiments/ 三组对照 + rich_experiments.py(E20–E28)+ rich_experim tests/ 离线单测(录制样例 + 自查回归 + 多轮链 E2E) tools/ mock_backend + verify_cache + smoke_e2e + recordings data/ 34 组实验原始数据(逐轮 jsonl + 汇总 csv,溯源表见 data/README.md) +ts/ TypeScript 全量移植(对齐上游 Node 22 + TS 技术栈,v2.0) docs/ ir-schema.md / capability-matrix.md / experiment-results.md / LIMITATIONS.md / REVIEW.md / TRACK04_签字确认稿.md CHANGELOG.md 按日期演进记录(v0 契约冻结 → v1.2 → 实验交付 → 覆盖补全) ``` diff --git a/ts/README.md b/ts/README.md new file mode 100644 index 0000000..8764739 --- /dev/null +++ b/ts/README.md @@ -0,0 +1,46 @@ +# protocol-bridge · TypeScript 移植版 + +> 与上游 TencentDB-Agent-Memory(Node 22 / TypeScript 技术栈)对齐的全量移植。 +> 功能对齐 Python 版 v1.7:**56 项离线单测 + 25 项端到端冒烟全过**(与 Python 版用例一一镜像)。 +> Python 版保留在仓库根目录作参考实现;docs/ 下的契约、实验与取舍文档对两个实现同样适用。 + +## 快速开始 + +```bash +cd ts +npm install # 仅开发依赖(typescript + @types/node);运行零第三方依赖 +npm test # 56 项离线单测(node:test + Node 22 类型擦除,无需编译) +npm run smoke # 25 项端到端冒烟:真起 mock + 网关两个子进程 +npm run typecheck # tsc --noEmit(strict + erasableSyntaxOnly) +npm run gateway # 起网关(127.0.0.1:8080,转发到 PB_BACKEND,默认 mock) +npm run mock # 起 mock backend(127.0.0.1:9100) +``` + +环境要求:**Node.js ≥ 22.18**(type stripping 默认可用 + `node:sqlite` 无 flag)。 +环境变量与 Python 版一致:`PB_BACKEND` / `PB_API_KEY` / `PB_PORT` / `PB_METRICS` / `PB_DB` / `PB_CONCURRENCY` / `PB_ANTHROPIC_VERSION` / `PB_CHAT_PATH` / `PB_RESPONSE_PATH` / `PB_ANTHROPIC_PATH`。 + +## 目录映射(Python → TS) + +| Python | TypeScript | 说明 | +| --- | --- | --- | +| `src/ir/model.py` | `src/ir/model.ts` | IR 三层契约(Block/Message/IRRequest/IRUsage/SessionContext) | +| `src/adapters/*.py` | `src/adapters/*.ts` | 三对 adapter + 降级记录 + `assistantFromUpstream` | +| `src/state/*.py` | `src/state/*.ts` | SessionStore(node:sqlite)/ ToolIdMap / SessionConfig | +| `src/gateway/server.py` | `src/gateway/server.ts` | 网关主进程(convert / finalizeTurn / 两条 SSE 路径 / 501) | +| `src/gateway/sse.py` | `src/gateway/sse.ts` | SSE 分帧 + Anthropic→Chat 转换 + Chat 旁路收集 | +| `src/observability/metrics.py` | `src/observability/metrics.ts` | 5 项埋点 + hitRate | +| `src/warmup/prewarm.py` | `src/warmup/prewarm.ts` | max_tokens:0 预热 | +| `tools/mock_backend.py` | `tools/mock_backend.ts` | 三协议形状 + 有状态缓存模拟 + SSE | +| `tools/smoke_e2e.py` | `tools/smoke_e2e.ts` | 7 组 25 项断言 | +| `tests/test_offline.py`(56 项) | `tests/offline.test.ts`(56 项) | 用例一一镜像 | + +## 差异清单(有意为之,均不影响语义) + +1. **不读系统代理**:Node 的 `http.request` 不读 `http_proxy` 环境变量,恒直连——与 Python 版 `PB_DIRECT=1` 的行为一致;`PB_DIRECT` 保留兼容但为 no-op。 +2. **SQLite 用 `node:sqlite`**(Node 内置,experimental warning 属正常):表结构、SQL、惰性淘汰语义与 Python 版完全一致;单线程同步驱动,Python 版 `threading.Lock` 与「锁外调用防死锁」的注释在 TS 版天然不适用。 +3. **property 测试 PRNG**:Python 用 `random.Random`(Mersenne Twister),TS 用 mulberry32——种子固定、序列不同,断言的四条性质(断点 [3,4] / 未知字段必降级 / 工具参数守恒 / signature 守恒)完全一致。 +4. **未移植**(后续 PR 补):`src/webui/`(弹网页仪表盘)、`tools/verify_cache.py`(验站脚本)、`experiments/`(真实 API 实验脚本——研究代码,跟随 Python 版即可)。 + +## 与上游 MemoryProxy 的关系 + +本目录按上游技术栈(Node 22 + TypeScript + 零重型依赖)组织,模块边界与上游 `MemoryProxy/src/` 的 handler/adapter 结构对应——断点布局(3 固定 + 1 滚动)、usage 归一、命中率埋点、状态层重放均可整体或按模块迁入。34 组实验的量化结论见根目录 `docs/experiment-results.md`。 diff --git a/ts/config/session.json b/ts/config/session.json new file mode 100644 index 0000000..fdbc35c --- /dev/null +++ b/ts/config/session.json @@ -0,0 +1,12 @@ +{ + "_comment": "Session 边界可切换配置(机器读)。等价于执行方案 3.9.1 的 session.yaml;因离线环境不装 pyyaml,统一用 JSON。改配置即可切换重放策略,不动 adapter 与实验脚本。", + "session": { + "key_granularity": "single_task", + "key_fields": ["task-id"], + "replay_from": "full", + "sliding_window_n": 20, + "end_policy": "ttl", + "ttl_seconds": 1800, + "on_end": "archive" + } +} diff --git a/ts/package-lock.json b/ts/package-lock.json new file mode 100644 index 0000000..551a8d2 --- /dev/null +++ b/ts/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "protocol-bridge-ts", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "protocol-bridge-ts", + "version": "2.0.0", + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ts/package.json b/ts/package.json new file mode 100644 index 0000000..f1d529e --- /dev/null +++ b/ts/package.json @@ -0,0 +1,21 @@ +{ + "name": "protocol-bridge-ts", + "version": "2.0.0", + "private": true, + "description": "OpenAI Chat / Responses 与 Anthropic 协议转换层(TypeScript 移植版,对应 Python v1.7)", + "type": "module", + "engines": { + "node": ">=22.18" + }, + "scripts": { + "test": "node --test \"tests/*.test.ts\"", + "smoke": "node tools/smoke_e2e.ts", + "typecheck": "tsc --noEmit", + "gateway": "node src/gateway/server.ts", + "mock": "node tools/mock_backend.ts" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0" + } +} diff --git a/ts/src/adapters/anthropic.ts b/ts/src/adapters/anthropic.ts new file mode 100644 index 0000000..58a549a --- /dev/null +++ b/ts/src/adapters/anthropic.ts @@ -0,0 +1,191 @@ +/** Anthropic Messages ↔ IR(TS 移植自 src/adapters/anthropic.py)。 + * + * 关键点: + * - system 独立成字段(Block[]),渲染顺序 tools → system → messages。 + * - cache_control 断点:只在 L2 断点布局指定的块上打(3 固定 + 1 滚动,用满 4 个上限)。 + * - usage: input_tokens 不含缓存读写;cache_creation/cache_read 单列。 + */ + +import * as ir from "../ir/model.ts"; +import { Adapter, Dropped, recordUnknown } from "./base.ts"; + +const BP = { type: "ephemeral" }; + +// to_ir 真正消费的顶层字段;其余一律进降级记录(见 base.recordUnknown) +const KNOWN_TOP = new Set(["model", "max_tokens", "temperature", "stream", + "system", "messages", "tools", "thinking", "tool_choice"]); + +export class AnthropicAdapter extends Adapter { + name = "anthropic"; + + /** IR 块 → Anthropic content 块。无法映射的一律进降级记录,绝不静默丢弃。 */ + private static renderBlock(b: ir.Block, dropped: Dropped): ir.Json | null { + if (b.kind === ir.TEXT) { + return { type: "text", text: b.text ?? "" }; + } + if (b.kind === ir.TOOL_USE) { + return { type: "tool_use", id: b.tool_id, name: b.tool_name, + input: b.tool_input ?? {} }; + } + if (b.kind === ir.TOOL_RESULT) { + return { type: "tool_result", tool_use_id: b.tool_id, + content: b.text ?? "" }; + } + if (b.kind === ir.THINKING) { + // v1.4:signature 必须原样回传(缺失下一轮直接 400); + // redacted_thinking 逐字节透传 data。 + if (b.extra.redacted) { + return { type: "redacted_thinking", data: b.extra.data ?? "" }; + } + const out: ir.Json = { type: "thinking", thinking: b.text ?? "" }; + if (b.extra.signature) out.signature = b.extra.signature; + return out; + } + dropped.add(`block.${b.kind}`, "IR 块无 Anthropic 对应类型(方案 3.8 不做)", + "explicit"); + return null; + } + + to_ir(payload: ir.Json, dropped: Dropped): ir.IRRequest { + const req = new ir.IRRequest({ + model: payload.model ?? "", + max_tokens: payload.max_tokens ?? 1024, + temperature: payload.temperature ?? null, + stream: Boolean(payload.stream ?? false), + }); + const system = payload.system; + if (typeof system === "string") { + req.system.push(new ir.Block({ kind: ir.TEXT, text: system })); + } else if (Array.isArray(system)) { + for (const b of system) { + req.system.push(new ir.Block({ + kind: ir.TEXT, text: b.text ?? "", + cache_breakpoint: "cache_control" in b })); + } + } + for (const m of payload.messages ?? []) { + const msg = new ir.Message({ role: m.role ?? "user" }); + const content = m.content; + if (typeof content === "string") { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: content })); + } else if (Array.isArray(content)) { + for (const b of content) { + const t = b?.type; + const bp = b !== null && typeof b === "object" && "cache_control" in b; + if (t === "text") { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: b.text ?? "", cache_breakpoint: bp })); + } else if (t === "tool_use") { + msg.blocks.push(new ir.Block({ kind: ir.TOOL_USE, tool_name: b.name, + tool_id: b.id, tool_input: b.input ?? {}, + cache_breakpoint: bp })); + } else if (t === "tool_result") { + msg.blocks.push(new ir.Block({ kind: ir.TOOL_RESULT, tool_id: b.tool_use_id, + text: resultText(b), cache_breakpoint: bp })); + } else if (t === "thinking") { + // v1.4:signature 进 extra 保留——下一轮必须原样回传,丢则 400 + msg.blocks.push(new ir.Block({ + kind: ir.THINKING, text: b.thinking ?? "", + extra: { signature: b.signature ?? "" } })); + } else if (t === "redacted_thinking") { + // 逐字节透传 data,不能判成无效块丢掉(问题清单组4#5) + msg.blocks.push(new ir.Block({ + kind: ir.THINKING, text: "", + extra: { redacted: true, data: b.data ?? "" } })); + } else { + dropped.add(`content.${t}`, "Anthropic 内容块,方案 3.8 不做", "explicit"); + } + } + } + req.messages.push(msg); + } + for (const t of payload.tools ?? []) { + req.tools.push(new ir.Tool({ name: t.name ?? "", + description: t.description ?? "", + input_schema: t.input_schema ?? {}, + cache_breakpoint: "cache_control" in t })); + } + // IR v0 没有 extended thinking / tool_choice 的字段,但不能静默丢弃: + // 存进 extra,from_ir 原样渲染回去 —— 既保证 anthropic→anthropic 无损, + // 也让 validateWarmup 的 thinking 冲突检查真正可达。 + for (const k of ["thinking", "tool_choice"]) { + if (payload[k] !== undefined && payload[k] !== null) { + req.extra[k] = payload[k]; + } + } + recordUnknown(payload, KNOWN_TOP, dropped, "Anthropic 参数暂无 IR 映射"); + return req; + } + + from_ir(req: ir.IRRequest, dropped: Dropped, ctx: ir.SessionContext | null = null): ir.Json { + const tools: ir.Json[] = []; + req.tools.forEach((t, i) => { + const td: ir.Json = { name: t.name, description: t.description, input_schema: t.input_schema }; + if (ctx?.bp_after_tools && i === req.tools.length - 1) { + td.cache_control = { ...BP }; + } + tools.push(td); + }); + + const system: ir.Json[] = []; + req.system.forEach((b, i) => { + const sb: ir.Json = { type: "text", text: b.text ?? "" }; + if (ctx?.bp_after_system && i === req.system.length - 1) { + sb.cache_control = { ...BP }; + } + system.push(sb); + }); + + const messages: ir.Json[] = []; + const n = req.messages.length; + // 历史静态段末尾:重放进来的历史最后一条(ctx.history 即本轮重放切片) + const histEnd = (ctx?.history?.length ?? 0) - 1; + req.messages.forEach((msg, i) => { + const blocks: ir.Json[] = []; + for (const b of msg.blocks) { + const bd = AnthropicAdapter.renderBlock(b, dropped); + if (bd !== null) blocks.push(bd); + } + // 固定断点③:历史静态段末尾(若与尾部断点重合,自然只落一个) + if (ctx?.bp_after_history_static && histEnd >= 0 && histEnd === i && blocks.length) { + blocks[blocks.length - 1].cache_control = { ...BP }; + } + // 滚动尾部断点:打在最后一条消息的最后一个块 + if (ctx?.bp_rolling_tail && i === n - 1 && blocks.length) { + blocks[blocks.length - 1].cache_control = { ...BP }; + } + messages.push({ role: msg.role, + content: blocks.length ? blocks : [{ type: "text", text: "" }] }); + }); + + const out: ir.Json = { model: req.model, messages, max_tokens: req.max_tokens, + stream: req.stream }; + if (system.length) out.system = system; + if (tools.length) out.tools = tools; + if (req.temperature !== null) out.temperature = req.temperature; + // to_ir 存进 extra 的 Anthropic 专属参数,原样渲染回去(anthropic→anthropic 无损) + for (const k of ["thinking", "tool_choice"]) { + if (req.extra[k] !== undefined && req.extra[k] !== null) { + out[k] = req.extra[k]; + } + } + return out; + } +} + +function resultText(b: ir.Json): string { + const c = b.content; + if (typeof c === "string") return c; + if (Array.isArray(c)) { + return c.map((p) => (p !== null && typeof p === "object") ? (p.text ?? "") : "").join(""); + } + return ""; +} + +export function usageFromAnthropic(usage: ir.Json): ir.IRUsage { + return new ir.IRUsage({ + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + }); +} diff --git a/ts/src/adapters/base.ts b/ts/src/adapters/base.ts new file mode 100644 index 0000000..6f6da3f --- /dev/null +++ b/ts/src/adapters/base.ts @@ -0,0 +1,139 @@ +/** adapter 基类与降级路径记录(TS 移植自 src/adapters/base.py)。 + * + * 每个协议实现两个方向:to_ir / from_ir。 + * 不可映射的字段/参数必须进降级路径,绝不静默丢弃(方案 3.3 第④⑤项暴露)。 + */ + +import * as ir from "../ir/model.ts"; + +export interface DroppedItem { + field: string; + reason: string; + path: string; // "explicit" 显式降级 / "silent" 静默通过(应尽量避免) +} + +/** 被丢弃/降级的参数清单(配合告警)。 */ +export class Dropped { + items: DroppedItem[] = []; + + add(field: string, reason: string, path = "explicit"): void { + this.items.push({ field, reason, path }); + } + + get length(): number { + return this.items.length; + } +} + +/** 把 adapter 未消费的顶层字段显式记进降级路径。 + * + * 三个 adapter 的 to_ir 末尾都必须调它。漏调 = 字段被静默丢弃 —— 而 + * 「被丢弃的参数清单」是命中率实验第④项暴露,静默丢弃会让这份清单不可信, + * 也会让「三方互转无信息损失」的结论站不住。 + */ +export function recordUnknown(payload: ir.Json, known: Set, dropped: Dropped, + reason = "该协议参数暂无 IR 映射"): void { + for (const k of Object.keys(payload)) { + if (!known.has(k)) { + dropped.add(k, reason, "explicit"); + } + } +} + +export abstract class Adapter { + abstract name: string; + abstract to_ir(payload: ir.Json, dropped: Dropped): ir.IRRequest; + abstract from_ir(req: ir.IRRequest, dropped: Dropped, ctx?: ir.SessionContext | null): ir.Json; +} + +/** OpenAI 系的工具参数是 JSON **字符串**,Anthropic 是 dict。 + * + * 跨协议转换必须解析成 dict 进 IR.tool_input,否则到 Anthropic 侧会渲染成 + * 空 input {}(第三轮自查发现的参数丢失 bug)。解析失败返回 {},原始字符串 + * 由调用方留在 extra["arguments"] 兜底(同协议往返无损)。 + */ +export function parseArguments(raw: unknown): ir.Json { + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + return raw as ir.Json; + } + if (typeof raw === "string" && raw.trim()) { + try { + const v = JSON.parse(raw); + return (v !== null && typeof v === "object" && !Array.isArray(v)) ? v : {}; + } catch { + return {}; + } + } + return {}; +} + +/** 上游响应 → IR assistant 消息(状态层重放的原料)。 + * + * v1.4 起保留**结构化块**:thinking(含 signature / redacted data)与 + * tool_use(含解析后的 tool_input)必须进历史——Anthropic 要求下一轮原样 + * 回传 thinking + signature,缺失直接 400;tool_use 丢失则多轮工具链断链。 + * 返回 null 表示本轮没有任何可重放内容(如空响应)。 + */ +export function assistantFromUpstream(target: string, resp: ir.Json): ir.Message | null { + const blocks: ir.Block[] = []; + if (target === "anthropic") { + for (const b of resp.content ?? []) { + if (b === null || typeof b !== "object") continue; + const t = b.type; + if (t === "text") { + blocks.push(new ir.Block({ kind: ir.TEXT, text: b.text ?? "" })); + } else if (t === "thinking") { + blocks.push(new ir.Block({ kind: ir.THINKING, text: b.thinking ?? "", + extra: { signature: b.signature ?? "" } })); + } else if (t === "redacted_thinking") { + blocks.push(new ir.Block({ kind: ir.THINKING, text: "", + extra: { redacted: true, data: b.data ?? "" } })); + } else if (t === "tool_use") { + blocks.push(new ir.Block({ kind: ir.TOOL_USE, tool_name: b.name, + tool_id: b.id, tool_input: b.input ?? {} })); + } + } + } else if (target === "openai_response") { + for (const item of resp.output ?? []) { + if (item === null || typeof item !== "object") continue; + const t = item.type; + if (t === "message") { + for (const c of item.content ?? []) { + if (c !== null && typeof c === "object" && (c.type === "output_text" || c.type === "text")) { + blocks.push(new ir.Block({ kind: ir.TEXT, text: c.text ?? "" })); + } + } + } else if (t === "function_call") { + const raw = item.arguments ?? ""; + blocks.push(new ir.Block({ kind: ir.TOOL_USE, tool_name: item.name, + tool_id: item.call_id, + tool_input: parseArguments(raw), + extra: { arguments: raw } })); + } else if (t === "reasoning") { + const summary = item.summary; + const text = typeof summary === "string" ? summary : ""; + blocks.push(new ir.Block({ kind: ir.THINKING, text, + extra: { encrypted_content: item.encrypted_content ?? "" } })); + } + } + } else { // openai_chat + let m: ir.Json = {}; + try { + m = resp.choices[0].message ?? {}; + } catch { m = {}; } + if (m.content) { + blocks.push(new ir.Block({ kind: ir.TEXT, text: m.content })); + } + for (const tc of m.tool_calls ?? []) { + const fn = (tc !== null && typeof tc === "object") ? (tc.function ?? {}) : {}; + const raw = fn.arguments ?? ""; + blocks.push(new ir.Block({ kind: ir.TOOL_USE, tool_name: fn.name, + tool_id: tc.id, tool_input: parseArguments(raw), + extra: { arguments: raw } })); + } + } + // 纯空文本(如异常响应)不进历史 + const kept = blocks.filter((b) => b.kind !== ir.TEXT || (b.text ?? "").trim()); + if (!kept.length) return null; + return new ir.Message({ role: "assistant", blocks: kept }); +} diff --git a/ts/src/adapters/chat.ts b/ts/src/adapters/chat.ts new file mode 100644 index 0000000..20d4fd1 --- /dev/null +++ b/ts/src/adapters/chat.ts @@ -0,0 +1,150 @@ +/** OpenAI Chat Completions ↔ IR(TS 移植自 src/adapters/chat.py)。 + * + * 关键点: + * - messages[].role: system 上提到 L1.system;user/assistant 进 L1.messages。 + * - tool_calls -> tool_use;role=tool -> tool_result。 + * - usage: prompt_tokens 含缓存,cached_tokens 为子集(归一到 IRUsage)。 + */ + +import * as ir from "../ir/model.ts"; +import { Adapter, Dropped, parseArguments, recordUnknown } from "./base.ts"; + +// Chat Completions 顶层字段中,本 adapter 真正消费的部分。 +// 其余顶层字段一律进降级记录:命中率实验的第④项暴露依赖这份清单准确, +// 静默吞掉一个字段(如误发的 top-level system)会让埋点失真。 +const KNOWN_TOP = new Set([ + "model", "messages", "tools", + "max_tokens", "max_completion_tokens", "temperature", "stream", "system", +]); + +export class ChatAdapter extends Adapter { + name = "openai_chat"; + + to_ir(payload: ir.Json, dropped: Dropped): ir.IRRequest { + const req = new ir.IRRequest({ + model: payload.model ?? "", + max_tokens: payload.max_completion_tokens ?? payload.max_tokens ?? 1024, + temperature: payload.temperature ?? null, + stream: Boolean(payload.stream ?? false), + }); + for (const m of payload.messages ?? []) { + const role = m.role ?? "user"; + if (role === "system") { + req.system.push(new ir.Block({ kind: ir.TEXT, text: m.content ?? "" })); + continue; + } + const msg = new ir.Message({ role }); + const content = m.content; + if (typeof content === "string") { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: content })); + } else if (Array.isArray(content)) { + for (const part of content) { + const t = part?.type; + if (t === "text") { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: part.text ?? "" })); + } else { + dropped.add(`content.${t}`, "Chat 多模态内容块,方案 3.8 不做", "explicit"); + } + } + } + for (const tc of m.tool_calls ?? []) { + const fn = tc.function ?? {}; + const rawArgs = fn.arguments ?? ""; + msg.blocks.push(new ir.Block({ + kind: ir.TOOL_USE, tool_name: fn.name, tool_id: tc.id, + // v1.4:参数解析成 dict 进 tool_input——不解析的话跨协议到 + // Anthropic 会渲染成空 input {}(第三轮自查发现的参数丢失 bug)。 + // 原始字符串留 extra,chat→chat 往返无损。 + tool_input: parseArguments(rawArgs), + extra: { arguments: rawArgs }, + })); + } + if (role === "tool") { + msg.role = "user"; + msg.blocks.push(new ir.Block({ + kind: ir.TOOL_RESULT, tool_id: m.tool_call_id, + text: m.content ?? "", + })); + } + req.messages.push(msg); + } + for (const t of payload.tools ?? []) { + const fn = t.function ?? t; + req.tools.push(new ir.Tool({ + name: fn.name ?? "", description: fn.description ?? "", + input_schema: fn.parameters ?? {}, + })); + } + // 顶层 system 非 Chat 规范字段(Responses 用 instructions),但实践中 + // 常被误发到 Chat 路由:按语义上提到 IR.system,同时留一条降级记录, + // 既不丢语义也不掩盖"这不是规范写法"这个事实。 + if ("system" in payload && typeof payload.system === "string") { + req.system.push(new ir.Block({ kind: ir.TEXT, text: payload.system })); + dropped.add("system", "Chat 规范无顶层 system 字段,已按语义上提至 IR.system", + "explicit"); + } + // 其余未消费的顶层字段:显式进降级记录(方案 3.3 第④项) + recordUnknown(payload, KNOWN_TOP, dropped, "Chat 参数暂无 IR 映射"); + return req; + } + + from_ir(req: ir.IRRequest, dropped: Dropped): ir.Json { + const messages: ir.Json[] = []; + if (req.system.length) { + messages.push({ role: "system", + content: req.system.map((b) => b.text ?? "").join("") }); + } + for (const msg of req.messages) { + const textParts: string[] = []; + const toolCalls: ir.Json[] = []; + const toolResults: ir.Json[] = []; + for (const b of msg.blocks) { + if (b.kind === ir.TEXT) { + textParts.push(b.text ?? ""); + } else if (b.kind === ir.TOOL_USE) { + toolCalls.push({ id: b.tool_id, type: "function", + function: { name: b.tool_name, + arguments: argumentsStr(b) } }); + } else if (b.kind === ir.TOOL_RESULT) { + toolResults.push({ role: "tool", tool_call_id: b.tool_id, + content: b.text ?? "" }); + } + } + if (toolResults.length) { + messages.push(...toolResults); + continue; + } + const m: ir.Json = { role: msg.role, content: textParts.join("") }; + if (toolCalls.length) m.tool_calls = toolCalls; + messages.push(m); + } + const out: ir.Json = { model: req.model, messages, + max_completion_tokens: req.max_tokens, stream: req.stream }; + if (req.temperature !== null) out.temperature = req.temperature; + if (req.tools.length) { + out.tools = req.tools.map((t) => ({ type: "function", function: { + name: t.name, description: t.description, parameters: t.input_schema } })); + } + return out; + } +} + +/** Chat/Responses 侧的 arguments 是 JSON 字符串。 + * + * 优先用 extra 里的原始字符串(同协议往返字节无损);没有则从 tool_input + * 序列化——anthropic→chat 方向靠这条路把 dict 参数带过去(v1.4 修复)。 + */ +function argumentsStr(b: ir.Block): string { + const raw = b.extra.arguments; + if (typeof raw === "string" && raw.trim()) return raw; + return JSON.stringify(b.tool_input ?? {}); +} + +export function usageFromChat(usage: ir.Json): ir.IRUsage { + const details = usage.prompt_tokens_details ?? {}; + return new ir.IRUsage({ + input_tokens: usage.prompt_tokens ?? 0, + output_tokens: usage.completion_tokens ?? 0, + cache_read_input_tokens: details.cached_tokens ?? 0, + }); +} diff --git a/ts/src/adapters/response.ts b/ts/src/adapters/response.ts new file mode 100644 index 0000000..d43435f --- /dev/null +++ b/ts/src/adapters/response.ts @@ -0,0 +1,129 @@ +/** OpenAI Responses ↔ IR(TS 移植自 src/adapters/response.py)。 + * + * 关键点: + * - input 为结构化条目列表(message / function_call / function_call_output / reasoning)。 + * - previous_response_id 是有状态字段,由网关/状态层在调用前解析、在响应后登记; + * adapter 只负责把它透传到 extra,真正重放在状态层完成。 + * - usage: input_tokens + input_tokens_details.cached_tokens。 + */ + +import * as ir from "../ir/model.ts"; +import { Adapter, Dropped, parseArguments, recordUnknown } from "./base.ts"; + +// to_ir 真正消费的顶层字段;其余一律进降级记录(见 base.recordUnknown) +const KNOWN_TOP = new Set(["model", "input", "instructions", "tools", "max_output_tokens", + "temperature", "stream", "previous_response_id"]); + +export class ResponseAdapter extends Adapter { + name = "openai_response"; + + to_ir(payload: ir.Json, dropped: Dropped): ir.IRRequest { + const req = new ir.IRRequest({ + model: payload.model ?? "", + max_tokens: payload.max_output_tokens ?? 1024, + temperature: payload.temperature ?? null, + stream: Boolean(payload.stream ?? false), + }); + if (payload.instructions) { + req.system.push(new ir.Block({ kind: ir.TEXT, text: payload.instructions })); + } + for (const item of payload.input ?? []) { + const t = item?.type; + if (t === "message") { + const msg = new ir.Message({ role: item.role ?? "user" }); + const content = item.content; + if (typeof content === "string") { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: content })); + } else if (Array.isArray(content)) { + for (const part of content) { + if (["input_text", "output_text", "text"].includes(part?.type)) { + msg.blocks.push(new ir.Block({ kind: ir.TEXT, text: part.text ?? "" })); + } else { + dropped.add(`input.${part?.type}`, "Responses 内容块,方案 3.8 不做", "explicit"); + } + } + } + req.messages.push(msg); + } else if (t === "function_call") { + const rawArgs = item.arguments ?? ""; + req.messages.push(new ir.Message({ role: "assistant", blocks: [new ir.Block({ + kind: ir.TOOL_USE, tool_name: item.name, tool_id: item.call_id, + // v1.4:解析成 dict 进 tool_input,跨协议到 Anthropic 不再丢参数 + tool_input: parseArguments(rawArgs), + extra: { arguments: rawArgs } })] })); + } else if (t === "function_call_output") { + req.messages.push(new ir.Message({ role: "user", blocks: [new ir.Block({ + kind: ir.TOOL_RESULT, tool_id: item.call_id, + text: item.output ?? "" })] })); + } else if (t === "reasoning") { + req.messages.push(new ir.Message({ role: "assistant", blocks: [new ir.Block({ + kind: ir.THINKING, + text: typeof item.summary === "string" ? item.summary : "" })] })); + } else { + dropped.add(`input.${t}`, "Responses 条目类型暂无映射", "explicit"); + } + } + for (const t of payload.tools ?? []) { + req.tools.push(new ir.Tool({ name: t.name ?? "", + description: t.description ?? "", + input_schema: t.parameters ?? {} })); + } + // 有状态字段:交给状态层处理,这里透传 + if ("previous_response_id" in payload) { + req.extra.previous_response_id = payload.previous_response_id; + } + recordUnknown(payload, KNOWN_TOP, dropped, "Responses 参数暂无 IR 映射"); + return req; + } + + from_ir(req: ir.IRRequest, dropped: Dropped): ir.Json { + const items: ir.Json[] = []; + for (const msg of req.messages) { + for (const b of msg.blocks) { + if (b.kind === ir.TEXT) { + items.push({ type: "message", role: msg.role, + content: [{ type: msg.role === "user" ? "input_text" : "output_text", + text: b.text ?? "" }] }); + } else if (b.kind === ir.TOOL_USE) { + items.push({ type: "function_call", name: b.tool_name, + call_id: b.tool_id, arguments: argumentsStr(b) }); + } else if (b.kind === ir.TOOL_RESULT) { + items.push({ type: "function_call_output", + call_id: b.tool_id, output: b.text ?? "" }); + } + } + } + const out: ir.Json = { model: req.model, input: items, + max_output_tokens: req.max_tokens, stream: req.stream }; + if (req.system.length) { + out.instructions = req.system.map((b) => b.text ?? "").join(""); + } + if (req.temperature !== null) out.temperature = req.temperature; + if (req.tools.length) { + out.tools = req.tools.map((t) => ({ type: "function", name: t.name, + description: t.description, + parameters: t.input_schema })); + } + // 不再向上游回传 previous_response_id:历史已由本网关状态层重放进 input, + // 再传一次会让上游(真 OpenAI)二次拼接历史 → 上下文重复且 token 翻倍; + // 若传的是网关自己生成的 resp_xxx,上游根本不认识 → 直接 404。 + // 给客户端的 response_id 由网关在响应里注入(见 gateway/server.ts)。 + return out; + } +} + +/** 优先用 extra 原始字符串(同协议无损);否则从 tool_input 序列化(v1.4)。 */ +function argumentsStr(b: ir.Block): string { + const raw = b.extra.arguments; + if (typeof raw === "string" && raw.trim()) return raw; + return JSON.stringify(b.tool_input ?? {}); +} + +export function usageFromResponse(usage: ir.Json): ir.IRUsage { + const details = usage.input_tokens_details ?? {}; + return new ir.IRUsage({ + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: details.cached_tokens ?? 0, + }); +} diff --git a/ts/src/gateway/server.ts b/ts/src/gateway/server.ts new file mode 100644 index 0000000..9beaf89 --- /dev/null +++ b/ts/src/gateway/server.ts @@ -0,0 +1,666 @@ +/** 网关/代理主进程:HTTP 转发 + SSE 流式(TS 移植自 src/gateway/server.py,对齐 v1.7)。 + * + * 落点(方案 3.2):独立进程承载状态层,是被「必须支持 previous_response_id」锁定的形态。 + * + * 请求流程: + * 入口按 target 选 adapter -> to_ir -> (有状态则状态层重放历史) -> + * from_ir 渲染目标协议(Anthropic 侧按断点布局打 cache_control)-> + * 转发到 backend(默认 mock,可离线跑通)-> 归一 usage + 命中率埋点 -> + * 非流式 JSON / 流式 SSE 回客户端。 + * + * 与 Python 版的已知差异(见 ts/README.md「差异清单」): + * - 不读系统代理环境变量(Node 的 http.request 默认直连);PB_DIRECT 保留兼容但为 no-op。 + * - SQLite 用 Node 内置 node:sqlite(experimental warning 属正常)。 + */ + +import http from "node:http"; +import { once } from "node:events"; +import { TextDecoder } from "node:util"; +import * as ir from "../ir/model.ts"; +import { Dropped, assistantFromUpstream } from "../adapters/base.ts"; +import { ChatAdapter, usageFromChat } from "../adapters/chat.ts"; +import { ResponseAdapter, usageFromResponse } from "../adapters/response.ts"; +import { AnthropicAdapter, usageFromAnthropic } from "../adapters/anthropic.ts"; +import { load as loadSessionConfig } from "../state/session_config.ts"; +import { SessionStore, Session } from "../state/store.ts"; +import { MetricsLog, NORMAL, WARMUP } from "../observability/metrics.ts"; +import { AnthropicToChatStream, ChatStreamCollector, SseEventParser } from "./sse.ts"; + +/** 模块级可变配置(对齐 Python 的模块全局,测试可整体替换)。 */ +export const gw = { + BACKEND_URL: (process.env.PB_BACKEND ?? "http://127.0.0.1:9100").replace(/\/$/, ""), + BACKEND_KEY: process.env.PB_API_KEY ?? "", // 真实后端 Bearer key(仅环境变量,不落盘) + DIRECT: process.env.PB_DIRECT === "1", // TS 版 no-op:Node 不读系统代理 + BIND_HOST: "127.0.0.1", + BIND_PORT: Number(process.env.PB_PORT ?? "8080"), + METRICS_PATH: process.env.PB_METRICS ?? "metrics.jsonl", + BACKEND_PATH: { + openai_chat: process.env.PB_CHAT_PATH ?? "/chat/completions", + openai_response: process.env.PB_RESPONSE_PATH ?? "/responses", + anthropic: process.env.PB_ANTHROPIC_PATH ?? "/v1/messages", + } as Record, + CFG: loadSessionConfig(), + STORE: null as unknown as SessionStore, + METRICS: null as unknown as MetricsLog, + CONCURRENCY: Number(process.env.PB_CONCURRENCY ?? "2"), +}; +gw.STORE = new SessionStore(gw.CFG); +gw.METRICS = new MetricsLog(gw.METRICS_PATH); + +const ADAPTERS: Record = { + openai_chat: new ChatAdapter(), + openai_response: new ResponseAdapter(), + anthropic: new AnthropicAdapter(), +}; + +/** 并发限流(替代 Python 的 threading.Semaphore)。 */ +class Semaphore { + private count: number; + private queue: (() => void)[] = []; + + constructor(n: number) { this.count = n; } + + async acquire(): Promise { + if (this.count > 0) { this.count--; return; } + await new Promise((resolve) => this.queue.push(resolve)); + } + + release(): void { + const next = this.queue.shift(); + if (next) next(); + else this.count++; + } +} + +const GATE = new Semaphore(gw.CONCURRENCY); + +// --------------------------------------------------------------------------- +// 预热请求(max_tokens:0):单独语义 + 拒绝条件校验(方案 3.7,v3 已核实官方行为) +// --------------------------------------------------------------------------- + +export function isWarmup(payload: ir.Json): boolean { + const mt = payload.max_tokens ?? payload.max_output_tokens ?? 1; + try { + return Number.parseInt(String(mt), 10) === 0; + } catch { + return false; + } +} + +/** 预热请求的拒绝条件:冲突则返回错误消息(应回 invalid_request_error)。 + * + * 必须校验**转换后、即将发给上游的 rendered**(目标协议形态),不能用源协议 + * payload——拒绝条件是上游(target)的行为,且字段只存在于目标协议里 + * (如 thinking 属 Anthropic、response_format 属 OpenAI)。 + */ +export function validateWarmup(rendered: ir.Json, target: string): string | null { + if (rendered.stream) { + return "warmup(max_tokens:0) 不能同时带 stream:true"; + } + if (target === "anthropic") { + const thinking = rendered.thinking; + if (thinking !== null && typeof thinking === "object" && thinking.type === "enabled") { + return "warmup(max_tokens:0) 不能同时带 extended thinking"; + } + } else { + // OpenAI 系:structured outputs(Chat 用 response_format;Responses 用 text.format) + if (rendered.response_format || rendered.output_config?.format || rendered.text?.format) { + return "warmup(max_tokens:0) 不能同时带 structured outputs"; + } + } + const tc = rendered.tool_choice; + if (tc !== null && typeof tc === "object" && ["tool", "any"].includes(tc.type)) { + return "warmup(max_tokens:0) 不能同时带 tool_choice={type:tool/any}"; + } + return null; +} + +// --------------------------------------------------------------------------- +// 记忆注入(方案 3.9 / TRACK 01):session meta → IR system 尾部,文本幂等去重 +// --------------------------------------------------------------------------- + +/** 把 session.meta 的记忆块注入 IR system 尾部。返回注入条数。 + * + * 幂等去重:按文本比对——外部请求若已注入相同文本则跳过, + * 避免同一段记忆重复注入、重复计费(方案 3.9 耦合点 2)。 + * 记忆上限(TRACK 04 第三参数):会话 meta.memory_cap > 全局 + * cfg.session_memory_cap > 0(不限制);达到上限即停,保留已注入的稳定前缀。 + */ +export function injectMemories(req: ir.IRRequest, session: Session | null): number { + const memories = session?.meta?.memories; + if (!memories || !Array.isArray(memories)) return 0; + const cap = session?.meta?.memory_cap || gw.CFG.session_memory_cap; + const seen = new Set(req.system.filter((b) => b.kind === ir.TEXT && b.text) + .map((b) => b.text)); + // cap 语义 = 本会话「由记忆注入产生的块」总数上限。 + // 用 extra 标记识别已注入块:原始 system 里恰好同名的文本不算注入、不占名额; + // 已注入块占名额——保证跨轮幂等(下轮同一批记忆不会突破 cap 继续加)。 + let injectedCount = req.system.filter((b) => b.extra.injected_memory).length; + let added = 0; + for (const m of memories) { + if (m === null || typeof m !== "object") continue; + const text = String(m.content ?? m.preview ?? "").trim(); + if (!text || seen.has(text)) continue; // 空文本 / 已在 system 中 → 幂等跳过 + if (cap && injectedCount >= cap) break; + req.system.push(new ir.Block({ kind: ir.TEXT, text, + extra: { injected_memory: true } })); + seen.add(text); + injectedCount++; + added++; + } + return added; +} + +export interface ConvertResult { + out: ir.Json; + req: ir.IRRequest; + dropped: Dropped; + session: Session | null; + injected: number; + replayed: ir.Message[]; +} + +/** source 协议 payload -> target 协议 payload。 + * + * `replayed` 是本轮**真正重放进来的历史切片**(不含本轮新消息), + * 供埋点区分「重放代价」与「本轮输入」——两者混在一起会让重放指标系统性偏高。 + */ +export function convert(source: string, target: string, payload: ir.Json, + headers: Record): ConvertResult { + const dropped = new Dropped(); + const src = ADAPTERS[source]; + const dst = ADAPTERS[target]; + + const req = src.to_ir(payload, dropped); + + // 有状态:Response 的 previous_response_id -> 状态层重放历史 + let session: Session | null = null; + let replayed: ir.Message[] = []; + const prevId = req.extra.previous_response_id; + if (prevId) { + session = gw.STORE.resolvePrevious(prevId); + if (session !== null) { + replayed = gw.STORE.replay(session); + req.messages = [...replayed, ...req.messages]; + } else { + dropped.add("previous_response_id", "未找到对应会话,按无状态处理", "explicit"); + } + } + + // 需要会话的三种情况,缺一个多轮链路就起不来: + // anthropic —— 记忆注入 + 断点布局都要挂会话 + // openai_response —— Responses 是带状态协议,网关必须回一个自己能解析的 + // response_id 给客户端,否则第一轮之后无从续接 + // previous_response_id 已解析出会话 + if (session === null && (target === "anthropic" || target === "openai_response")) { + session = gw.STORE.getOrCreate(gw.CFG.sessionKey(headers)); + } + + // v1.4 工具 ID 双向映射(问题清单组4#3,需会话作用域;无会话则直通) + if (session !== null) { + const idm = gw.STORE.idmap; + for (const m of req.messages) { + for (const b of m.blocks) { + if ((b.kind === ir.TOOL_USE || b.kind === ir.TOOL_RESULT) && b.tool_id) { + b.tool_id = idm.incoming(session.key, b.tool_id, source); + } + } + } + } + + // Anthropic 目标:按 L2 断点布局打 cache_control + const ctx = target === "anthropic" + ? new ir.SessionContext({ session_key: session?.key ?? "", history: replayed }) + : null; + + // 记忆注入:重放之后、渲染之前(注入内容进 system 尾部,吃 system 后断点) + const injected = session ? injectMemories(req, session) : 0; + + if (session !== null) { + const idm = gw.STORE.idmap; + for (const m of req.messages) { + for (const b of m.blocks) { + if ((b.kind === ir.TOOL_USE || b.kind === ir.TOOL_RESULT) && b.tool_id) { + b.tool_id = idm.outgoing(session.key, b.tool_id, target); + } + } + } + } + + const out = target === "anthropic" + ? dst.from_ir(req, dropped, ctx) + : dst.from_ir(req, dropped); + return { out, req, dropped, session, injected, replayed }; +} + +// --------------------------------------------------------------------------- +// 上游转发(Node 不读系统代理,恒直连——与 Python 版 PB_DIRECT=1 行为一致) +// --------------------------------------------------------------------------- + +function backendHeaders(target = ""): Record { + const headers: Record = { + "Content-Type": "application/json", + // 部分真实网关(如 Cloudflare 前置)按 UA 拦 bot,带浏览器 UA 更稳 + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 Chrome/126.0 Safari/537.36", + }; + if (gw.BACKEND_KEY) { + headers.Authorization = `Bearer ${gw.BACKEND_KEY}`; + // Anthropic 官方协议用 x-api-key;多数兼容端点两种都认,两个都带无害 + headers["x-api-key"] = gw.BACKEND_KEY; + } + if (target === "anthropic") { + // Anthropic 原生端点常校验该头 + headers["anthropic-version"] = process.env.PB_ANTHROPIC_VERSION ?? "2023-06-01"; + } + return headers; +} + +/** 上游非 2xx(对齐 Python urllib.error.HTTPError:code + 可读 body)。 */ +export class UpstreamHttpError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, body: string) { + super(`upstream HTTP ${statusCode}`); + this.statusCode = statusCode; + this.body = body; + } +} + +function requestUpstream(url: string, payload: ir.Json, target: string): Promise { + const data = Buffer.from(JSON.stringify(payload), "utf-8"); + const u = new URL(url); + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: u.hostname, + port: u.port || (u.protocol === "https:" ? 443 : 80), + path: u.pathname + u.search, + method: "POST", + headers: { ...backendHeaders(target), "Content-Length": String(data.length) }, + timeout: 60000, + }, (resp) => { + if ((resp.statusCode ?? 500) >= 400) { + const chunks: Buffer[] = []; + resp.on("data", (c) => chunks.push(c)); + resp.on("end", () => reject(new UpstreamHttpError( + resp.statusCode ?? 500, + Buffer.concat(chunks).toString("utf-8").slice(0, 500)))); + return; + } + resolve(resp); + }); + req.on("timeout", () => req.destroy(new Error("upstream timeout"))); + req.on("error", reject); + req.end(data); + }); +} + +export async function postJson(url: string, payload: ir.Json, target = ""): Promise { + const resp = await requestUpstream(url, payload, target); + const chunks: Buffer[] = []; + resp.on("data", (c) => chunks.push(c)); + await once(resp, "end"); + return JSON.parse(Buffer.concat(chunks).toString("utf-8")); +} + +function openStream(url: string, payload: ir.Json, target = ""): Promise { + return requestUpstream(url, payload, target); +} + +// --------------------------------------------------------------------------- +// 流尾共用逻辑(非流式响应与 SSE 流式同一条路径) +// --------------------------------------------------------------------------- + +function usageFor(target: string, resp: ir.Json): ir.IRUsage { + const u = resp.usage ?? {}; + if (target === "anthropic") return usageFromAnthropic(u); + if (target === "openai_response") return usageFromResponse(u); + return usageFromChat(u); +} + +/** usage 归一 + 命中率埋点 + 本轮历史落库 + 登记响应 id。 + * + * 流式场景传入的 backendResp 是 AnthropicToChatStream.syntheticResponse() + * 拼出的非流式形状——两条路径语义一致,不另造埋点口径。 + */ +export function finalizeTurn(target: string, backendResp: ir.Json, req: ir.IRRequest, + dropped: Dropped, session: Session | null, + replayedHist: ir.Message[], warmup: boolean): string | null { + const usage = usageFor(target, backendResp); + // 重放代价只算真正重放的历史切片,不含本轮新消息 + const replayedChars = replayedHist.reduce( + (n, m) => n + m.blocks.reduce((k, b) => k + (b.text ?? "").length, 0), 0); + // 按 extra 标记统计(而非按尾部切片),注入块位置变化时依然准确 + const injectedChars = req.system.filter((b) => b.extra.injected_memory) + .reduce((n, b) => n + (b.text ?? "").length, 0); + gw.METRICS.recordTurn(usage, injectedChars, replayedChars, + dropped.items as unknown as ir.Json[], + dropped.length ? "explicit" : "none", + warmup ? WARMUP : NORMAL); + + // 有状态:登记响应供下一轮 previous_response_id 引用。 + // 预热轮不登记——它不产出可引用的对话轮,登记会污染响应链。 + let rid: string | null = null; + if (session !== null && !warmup) { + // 本轮对话必须落进会话历史,否则下一轮 previous_response_id 重放出的是空列表, + // 整个状态层等于空转(历史永远是空 -> 重放永远是空 -> 多轮链路第一轮就断)。 + // 只追加本轮新消息:重放切片已在历史里,再加一次会自我复制。 + const newTurn = req.messages.slice(replayedHist.length); + const reply = assistantFromUpstream(target, backendResp); + if (reply !== null) newTurn.push(reply); + if (newTurn.length) { + // 落库前把 ID 翻回 canonical:newTurn 里的 ID 刚被 outgoing + // 翻成目标协议形式,reply 里的是上游新生成的——历史必须存 + // canonical,否则跨协议续轮时映射链会退化。 + const idm = gw.STORE.idmap; + for (const m of newTurn) { + for (const b of m.blocks) { + if ((b.kind === ir.TOOL_USE || b.kind === ir.TOOL_RESULT) && b.tool_id) { + b.tool_id = idm.incoming(session.key, b.tool_id, target); + } + } + } + gw.STORE.append(session, newTurn); + } + rid = gw.STORE.recordResponse(session); + } + return rid; +} + +// --------------------------------------------------------------------------- +// HTTP 入口 +// --------------------------------------------------------------------------- + +function jsonResponse(res: http.ServerResponse, code: number, obj: ir.Json): void { + const body = Buffer.from(JSON.stringify(obj), "utf-8"); + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", + "Content-Length": String(body.length) }); + res.end(body); +} + +async function readBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + await once(req, "end"); + const text = Buffer.concat(chunks).toString("utf-8"); + return text ? JSON.parse(text) : {}; +} + +export async function handleRequest(req: http.IncomingMessage, + res: http.ServerResponse): Promise { + if (req.method !== "POST") { + return jsonResponse(res, 405, { error: "method not allowed" }); + } + const payload = await readBody(req); + // 路由:/v1/{source}/to/{target} + const parts = (req.url ?? "").split("/").filter(Boolean); + + // 管理口:POST /v1/admin/session/meta {"key": "...", "meta": {...}} + // 供实验脚本/未来的弹网页 Session Init 写入记忆与 memory_cap + if (parts[0] === "v1" && parts[1] === "admin" && parts.length === 4 && + parts[2] === "session" && parts[3] === "meta") { + const key = payload.key; + const meta = payload.meta; + if (!key || meta === null || typeof meta !== "object") { + return jsonResponse(res, 400, { error: "need {key, meta}" }); + } + gw.STORE.updateMeta(key, meta); + return jsonResponse(res, 200, { ok: true, key }); + } + + if (parts.length < 4 || parts[0] !== "v1" || parts[2] !== "to") { + return jsonResponse(res, 400, { error: "path must be /v1/{source}/to/{target}" }); + } + const source = parts[1]; + const target = parts[3]; + // 未知协议必须回 400:不拦的话 ADAPTERS[target] 取到 undefined, + // 转换管线直接崩、连接被掐断,客户端只看到连接重置而非错误信息。 + if (!(source in ADAPTERS) || !(target in ADAPTERS)) { + return jsonResponse(res, 400, { error: { + message: `unknown protocol: ${source} -> ${target}`, + type: "invalid_request_error", + supported: Object.keys(ADAPTERS).sort() } }); + } + + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === "string") headers[k.toLowerCase()] = v; + } + + const { out, req: irReq, dropped, session, injected, replayed } = + convert(source, target, payload, headers); + + // 预热请求:单独语义,用**转换后发给上游的报文**过拒绝条件校验(方案 3.7) + const warmup = isWarmup(payload); + if (warmup) { + const werr = validateWarmup(out, target); + if (werr) { + return jsonResponse(res, 400, { error: { message: werr, + type: "invalid_request_error" } }); + } + } + + // SSE 流式(LIMITATIONS #1): + // chat 客户端 ← anthropic 上游 —— 逐块转换(v1.6) + // 同协议直通(chat←chat / anthropic←anthropic)—— 字节透传 + 旁路收集(v1.7) + // 其余方向(response 源三维寻址 / anthropic←chat 等)未实现: + // 显式 501——否则 postJson 会把 SSE 当 JSON 解析,炸成语焉不详的 502。 + // warmup+stream 已在上方被 validateWarmup 拦截,走到这里的一定不是预热轮。 + if (out.stream) { + if (source === "openai_chat" && target === "anthropic") { + return streamFromAnthropic(res, out, irReq, dropped, session, replayed); + } + if (source === target && (source === "openai_chat" || source === "anthropic")) { + return streamPassthrough(res, out, irReq, dropped, session, replayed, target); + } + return jsonResponse(res, 501, { error: { + message: `暂不支持 ${source} -> ${target} 方向的流式;请改用 stream:false`, + type: "invalid_request_error", + supported_streaming: ["openai_chat->anthropic", + "openai_chat->openai_chat", + "anthropic->anthropic"] } }); + } + + // mock 与真实后端走同一套端点映射。tools/mock_backend 已按端点路径 + // 返回对应协议形状,不再为 mock 特判——否则离线跑的和上线跑的不是同一条路径, + // 上线时才暴露的问题离线永远测不出来。 + const path = gw.BACKEND_PATH[target] ?? req.url ?? ""; + let backendResp: ir.Json; + await GATE.acquire(); + try { + backendResp = await postJson(gw.BACKEND_URL + path, out, target); + } catch (e) { + if (e instanceof UpstreamHttpError) { + // 上游错误原样透出状态码,避免把 4xx/5xx 伪装成网关 500 + return jsonResponse(res, e.statusCode, { + error: { message: "backend error", type: "upstream_error", + status: e.statusCode, detail: e.body } }); + } + return jsonResponse(res, 502, { + error: { message: `backend unreachable: ${(e as Error).message}`, + type: "upstream_error" } }); + } finally { + GATE.release(); + } + + const rid = finalizeTurn(target, backendResp, irReq, dropped, session, + replayed, warmup); + + const body: ir.Json = { ...backendResp }; + // 客户端说 Responses 协议时,下一轮会带 previous_response_id 回来。 + // 那个 id 必须是网关自己能解析的,否则多轮链路在第一轮之后就断了 + // (上游真实 id 我们解析不了,上游同样解析不了我们的 id)。 + // 上游原始 id 保留在 _bridge_upstream_id 便于对账。 + if (rid && source === "openai_response") { + body._bridge_upstream_id = backendResp.id ?? ""; + body.id = rid; + } + body._bridge = { dropped: dropped.items, session: Boolean(session), + injected_memories: injected, + warmup, + replayed_messages: replayed.length }; + jsonResponse(res, 200, body); +} + +// --------------------------------------------------------------------------- +// SSE 流式两条路径 +// --------------------------------------------------------------------------- + +function sseHead(res: http.ServerResponse): void { + res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + "Connection": "close" }); +} + +/** SSE 逐块流式:Anthropic 上游事件 → Chat chunk 帧即时下发(v1.6)。 + * + * 与非流式的差别只在传输段:转换(to_ir/from_ir)、限流、usage 归一、 + * 埋点、历史落库全部复用同一条路径(finalizeTurn + syntheticResponse)。 + */ +async function streamFromAnthropic(res: http.ServerResponse, out: ir.Json, + irReq: ir.IRRequest, dropped: Dropped, + session: Session | null, + replayed: ir.Message[]): Promise { + const path = gw.BACKEND_PATH.anthropic; + await GATE.acquire(); // 限流:流式请求同样占并发名额 + let upstream: http.IncomingMessage; + try { + upstream = await openStream(gw.BACKEND_URL + path, out, "anthropic"); + } catch (e) { + GATE.release(); + if (e instanceof UpstreamHttpError) { + return jsonResponse(res, e.statusCode, { + error: { message: "backend error", type: "upstream_error", + status: e.statusCode, detail: e.body } }); + } + return jsonResponse(res, 502, { + error: { message: `backend unreachable: ${(e as Error).message}`, + type: "upstream_error" } }); + } + + sseHead(res); + const conv = new AnthropicToChatStream(); + const parser = new SseEventParser(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + try { + for await (const chunk of upstream) { + const text = decoder.decode(chunk as Buffer, { stream: true }); + for (const [event, data] of parser.feed(text)) { + for (const frame of conv.feed(event, data)) { + if (!res.write(frame)) await once(res, "drain"); + } + } + } + const tail = decoder.decode(); + for (const [event, data] of parser.feed(tail)) { + for (const frame of conv.feed(event, data)) res.write(frame); + } + } catch { + // 客户端中途断开:流尾落库/埋点照常(这轮 API 费用已实际发生) + } finally { + upstream.destroy(); + res.end(); + GATE.release(); + } + + // 流尾:与非流式同一套 usage 归一 + 埋点 + 历史落库。 + // dropped 清单对流式客户端不可见(SSE 帧里没有它的位置)—— + // 已写进 LIMITATIONS #1 的范围说明,埋点里 degradation 仍如实记录。 + finalizeTurn("anthropic", conv.syntheticResponse(), irReq, dropped, + session, replayed, false); +} + +/** 同协议直通流式(v1.7):字节原样透传,旁路收集流尾汇总。 + * + * 与转换流式的差别:转发的是上游**原始字节**(一个比特都不动), + * 收集器只负责把 usage / 文本攒下来供 finalizeTurn 复用。 + */ +async function streamPassthrough(res: http.ServerResponse, out: ir.Json, + irReq: ir.IRRequest, dropped: Dropped, + session: Session | null, + replayed: ir.Message[], target: string): Promise { + const path = gw.BACKEND_PATH[target] ?? ""; + await GATE.acquire(); + let upstream: http.IncomingMessage; + try { + upstream = await openStream(gw.BACKEND_URL + path, out, target); + } catch (e) { + GATE.release(); + if (e instanceof UpstreamHttpError) { + return jsonResponse(res, e.statusCode, { + error: { message: "backend error", type: "upstream_error", + status: e.statusCode, detail: e.body } }); + } + return jsonResponse(res, 502, { + error: { message: `backend unreachable: ${(e as Error).message}`, + type: "upstream_error" } }); + } + + sseHead(res); + // 复用转换器/收集器做旁路汇总(其产出帧丢弃,只要 usage/文本) + const anthropicCollector = target === "anthropic" ? new AnthropicToChatStream() : null; + const chatCollector = target === "anthropic" ? null : new ChatStreamCollector(); + const parser = new SseEventParser(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + try { + for await (const chunk of upstream) { + // 字节原样透传(背压感知) + if (!res.write(chunk)) await once(res, "drain"); + const text = decoder.decode(chunk as Buffer, { stream: true }); + if (anthropicCollector) { + for (const [event, data] of parser.feed(text)) { + anthropicCollector.feed(event, data); + } + } else { + for (const [event, data] of parser.feed(text)) { + void event; + // Chat 流是裸 data: 帧:parseSseLines 语义下 data 可能是字符串 + if (typeof data === "string") chatCollector!.feedData(data); + else chatCollector!.feedData(JSON.stringify(data)); + } + } + } + const tail = decoder.decode(); + if (anthropicCollector) { + for (const [event, data] of parser.feed(tail)) anthropicCollector.feed(event, data); + } else { + for (const [, data] of parser.feed(tail)) { + if (typeof data === "string") chatCollector!.feedData(data); + else chatCollector!.feedData(JSON.stringify(data)); + } + } + } catch { + // 客户端中途断开:用已收到的部分照常落账(费用已实际发生) + } finally { + upstream.destroy(); + res.end(); + GATE.release(); + } + + const synthetic = anthropicCollector + ? anthropicCollector.syntheticResponse() + : chatCollector!.syntheticResponse(); + finalizeTurn(target, synthetic, irReq, dropped, session, replayed, false); +} + +// --------------------------------------------------------------------------- + +export function createServer(): http.Server { + return http.createServer((req, res) => { + handleRequest(req, res).catch((e) => { + // 兜底:任何未预期异常回 500 JSON,不能让连接被裸掐断 + try { + jsonResponse(res, 500, { error: { message: String(e), type: "internal_error" } }); + } catch { /* 连接已断则放弃 */ } + }); + }); +} + +// 直接运行(node src/gateway/server.ts)时启动网关;被测试 import 时不启动 +if (process.argv[1] && /server\.ts$/.test(process.argv[1])) { + const srv = createServer(); + srv.listen(gw.BIND_PORT, gw.BIND_HOST, () => { + console.log(`protocol-bridge gateway(ts) on http://${gw.BIND_HOST}:${gw.BIND_PORT} -> backend ${gw.BACKEND_URL}`); + }); +} diff --git a/ts/src/gateway/sse.ts b/ts/src/gateway/sse.ts new file mode 100644 index 0000000..c28a785 --- /dev/null +++ b/ts/src/gateway/sse.ts @@ -0,0 +1,262 @@ +/** SSE 流式三件套(TS 移植自 src/gateway/sse.py,对应 v1.6/v1.7): + * + * - parseSseLines:把 SSE 行流解析成 (event, data) 事件(单测用,同步可迭代输入) + * - SseEventParser:增量分帧器(网关用,按 chunk 喂入,吐出完整事件) + * - AnthropicToChatStream:Anthropic 事件 → Chat chunk 逐块转换 + * - ChatStreamCollector:chat←chat 直通流式的旁路收集器 + * + * 范围(LIMITATIONS #1):chat 客户端 ← anthropic 上游逐块转换 + + * chat←chat / anthropic←anthropic 同协议透传;其余方向显式 501。 + */ + +import type { Json } from "../ir/model.ts"; + +// Anthropic stop_reason → Chat finish_reason +const STOP_MAP: Record = { + end_turn: "stop", stop_sequence: "stop", max_tokens: "length", + tool_use: "tool_calls", refusal: "stop", +}; + +export type SseEvent = [string | null, unknown]; + +/** 把 SSE 行流解析成 (event, data) 事件对,逐事件 yield(单测/离线用)。 + * + * data 按 SSE 规范可多行拼接;能解析成 JSON 就给对象,否则给原字符串。 + * 流尾残留的半帧容错丢弃(正常结束的上游最后一定是 message_stop 完整帧)。 + */ +export function* parseSseLines(lineIter: Iterable): Generator { + let event: string | null = null; + let dataLines: string[] = []; + for (const raw of lineIter) { + const line = (typeof raw === "string" ? raw : String(raw)).replace(/\r?\n$/, ""); + if (!line) { + if (dataLines.length) { + const text = dataLines.join("\n"); + let data: unknown = text; + try { data = JSON.parse(text); } catch { /* 原样透传 */ } + yield [event, data]; + } + event = null; + dataLines = []; + continue; + } + if (line.startsWith(":")) continue; // 注释 / 心跳行 + const idx = line.indexOf(":"); + const field = idx >= 0 ? line.slice(0, idx) : line; + const value = (idx >= 0 ? line.slice(idx + 1) : "").replace(/^ /, ""); + if (field === "event") event = value; + else if (field === "data") dataLines.push(value); + } +} + +/** 增量分帧器:按 chunk 喂入 UTF-8 文本,吐出完整 (event, data) 事件。 + * 跨 chunk 的半行、半帧都缓冲,适配上游任意切片。 */ +export class SseEventParser { + private buf = ""; + + /** 喂入一段文本(可以是不完整行),返回本次凑齐的事件。 */ + feed(chunk: string): SseEvent[] { + this.buf += chunk; + const events: SseEvent[] = []; + let idx: number; + while ((idx = this.buf.indexOf("\n")) >= 0) { + const line = this.buf.slice(0, idx).replace(/\r$/, ""); + this.buf = this.buf.slice(idx + 1); + const ev = this.feedLine(line); + if (ev) events.push(ev); + } + return events; + } + + private event: string | null = null; + private dataLines: string[] = []; + + private feedLine(line: string): SseEvent | null { + if (!line) { + if (this.dataLines.length) { + const text = this.dataLines.join("\n"); + let data: unknown = text; + try { data = JSON.parse(text); } catch { /* 原样透传 */ } + const out: SseEvent = [this.event, data]; + this.event = null; + this.dataLines = []; + return out; + } + return null; + } + if (line.startsWith(":")) return null; + const idx = line.indexOf(":"); + const field = idx >= 0 ? line.slice(0, idx) : line; + const value = (idx >= 0 ? line.slice(idx + 1) : "").replace(/^ /, ""); + if (field === "event") this.event = value; + else if (field === "data") this.dataLines.push(value); + return null; + } +} + +/** Anthropic SSE 事件 → Chat completion.chunk 帧(逐块、可即时下发)。 + * + * 用法:每个上游事件 feed() 一次,把返回的帧立即写给客户端; + * 流结束后用 usage() / syntheticResponse() 做埋点与历史落库。 + */ +export class AnthropicToChatStream { + msgId = "chatcmpl_stream"; + model = ""; + created = Math.floor(Date.now() / 1000); + private usageIn: Json = {}; + private outputTokens = 0; + private textParts: string[] = []; + // index -> {id,name,args[]};input_json_delta 逐段累积 + private toolOpen = new Map(); + private toolsDone: { id: string; name: string; args: string[] }[] = []; + private sentRole = false; + + private chunk(delta: Json, finish: string | null = null): string { + const frame = { + id: this.msgId, object: "chat.completion.chunk", + created: this.created, model: this.model, + choices: [{ index: 0, delta, finish_reason: finish }], + }; + return "data: " + JSON.stringify(frame) + "\n\n"; + } + + private roleFrame(): string { + this.sentRole = true; + return this.chunk({ role: "assistant" }); + } + + /** 喂一个 Anthropic 事件,返回要立即下发客户端的 SSE 帧(可为空数组)。 */ + feed(event: string | null, data: unknown): string[] { + if (data === null || typeof data !== "object") return []; + const d = data as Json; + const etype: string = d.type ?? event ?? ""; + const out: string[] = []; + if (etype === "message_start") { + const msg = d.message ?? {}; + this.msgId = msg.id ?? this.msgId; + this.model = msg.model ?? this.model; + this.usageIn = msg.usage ?? {}; + out.push(this.roleFrame()); + } else if (etype === "content_block_start") { + const idx = d.index ?? 0; + const block = d.content_block ?? {}; + if (block.type === "tool_use") { + this.toolOpen.set(idx, { id: block.id ?? "", name: block.name ?? "", args: [] }); + } + } else if (etype === "content_block_delta") { + const idx = d.index ?? 0; + const delta = d.delta ?? {}; + const dt = delta.type; + if (dt === "text_delta") { + const text = delta.text ?? ""; + this.textParts.push(text); + if (!this.sentRole) out.push(this.roleFrame()); + out.push(this.chunk({ content: text })); + } else if (dt === "input_json_delta") { + const tb = this.toolOpen.get(idx); + if (tb) tb.args.push(delta.partial_json ?? ""); + } + // thinking_delta / signature_delta:Chat 无对应概念,不落帧 + } else if (etype === "content_block_stop") { + const idx = d.index ?? 0; + const tb = this.toolOpen.get(idx); + if (tb) { + this.toolOpen.delete(idx); + this.toolsDone.push(tb); + // 工具参数缓冲到块结束一次性发出(input_json_delta 非完整 JSON) + out.push(this.chunk({ tool_calls: [{ + index: 0, id: tb.id, type: "function", + function: { name: tb.name, arguments: tb.args.join("") } }] })); + } + } else if (etype === "message_delta") { + const delta = d.delta ?? {}; + const usage = d.usage ?? {}; + this.outputTokens = usage.output_tokens ?? this.outputTokens; + const sr = delta.stop_reason; + if (sr) out.push(this.chunk({}, STOP_MAP[sr] ?? "stop")); + } else if (etype === "message_stop") { + out.push("data: [DONE]\n\n"); + } else if (etype === "error") { + const err = d.error ?? {}; + out.push(this.chunk({ content: `[上游错误] ${err.message ?? "unknown"}` })); + } + // ping 等控制事件:不落帧 + return out; + } + + /** 合并 message_start / message_delta 的 usage(保持 Anthropic 口径)。 */ + usage(): Json { + return { ...this.usageIn, output_tokens: this.outputTokens }; + } + + /** 把累计内容拼成 Anthropic 非流式响应形状——直接复用 + * assistantFromUpstream("anthropic", ...) 与 usageFor, + * 流式与非流式的落库/埋点走同一条代码路径,不另造语义。 */ + syntheticResponse(): Json { + const content: Json[] = []; + const text = this.textParts.join(""); + if (text) content.push({ type: "text", text }); + const done = [...this.toolsDone]; + // 容错:流被截断时未正常 content_block_stop 的工具块也入史 + for (const i of [...this.toolOpen.keys()].sort((a, b) => a - b)) { + done.push(this.toolOpen.get(i)!); + } + for (const tb of done) { + let toolInput: Json = {}; + try { toolInput = JSON.parse(tb.args.join("") || "{}"); } catch { /* 容错 */ } + content.push({ type: "tool_use", id: tb.id, name: tb.name, input: toolInput }); + } + return { content, usage: this.usage() }; + } +} + +/** chat←chat 直通流式的流尾收集器(不转换,只汇总)。 + * + * Chat 流式响应默认不带 usage(除非客户端传 stream_options.include_usage), + * 收不到就是 0——如实记录,不编造。 + */ +export class ChatStreamCollector { + private textParts: string[] = []; + private usageData: Json = {}; + // index -> {id,name,args[]}(Chat 流的工具参数也是分段 delta) + private toolSlots = new Map(); + + /** 喂一行 SSE data 内容(已去掉 'data:' 前缀)。 */ + feedData(dataStr: string): void { + const s = dataStr.trim(); + if (!s || s === "[DONE]") return; + let d: Json; + try { d = JSON.parse(s); } catch { return; } + if (d.usage !== null && typeof d.usage === "object") this.usageData = d.usage; + for (const ch of d.choices ?? []) { + const delta = ch.delta ?? {}; + if (delta.content) this.textParts.push(delta.content); + for (const tc of delta.tool_calls ?? []) { + const i = tc.index ?? 0; + if (!this.toolSlots.has(i)) this.toolSlots.set(i, { id: "", name: "", args: [] }); + const slot = this.toolSlots.get(i)!; + if (tc.id) slot.id = tc.id; + const fn = tc.function ?? {}; + if (fn.name) slot.name = fn.name; + if (fn.arguments) slot.args.push(fn.arguments); + } + } + } + + usage(): Json { + return { ...this.usageData }; + } + + /** 拼成 Chat 非流式响应形状,复用 usageFor / assistantFromUpstream。 */ + syntheticResponse(): Json { + const msg: Json = { role: "assistant", content: this.textParts.join("") }; + if (this.toolSlots.size) { + msg.tool_calls = [...this.toolSlots.keys()].sort((a, b) => a - b).map((i) => { + const s = this.toolSlots.get(i)!; + return { id: s.id, type: "function", + function: { name: s.name, arguments: s.args.join("") } }; + }); + } + return { choices: [{ index: 0, message: msg }], usage: this.usageData }; + } +} diff --git a/ts/src/ir/model.ts b/ts/src/ir/model.ts new file mode 100644 index 0000000..5058aae --- /dev/null +++ b/ts/src/ir/model.ts @@ -0,0 +1,179 @@ +/** IR(中间表示)数据模型 —— 三方唯一契约(v0 冻结,TS 移植自 src/ir/model.py)。 + * + * 三层: + * L0 内容块模型 :消息内部的最小组成(text / tool_use / tool_result / thinking) + * L1 规范请求 :一次调用的完整规范化请求(model / system / messages / tools / 采样参数) + * L2 会话与缓存 :跨轮上下文(session 标识、重放起点、缓存断点布局) + * + * 设计约束(对应执行方案 v3): + * - 每个协议只写 to_ir / from_ir 两个方向,3 对 adapter 替代 6 方向两两互转。 + * - L2 的断点布局 = 3 个固定分层断点 + 1 个滚动尾部断点(用满官方 4 个 cache_control 上限)。 + * - 未知/不可映射字段一律进 `extra`,绝不静默丢弃(丢弃要进降级路径埋点)。 + */ + +export type Json = Record; + +// --------------------------------------------------------------------------- +// L0 · 内容块模型 +// --------------------------------------------------------------------------- + +export const TEXT = "text"; +export const TOOL_USE = "tool_use"; +export const TOOL_RESULT = "tool_result"; +export const THINKING = "thinking"; +export const IMAGE = "image"; // 多模态:方案 3.8 明确不做,仅占位以便显式降级 +export const DOCUMENT = "document"; + +/** L0 内容块。`kind` 决定其余字段的语义。 */ +export class Block { + kind: string; + text: string | null; + tool_name: string | null; + tool_id: string | null; + tool_input: Json | null; + /** 缓存断点标记(Anthropic cache_control);仅允许出现在断点布局指定的块上 */ + cache_breakpoint: boolean; + /** 未被规范化的原始字段(显式降级的证据,不静默丢弃) */ + extra: Json; + + constructor(init: { kind: string } & Partial) { + this.kind = init.kind; + this.text = init.text ?? null; + this.tool_name = init.tool_name ?? null; + this.tool_id = init.tool_id ?? null; + this.tool_input = init.tool_input ?? null; + this.cache_breakpoint = init.cache_breakpoint ?? false; + this.extra = init.extra ?? {}; + } +} + +/** L0 消息。role 规范化为 user / assistant / system(system 也可上提到 L1.system)。 */ +export class Message { + role: string; + blocks: Block[]; + + constructor(init: { role: string; blocks?: Block[] }) { + this.role = init.role; + this.blocks = init.blocks ?? []; + } + + static text(role: string, text: string): Message { + return new Message({ role, blocks: [new Block({ kind: TEXT, text })] }); + } +} + +// --------------------------------------------------------------------------- +// L1 · 规范请求 +// --------------------------------------------------------------------------- + +export class Tool { + name: string; + description: string; + input_schema: Json; + cache_breakpoint: boolean; + extra: Json; + + constructor(init: { name: string } & Partial) { + this.name = init.name; + this.description = init.description ?? ""; + this.input_schema = init.input_schema ?? {}; + this.cache_breakpoint = init.cache_breakpoint ?? false; + this.extra = init.extra ?? {}; + } +} + +/** 一次调用的规范化请求。 */ +export class IRRequest { + model: string; + system: Block[]; // 渲染顺序固定在 tools 之后 + messages: Message[]; + tools: Tool[]; + max_tokens: number; + temperature: number | null; + stream: boolean; + /** 采样/生成等其他参数(不可映射时显式降级) */ + extra: Json; + + constructor(init: Partial = {}) { + this.model = init.model ?? ""; + this.system = init.system ?? []; + this.messages = init.messages ?? []; + this.tools = init.tools ?? []; + this.max_tokens = init.max_tokens ?? 1024; + this.temperature = init.temperature ?? null; + this.stream = init.stream ?? false; + this.extra = init.extra ?? {}; + } +} + +/** 归一化 usage。三家口径不同,统一拆出缓存读写,避免对账全错。 */ +export class IRUsage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + + constructor(init: Partial = {}) { + this.input_tokens = init.input_tokens ?? 0; + this.output_tokens = init.output_tokens ?? 0; + this.cache_creation_input_tokens = init.cache_creation_input_tokens ?? 0; + this.cache_read_input_tokens = init.cache_read_input_tokens ?? 0; + } + + /** Anthropic 口径:input_tokens 不含缓存读写 */ + get total_input(): number { + return this.input_tokens + this.cache_creation_input_tokens + this.cache_read_input_tokens; + } +} + +export class IRResponse { + id: string; + model: string; + blocks: Block[]; + stop_reason: string | null; + usage: IRUsage; + /** max_tokens:0 预热响应是"畸形"的(空 content),用此标记避免被当错误 */ + is_warmup: boolean; + extra: Json; + + constructor(init: Partial = {}) { + this.id = init.id ?? ""; + this.model = init.model ?? ""; + this.blocks = init.blocks ?? []; + this.stop_reason = init.stop_reason ?? null; + this.usage = init.usage ?? new IRUsage(); + this.is_warmup = init.is_warmup ?? false; + this.extra = init.extra ?? {}; + } +} + +// --------------------------------------------------------------------------- +// L2 · 会话与缓存上下文 +// --------------------------------------------------------------------------- + +/** 跨轮上下文。重放起点/缓存断点布局由 config/session.json 驱动。 */ +export class SessionContext { + session_key: string; // 由 key_fields 拼出的状态键 + history: Message[]; // 已重放的历史 + cursor: number; // last_breakpoint 重放起点游标 + // 缓存断点布局:固定分层断点的位置标记(tools 后 / system 后 / 历史静态段后) + bp_after_tools: boolean; + bp_after_system: boolean; + bp_after_history_static: boolean; + bp_rolling_tail: boolean; // 第 4 个(滚动尾部) + + constructor(init: Partial = {}) { + this.session_key = init.session_key ?? ""; + this.history = init.history ?? []; + this.cursor = init.cursor ?? 0; + this.bp_after_tools = init.bp_after_tools ?? true; + this.bp_after_system = init.bp_after_system ?? true; + this.bp_after_history_static = init.bp_after_history_static ?? true; + this.bp_rolling_tail = init.bp_rolling_tail ?? true; + } +} + +/** 序列化(冻结契约的 wire 形态) */ +export function dumps(obj: unknown): string { + return JSON.stringify(obj); +} diff --git a/ts/src/observability/metrics.ts b/ts/src/observability/metrics.ts new file mode 100644 index 0000000..5b38767 --- /dev/null +++ b/ts/src/observability/metrics.ts @@ -0,0 +1,100 @@ +/** 命中率埋点 —— 5 项必须暴露(方案 3.3),第一天就搭好而不是最后补。 + * (TS 移植自 src/observability/metrics.py) + * + * 没有这 5 个数字,「记忆注入对 KV Cache 的影响」就无法给出可信结论。 + * 真实命中率字段(cache_creation/cache_read)只有真实 API 才返回;离线阶段 + * 框架先就绪,字段在 mock/录制样例中以 0 或样例值占位。 + */ + +import { appendFileSync } from "node:fs"; +import type { IRUsage, Json } from "../ir/model.ts"; + +export const NORMAL = "normal"; +export const WARMUP = "warmup"; + +/** 单轮必须暴露的 5 项。 */ +export interface TurnMetrics { + ts: number; + /** 轮次类型:预热轮(max_tokens:0)恒 cache_read=0,必须与正常轮分开, + * 否则会稀释北极星分母(修复:预热轮不进 hitRate 统计)。 */ + kind: string; + // ① 每轮缓存命中率(北极星) + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + input_tokens: number; + // ② 注入 token 数(记忆注入成本) + injected_tokens: number; + // ③ 重放 token 数(无状态重放代价) + replayed_tokens: number; + // ④ 被丢弃的参数清单 + dropped_params: Json[]; + // ⑤ 走了哪条降级路径(explicit / silent) + degradation_path: string; +} + +export function makeTurnMetrics(init: Partial = {}): TurnMetrics { + return { + ts: init.ts ?? Date.now() / 1000, + kind: init.kind ?? NORMAL, + cache_creation_input_tokens: init.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: init.cache_read_input_tokens ?? 0, + input_tokens: init.input_tokens ?? 0, + injected_tokens: init.injected_tokens ?? 0, + replayed_tokens: init.replayed_tokens ?? 0, + dropped_params: init.dropped_params ?? [], + degradation_path: init.degradation_path ?? "none", + }; +} + +export function cacheHit(m: TurnMetrics): boolean { + return m.cache_read_input_tokens > 0; +} + +/** creation 与 read 同时为 0 = 完全没用上缓存(多半没够最小阈值)。 */ +export function cacheUsed(m: TurnMetrics): boolean { + return !(m.cache_creation_input_tokens === 0 && m.cache_read_input_tokens === 0); +} + +/** 追加写 JSONL,供实验脚本汇总命中率。 + * Python 版需要锁(ThreadingHTTPServer 并发写可能坏行); + * Node 单线程 + appendFileSync 同步写,天然无竞态。 + */ +export class MetricsLog { + path: string; + + constructor(path: string) { + this.path = path; + } + + record(m: TurnMetrics): void { + appendFileSync(this.path, JSON.stringify(m) + "\n", "utf-8"); + } + + recordTurn(usage: IRUsage, injected: number, replayed: number, + dropped: Json[], degradation: string, kind: string = NORMAL): TurnMetrics { + const m = makeTurnMetrics({ + kind, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + cache_read_input_tokens: usage.cache_read_input_tokens, + input_tokens: usage.input_tokens, + injected_tokens: injected, + replayed_tokens: replayed, + dropped_params: [...dropped], + degradation_path: degradation, + }); + this.record(m); + return m; + } +} + +/** cache_read 命中率 = 命中轮数 / 总轮数(北极星)。 + * + * 默认剔除预热轮:预热(max_tokens:0)的目的是写缓存,其 cache_read 恒为 0, + * 把它算进分母会系统性拉低命中率,且拉低幅度取决于预热轮占比, + * 让不同批次之间的命中率不可比。 + */ +export function hitRate(metrics: TurnMetrics[], includeWarmup = false): number { + const ms = includeWarmup ? metrics : metrics.filter((m) => m.kind !== WARMUP); + if (!ms.length) return 0; + return ms.filter(cacheHit).length / ms.length; +} diff --git a/ts/src/state/idmap.ts b/ts/src/state/idmap.ts new file mode 100644 index 0000000..d421642 --- /dev/null +++ b/ts/src/state/idmap.ts @@ -0,0 +1,95 @@ +/** 工具调用 ID 双向持久映射(v1.4,问题清单组4#3;TS 移植自 src/state/idmap.py)。 + * + * 三家工具 ID 命名不同:Anthropic `toolu_*`、Chat `call_*`、Responses 同时有 + * `call_id` 与 `item.id`。ID 由服务端生成、每次都变,跨协议往返必须维护一张 + * 映射表——否则 Anthropic→Chat→Anthropic 一圈回来,最初那个 `toolu_*` 就丢了, + * 上游校验 tool_use_id 对应关系时直接 400。 + * + * 设计: + * - canonical id = 该 ID **首次出现时的原始形式**(含其来源协议)。 + * - `incoming(session, extId, proto)`:把某协议的外部 ID 归一到 canonical。 + * - `outgoing(session, canon, proto)`:取 canonical 在某协议下的外部形式; + * 目标协议就是来源协议时返回 canonical 本身(字节无损),否则铸造新 ID + * (按目标协议习惯前缀)并持久化——同一会话内稳定,前缀序列化不被破坏。 + * - 映射随会话存 SQLite,TTL/归档与会话表同生命周期(不单独维护过期)。 + * - 冲突策略:first-wins。同一 (session, proto, ext) 永不改绑; + * 同一 canon 在不同 proto 下各有独立外部形式。 + */ + +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; + +// 各协议的 ID 习惯前缀(铸造新 ID 时用;仅约定,不做严格校验) +const PROTO_PREFIX: Record = { + anthropic: "toolu_", + openai_chat: "call_", + openai_response: "call_", +}; + +/** 会话级工具 ID 映射表。与 SessionStore 共用一个 SQLite 连接。 */ +export class ToolIdMap { + private db: DatabaseSync; + + constructor(db: DatabaseSync) { + this.db = db; + this.db.exec(` + CREATE TABLE IF NOT EXISTS tool_id_map ( + session_key TEXT NOT NULL, + proto TEXT NOT NULL, + ext_id TEXT NOT NULL, + canon TEXT NOT NULL, + origin_proto TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (session_key, proto, ext_id) + ); + CREATE INDEX IF NOT EXISTS idx_tool_id_canon + ON tool_id_map (session_key, canon); + `); + } + + /** 外部 ID → canonical。未见过的 ID 以其本身为 canonical 登记。 */ + incoming(sessionKey: string, extId: string | null, proto: string): string | null { + if (!extId) return extId; + const row = this.db.prepare( + "SELECT canon FROM tool_id_map WHERE session_key=? AND proto=? AND ext_id=?") + .get(sessionKey, proto, extId) as { canon: string } | undefined; + if (row) return row.canon; + this.db.prepare( + "INSERT OR IGNORE INTO tool_id_map " + + "(session_key, proto, ext_id, canon, origin_proto, created_at) " + + "VALUES (?,?,?,?,?,?)") + .run(sessionKey, proto, extId, extId, proto, Date.now() / 1000); + return extId; + } + + /** canonical → 目标协议外部形式(稳定、可持久、按目标协议前缀铸造)。 */ + outgoing(sessionKey: string, canon: string | null, proto: string): string | null { + if (!canon) return canon; + // 已铸造过 → 直接用(跨轮稳定,前缀不抖动) + const row = this.db.prepare( + "SELECT ext_id FROM tool_id_map WHERE session_key=? AND proto=? AND canon=?") + .get(sessionKey, proto, canon) as { ext_id: string } | undefined; + if (row) return row.ext_id; + // 来源协议就是目标协议 → canonical 原样返回(同协议字节无损) + const origin = this.db.prepare( + "SELECT origin_proto FROM tool_id_map WHERE session_key=? AND canon=? LIMIT 1") + .get(sessionKey, canon) as { origin_proto: string } | undefined; + if (origin && origin.origin_proto === proto) return canon; + // 铸造新 ID 并持久化 + const ext = (PROTO_PREFIX[proto] ?? "call_") + randomUUID().replaceAll("-", "").slice(0, 24); + this.db.prepare( + "INSERT OR IGNORE INTO tool_id_map " + + "(session_key, proto, ext_id, canon, origin_proto, created_at) " + + "VALUES (?,?,?,?,?,?)") + .run(sessionKey, proto, ext, canon, + origin ? origin.origin_proto : proto, Date.now() / 1000); + return ext; + } + + size(sessionKey: string): number { + const row = this.db.prepare( + "SELECT COUNT(*) AS n FROM tool_id_map WHERE session_key=?") + .get(sessionKey) as { n: number }; + return row.n; + } +} diff --git a/ts/src/state/session_config.ts b/ts/src/state/session_config.ts new file mode 100644 index 0000000..8c3d83b --- /dev/null +++ b/ts/src/state/session_config.ts @@ -0,0 +1,103 @@ +/** Session 边界配置(可切换)—— 读 config/session.json,驱动重放策略。 + * (TS 移植自 src/state/session_config.py) + * + * 落地纪律(对应执行方案 3.9.1): + * - key_granularity:启动时读配置,运行中途切换会致已存会话键值错乱。 + * - replay_from / end_policy:可热切换(只在读历史时生效)。 + * - 实验脚本每次跑之前,把本配置完整快照写进报告头部,与命中率数据绑死。 + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Message } from "../ir/model.ts"; + +// key_granularity +export const SINGLE_TASK = "single_task"; +export const THREE_LEVEL = "three_level"; +// replay_from +export const FULL = "full"; +export const LAST_BREAKPOINT = "last_breakpoint"; +export const SLIDING_WINDOW = "sliding_window"; +// end_policy +export const TTL = "ttl"; +export const EXPLICIT_CLOSE = "explicit_close"; + +export class SessionConfig { + key_granularity: string; + key_fields: string[]; + replay_from: string; + sliding_window_n: number; + end_policy: string; + ttl_seconds: number; + on_end: string; + /** 记忆注入上限(TRACK 04 第三参数):0=不限制;会话 meta.memory_cap 可覆盖 */ + session_memory_cap: number; + + constructor(init: Partial = {}) { + this.key_granularity = init.key_granularity ?? SINGLE_TASK; + this.key_fields = init.key_fields ?? ["task-id"]; + this.replay_from = init.replay_from ?? FULL; + this.sliding_window_n = init.sliding_window_n ?? 20; + this.end_policy = init.end_policy ?? TTL; + this.ttl_seconds = init.ttl_seconds ?? 1800; + this.on_end = init.on_end ?? "archive"; + this.session_memory_cap = init.session_memory_cap ?? 0; + } + + /** 由 key_fields 从请求头拼状态键。 */ + sessionKey(headers: Record): string { + return this.key_fields.map((k) => String(headers[k] ?? "")).join("|"); + } + + /** 实验报告头部要绑定的配置快照。 */ + snapshot(): Record { + return { + key_granularity: this.key_granularity, + key_fields: [...this.key_fields], + replay_from: this.replay_from, + sliding_window_n: this.sliding_window_n, + end_policy: this.end_policy, + ttl_seconds: this.ttl_seconds, + on_end: this.on_end, + session_memory_cap: this.session_memory_cap, + }; + } +} + +const DEFAULT_PATH = join( + dirname(fileURLToPath(import.meta.url)), "..", "..", "config", "session.json"); + +/** 从 JSON 加载;文件缺失或缺字段时回落到默认假设值。 */ +export function load(path?: string): SessionConfig { + const p = path ?? DEFAULT_PATH; + let raw: Record = {}; + try { + raw = (JSON.parse(readFileSync(p, "utf-8")).session ?? {}) as Record; + } catch { + raw = {}; + } + const cfg = new SessionConfig(); + for (const [k, v] of Object.entries(raw)) { + if (k in cfg) { + (cfg as unknown as Record)[k] = v; + } + } + return cfg; +} + +/** 重放起点策略接口:配置决定用哪个实现。 + * + * history: 该 session 已存的完整历史 + * cursor: last_breakpoint 的游标 + * 返回:本轮要拼进前缀的消息 + */ +export function buildPrefix(history: Message[], cursor: number, cfg: SessionConfig): Message[] { + if (cfg.replay_from === FULL) return [...history]; + if (cfg.replay_from === LAST_BREAKPOINT) return history.slice(cursor); + // sliding_window + // n<=0 必须返回空窗口。直接写 history.slice(-n) 在 n==0 时等价于 history.slice(0), + // 会静默退化成 full —— 实验组间差异就此消失且极难察觉。 + const n = cfg.sliding_window_n; + return n > 0 ? history.slice(-n) : []; +} diff --git a/ts/src/state/store.ts b/ts/src/state/store.ts new file mode 100644 index 0000000..95dc88b --- /dev/null +++ b/ts/src/state/store.ts @@ -0,0 +1,258 @@ +/** 状态层:SQLite 持久化会话表 + TTL 淘汰 + previous_response_id 状态键。 + * (TS 移植自 src/state/store.py,SQLite 用 Node 内置 node:sqlite) + * + * 为什么必须有它(方案 1.2):OpenAI Response 的 previous_response_id 是 + * 「上一轮已存服务端,下一轮只传 ID」的有状态设计;而 Chat / Anthropic 要求每轮 + * 重发完整历史。转换层必须自己变成带存储的服务,把 previous_response_id 解析回 + * 完整历史再重放给无状态协议。 + * + * 持久化(v1.1):进程重启会话不丢,TTL 归档(archive)语义可落地查证。 + * history / meta 以 JSON 落库;接口与 Python 版保持一致,上层无感。 + */ + +import { mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import * as ir from "../ir/model.ts"; +import { ToolIdMap } from "./idmap.ts"; +import { SessionConfig, buildPrefix, TTL } from "./session_config.ts"; + +const DEFAULT_DB = join( + dirname(fileURLToPath(import.meta.url)), "..", "..", "data", "sessions.db"); + +/** Message/Block → 可落库 plain object;反向同理。 */ +function msgToPlain(m: ir.Message | ir.Json): ir.Json { + if (!(m instanceof ir.Message)) return m; + return { role: m.role, blocks: m.blocks.map((b) => ({ ...b })) }; +} + +function msgFromPlain(d: ir.Json): ir.Message { + return new ir.Message({ + role: d.role, + blocks: (d.blocks ?? []).map((b: ir.Json) => + new ir.Block(b as { kind: string } & Partial)), + }); +} + +export class Session { + key: string; + history: ir.Message[] = []; // 内存形态;落库为 plain object + cursor = 0; // last_breakpoint 游标 + created_at: number; + touched_at: number; + closed = false; + meta: ir.Json = {}; // memories / memory_cap 等会话级配置 + /** response_id -> 此 session 中已生成的响应序号(处理并发分叉) */ + responses: Record = {}; + + constructor(init: { key: string } & Partial) { + const now = Date.now() / 1000; + this.key = init.key; + this.history = init.history ?? []; + this.cursor = init.cursor ?? 0; + this.created_at = init.created_at ?? now; + this.touched_at = init.touched_at ?? now; + this.closed = init.closed ?? false; + this.meta = init.meta ?? {}; + this.responses = init.responses ?? {}; + } +} + +interface SessionRow { + key: string; + history: string; + cursor: number; + created_at: number; + touched_at: number; + closed: number; + meta: string; + responses: string; +} + +/** 线程安全的 SQLite 会话表(Node 单线程 + 同步驱动,无需 Python 的锁)。 + * + * dbPath: undefined → 环境变量 PB_DB → 项目 data/sessions.db;测试可传 ":memory:"。 + */ +export class SessionStore { + cfg: SessionConfig; + idmap: ToolIdMap; + private db: DatabaseSync; + /** 上次惰性淘汰时间戳(测试可写,用于绕过节流) */ + _lastEvict = 0; + /** 惰性淘汰最小间隔(秒) */ + static readonly EVICT_INTERVAL = 60; + + constructor(cfg: SessionConfig, dbPath?: string) { + this.cfg = cfg; + const path = dbPath ?? process.env.PB_DB ?? DEFAULT_DB; + if (path !== ":memory:") { + mkdirSync(dirname(resolve(path)), { recursive: true }); + } + this.db = new DatabaseSync(path); + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + key TEXT PRIMARY KEY, + history TEXT NOT NULL DEFAULT '[]', + cursor INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + touched_at REAL NOT NULL, + closed INTEGER NOT NULL DEFAULT 0, + meta TEXT NOT NULL DEFAULT '{}', + responses TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS resp_index ( + rid TEXT PRIMARY KEY, + session_key TEXT NOT NULL + ); + `); + // v1.4:工具调用 ID 双向映射表(与会话同库同生命周期) + this.idmap = new ToolIdMap(this.db); + } + + // -- 序列化辅助 ---------------------------------------------------------- + private static rowToSession(row: SessionRow): Session { + return new Session({ + key: row.key, + history: (JSON.parse(row.history) as ir.Json[]).map(msgFromPlain), + cursor: row.cursor, + created_at: row.created_at, + touched_at: row.touched_at, + closed: Boolean(row.closed), + meta: JSON.parse(row.meta), + responses: JSON.parse(row.responses), + }); + } + + /** @internal 测试与 store 内部使用 */ + _load(key: string): Session | null { + const row = this.db.prepare( + "SELECT key,history,cursor,created_at,touched_at,closed,meta,responses " + + "FROM sessions WHERE key=?").get(key) as SessionRow | undefined; + return row ? SessionStore.rowToSession(row) : null; + } + + /** @internal 测试与 store 内部使用 */ + _save(s: Session): void { + this.db.prepare( + "INSERT OR REPLACE INTO sessions " + + "(key,history,cursor,created_at,touched_at,closed,meta,responses) " + + "VALUES (?,?,?,?,?,?,?,?)") + .run(s.key, + JSON.stringify(s.history.map(msgToPlain)), + s.cursor, s.created_at, s.touched_at, s.closed ? 1 : 0, + JSON.stringify(s.meta), JSON.stringify(s.responses)); + } + + // -- session 生命周期 -------------------------------------------------- + getOrCreate(key: string): Session { + // 惰性淘汰(LIMITATIONS #11):由流量驱动,无后台线程; + // 同步驱动无锁需求(Python 版需在锁外调用避免非可重入锁死锁)。 + this.maybeEvict(); + let s = this._load(key); + if (s === null || s.closed) { + s = new Session({ key }); + } + s.touched_at = Date.now() / 1000; + this._save(s); + return s; + } + + /** 把 previous_response_id 解析回它所属的 session。 */ + resolvePrevious(previousResponseId: string | null): Session | null { + if (!previousResponseId) return null; + const row = this.db.prepare( + "SELECT session_key FROM resp_index WHERE rid=?") + .get(previousResponseId) as { session_key: string } | undefined; + return row ? this._load(row.session_key) : null; + } + + /** 登记一个新响应,返回 response_id(供下一轮 previous_response_id 引用)。 + * + * 注意:调用方持有的 session 可能是旧快照(如中途 updateMeta 过), + * 必须先读最新行再改,避免用旧 meta 覆盖新值。 + */ + recordResponse(session: Session): string { + const rid = "resp_" + randomUUID().replaceAll("-", "").slice(0, 16); + const fresh = this._load(session.key) ?? session; + fresh.responses[rid] = Object.keys(fresh.responses).length; + fresh.touched_at = Date.now() / 1000; + this._save(fresh); + // 同步调用方对象的 meta/responses,防止其后续再 _save 旧值 + session.meta = fresh.meta; + session.responses = fresh.responses; + session.history = fresh.history; + session.cursor = fresh.cursor; + this.db.prepare( + "INSERT OR REPLACE INTO resp_index (rid, session_key) VALUES (?,?)") + .run(rid, session.key); + return rid; + } + + /** 合并写入会话 meta(如 memories / memory_cap),保留既有键。 */ + updateMeta(key: string, kw: ir.Json): void { + let s = this._load(key); + if (s === null) s = new Session({ key }); + Object.assign(s.meta, kw); + s.touched_at = Date.now() / 1000; + this._save(s); + } + + close(key: string): void { + const s = this._load(key); + if (s) { + s.closed = true; + this._save(s); + } + } + + /** 关闭底层 SQLite 连接(进程退出/测试清理用)。 */ + shutdown(): void { + this.db.close(); + } + + // -- 重放 --------------------------------------------------------------- + /** 按当前配置的重放起点,返回本轮要拼进前缀的历史。 */ + replay(session: Session): ir.Message[] { + return buildPrefix(session.history, session.cursor, this.cfg); + } + + append(session: Session, messages: ir.Message[]): void { + session.history.push(...messages); + session.cursor = session.history.length; + session.touched_at = Date.now() / 1000; + this._save(session); + } + + // -- TTL 淘汰 ----------------------------------------------------------- + /** 惰性 TTL 淘汰:读写路径顺手调用,按 EVICT_INTERVAL 节流。 + * + * 返回实际淘汰数量;处于节流窗口内时返回 0(未执行扫描)。 + * _lastEvict 的竞写在 Python 里是良性的,Node 单线程下天然无竞态。 + */ + maybeEvict(now?: number): number { + const t = now ?? Date.now() / 1000; + if (t - this._lastEvict < SessionStore.EVICT_INTERVAL) return 0; + this._lastEvict = t; + return this.evictExpired(t); + } + + /** TTL 结束策略下淘汰超时会话。返回淘汰数量。 */ + evictExpired(now?: number): number { + if (this.cfg.end_policy !== TTL) return 0; + const t = now ?? Date.now() / 1000; + const cutoff = t - this.cfg.ttl_seconds; + const rows = this.db.prepare( + "SELECT key FROM sessions WHERE closed=0 AND touched_at number { + let a = seed >>> 0; + return () => { + a = (a + 0x6D2B79F5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +class Rng { + private next: () => number; + constructor(seed: number) { this.next = mulberry32(seed); } + random(): number { return this.next(); } + randint(lo: number, hi: number): number { + return lo + Math.floor(this.next() * (hi - lo + 1)); + } + choice(arr: T[]): T { return arr[Math.floor(this.next() * arr.length)]; } +} + +// --------------------------------------------------------------------------- +// TestAdapterRoundTrip +// --------------------------------------------------------------------------- + +describe("TestAdapterRoundTrip", () => { + test("chat→anthropic 断点数 ≤4 且 ≥3", () => { + const payload = { + model: "gpt-x", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "again" }, + ], + tools: [{ type: "function", function: { name: "search", description: "d", parameters: {} } }], + }; + const req = new ChatAdapter().to_ir(payload, new Dropped()); + assert.equal(req.system.length, 1); + assert.equal(req.messages.length, 3); + assert.equal(req.tools.length, 1); + + const ctx = new ir.SessionContext(); // 默认 3 固定 + 1 滚动 + const out = new AnthropicAdapter().from_ir(req, new Dropped(), ctx); + const bps = countBreakpoints(out); + assert.ok(bps <= 4, "cache_control 断点不得超过官方 4 个上限"); + assert.ok(bps >= 3, "3 固定 + 1 滚动应至少 3 个"); + }); + + test("usage 三口径归一", () => { + const a = usageFromAnthropic({ input_tokens: 8, output_tokens: 0, + cache_creation_input_tokens: 5120, + cache_read_input_tokens: 0 }); + assert.equal(a.total_input, 5128); + const c = usageFromChat({ prompt_tokens: 2006, completion_tokens: 300, + prompt_tokens_details: { cached_tokens: 1920 } }); + assert.equal(c.cache_read_input_tokens, 1920); + }); +}); + +// --------------------------------------------------------------------------- +// TestStateLayer +// --------------------------------------------------------------------------- + +describe("TestStateLayer", () => { + test("previous_response_id 重放", () => { + const store = new SessionStore(new SessionConfig(), ":memory:"); + const s = store.getOrCreate("task-1"); + store.append(s, [ir.Message.text("user", "u1"), ir.Message.text("assistant", "a1")]); + const rid = store.recordResponse(s); + // 下一轮带 previous_response_id,应解析回同一 session 并重放历史 + const s2 = store.resolvePrevious(rid); + assert.ok(s2 !== null); + assert.equal(store.replay(s2).length, 2); + store.shutdown(); + }); + + test("build_prefix 三策略", () => { + const hist = Array.from({ length: 5 }, (_, i) => ir.Message.text("user", `m${i}`)); + let cfg = new SessionConfig({ replay_from: FULL }); + assert.equal(buildPrefix(hist, 0, cfg).length, 5); + cfg = new SessionConfig({ replay_from: LAST_BREAKPOINT }); + assert.equal(buildPrefix(hist, 3, cfg).length, 2); + cfg = new SessionConfig({ replay_from: SLIDING_WINDOW, sliding_window_n: 2 }); + assert.equal(buildPrefix(hist, 0, cfg).length, 2); + }); + + test("TTL 淘汰", () => { + const cfg = new SessionConfig({ end_policy: "ttl", ttl_seconds: 1, on_end: "archive" }); + const store = new SessionStore(cfg, ":memory:"); + const s = store.getOrCreate("task-x"); + s.touched_at -= 10; // 假装已超时(持久化层需写回才生效) + store._save(s); + assert.equal(store.evictExpired(), 1); + store.shutdown(); + }); + + test("惰性淘汰按间隔节流(LIMITATIONS #11 回归)", () => { + const cfg = new SessionConfig({ end_policy: "ttl", ttl_seconds: 1, on_end: "archive" }); + const store = new SessionStore(cfg, ":memory:"); + const t0 = Date.now() / 1000; + const s = store.getOrCreate("task-old"); + s.touched_at = t0 - 100; + store._save(s); + // getOrCreate 本身已触发过一次实时惰性淘汰(节流窗由此刻起算), + // 重置 _lastEvict 以便用显式 now 做确定性验证 + store._lastEvict = 0; + // 首次触发:淘汰 1 个超时会话 + assert.equal(store.maybeEvict(t0), 1); + // 节流窗口内(< 60s):不再扫描,即使又有新超时会话 + const s2 = store.getOrCreate("task-old2"); + s2.touched_at = t0 - 100; + store._save(s2); + assert.equal(store.maybeEvict(t0 + 30), 0); + // 窗口过后:恢复淘汰 + assert.equal(store.maybeEvict(t0 + 61), 1); + store.shutdown(); + }); + + test("正常读写路径顺手淘汰超时会话(LIMITATIONS #11 回归)", () => { + const cfg = new SessionConfig({ end_policy: "ttl", ttl_seconds: 1, on_end: "archive" }); + const store = new SessionStore(cfg, ":memory:"); + const s = store.getOrCreate("task-expired"); + s.touched_at -= 100; + store._save(s); + store._lastEvict = 0; // 绕过节流,模拟「距上次淘汰已很久」 + store.getOrCreate("task-new"); // 任意流量即触发 + assert.equal(store._load("task-expired")!.closed, true); + assert.equal(store._load("task-new")!.closed, false); + store.shutdown(); + }); + + test("SQLite 重启不丢(换连接后会话与 meta 仍在)", () => { + const td = mkdtempSync(join(tmpdir(), "pb-ts-")); + try { + const db = join(td, "s.db"); + const cfg = new SessionConfig(); + const st1 = new SessionStore(cfg, db); + const s = st1.getOrCreate("task-p"); + st1.append(s, [ir.Message.text("user", "u1")]); + st1.updateMeta("task-p", { memories: [{ content: "记住:偏好简洁" }], memory_cap: 3 }); + const rid = st1.recordResponse(s); + // 模拟进程重启:新实例读同一文件 + const st2 = new SessionStore(cfg, db); + const s2 = st2.resolvePrevious(rid); + assert.ok(s2 !== null); + assert.equal(st2.replay(s2).length, 1); + assert.equal(s2.meta.memory_cap, 3); + assert.equal(s2.meta.memories[0].content, "记住:偏好简洁"); + st1.shutdown(); + st2.shutdown(); + } finally { + rmSync(td, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// TestWarmup +// --------------------------------------------------------------------------- + +describe("TestWarmup", () => { + test("预热构造与校验", () => { + const w = prewarm.buildWarmupRequest("system prompt", "claude-opus-4-8"); + assert.equal(w.max_tokens, 0); + assert.equal(w.messages[0].content, prewarm.PLACEHOLDER); + assert.deepEqual(prewarm.validateWarmup(w), []); + // 断点打在 system 而非占位消息 + assert.ok("cache_control" in w.system[0]); + assert.ok(!("cache_control" in w.messages[0])); + }); + + test("冲突参数被拒", () => { + const bad = { max_tokens: 0, stream: true, tool_choice: { type: "any" } }; + assert.ok(prewarm.validateWarmup(bad).includes("stream")); + assert.ok(prewarm.validateWarmup(bad).includes("tool_choice")); + }); + + test("畸形预热响应解析", () => { + const body = { id: "msg_1", model: "m", content: [], + stop_reason: "max_tokens", + usage: { input_tokens: 8, output_tokens: 0, + cache_creation_input_tokens: 5120 } }; + assert.ok(prewarm.isWarmupResponse(body)); + const r = prewarm.parseWarmupResponse(body); + assert.ok(r.is_warmup); + assert.deepEqual(r.blocks, []); + assert.equal(r.usage.cache_creation_input_tokens, 5120); + }); +}); + +// --------------------------------------------------------------------------- +// TestSessionConfig +// --------------------------------------------------------------------------- + +describe("TestSessionConfig", () => { + test("加载默认值与热切换", () => { + const cfg = load(); // 从 config/session.json + assert.equal(cfg.replay_from, "full"); + const snap = cfg.snapshot(); + assert.ok("replay_from" in snap); + // 热切换重放策略,不动其余配置 + cfg.replay_from = "sliding_window"; + assert.equal(cfg.replay_from, "sliding_window"); + assert.equal(cfg.key_granularity, "single_task"); + }); +}); + +// --------------------------------------------------------------------------- +// TestGatewayPolicies +// --------------------------------------------------------------------------- + +describe("TestGatewayPolicies", () => { + test("预热拒绝条件(校验转换后报文)", () => { + assert.ok(gwmod.isWarmup({ max_tokens: 0 })); + assert.ok(gwmod.isWarmup({ max_output_tokens: 0 })); + assert.ok(!gwmod.isWarmup({ max_tokens: 100 })); + // 四类冲突(Anthropic 侧查 thinking;OpenAI 侧查 structured outputs) + assert.ok(gwmod.validateWarmup({ stream: true }, "anthropic")!.includes("stream")); + assert.ok(gwmod.validateWarmup({ thinking: { type: "enabled" } }, "anthropic")!.includes("thinking")); + assert.ok(gwmod.validateWarmup({ response_format: { type: "json_schema" } }, "openai_chat")!.includes("structured")); + assert.ok(gwmod.validateWarmup({ tool_choice: { type: "any" } }, "anthropic")!.includes("tool_choice")); + assert.equal(gwmod.validateWarmup({ max_tokens: 0 }, "anthropic"), null); + }); + + test("记忆注入:幂等去重 + memory_cap", () => { + const req = new ir.IRRequest({ system: [new ir.Block({ kind: ir.TEXT, text: "已有系统提示" })] }); + const s = new Session({ key: "t", meta: { memories: [ + { content: "记忆A" }, + { content: "已有系统提示" }, // 与 system 已有文本重复 → 跳过 + { content: "记忆B" }, + { content: "记忆C" }, + ], memory_cap: 2 } }); + const n = gwmod.injectMemories(req, s); + assert.equal(n, 2, "去重后剩 3 条候选,cap=2 只注入 2 条"); + const texts = req.system.map((b) => b.text); + assert.deepEqual(texts, ["已有系统提示", "记忆A", "记忆B"]); + // 幂等:重复注入不再加 + assert.equal(gwmod.injectMemories(req, s), 0); + }); + + test("memory_cap 回退链:会话 meta > 全局 cfg > 不限", () => { + const req = new ir.IRRequest(); + const s = new Session({ key: "t", meta: { memories: + Array.from({ length: 5 }, (_, i) => ({ content: `m${i}` })) } }); + // 会话无 memory_cap → 用全局 cfg(0=不限) + const old = gwmod.gw.CFG.session_memory_cap; + gwmod.gw.CFG.session_memory_cap = 3; + try { + assert.equal(gwmod.injectMemories(req, s), 3); + } finally { + gwmod.gw.CFG.session_memory_cap = old; + } + }); +}); + +// --------------------------------------------------------------------------- +// TestAuditFindings(两批自查 + 顺带修复的回归测试) +// --------------------------------------------------------------------------- + +describe("TestAuditFindings", () => { + test("有重放历史时打满 4 个断点", () => { + // 修复前:bp_after_history_static 从未落地,实际只打 3 个 + const payload = { + model: "m", + messages: [{ role: "system", content: "sys" }, + { role: "user", content: "u1" }, + { role: "assistant", content: "a1" }, + { role: "user", content: "u2" }], + tools: [{ type: "function", function: { name: "search", description: "d", parameters: {} } }], + }; + const req = new ChatAdapter().to_ir(payload, new Dropped()); + const ctx = new ir.SessionContext({ history: [ir.Message.text("user", "u0"), + ir.Message.text("assistant", "a0")] }); + req.messages = [...ctx.history, ...req.messages]; // 模拟重放 2 条历史 + const out = new AnthropicAdapter().from_ir(req, new Dropped(), ctx); + assert.equal(countBreakpoints(out), 4); + }); + + test("无映射块必须进降级记录(不静默丢弃)", () => { + const req = new ir.IRRequest({ messages: [new ir.Message({ + role: "user", blocks: [new ir.Block({ kind: ir.IMAGE, text: "[img]" })] })] }); + const d = new Dropped(); + new AnthropicAdapter().from_ir(req, d); + assert.ok(d.items.some((i) => JSON.stringify(i).includes("image")), + `image 块被静默丢弃且无记录:${JSON.stringify(d.items)}`); + }); + + test("预热校验对象是目标协议报文", () => { + assert.ok(gwmod.validateWarmup({ response_format: { type: "json_object" } }, "openai_chat")!.includes("structured")); + assert.equal(gwmod.validateWarmup({ response_format: { type: "json_object" } }, "anthropic"), null, + "Anthropic 上游不看 response_format,不应误报"); + }); + + test("上游 4xx/5xx 抛 UpstreamHttpError(状态码不吞)", async () => { + const srv = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(529, { "Content-Type": "application/json" }); + res.end('{"error":"overloaded"}'); + }); + }); + await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + const port = (srv.address() as { port: number }).port; + try { + await assert.rejects( + gwmod.postJson(`http://127.0.0.1:${port}/x`, {}, "openai_chat"), + (e: unknown) => e instanceof gwmod.UpstreamHttpError && + (e as gwmod.UpstreamHttpError).statusCode === 529); + } finally { + srv.close(); + } + }); + + test("重放指标只算真正重放的历史(不含本轮新消息)", () => { + const oldStore = gwmod.gw.STORE; + const st = new SessionStore(new SessionConfig(), ":memory:"); + gwmod.gw.STORE = st; + try { + const s = st.getOrCreate("k-replay"); + st.append(s, [ir.Message.text("user", "历史".repeat(20))]); + const rid = st.recordResponse(s); + const payload = { model: "m", + input: [{ type: "message", role: "user", + content: "本轮".repeat(20) }], + previous_response_id: rid }; + const { req, replayed } = gwmod.convert( + "openai_response", "openai_chat", payload, {}); + assert.equal(replayed.length, 1, "重放切片应只含 1 条历史"); + assert.equal(req.messages.length, 2, "本轮 1 条 + 重放 1 条"); + assert.equal( + replayed.map((m) => m.blocks.map((b) => b.text ?? "").join("")).join(""), + "历史".repeat(20)); + } finally { + st.shutdown(); + gwmod.gw.STORE = oldStore; + } + }); + + test("预热轮不进命中率分母", () => { + const ms = [ + makeTurnMetrics({ kind: NORMAL, cache_read_input_tokens: 100 }), + makeTurnMetrics({ kind: NORMAL, cache_read_input_tokens: 0 }), + makeTurnMetrics({ kind: WARMUP, cache_read_input_tokens: 0 }), + ]; + assert.ok(Math.abs(hitRate(ms) - 0.5) < 1e-9, "2 个正常轮命中 1 个"); + assert.ok(Math.abs(hitRate(ms, true) - 1 / 3) < 1e-9); + }); + + test("sliding_window n<=0 必须空窗口", () => { + // 修复前 history[-n:] 在 n==0 时静默退化成 full + const hist = Array.from({ length: 5 }, (_, i) => ir.Message.text("user", `m${i}`)); + const cfg = new SessionConfig({ replay_from: SLIDING_WINDOW, sliding_window_n: 0 }); + assert.deepEqual(buildPrefix(hist, 0, cfg), []); + }); + + test("Chat 未知顶层字段必进降级记录", () => { + const payload = { model: "m", + messages: [{ role: "user", content: "hi" }], + system: "顶层系统提示", // 非 Chat 规范字段 + top_p: 0.9 }; // IR 无映射字段 + const d = new Dropped(); + const req = new ChatAdapter().to_ir(payload, d); + assert.deepEqual(req.system.map((b) => b.text), ["顶层系统提示"], + "顶层 system 应按语义上提,语义不能丢"); + const fields = new Set(d.items.map((i) => i.field)); + assert.ok(fields.has("system")); + assert.ok(fields.has("top_p"), "IR 无映射的顶层参数被静默丢弃"); + }); + + test("previous_response_id 不回传上游", () => { + const req = new ir.IRRequest({ extra: { previous_response_id: "resp_abc" } }); + const out = new ResponseAdapter().from_ir(req, new Dropped()); + assert.ok(!("previous_response_id" in out)); + }); + + test("Anthropic thinking/tool_choice 经 extra 无损往返", () => { + const d = new Dropped(); + const req = new AnthropicAdapter().to_ir( + { model: "m", max_tokens: 10, + thinking: { type: "enabled", budget_tokens: 1024 }, + tool_choice: { type: "auto" }, + messages: [{ role: "user", content: "hi" }] }, d); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + assert.deepEqual(out.thinking, { type: "enabled", budget_tokens: 1024 }); + assert.deepEqual(out.tool_choice, { type: "auto" }); + }); +}); + +// --------------------------------------------------------------------------- +// TestStatefulChainE2E:真起网关 + mock,验多轮 previous_response_id 链路 +// --------------------------------------------------------------------------- + +describe("TestStatefulChainE2E", () => { + let td: string; + let mockSrv: http.Server; + let gwSrv: http.Server; + let url: string; + let origStore: SessionStore; + let origMetrics: MetricsLog; + let origBackend: string; + + before(async () => { + td = mkdtempSync(join(tmpdir(), "pb-ts-e2e-")); + origStore = gwmod.gw.STORE; + origMetrics = gwmod.gw.METRICS; + origBackend = gwmod.gw.BACKEND_URL; + gwmod.gw.STORE = new SessionStore(gwmod.gw.CFG, join(td, "s.db")); + gwmod.gw.METRICS = new MetricsLog(join(td, "m.jsonl")); + + mockSrv = http.createServer((req, res) => { void mockHandler(req, res); }); + await new Promise((r) => mockSrv.listen(0, "127.0.0.1", r)); + const mockPort = (mockSrv.address() as { port: number }).port; + gwmod.gw.BACKEND_URL = `http://127.0.0.1:${mockPort}`; + + gwSrv = gwmod.createServer(); + await new Promise((r) => gwSrv.listen(0, "127.0.0.1", r)); + url = `http://127.0.0.1:${(gwSrv.address() as { port: number }).port}`; + }); + + after(async () => { + gwSrv.close(); + mockSrv.close(); + gwmod.gw.STORE.shutdown(); + gwmod.gw.STORE = origStore; + gwmod.gw.METRICS = origMetrics; + gwmod.gw.BACKEND_URL = origBackend; + origStore.shutdown(); + rmSync(td, { recursive: true, force: true }); + }); + + async function post(path: string, payload: ir.Json): Promise { + const resp = await fetch(url + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return resp.json() as Promise; + } + + test("多轮 previous_response_id 链:历史持续累积", async () => { + const r1 = await post("/v1/openai_response/to/openai_response", { + model: "mock", + input: [{ type: "message", role: "user", content: "第一轮问题" }], + }); + const rid = r1.id; + // 不能只断言前缀 —— mock 的 id 恰好也是 resp_ 开头,会假通过。 + // 真正的性质是:这个 id 网关自己解得回会话。 + assert.ok(gwmod.gw.STORE.resolvePrevious(rid) !== null, + `网关回的 response_id 必须自己能解析,实际 ${rid}`); + assert.equal(r1._bridge.replayed_messages, 0, "首轮无历史可重放"); + + const r2 = await post("/v1/openai_response/to/openai_response", { + model: "mock", + previous_response_id: rid, + input: [{ type: "message", role: "user", content: "第二轮问题" }], + }); + assert.equal(r2._bridge.replayed_messages, 2, + "第 2 轮应重放 2 条(上轮 user + assistant)"); + assert.ok(r2._bridge.session); + + const r3 = await post("/v1/openai_response/to/openai_response", { + model: "mock", + previous_response_id: r2.id, + input: [{ type: "message", role: "user", content: "第三轮问题" }], + }); + assert.equal(r3._bridge.replayed_messages, 4, + "第 3 轮应重放 4 条,历史必须持续累积而非恒为空"); + }); + + test("预热轮不进响应链", async () => { + const w = prewarm.buildWarmupRequest("sys", "mock"); + const rw = await post("/v1/anthropic/to/anthropic", w); + assert.ok(rw._bridge.warmup); + assert.equal(rw.usage.output_tokens, 0); + assert.ok(!String(rw.id ?? "").startsWith("resp_"), + "预热轮不应登记进 previous_response_id 响应链"); + }); +}); + +// --------------------------------------------------------------------------- +// TestCrossProtocolToolArgs(第三轮自查回归 #1:跨协议工具参数丢失) +// --------------------------------------------------------------------------- + +describe("TestCrossProtocolToolArgs", () => { + test("Chat→Anthropic 保留 tool_input", () => { + const payload = { model: "gpt-x", messages: [ + { role: "assistant", content: null, tool_calls: [ + { id: "call_1", type: "function", function: { + name: "search", arguments: '{"city": "天津", "n": 3}' } }] }, + ] }; + const req = new ChatAdapter().to_ir(payload, new Dropped()); + const b = req.messages[0].blocks[0]; + assert.deepEqual(b.tool_input, { city: "天津", n: 3 }, + "arguments 字符串必须解析成 dict 进 tool_input"); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + const tu = out.messages[0].content[0]; + assert.deepEqual(tu.input, { city: "天津", n: 3 }, + "Chat→Anthropic 的 tool_use.input 不得为空 {}"); + }); + + test("Anthropic→Chat 保留 arguments", () => { + const payload = { model: "claude-x", max_tokens: 100, messages: [ + { role: "assistant", content: [ + { type: "tool_use", id: "toolu_1", name: "search", + input: { q: "犀牛鸟" } }] }, + ] }; + const req = new AnthropicAdapter().to_ir(payload, new Dropped()); + const out = new ChatAdapter().from_ir(req, new Dropped()); + const args = out.messages[0].tool_calls[0].function.arguments; + assert.deepEqual(JSON.parse(args), { q: "犀牛鸟" }, + "Anthropic→Chat 的 arguments 不得退化成 '{}'"); + }); + + test("Responses→Anthropic 保留 tool_input", () => { + const payload = { model: "gpt-x", input: [ + { type: "function_call", name: "calc", call_id: "call_9", + arguments: '{"x": 1}' }] }; + const req = new ResponseAdapter().to_ir(payload, new Dropped()); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + assert.deepEqual(out.messages[0].content[0].input, { x: 1 }); + }); + + test("同协议往返字节无损", () => { + const raw = '{"a": 1, "b": [1,2]}'; // 带非常规空格 + const payload = { model: "gpt-x", messages: [ + { role: "assistant", content: null, tool_calls: [ + { id: "call_1", type: "function", function: { name: "f", arguments: raw } }] }, + ] }; + const req = new ChatAdapter().to_ir(payload, new Dropped()); + const out = new ChatAdapter().from_ir(req, new Dropped()); + assert.equal(out.messages[0].tool_calls[0].function.arguments, raw); + }); +}); + +// --------------------------------------------------------------------------- +// TestThinkingSignature(第三轮自查回归 #2:signature/redacted 往返) +// --------------------------------------------------------------------------- + +describe("TestThinkingSignature", () => { + test("thinking signature 往返保留", () => { + const payload = { model: "claude-x", max_tokens: 100, messages: [ + { role: "assistant", content: [ + { type: "thinking", thinking: "推理过程", signature: "sig_abc123" }, + { type: "text", text: "答案" }] }, + ] }; + const req = new AnthropicAdapter().to_ir(payload, new Dropped()); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + const blk = out.messages[0].content[0]; + assert.equal(blk.signature, "sig_abc123", "thinking 的 signature 往返必须保留"); + }); + + test("redacted_thinking 逐字节透传", () => { + const payload = { model: "claude-x", max_tokens: 100, messages: [ + { role: "assistant", content: [ + { type: "redacted_thinking", data: "enc_逐字节数据==" }] }, + ] }; + const req = new AnthropicAdapter().to_ir(payload, new Dropped()); + assert.equal(req.messages[0].blocks.length, 1, + "redacted_thinking 不得被判成无效块丢弃"); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + assert.deepEqual(out.messages[0].content[0], + { type: "redacted_thinking", data: "enc_逐字节数据==" }); + }); +}); + +// --------------------------------------------------------------------------- +// TestAssistantFromUpstream(状态层重放原料保留结构化块) +// --------------------------------------------------------------------------- + +describe("TestAssistantFromUpstream", () => { + test("anthropic 结构化块(thinking/text/tool_use)", () => { + const resp = { content: [ + { type: "thinking", thinking: "想", signature: "s1" }, + { type: "text", text: "答" }, + { type: "tool_use", id: "toolu_7", name: "search", input: { q: "x" } }] }; + const msg = assistantFromUpstream("anthropic", resp)!; + assert.deepEqual(msg.blocks.map((b) => b.kind), [ir.THINKING, ir.TEXT, ir.TOOL_USE]); + assert.equal(msg.blocks[0].extra.signature, "s1"); + assert.deepEqual(msg.blocks[2].tool_input, { q: "x" }); + }); + + test("chat tool_calls", () => { + const resp = { choices: [{ message: { + content: null, + tool_calls: [{ id: "call_3", type: "function", function: { + name: "f", arguments: '{"k": 2}' } }] } }] }; + const msg = assistantFromUpstream("openai_chat", resp)!; + assert.equal(msg.blocks.length, 1); + assert.equal(msg.blocks[0].kind, ir.TOOL_USE); + assert.deepEqual(msg.blocks[0].tool_input, { k: 2 }); + }); + + test("response function_call + reasoning", () => { + const resp = { output: [ + { type: "reasoning", summary: "想了一下", encrypted_content: "enc1" }, + { type: "function_call", name: "f", call_id: "call_5", arguments: '{"y": true}' }, + { type: "message", content: [{ type: "output_text", text: "好" }] }] }; + const msg = assistantFromUpstream("openai_response", resp)!; + assert.deepEqual(msg.blocks.map((b) => b.kind), [ir.THINKING, ir.TOOL_USE, ir.TEXT]); + assert.deepEqual(msg.blocks[1].tool_input, { y: true }); + }); + + test("空响应返回 null", () => { + assert.equal(assistantFromUpstream("anthropic", { content: [] }), null); + assert.equal(assistantFromUpstream("openai_chat", {}), null); + }); +}); + +// --------------------------------------------------------------------------- +// TestToolIdMap(工具 ID 双向持久映射) +// --------------------------------------------------------------------------- + +describe("TestToolIdMap", () => { + function makeStore() { + const store = new SessionStore(new SessionConfig(), ":memory:"); + return { store, idm: store.idmap }; + } + + test("同协议 outgoing 字节无损", () => { + const { store, idm } = makeStore(); + const canon = idm.incoming("s1", "toolu_abc", "anthropic"); + assert.equal(canon, "toolu_abc"); + assert.equal(idm.outgoing("s1", canon, "anthropic"), "toolu_abc", + "同协议 outgoing 必须原样返回 canonical"); + store.shutdown(); + }); + + test("跨协议铸造:call_ 前缀 + 稳定", () => { + const { store, idm } = makeStore(); + idm.incoming("s1", "toolu_abc", "anthropic"); + const e1 = idm.outgoing("s1", "toolu_abc", "openai_chat")!; + assert.ok(e1.startsWith("call_"), "Chat 侧铸造 ID 应用 call_ 前缀"); + const e2 = idm.outgoing("s1", "toolu_abc", "openai_chat"); + assert.equal(e1, e2, "同一会话内铸造结果必须稳定(前缀序列化不抖动)"); + store.shutdown(); + }); + + test("Anthropic→Chat→Anthropic 还原", () => { + const { store, idm } = makeStore(); + const canon = idm.incoming("s1", "toolu_orig", "anthropic"); + const chatExt = idm.outgoing("s1", canon, "openai_chat"); + const back = idm.incoming("s1", chatExt, "openai_chat"); + assert.equal(back, canon, "外部形式必须能反解回 canonical"); + assert.equal(idm.outgoing("s1", back, "anthropic"), "toolu_orig"); + store.shutdown(); + }); + + test("会话间隔离(并发分叉不串号)", () => { + const { store, idm } = makeStore(); + idm.incoming("s1", "toolu_abc", "anthropic"); + assert.equal(idm.incoming("s2", "toolu_abc", "anthropic"), "toolu_abc"); + const e1 = idm.outgoing("s1", "toolu_abc", "openai_chat"); + const e2 = idm.outgoing("s2", "toolu_abc", "openai_chat"); + assert.notEqual(e1, e2, "不同会话的铸造互不影响"); + store.shutdown(); + }); + + test("共享 db 与 size", () => { + const { store, idm } = makeStore(); + idm.incoming("s1", "call_1", "openai_chat"); + idm.outgoing("s1", "call_1", "anthropic"); + assert.equal(idm.size("s1"), 2); + store.shutdown(); + }); +}); + +// --------------------------------------------------------------------------- +// TestPropertyRoundTrip(property-based 往返,440 随机用例、固定种子可复现) +// --------------------------------------------------------------------------- + +describe("TestPropertyRoundTrip", () => { + const SEED = 20260909; + const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ中文测试字 "; + + function randText(rng: Rng, n = 40): string { + const len = rng.randint(1, n); + let s = ""; + for (let i = 0; i < len; i++) s += rng.choice(LETTERS.split("")); + return s; + } + + function randChatPayload(rng: Rng): ir.Json { + const msgs: ir.Json[] = [{ role: "system", content: randText(rng) }]; + const count = rng.randint(1, 6); + for (let i = 0; i < count; i++) { + const role = i % 2 === 0 ? "user" : "assistant"; + const m: ir.Json = { role, content: randText(rng) }; + if (role === "assistant" && rng.random() < 0.5) { + m.tool_calls = [{ + id: `call_${rng.randint(0, 999)}`, type: "function", + function: { name: "f", arguments: JSON.stringify( + { k: rng.randint(0, 100), s: randText(rng, 8) }) } }]; + } + msgs.push(m); + } + return { model: "gpt-x", messages: msgs, + tools: [{ type: "function", function: { + name: "f", description: "d", parameters: { type: "object" } } }], + [`x_rand_${rng.randint(0, 999)}`]: rng.random(), + logit_bias: { "1": 1 } }; + } + + function randAnthropicPayload(rng: Rng): ir.Json { + const msgs: ir.Json[] = []; + const count = rng.randint(1, 5); + for (let i = 0; i < count; i++) { + const role = i % 2 === 0 ? "user" : "assistant"; + const content: ir.Json[] = [{ type: "text", text: randText(rng) }]; + if (role === "assistant") { + if (rng.random() < 0.6) { + content.unshift({ type: "thinking", thinking: randText(rng), + signature: `sig_${rng.randint(0, 9999)}` }); + } + if (rng.random() < 0.5) { + content.push({ type: "tool_use", id: `toolu_${rng.randint(0, 999)}`, + name: "f", input: { v: rng.randint(0, 50) } }); + } + } + msgs.push({ role, content }); + } + return { model: "claude-x", max_tokens: 128, messages: msgs, + tools: [{ name: "f", description: "d", + input_schema: { type: "object" } }], + [`x_rand_${rng.randint(0, 999)}`]: 1, top_p: 0.9 }; + } + + function randResponsePayload(rng: Rng): ir.Json { + const items: ir.Json[] = []; + const count = rng.randint(1, 5); + for (let i = 0; i < count; i++) { + if (i % 2 === 0) { + items.push({ type: "message", role: "user", content: randText(rng) }); + } else { + items.push({ type: "message", role: "assistant", + content: [{ type: "output_text", text: randText(rng) }] }); + if (rng.random() < 0.5) { + items.push({ type: "function_call", name: "f", + call_id: `call_${rng.randint(0, 999)}`, + arguments: JSON.stringify({ v: rng.randint(0, 50) }) }); + } + } + } + return { model: "gpt-x", input: items, + [`x_rand_${rng.randint(0, 999)}`]: "x" }; + } + + test("性质:chat→anthropic(未知字段必降级 / 断点恒 [3,4] / 参数守恒)", () => { + const rng = new Rng(SEED); + const known = new Set(["model", "messages", "tools", "max_tokens", + "max_completion_tokens", "temperature", "stream", "system"]); + for (let c = 0; c < 120; c++) { + const p = randChatPayload(rng); + const d1 = new Dropped(); + const req = new ChatAdapter().to_ir(p, d1); + const droppedFields = new Set(d1.items.map((d) => d.field)); + for (const k of Object.keys(p)) { + if (!known.has(k)) { + assert.ok(droppedFields.has(k), `case ${c}: 未知字段 ${k} 被静默丢弃`); + } + } + const out = new AnthropicAdapter().from_ir(req, new Dropped(), new ir.SessionContext()); + const bps = countBreakpoints(out); + assert.ok(bps >= 3 && bps <= 4, `case ${c}: 断点数 ${bps} 越界`); + const src = p.messages.flatMap((m: ir.Json) => m.tool_calls ?? []) + .map((tc: ir.Json) => JSON.parse(tc.function.arguments)); + const got = out.messages.flatMap((m: ir.Json) => m.content) + .filter((b: ir.Json) => b.type === "tool_use") + .map((b: ir.Json) => b.input); + assert.deepEqual(src, got, `case ${c}: 工具参数跨协议变形`); + } + }); + + test("性质:anthropic→chat(未知字段必降级 / 参数守恒)", () => { + const rng = new Rng(SEED + 1); + const known = new Set(["model", "max_tokens", "temperature", "stream", + "system", "messages", "tools", "thinking", "tool_choice"]); + for (let c = 0; c < 120; c++) { + const p = randAnthropicPayload(rng); + const d1 = new Dropped(); + const req = new AnthropicAdapter().to_ir(p, d1); + const droppedFields = new Set(d1.items.map((d) => d.field)); + for (const k of Object.keys(p)) { + if (!known.has(k)) { + assert.ok(droppedFields.has(k), `case ${c}: 未知字段 ${k} 被静默丢弃`); + } + } + const out = new ChatAdapter().from_ir(req, new Dropped()); + const srcInputs = p.messages.flatMap((m: ir.Json) => m.content) + .filter((b: ir.Json) => b.type === "tool_use") + .map((b: ir.Json) => b.input); + const gotArgs = out.messages.flatMap((m: ir.Json) => m.tool_calls ?? []) + .map((tc: ir.Json) => JSON.parse(tc.function.arguments)); + assert.deepEqual(srcInputs, gotArgs, `case ${c}: Anthropic→Chat 参数变形`); + } + }); + + test("性质:thinking signature 有序守恒", () => { + const rng = new Rng(SEED + 2); + for (let c = 0; c < 80; c++) { + const p = randAnthropicPayload(rng); + const req = new AnthropicAdapter().to_ir(p, new Dropped()); + const out = new AnthropicAdapter().from_ir(req, new Dropped()); + const srcSigs = p.messages.flatMap((m: ir.Json) => m.content) + .filter((b: ir.Json) => b.type === "thinking") + .map((b: ir.Json) => b.signature); + const gotSigs = out.messages.flatMap((m: ir.Json) => m.content) + .filter((b: ir.Json) => b.type === "thinking") + .map((b: ir.Json) => b.signature); + assert.deepEqual(srcSigs, gotSigs, `case ${c}: thinking signature 往返丢失`); + } + }); + + test("性质:response→anthropic(未知字段必降级 / 参数守恒)", () => { + const rng = new Rng(SEED + 3); + for (let c = 0; c < 100; c++) { + const p = randResponsePayload(rng); + const d1 = new Dropped(); + const req = new ResponseAdapter().to_ir(p, d1); + const droppedFields = new Set(d1.items.map((d) => d.field)); + for (const k of Object.keys(p)) { + if (k !== "model" && k !== "input") { + assert.ok(droppedFields.has(k), `case ${c}: 未知字段 ${k} 被静默丢弃`); + } + } + const out = new AnthropicAdapter().from_ir(req, new Dropped(), new ir.SessionContext()); + const src = p.input.filter((it: ir.Json) => it.type === "function_call") + .map((it: ir.Json) => JSON.parse(it.arguments)); + const got = out.messages.flatMap((m: ir.Json) => m.content) + .filter((b: ir.Json) => b.type === "tool_use") + .map((b: ir.Json) => b.input); + assert.deepEqual(src, got, `case ${c}: Responses→Anthropic 参数变形`); + } + }); +}); + +// --------------------------------------------------------------------------- +// TestSseConversion(Anthropic SSE → Chat chunk 逐块转换) +// --------------------------------------------------------------------------- + +describe("TestSseConversion", () => { + function feedSeq(conv: AnthropicToChatStream, events: ir.Json[]): string[] { + const frames: string[] = []; + for (const ev of events) { + frames.push(...conv.feed(ev.type ?? null, ev)); + } + return frames; + } + + function parseFrame(frame: string): ir.Json { + assert.ok(frame.startsWith("data: ") && frame.endsWith("\n\n")); + return JSON.parse(frame.slice("data: ".length).trim()); + } + + test("SSE 分帧:注释行跳过 / 非 JSON 透传", () => { + const raw = [ + "event: message_start\n", + 'data: {"type":"message_start","message":{"id":"msg_1"}}\n', + "\n", + ": heartbeat-comment\n", + 'data: {"type":"ping"}\n', + "\n", + "data: 非JSON行\n", + "\n", + ]; + const events = [...parseSseLines(raw)]; + assert.equal(events.length, 3); + assert.deepEqual(events[0], ["message_start", + { type: "message_start", message: { id: "msg_1" } }]); + assert.deepEqual(events[1][1], { type: "ping" }); // 注释行被跳过 + assert.equal(events[2][1], "非JSON行"); // 非 JSON 原样透传 + }); + + test("SSE 分帧:尾部半帧容错丢弃", () => { + const raw = ['data: {"type":"message_stop"}\n', "\n", 'data: {"type":"truncat']; + const events = [...parseSseLines(raw)]; + assert.equal(events.length, 1); // 尾部半帧容错丢弃,不崩 + }); + + test("text_delta 即时下发", () => { + const conv = new AnthropicToChatStream(); + const frames = feedSeq(conv, [ + { type: "message_start", message: { + id: "msg_x", model: "claude-mock", + usage: { input_tokens: 100, cache_read_input_tokens: 80 } } }, + { type: "content_block_delta", index: 0, + delta: { type: "text_delta", text: "你" } }, + { type: "content_block_delta", index: 0, + delta: { type: "text_delta", text: "好" } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 7 } }, + { type: "message_stop" }, + ]); + // role + 2 文本帧 + finish + DONE:文本逐块即时下发,不等流尾 + assert.equal(frames.length, 5); + const first = parseFrame(frames[0]); + assert.deepEqual(first.choices[0].delta, { role: "assistant" }); + assert.equal(first.id, "msg_x"); + assert.equal(first.model, "claude-mock"); + assert.equal(first.object, "chat.completion.chunk"); + const texts = frames.slice(1, 3).map((f) => parseFrame(f).choices[0].delta.content ?? ""); + assert.equal(texts.join(""), "你好"); + const fin = parseFrame(frames[3]); + assert.equal(fin.choices[0].finish_reason, "stop"); + assert.equal(frames[4], "data: [DONE]\n\n"); + }); + + test("工具参数缓冲到块结束一次性发", () => { + const conv = new AnthropicToChatStream(); + const f1 = conv.feed("content_block_start", + { type: "content_block_start", index: 1, + content_block: { type: "tool_use", + id: "toolu_1", name: "get_weather" } }); + assert.deepEqual(f1, []); // 工具块开始不落帧 + const f2 = conv.feed("content_block_delta", + { type: "content_block_delta", index: 1, + delta: { type: "input_json_delta", + partial_json: '{"city": "天' } }); + const f3 = conv.feed("content_block_delta", + { type: "content_block_delta", index: 1, + delta: { type: "input_json_delta", + partial_json: '津"}' } }); + assert.deepEqual([...f2, ...f3], []); // 参数片段期间不落帧 + const f4 = conv.feed("content_block_stop", + { type: "content_block_stop", index: 1 }); + assert.equal(f4.length, 1); + const chunk = parseFrame(f4[0]); + const tc = chunk.choices[0].delta.tool_calls[0]; + assert.equal(tc.id, "toolu_1"); + assert.equal(tc.function.name, "get_weather"); + assert.equal(tc.function.arguments, '{"city": "天津"}'); + // 落库原料:工具参数被解析回 dict + const synth = conv.syntheticResponse(); + const toolBlocks = synth.content.filter((b: ir.Json) => b.type === "tool_use"); + assert.deepEqual(toolBlocks[0].input, { city: "天津" }); + }); + + test("stop_reason 映射", () => { + for (const [anthropicReason, chatReason] of [ + ["end_turn", "stop"], ["max_tokens", "length"], ["tool_use", "tool_calls"], + ] as const) { + const conv = new AnthropicToChatStream(); + const frames = conv.feed("message_delta", + { type: "message_delta", + delta: { stop_reason: anthropicReason }, + usage: { output_tokens: 3 } }); + const chunk = parseFrame(frames[0]); + assert.equal(chunk.choices[0].finish_reason, chatReason); + } + }); + + test("usage 流尾合并 + 落库复用", () => { + const conv = new AnthropicToChatStream(); + feedSeq(conv, [ + { type: "message_start", message: { + id: "msg_u", model: "m", + usage: { input_tokens: 5978, + cache_creation_input_tokens: 5978, + cache_read_input_tokens: 0 } } }, + { type: "content_block_delta", index: 0, + delta: { type: "text_delta", text: "答复" } }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 12 } }, + ]); + const u = usageFromAnthropic(conv.usage()); + assert.equal(u.input_tokens, 5978); + assert.equal(u.cache_creation_input_tokens, 5978); + assert.equal(u.output_tokens, 12); + const reply = assistantFromUpstream("anthropic", conv.syntheticResponse()); + assert.ok(reply !== null); + assert.equal(reply.blocks[0].text, "答复"); + }); + + test("上游错误帧", () => { + const conv = new AnthropicToChatStream(); + const frames = conv.feed("error", { type: "error", error: { + type: "overloaded_error", message: "Overloaded" } }); + const chunk = parseFrame(frames[0]); + assert.ok(String(chunk.choices[0].delta.content ?? "").includes("Overloaded")); + }); +}); + +// --------------------------------------------------------------------------- +// TestChatStreamCollector(chat←chat 直通流式旁路收集) +// --------------------------------------------------------------------------- + +describe("TestChatStreamCollector", () => { + test("文本与 usage 汇总", () => { + const c = new ChatStreamCollector(); + c.feedData(JSON.stringify({ choices: [{ delta: { role: "assistant", content: "你" } }] })); + c.feedData(JSON.stringify({ choices: [{ delta: { content: "好" } }] })); + c.feedData(JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 100, completion_tokens: 7 } })); + c.feedData("[DONE]"); + assert.equal(c.usage().prompt_tokens, 100); + const synth = c.syntheticResponse(); + assert.equal(synth.choices[0].message.content, "你好"); + // 复用既有落库/归一路径 + const reply = assistantFromUpstream("openai_chat", synth)!; + assert.equal(reply.blocks[0].text, "你好"); + const u = usageFromChat(synth.usage); + assert.equal(u.output_tokens, 7); + }); + + test("工具槽位拼接(Chat 流参数同样分段)", () => { + const c = new ChatStreamCollector(); + c.feedData(JSON.stringify({ choices: [{ delta: { tool_calls: [ + { index: 0, id: "call_1", type: "function", + function: { name: "get_weather", arguments: '{"ci' } }] } }] })); + c.feedData(JSON.stringify({ choices: [{ delta: { tool_calls: [ + { index: 0, function: { arguments: 'ty":"天津"}' } }] } }] })); + const synth = c.syntheticResponse(); + const tc = synth.choices[0].message.tool_calls[0]; + assert.equal(tc.id, "call_1"); + assert.equal(tc.function.arguments, '{"city":"天津"}'); + }); + + test("脏数据与空输入容错", () => { + const c = new ChatStreamCollector(); + c.feedData(""); // 空行 + c.feedData("非JSON"); // 容错跳过 + c.feedData("[DONE]"); + assert.deepEqual(c.usage(), {}); + const synth = c.syntheticResponse(); + assert.equal(synth.choices[0].message.content, ""); + }); +}); diff --git a/ts/tools/mock_backend.ts b/ts/tools/mock_backend.ts new file mode 100644 index 0000000..14a6189 --- /dev/null +++ b/ts/tools/mock_backend.ts @@ -0,0 +1,252 @@ +/** mock backend:离线顶替真实 Anthropic/OpenAI 端点,供网关跑通链路。 + * (TS 移植自 tools/mock_backend.py) + * + * 按**请求路径**返回对应协议的响应形状: + * /v1/messages -> Anthropic Messages + * /responses -> OpenAI Responses + * /chat/completions -> OpenAI Chat Completions + * + * 缓存行为为**有状态模拟**:按带 cache_control 的前缀内容哈希记账——首次见到某 + * 前缀报 cache_creation,再次见到同一前缀报 cache_read,供离线验证 + * 「预热 -> 命中」两步链路。不代表真实缓存行为(无 TTL、无最小长度门槛)。 + * + * 注意:前缀哈希只认 system / messages / input 里的文本块,且只有带 + * cache_control 的块才算前缀末尾——与 Anthropic 的语义一致,但比真实实现粗糙。 + */ + +import http from "node:http"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import type { Json } from "../src/ir/model.ts"; + +const BIND = { host: "127.0.0.1", port: 9100 }; + +// 已建缓存的前缀哈希集合(模拟 cache 命名空间) +const SEEN_PREFIXES = new Set(); + +function prefixHashUptoBreakpoint(payload: Json): [string | null, number] { + /** 找到最后一个带 cache_control 的块,返回 [前缀哈希, 前缀字符数]。 */ + const parts: string[] = []; + let lastMarked = -1; + + function walk(blocks: unknown[]): void { + for (const b of blocks) { + const isObj = b !== null && typeof b === "object"; + parts.push(isObj ? ((b as Json).text ?? "") : String(b)); + if (isObj && (b as Json).cache_control) lastMarked = parts.length - 1; + } + } + + const sys = payload.system; + if (typeof sys === "string") parts.push(sys); + else if (Array.isArray(sys)) walk(sys); + for (const m of payload.messages ?? []) { + const c = m?.content; + walk(Array.isArray(c) ? c : [{ text: String(c ?? "") }]); + } + // Responses 协议:条目在 input 里 + for (const item of payload.input ?? []) { + if (item?.type !== "message") continue; + const c = item.content; + walk(Array.isArray(c) ? c : [{ text: String(c ?? "") }]); + } + + if (lastMarked < 0) return [null, 0]; + const prefix = parts.slice(0, lastMarked + 1).join(""); + return [createHash("sha256").update(prefix, "utf-8").digest("hex"), prefix.length]; +} + +/** 按前缀哈希记账,返回 [cache_creation, cache_read]。 */ +function cacheTick(payload: Json): [number, number] { + const [ph, plen] = prefixHashUptoBreakpoint(payload); + if (ph === null) return [0, 0]; + if (SEEN_PREFIXES.has(ph)) return [0, plen]; // 再次见到同一前缀 -> 读命中 + SEEN_PREFIXES.add(ph); + return [plen, 0]; // 首次 -> 写缓存 +} + +/** 粗略的输入量(字符数),只为让 usage 各字段非零可看。 */ +function inputChars(payload: Json): number { + let n = 512; + const sys = payload.system; + if (typeof sys === "string") n += sys.length; + else if (Array.isArray(sys)) n += sys.reduce((k, b) => k + (b?.text?.length ?? 0), 0); + for (const m of payload.messages ?? []) { + const c = m?.content; + n += typeof c === "string" ? c.length + : (Array.isArray(c) ? c.reduce((k, b) => k + (b?.text?.length ?? 0), 0) : 0); + } + for (const item of payload.input ?? []) { + const c = item?.content; + n += typeof c === "string" ? c.length + : (Array.isArray(c) ? c.reduce((k, b) => k + (b?.text?.length ?? 0), 0) : 0); + } + return n; +} + +function isWarmupPayload(payload: Json): boolean { + for (const k of ["max_tokens", "max_output_tokens", "max_completion_tokens"]) { + try { + if (Number.parseInt(String(payload[k] ?? 1), 10) === 0) return true; + } catch { /* 继续看下一个字段 */ } + } + return false; +} + +// --------------------------------------------------------------------------- +// 三种协议各自的响应形状 +// --------------------------------------------------------------------------- + +function anthropicResponse(payload: Json): Json { + const [creation, read] = cacheTick(payload); + const usage = { input_tokens: inputChars(payload), + output_tokens: isWarmupPayload(payload) ? 0 : 12, + cache_creation_input_tokens: creation, + cache_read_input_tokens: read }; + if (isWarmupPayload(payload)) { + // max_tokens:0 预热:空 content + stop_reason=max_tokens(官方行为) + return { id: "msg_mock_warmup", type: "message", role: "assistant", + content: [], model: payload.model ?? "mock", + stop_reason: "max_tokens", usage }; + } + return { id: "msg_mock01", type: "message", role: "assistant", + content: [{ type: "text", text: "mock reply" }], + model: payload.model ?? "mock", stop_reason: "end_turn", usage }; +} + +function openaiResponseResponse(payload: Json): Json { + const [, read] = cacheTick(payload); + const usage = { input_tokens: inputChars(payload), + output_tokens: isWarmupPayload(payload) ? 0 : 12, + input_tokens_details: { cached_tokens: read }, + output_tokens_details: { reasoning_tokens: 0 } }; + if (isWarmupPayload(payload)) { + return { id: "resp_mock_warmup", object: "response", output: [], + model: payload.model ?? "mock", status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, usage }; + } + return { id: "resp_mock01", object: "response", + output: [{ type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "mock reply", + annotations: [] }] }], + model: payload.model ?? "mock", status: "completed", usage }; +} + +function chatResponse(payload: Json): Json { + const [, read] = cacheTick(payload); + const usage = { prompt_tokens: inputChars(payload), + completion_tokens: isWarmupPayload(payload) ? 0 : 12, + prompt_tokens_details: { cached_tokens: read } }; + const content = isWarmupPayload(payload) ? "" : "mock reply"; + return { id: "chatcmpl_mock01", object: "chat.completion", + choices: [{ index: 0, + message: { role: "assistant", content }, + finish_reason: isWarmupPayload(payload) ? "length" : "stop" }], + model: payload.model ?? "mock", usage }; +} + +function payloadResponse(payload: Json, path: string): Json { + /** 按请求路径挑响应形状(网关转发真实后端时路径是协议专属端点)。 */ + if (path.replace(/\/$/, "").endsWith("/responses")) return openaiResponseResponse(payload); + if (path.includes("chat/completions")) return chatResponse(payload); + return anthropicResponse(payload); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** 流式响应。Anthropic 路径发**完整官方事件序列**(message_start → + * content_block_* → message_delta → message_stop,带 usage 与缓存字段), + * 供网关 SSE 逐块转换的离线测试;chat 路径发 Chat 形状 chunk。 */ +async function* sseLines(payload: Json, path: string): AsyncGenerator { + if (path.replace(/\/$/, "").endsWith("/v1/messages") || path.endsWith("/messages")) { + const [creation, read] = cacheTick(payload); + const chunks = ["mock ", "stream ", "reply"]; + const msgStart = { type: "message_start", message: { + id: "msg_mock_stream", type: "message", role: "assistant", + model: payload.model ?? "mock", content: [], + usage: { input_tokens: inputChars(payload), output_tokens: 1, + cache_creation_input_tokens: creation, + cache_read_input_tokens: read } } }; + yield "event: message_start\n"; + yield `data: ${JSON.stringify(msgStart)}\n\n`; + yield "event: content_block_start\n"; + yield 'data: {"type":"content_block_start","index":0,' + + '"content_block":{"type":"text","text":""}}\n\n'; + for (const c of chunks) { + const delta = { type: "content_block_delta", index: 0, + delta: { type: "text_delta", text: c } }; + yield "event: content_block_delta\n"; + yield `data: ${JSON.stringify(delta)}\n\n`; + await sleep(10); + } + yield "event: content_block_stop\n"; + yield 'data: {"type":"content_block_stop","index":0}\n\n'; + yield "event: message_delta\n"; + yield 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},' + + '"usage":{"output_tokens":12}}\n\n'; + yield "event: message_stop\n"; + yield 'data: {"type":"message_stop"}\n\n'; + return; + } + if (path.includes("chat/completions")) { + const [, read] = cacheTick(payload); + const chunks = ["mock ", "stream ", "reply"]; + for (const [i, c] of chunks.entries()) { + const delta = i === 0 ? { role: "assistant", content: c } : { content: c }; + const frame = { id: "chatcmpl_mock_stream", object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: payload.model ?? "mock", + choices: [{ index: 0, delta, finish_reason: null }] }; + yield `data: ${JSON.stringify(frame)}\n\n`; + await sleep(10); + } + const fin = { id: "chatcmpl_mock_stream", object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: payload.model ?? "mock", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: inputChars(payload), + completion_tokens: 12, + prompt_tokens_details: { cached_tokens: read } } }; + yield `data: ${JSON.stringify(fin)}\n\n`; + yield "data: [DONE]\n\n"; + return; + } + for (const c of ["mock ", "stream ", "reply"]) { + yield `data: ${JSON.stringify({ type: "content_block_delta", + delta: { type: "text_delta", text: c } })}\n\n`; + await sleep(10); + } + yield 'data: {"type": "message_stop"}\n\n'; +} + +export async function mockHandler(req: http.IncomingMessage, + res: http.ServerResponse): Promise { + if (req.method !== "POST") { + res.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + await once(req, "end"); + const payload: Json = JSON.parse(Buffer.concat(chunks).toString("utf-8") || "{}"); + if (payload.stream) { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + for await (const line of sseLines(payload, req.url ?? "")) { + res.write(line); + } + res.end(); + return; + } + const body = Buffer.from(JSON.stringify(payloadResponse(payload, req.url ?? "")), + "utf-8"); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", + "Content-Length": String(body.length) }); + res.end(body); +} + +if (process.argv[1] && /mock_backend\.ts$/.test(process.argv[1])) { + http.createServer((req, res) => { void mockHandler(req, res); }) + .listen(BIND.port, BIND.host, () => { + console.log(`mock backend(ts) on http://${BIND.host}:${BIND.port}`); + }); +} diff --git a/ts/tools/smoke_e2e.ts b/ts/tools/smoke_e2e.ts new file mode 100644 index 0000000..6b0fa2b --- /dev/null +++ b/ts/tools/smoke_e2e.ts @@ -0,0 +1,254 @@ +/** 端到端冒烟:起 mock 后端 + 网关两个子进程,跑 7 组 25 项断言,退出码 0/1。 + * (TS 移植自 tools/smoke_e2e.py) + * + * 单元测试跑在进程内(替换 gw 单例),验的是逻辑; + * 这个脚本验的是**入口能不能真的起来**:`node src/gateway/server.ts` + * 和 `node tools/mock_backend.ts` 这两个真入口,以及跨进程 HTTP 链路。 + * + * 用法:npm run smoke(或 node tools/smoke_e2e.ts) + * 环境变量:NODE 指定解释器;PB_DB / PB_METRICS 由本脚本自动指向临时目录。 + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const NODE = process.execPath; +const GW_PORT = 8099; +const MOCK_PORT = 9100; + +const PROCS: ChildProcess[] = []; + +function waitPort(port: number, timeout = 15000): Promise { + const deadline = Date.now() + timeout; + return new Promise((resolve) => { + const tryOnce = () => { + const s = net.connect(port, "127.0.0.1"); + s.once("connect", () => { s.end(); resolve(true); }); + s.once("error", () => { + if (Date.now() < deadline) setTimeout(tryOnce, 200); + else resolve(false); + }); + }; + tryOnce(); + }); +} + +async function start(): Promise { + const tmp = mkdtempSync(join(tmpdir(), "pb-smoke-ts-")); + const env = { + ...process.env, + PB_DIRECT: "1", + PB_DB: join(tmp, "s.db"), + PB_METRICS: join(tmp, "m.jsonl"), + PB_PORT: String(GW_PORT), + }; + const mock = spawn(NODE, [join(ROOT, "tools", "mock_backend.ts")], + { cwd: ROOT, env, stdio: "ignore" }); + PROCS.push(mock); + if (!(await waitPort(MOCK_PORT))) throw new Error("mock 后端未能在 15s 内监听 9100"); + const gw = spawn(NODE, [join(ROOT, "src", "gateway", "server.ts")], + { cwd: ROOT, env, stdio: "ignore" }); + PROCS.push(gw); + if (!(await waitPort(GW_PORT))) throw new Error(`网关未能在 15s 内监听 ${GW_PORT}`); + return tmp; +} + +function stop(tmp: string): void { + for (const p of PROCS) p.kill(); + // 给子进程一点退出时间再清目录(异步冒烟里允许宽松处理) + setTimeout(() => rmSync(tmp, { recursive: true, force: true }), 300).unref(); +} + +async function post(path: string, payload: unknown): Promise { + const resp = await fetch(`http://127.0.0.1:${GW_PORT}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = new Error(`HTTP ${resp.status}`) as Error & { status: number }; + err.status = resp.status; + throw err; + } + return resp.json(); +} + +/** 流式 POST:逐帧收 SSE,返回 [data 帧列表, 各帧到达时间列表]。 + * 到达时间用来证明网关是**逐块下发**而非整读后再吐。 */ +async function postStream(path: string, payload: unknown): Promise<[string[], number[]]> { + const resp = await fetch(`http://127.0.0.1:${GW_PORT}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const frames: string[] = []; + const times: number[] = []; + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line.startsWith("data:")) { + frames.push(line.slice("data:".length).trim()); + times.push(Date.now()); + } + } + } + return [frames, times]; +} + +const CHECKS: [string, boolean, string][] = []; +function check(name: string, ok: boolean, detail = ""): void { + CHECKS.push([name, ok, detail]); + console.log(` [${ok ? "PASS" : "FAIL"}] ${name}` + (detail ? ` ${detail}` : "")); +} + +function tryJson(s: string): any | null { + try { return JSON.parse(s); } catch { return null; } +} + +async function main(): Promise { + console.log(`smoke(ts): mock=127.0.0.1:${MOCK_PORT} gateway=127.0.0.1:${GW_PORT}`); + const tmp = await start(); + try { + console.log("\n1) openai_chat -> openai_chat(同协议直通)"); + let r = await post("/v1/openai_chat/to/openai_chat", + { model: "mock", messages: [{ role: "user", content: "hi" }] }); + check("usage 归一有值", (r.usage?.prompt_tokens ?? 0) > 0, JSON.stringify(r.usage)); + check("无静默丢弃", !r._bridge.dropped?.length, JSON.stringify(r._bridge.dropped)); + + console.log("\n2) openai_response -> openai_chat(跨协议)"); + r = await post("/v1/openai_response/to/openai_chat", + { model: "mock", input: [{ type: "message", role: "user", content: "hi" }] }); + check("跨协议转换成功", r.choices !== undefined || r.id !== undefined); + + console.log("\n3) openai_response -> openai_response(多轮 previous_response_id)"); + const r1 = await post("/v1/openai_response/to/openai_response", + { model: "mock", input: [{ type: "message", role: "user", content: "第一轮" }] }); + check("首轮无历史可重放", r1._bridge.replayed_messages === 0, + `replayed=${r1._bridge.replayed_messages}`); + const r2 = await post("/v1/openai_response/to/openai_response", + { model: "mock", previous_response_id: r1.id, + input: [{ type: "message", role: "user", content: "第二轮" }] }); + check("第 2 轮重放 2 条", r2._bridge.replayed_messages === 2, + `replayed=${r2._bridge.replayed_messages}`); + const r3 = await post("/v1/openai_response/to/openai_response", + { model: "mock", previous_response_id: r2.id, + input: [{ type: "message", role: "user", content: "第三轮" }] }); + check("第 3 轮重放 4 条(历史持续累积)", r3._bridge.replayed_messages === 4, + `replayed=${r3._bridge.replayed_messages}`); + + console.log("\n4) anthropic -> anthropic(预热 max_tokens:0 + 冲突拦截)"); + const warm = { model: "mock", max_tokens: 0, + system: [{ type: "text", text: "s".repeat(30), + cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: "warmup" }] }; + r = await post("/v1/anthropic/to/anthropic", warm); + check("预热零输出计费", (r.usage?.output_tokens ?? -1) === 0, JSON.stringify(r.usage)); + check("预热标记为 warmup 轮", r._bridge.warmup === true); + check("预热写了缓存", (r.usage?.cache_creation_input_tokens ?? 0) > 0, + JSON.stringify(r.usage)); + try { + await post("/v1/anthropic/to/anthropic", + { ...warm, thinking: { type: "enabled", budget_tokens: 1024 } }); + check("thinking 与预热冲突被拦截", false, "请求通过了,未拦截"); + } catch (e) { + check("thinking 与预热冲突被拦截", (e as { status?: number }).status === 400, + `HTTP ${(e as { status?: number }).status}`); + } + + console.log("\n5) 非法路由 / 未知协议(必须回 400,不能掐断连接)"); + try { + await post("/v1/openai_chat/to/does_not_exist", { model: "mock", messages: [] }); + check("未知 target 回 400", false, "请求通过了"); + } catch (e) { + check("未知 target 回 400", (e as { status?: number }).status === 400, + `HTTP ${(e as { status?: number }).status}`); + } + + console.log("\n6) SSE 逐块流式(openai_chat 客户端 ← anthropic 上游)"); + const [frames, times] = await postStream("/v1/openai_chat/to/anthropic", + { model: "mock", stream: true, + messages: [{ role: "user", content: "hi" }] }); + const chunks = frames.filter((f) => f !== "[DONE]").map(tryJson).filter(Boolean); + check("收到多帧而非一整块", frames.length >= 5, `frames=${frames.length}`); + check("首帧是 role 帧", + chunks.length > 0 && chunks[0].choices[0].delta.role === "assistant", + frames[0]?.slice(0, 80) ?? "无帧"); + const text = chunks.map((c) => c.choices[0].delta.content ?? "").join(""); + check("delta 文本拼合完整", text === "mock stream reply", JSON.stringify(text)); + check("有 finish_reason=stop 帧", + chunks.some((c) => c.choices[0].finish_reason === "stop")); + check("以 [DONE] 收尾", frames.length > 0 && frames[frames.length - 1] === "[DONE]", + frames[frames.length - 1]?.slice(0, 40) ?? "无帧"); + // mock 每 chunk 间隔 10ms——若网关整读再吐,首尾帧会几乎同时到达 + const spread = times.length >= 2 ? (times[times.length - 1] - times[0]) / 1000 : 0; + check("帧是逐块到达的(非整读后补吐)", spread >= 0.015, + `首尾间隔 ${(spread * 1000).toFixed(1)}ms`); + try { + const lines = readFileSync(join(tmp, "m.jsonl"), "utf-8").trim().split("\n"); + const last = JSON.parse(lines[lines.length - 1]); + check("流式轮照常进埋点", + last.kind === "normal" && (last.input_tokens ?? 0) > 0, + JSON.stringify(last).slice(0, 120)); + } catch (e) { + check("流式轮照常进埋点", false, String(e)); + } + + console.log("\n7) SSE 直通流式(同协议透传)+ 未实现方向显式 501"); + // 7a anthropic -> anthropic:字节透传,客户端收到原样 Anthropic 事件 + const [fa, ta] = await postStream("/v1/anthropic/to/anthropic", + { model: "mock", stream: true, max_tokens: 64, + messages: [{ role: "user", content: "hi" }] }); + const deltas = fa.map(tryJson).filter(Boolean) + .filter((d) => d.type === "content_block_delta") + .map((d) => d.delta?.text ?? ""); + check("anthropic 透传文本拼合完整", deltas.join("") === "mock stream reply", + JSON.stringify(deltas.join(""))); + check("anthropic 透传收到 message_stop", fa.some((f) => f.includes("message_stop"))); + const spreadA = ta.length >= 2 ? (ta[ta.length - 1] - ta[0]) / 1000 : 0; + check("anthropic 透传逐块到达", spreadA >= 0.015, + `首尾间隔 ${(spreadA * 1000).toFixed(1)}ms`); + // 7b chat -> chat:字节透传,客户端收到原样 Chat chunk + const [fb, tb] = await postStream("/v1/openai_chat/to/openai_chat", + { model: "mock", stream: true, + messages: [{ role: "user", content: "hi" }] }); + const chunksB = fb.filter((f) => f !== "[DONE]").map(tryJson).filter(Boolean); + const textB = chunksB.map((c) => c.choices[0].delta.content ?? "").join(""); + check("chat 透传文本拼合完整", textB === "mock stream reply", JSON.stringify(textB)); + check("chat 透传以 [DONE] 收尾", fb.length > 0 && fb[fb.length - 1] === "[DONE]"); + const spreadB = tb.length >= 2 ? (tb[tb.length - 1] - tb[0]) / 1000 : 0; + check("chat 透传逐块到达", spreadB >= 0.015, + `首尾间隔 ${(spreadB * 1000).toFixed(1)}ms`); + // 7c 未实现的流式方向:显式 501(此前是把 SSE 当 JSON 解析炸成 502) + try { + await post("/v1/openai_response/to/anthropic", + { model: "mock", stream: true, + input: [{ type: "message", role: "user", content: "hi" }] }); + check("未实现流式方向显式 501", false, "请求通过了"); + } catch (e) { + check("未实现流式方向显式 501", (e as { status?: number }).status === 501, + `HTTP ${(e as { status?: number }).status}`); + } + } finally { + stop(tmp); + } + + const failed = CHECKS.filter(([, ok]) => !ok).map(([n]) => n); + console.log(`\n${CHECKS.length - failed.length}/${CHECKS.length} 通过`); + if (failed.length) console.log("失败项:" + failed.join("、")); + return failed.length ? 1 : 0; +} + +process.exitCode = await main(); diff --git a/ts/tsconfig.json b/ts/tsconfig.json new file mode 100644 index 0000000..e58b1d0 --- /dev/null +++ b/ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "tools/**/*.ts"] +}