From 96f38b84b296830c22d57d23c82433c95e490f50 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:29:52 +0800 Subject: [PATCH 01/62] docs: add audit remediation design (6-phase fix plan) Captures the full fix plan from the security/code-quality/UX audit: Phase 0 security hardening, Phase 1 packages/llm-core extraction, Phase 2 streaming/routing correctness, Phase 3 billing accounting, Phase 4 cheap-high-value UX, Phase 5 large product items. Co-Authored-By: Claude Fable 5 --- .../specs/2026-06-10-routebox-fixes-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-routebox-fixes-design.md diff --git a/docs/superpowers/specs/2026-06-10-routebox-fixes-design.md b/docs/superpowers/specs/2026-06-10-routebox-fixes-design.md new file mode 100644 index 0000000..b7cc15b --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-routebox-fixes-design.md @@ -0,0 +1,120 @@ +# RouteBox 修复计划 — 设计文档 + +**日期:** 2026-06-10 +**范围:** 全量修复(安全 + 代码质量 + UX)+ `packages/llm-core` 共享包重构 +**来源:** 三维度只读审计(安全 / 代码质量 / UX) + +## 背景与核心结论 + +RouteBox 是一个 macOS 菜单栏应用(Tauri + React),内置一个本地 LLM API 代理网关(`apps/gateway`),并有一个云端网关(`apps/cloud-gateway`,含 admin 面板、积分计费、key 池、JWT 鉴权)。 + +审计的核心发现:**云端网关质量明显好于本地网关,大量 bug 与安全问题源于两端代码复制后只修了云端一份**。本地网关缺少云端已有的熔断恢复、流式空闲超时、客户端断连传播等修复。因此本计划的地基是抽出共享包 `packages/llm-core`,让修复只做一遍。 + +## 组织方式:6 个阶段(方案 A) + +按"先止血、再固本、后增益"排序。每个阶段是可独立合并、可独立验证的单元(建议每阶段一个 PR)。 + +- **依赖关系:** Phase 0 无依赖,可最先合并;Phase 2/3 依赖 Phase 1;Phase 4/5 独立于其他阶段。 +- **测试策略:** 高风险路径(流式 / 计费 / 熔断)采用 TDD——每个 bug 先写一个会失败的复现测试,再修到绿。SSRF / 绑定地址 / UX 文案类用脚本或手动验证。 +- **实现节奏:** 本设计一次性写全 6 阶段;实现时逐个阶段完整修复后再进入下一阶段。 + +--- + +## Phase 0 — 安全止血(无依赖,最先合并) + +每项小且独立,一个 PR 内多个 commit。 + +| # | 改动 | 文件 | 做法 | +|---|------|------|------| +| C1 | 网关绑定 loopback | `apps/gateway/src/index.ts:127` | `export default` 加 `hostname: "127.0.0.1"` | +| C2a | token 不打印明文 | `apps/gateway/src/lib/auth.ts:29` | 改为掩码前缀,或 gate 在 debug flag 后 | +| C2b | drain 网关 stdout | `apps/desktop/src-tauri/src/commands.rs:269` | 读取/丢弃 stdout pipe,避免泄露 + 缓冲死锁 | +| C2c | token 不明文存 DB | `apps/gateway/src/lib/auth.ts:20` | 优先从 keychain 经 env 注入,去掉明文 settings 回退(或加密) | +| C3 | 常量时间比较 | `apps/gateway/src/lib/auth.ts:32` | `crypto.timingSafeEqual` + 长度守卫 | +| H2-sec | SSRF 白名单 | `apps/gateway/src/routes/api.ts:198`, `apps/gateway/src/lib/local-providers.ts:50` | baseUrl 限 loopback/RFC1918,拒绝 link-local/元数据段(169.254.0.0/16 等),probe/forward 禁重定向 | +| H3-sec | CORS 收紧 | `apps/cloud-gateway/src/index.ts:48` | no-origin 不返回 `"*"`,改为回显请求自身值或不设 CORS | +| H4-sec | Tauri entitlement | `apps/desktop/src-tauri/Entitlements.plist` | 评估开启 App Sandbox;至少收窄并注明 `network.server` 的必要性 | +| M4-sec | provider key 加密存储 | `apps/cloud-gateway/src/lib/provider-config.ts:55`, `apps/gateway/src/lib/db.ts:48` | AES-GCM 静态加密,运行时内存解密 | + +**主密钥策略:** 云端用环境变量注入的 secret(与现有 JWT secret 同源管理),本地用 macOS keychain。 + +**验收:** 局域网扫描确认网关仅监听 127.0.0.1;启动日志不含完整 token;DB 中 token / provider key 为密文;SSRF 测试用例(指向 169.254.169.254、内网地址)被拒绝。 + +--- + +## Phase 1 — 抽 `packages/llm-core`(地基,纯迁移不改行为) + +把两端复制漂移的约 600–800 行收敛到一个 pnpm workspace 包: + +- **`registry`** — `PROVIDER_REGISTRY`,以 `apps/gateway/src/lib/providers.ts:132` 为基准,合并 `apps/cloud-gateway/src/lib/key-pool.ts:31` 的分叉前缀差异。 +- **`adapters`** — Anthropic 请求适配,采用 gateway 的完整版 `apps/gateway/src/lib/providers.ts:425`(修复云端删减版静默丢弃 tool calls 与 image content 的问题)。 +- **`sse`** — `anthropicStreamToOpenAI` / `openaiStreamPassthrough`,以云端带修复的版本为基线。 +- **`pricing`** — `MODEL_PRICING` + `calculateCost` / `pricingFor`,统一两端数字。 + +**验收:** 两端改 import 后所有现有测试通过,行为零变化。这是纯结构迁移——所有 bug 留到 Phase 2/3 修,避免迁移与修复混在一起难以评审。 + +--- + +## Phase 2 — 流式/路由正确性(依赖 Phase 1,TDD) + +每项先写复现测试再修,改在 `llm-core` 里一次惠及两端。 + +- **H1** fallback route bug — `apps/gateway/src/routes/proxy.ts:549` catch 重试成功路径补 `Object.assign(route, { provider, model, isFallback: true })`,删除误导的 `retriedProvider` 变量。当前 bug:重试成功后仍读原失败 provider,导致流式转换器选错格式分支、计费记错模型、响应头撒谎。 +- **H2** 流式超时杀健康长流 — `apps/gateway/src/routes/proxy.ts:89` 与 `apps/cloud-gateway/src/routes/proxy.ts:852`:改为"首字节/响应头超时 + 流空闲超时",流开始后清除总超时计时器。 +- **H3** provider 永不恢复 — `apps/gateway/src/lib/metrics.ts:375`:`failStreak >= 3` 判死后 router 不再选它故永无恢复机会。加恢复超时(距 `lastFailure` N 秒后视为恢复)或周期性后台重探,镜像云端 `circuit-breaker.ts`。 +- **M8(并入 H3)** — `apps/gateway/src/routes/proxy.ts:536`:区分 timeout/abort 与连接拒绝,慢但健康的 provider 不应被三振判死。 +- **H4** 断连不取消上游 + 溢出 enqueue 崩溃 — `apps/gateway/src/routes/proxy.ts:176,323`:移植云端 guarded `push()`、`callOnDone` once-latch、客户端 abort 传播(`c.req.raw.signal`);溢出 `controller.error()` 后正确终止,不再在 errored controller 上 enqueue。 +- **M4** fallback 4xx 当成功 — `apps/gateway/src/routes/proxy.ts:585`:改为仅 `retryRes.ok` 才继续,否则返回上游错误状态。 + +--- + +## Phase 3 — 计费/账务正确性(TDD) + +- **M1** fallback 计价错 — `apps/cloud-gateway/src/routes/proxy.ts:786`:按实际服务模型(`scored._scoredModelId`)重新解析价格,metrics 也记录实际服务模型。 +- **M6** 充值幂等竞态 — `apps/cloud-gateway/src/lib/credits.ts:100`:为 `transactions.payment_ref` 加唯一(部分)索引;bonus 改用真正的 `idempotency_key` 列替代 `description LIKE` 匹配。 +- **M2** 迁移锁失效 — `apps/cloud-gateway/src/lib/db-cloud.ts:81`:`pg_try_advisory_lock` 是会话级但跑在连接池上;改用 `sql.begin(...)` + `pg_advisory_xact_lock`,或使用保留连接。 +- **M5** 指标 label 无界增长 — `apps/cloud-gateway/src/routes/proxy.ts:902`:只用 registry 校验过的 model id 作 label(否则归为 `"other"`),并转义 label 值防 Prometheus 输出污染。 +- **L5** 配额扣减副作用 — `apps/cloud-gateway/src/lib/quota.ts:66`:provider 返回 4xx 时退还已扣配额。 +- **M7-code** getStats 读时改基线 — `apps/gateway/src/lib/metrics.ts:316`:`prevRequests/prevTokens/prevCost` 每次调用被覆盖,多 WS 客户端时 delta 被打乱;改为对固定时间窗从 DB 计算 delta。 + +--- + +## Phase 4 — 便宜高收益 UX + +- **H1-ux** 引导文案指错 tab — `apps/desktop/src/components/Onboarding.tsx:121`、`apps/desktop/src/components/Settings.tsx:580`:文案 "Activity" → "Account";Settings 按钮接已有的 `onGoToAccount` 实现真跳转(当前只 `onClose()`)。 +- **H2-ux** 删除二次确认 + toast — `apps/desktop/src/components/AccountPage.tsx:80`、`apps/desktop/src/components/ProviderKeyManager.tsx:412`:加内联两步确认("Delete? / Yes"),错误经 toast 系统呈现(当前单击 5px 图标即不可逆吊销,失败仅 `console.warn`)。 +- **H5-ux** 网关失败恢复入口 — `apps/desktop/src/components/HeroSection.tsx:105`:失败状态行可点击重试,或渲染阻断式 "Gateway failed — Retry / Open Settings" 卡片。 +- **M5-ux** 余额轮询合并 + 可见性暂停 — `apps/desktop/src/App.tsx:204`(30s)与 `apps/desktop/src/hooks/useCloudAuth.ts:196`(10s)重复轮询且不在 `document.hidden` 暂停;合并为单一 hook 并在 `visibilitychange` 暂停。 +- **M7-ux** 价格文案统一 — `apps/cloud-gateway/admin.html:236` 显示 $9.99,落地页/App 显示 $9.90;统一为正确值。 +- **M6-ux** ProviderKeyManager 启动期自动重试 — `apps/desktop/src/components/ProviderKeyManager.tsx:31`:registry fetch 失败当前被静默吞掉;`gatewayState === "starting"` 时自动重试,并区分"网关启动中"与"网关不可达"。 +- **L6-ux** Web Search key 保存失败提示 — `apps/desktop/src/components/Settings.tsx:296`:`catch {}` 改为呈现错误。 +- **L4-ux** Activity 搜索扩展 — `apps/desktop/src/components/ActivityPage.tsx:34`:除 model 外也匹配 provider 与 status。 + +--- + +## Phase 5 — 大型产品项 + +- **托盘状态指示** — `apps/desktop/src-tauri/src/tray.rs:14`:运行/停止/失败图标切换 + 动态 tooltip + Start/Stop Gateway + Copy Endpoint 菜单项;经 Tauri event 从前端驱动状态变化。同时修 `tray.rs:15` 的 `default_window_icon().unwrap()` 启动期潜在 panic。 +- **Admin 移动端适配** — `apps/cloud-gateway/admin.html:23`:加 media query / 响应式侧栏 / 模态自适应(当前 0 个 media query,手机上完全溢出)。**同时消除 `admin.html` 与 `admin/index.html` 的字节级重复**(改为单一来源,见 `apps/cloud-gateway/src/lib/admin-page.ts:7`),`landing.html` 与 `apps/landing/index.html` 同理。 +- **Admin 登录表单语义(M1-ux)** — `apps/cloud-gateway/admin.html:119`:包 `
`、加 `autocomplete="email"/"current-password"`、提交时禁用按钮防双提交、加登出入口(当前 JWT 存 localStorage 直到过期)。顺带修分页可翻过尾页(`admin.html:971` 等,Next 到尾页应禁用)。 +- **网页注册/购买页(轻量版)** — `apps/landing/index.html:565`:当前 "Get Pro/Max" CTA 指向 `api.routebox.dev`(即落地页自身),形成死循环。**本次只做轻量版**:CTA 改指下载锚点 + 明确文案("下载 App,在 Account 标签内升级")。完整 web 注册/checkout 流程另开独立 spec。 + +**全局清理:** 删除仓库内散落的 macOS `._*` AppleDouble 垃圾文件并加入 `.gitignore`。 + +--- + +## 非目标(本计划不做) + +- 完整的 web 注册 / checkout 流程(Phase 5 仅做轻量 CTA 修复;完整流程另开 spec)。 +- 桌面端 i18n / 中文支持(L3-ux),除非另行确认目标受众。 +- JWT 刷新令牌 / 撤销列表(M1-sec):当前 24h TTL + 每请求查 status 已可接受,列为 backlog。 +- 无障碍/对比度全面整改(L1-ux / L2-ux):列为 backlog。 +- Rust / 控制平面(`routes/api.ts`、`routes/admin.ts`)的全面测试补齐:列为 backlog。 + +## 验证总览 + +- **Phase 0:** 端口绑定扫描、日志检查、DB 密文检查、SSRF 拒绝用例。 +- **Phase 1:** 两端现有测试全绿,行为零差异。 +- **Phase 2/3:** 每个 bug 配一个先失败后通过的复现测试(TDD);流式用 mock provider 验证长流不被杀、断连取消上游、溢出不崩。 +- **Phase 4:** 前端交互手动 + 现有 UI 测试;文案改动 grep 验证。 +- **Phase 5:** 托盘状态在 Tauri 内手动验证;admin 在移动视口手动验证;CTA 链接检查。 From 188274a14167987b99ff6509bc8b6063c8582bf4 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:36:25 +0800 Subject: [PATCH 02/62] docs: add Phase 0 security hardening implementation plan 12 bite-sized TDD tasks covering C1 loopback bind, C2a/b/c token hygiene, C3 timing-safe compare, H2 SSRF allowlist, H3 CORS, H4 entitlements, M4 AES-256-GCM at-rest encryption for provider keys (both gateways) + keychain-provisioned ROUTEBOX_DB_KEY. Co-Authored-By: Claude Fable 5 --- .../2026-06-10-routebox-phase0-security.md | 1212 +++++++++++++++++ 1 file changed, 1212 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-routebox-phase0-security.md diff --git a/docs/superpowers/plans/2026-06-10-routebox-phase0-security.md b/docs/superpowers/plans/2026-06-10-routebox-phase0-security.md new file mode 100644 index 0000000..2de2e45 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-routebox-phase0-security.md @@ -0,0 +1,1212 @@ +# RouteBox Phase 0 — 安全止血 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 关闭审计发现的 Critical/High 安全缺口,使本地网关只监听 loopback、停止泄露/明文存储凭据、阻断 SSRF,并对落地存储的 provider key 做静态加密。 + +**Architecture:** 改动集中在 `apps/gateway`(本地网关)、`apps/cloud-gateway`(云端)、`apps/desktop/src-tauri`(Tauri 宿主)。新增两个小工具模块(网关 `secrets.ts` 做 AES-256-GCM、`ssrf.ts` 做地址白名单),其余为窄改动。加密主密钥:网关从 `ROUTEBOX_DB_KEY` 环境变量读取(由 Tauri 从 keychain 注入),云端从 `PROVIDER_KEY_ENCRYPTION_KEY` 读取。两端的解密对旧的明文值做向后兼容透传,下次写入时自动升级为密文。 + +**Tech Stack:** TypeScript + Bun(`bun test` 为测试运行器,`*.test.ts` 同目录)、Hono、bun:sqlite、postgres.js、Rust(Tauri 2,`keyring` crate)。 + +--- + +## File Structure + +| 文件 | 责任 | 操作 | +|------|------|------| +| `apps/gateway/src/index.ts` | 网关入口/监听 | Modify(绑定 127.0.0.1) | +| `apps/gateway/src/lib/auth.ts` | token 解析/校验 | Modify(timingSafeEqual、掩码日志、加密 token) | +| `apps/gateway/src/lib/secrets.ts` | AES-256-GCM 加解密 | Create | +| `apps/gateway/src/lib/secrets.test.ts` | secrets 单测 | Create | +| `apps/gateway/src/lib/ssrf.ts` | 本地地址白名单校验 | Create | +| `apps/gateway/src/lib/ssrf.test.ts` | ssrf 单测 | Create | +| `apps/gateway/src/lib/db.ts` | SQLite provider key 读写 | Modify(读写处加解密) | +| `apps/gateway/src/lib/local-providers.ts` | 本地 provider 探测/转发 | Modify(probe 前做 SSRF 校验、禁重定向) | +| `apps/cloud-gateway/src/index.ts` | 云端入口/CORS | Modify(no-origin 不返回 `*`) | +| `apps/cloud-gateway/src/lib/crypto.ts` | 云端加解密工具 | Modify(新增 AES-256-GCM) | +| `apps/cloud-gateway/src/lib/crypto.test.ts` | 云端 crypto 单测 | Create | +| `apps/cloud-gateway/src/lib/env.ts` | 环境变量校验 | Modify(校验加密密钥) | +| `apps/cloud-gateway/src/lib/provider-config.ts` | provider key CRUD | Modify(读写处加解密) | +| `apps/desktop/src-tauri/src/keychain.rs` | keychain 封装 | Modify(新增 DB key 存取) | +| `apps/desktop/src-tauri/src/commands.rs` | 网关进程启动 | Modify(注入 DB key、丢弃 stdout) | +| `apps/desktop/src-tauri/Entitlements.plist` | macOS 权限 | Modify(注释说明 + 收窄评估) | + +**前置说明(执行者必读):** +- 测试运行:`cd apps/gateway && bun test ` 或 `cd apps/cloud-gateway && bun test `。 +- 加密格式约定(两端一致):密文字符串形如 `enc:v1:::`,其中 IV 12 字节、GCM tag 16 字节、AES-256-GCM。 +- 主密钥为 64 个十六进制字符(= 32 字节)。 +- **向后兼容:** 解密遇到不以 `enc:v1:` 开头的值时,视为旧明文直接返回(已有库里的明文 key 不会损坏),下次写入时自动加密。 +- 提交前请先 `find . -path ./node_modules -prune -o -name '._*' -delete` 清掉外置盘 AppleDouble 垃圾,避免污染提交。 + +--- + +## Task 1: 网关绑定 loopback(C1) + +**Files:** +- Modify: `apps/gateway/src/index.ts:127-131` + +- [ ] **Step 1: 修改默认导出,显式绑定 127.0.0.1** + +将文件末尾的默认导出: + +```ts +export default { + port, + fetch: app.fetch, + websocket, +}; +``` + +改为: + +```ts +export default { + port, + hostname: "127.0.0.1", // C1: 仅监听 loopback,禁止局域网访问本地代理 + fetch: app.fetch, + websocket, +}; +``` + +- [ ] **Step 2: 手动验证仅监听 loopback** + +Run: +```bash +cd apps/gateway && (ROUTEBOX_TOKEN=test bun run src/index.ts &) ; sleep 2 ; \ + lsof -nP -iTCP:3001 -sTCP:LISTEN ; \ + pkill -f "bun run src/index.ts" +``` +Expected: LISTEN 行地址为 `127.0.0.1:3001`(而非 `*:3001` 或 `0.0.0.0:3001`)。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/gateway/src/index.ts +git commit -m "fix(gateway): bind to 127.0.0.1 to prevent LAN exposure (C1)" +``` + +--- + +## Task 2: token 常量时间比较(C3) + +**Files:** +- Modify: `apps/gateway/src/lib/auth.ts:31-33` +- Test: `apps/gateway/src/lib/auth.test.ts` (Create) + +- [ ] **Step 1: 写失败测试** + +Create `apps/gateway/src/lib/auth.test.ts`: + +```ts +import { test, expect } from "bun:test"; + +// 在导入被测模块前设置一个固定 token,使 resolveToken 走 env 分支 +process.env.ROUTEBOX_TOKEN = "rb_testtoken_constant_time"; + +import { verifyToken } from "./auth"; + +test("verifyToken accepts the correct token", () => { + expect(verifyToken("rb_testtoken_constant_time")).toBe(true); +}); + +test("verifyToken rejects a wrong token of equal length", () => { + expect(verifyToken("rb_testtoken_constant_XXXX")).toBe(false); +}); + +test("verifyToken rejects a token of different length without throwing", () => { + expect(verifyToken("short")).toBe(false); + expect(verifyToken("")).toBe(false); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd apps/gateway && bun test src/lib/auth.test.ts` +Expected: 当前 `verifyToken` 用 `===` 也会让前两条通过,但本步骤目的是锁定行为;若失败应为模块加载相关错误。先记录基线。 + +- [ ] **Step 3: 改为常量时间比较** + +将 `apps/gateway/src/lib/auth.ts:31-33`: + +```ts +export function verifyToken(token: string): boolean { + return token === ROUTEBOX_TOKEN; +} +``` + +改为: + +```ts +export function verifyToken(token: string): boolean { + // C3: 常量时间比较,避免 token 计时侧信道 + const a = Buffer.from(token); + const b = Buffer.from(ROUTEBOX_TOKEN); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} +``` + +(`crypto` 已在文件顶部 `import crypto from "crypto";` 导入,无需新增。) + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd apps/gateway && bun test src/lib/auth.test.ts` +Expected: PASS(3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/gateway/src/lib/auth.ts apps/gateway/src/lib/auth.test.ts +git commit -m "fix(gateway): constant-time token comparison (C3)" +``` + +--- + +## Task 3: 启动日志不打印完整 token(C2a) + +**Files:** +- Modify: `apps/gateway/src/lib/auth.ts:28-29` + +- [ ] **Step 1: 掩码 token 日志** + +将 `apps/gateway/src/lib/auth.ts:28-29`: + +```ts +// Print token on startup so the user can configure their clients +console.log(` ROUTEBOX_TOKEN=${ROUTEBOX_TOKEN}`); +``` + +改为: + +```ts +// C2a: 不在日志中打印完整 token;只显示掩码前缀供识别 +const masked = ROUTEBOX_TOKEN.length > 10 + ? `${ROUTEBOX_TOKEN.slice(0, 6)}…${ROUTEBOX_TOKEN.slice(-4)}` + : "****"; +console.log(` ROUTEBOX_TOKEN=${masked} (full token in Settings / keychain)`); +``` + +- [ ] **Step 2: 手动验证日志不含完整 token** + +Run: +```bash +cd apps/gateway && ROUTEBOX_TOKEN=rb_supersecretvalue1234567890 bun run src/index.ts 2>&1 | head -8 & \ + sleep 2 ; pkill -f "bun run src/index.ts" +``` +Expected: 输出含 `ROUTEBOX_TOKEN=rb_sup…7890`,不含完整 `rb_supersecretvalue1234567890`。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/gateway/src/lib/auth.ts +git commit -m "fix(gateway): mask auth token in startup logs (C2a)" +``` + +--- + +## Task 4: 丢弃网关 stdout 避免泄露与缓冲死锁(C2b) + +**Files:** +- Modify: `apps/desktop/src-tauri/src/commands.rs:269` + +**背景:** 当前 `.stdout(Stdio::piped())` 把网关 stdout 接到一个从不被读取的管道。诊断信息走 stderr(已被读取),stdout 只承载常规日志,留着管道不读既泄露(若被日志采集捕获)又可能在缓冲区写满时阻塞网关。改为 `Stdio::null()` 直接丢弃 stdout。 + +- [ ] **Step 1: 将 stdout 改为 null** + +将 `apps/desktop/src-tauri/src/commands.rs:269`: + +```rust + .stdout(std::process::Stdio::piped()) +``` + +改为: + +```rust + // C2b: 丢弃 gateway stdout —— 诊断走 stderr(下方会读取); + // 不保留无人读取的管道,避免凭据泄露与管道缓冲写满导致的死锁 + .stdout(std::process::Stdio::null()) +``` + +- [ ] **Step 2: 编译验证** + +Run: `cd apps/desktop/src-tauri && cargo check` +Expected: 编译通过,无新增 warning/error。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src-tauri/src/commands.rs +git commit -m "fix(desktop): discard gateway stdout to prevent leak/deadlock (C2b)" +``` + +--- + +## Task 5: SSRF 白名单 + 禁重定向(H2-sec) + +**Files:** +- Create: `apps/gateway/src/lib/ssrf.ts` +- Create: `apps/gateway/src/lib/ssrf.test.ts` +- Modify: `apps/gateway/src/lib/local-providers.ts:50-76` 和 `:140-151` + +- [ ] **Step 1: 写失败测试** + +Create `apps/gateway/src/lib/ssrf.test.ts`: + +```ts +import { test, expect } from "bun:test"; +import { assertSafeLocalUrl } from "./ssrf"; + +test("allows loopback hosts", () => { + expect(() => assertSafeLocalUrl("http://localhost:11434/v1")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://127.0.0.1:1234/v1")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://[::1]:8080/v1")).not.toThrow(); +}); + +test("allows RFC1918 private ranges", () => { + expect(() => assertSafeLocalUrl("http://192.168.1.50:11434")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://10.0.0.5:1234")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://172.16.4.4:1234")).not.toThrow(); +}); + +test("rejects cloud metadata address", () => { + expect(() => assertSafeLocalUrl("http://169.254.169.254/latest/meta-data")).toThrow(); +}); + +test("rejects public hosts", () => { + expect(() => assertSafeLocalUrl("http://example.com/v1")).toThrow(); + expect(() => assertSafeLocalUrl("https://api.openai.com/v1")).toThrow(); +}); + +test("rejects non-http(s) schemes", () => { + expect(() => assertSafeLocalUrl("file:///etc/passwd")).toThrow(); + expect(() => assertSafeLocalUrl("gopher://127.0.0.1")).toThrow(); +}); + +test("rejects malformed urls", () => { + expect(() => assertSafeLocalUrl("not a url")).toThrow(); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd apps/gateway && bun test src/lib/ssrf.test.ts` +Expected: FAIL —— `Cannot find module './ssrf'` 或 `assertSafeLocalUrl is not a function`。 + +- [ ] **Step 3: 实现 ssrf.ts** + +Create `apps/gateway/src/lib/ssrf.ts`: + +```ts +// --------------------------------------------------------------------------- +// SSRF guard — 本地 provider 的 baseUrl 必须指向 loopback 或私有网段 +// --------------------------------------------------------------------------- + +/** 判断一个 IPv4 字符串是否属于 loopback / 私有 / link-local 网段 */ +function isPrivateIpv4(host: string): boolean { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const oct = m.slice(1).map(Number); + if (oct.some((n) => n > 255)) return false; + const [a, b] = oct; + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + // 169.254.0.0/16 (link-local, 含云元数据 169.254.169.254) 一律拒绝 + return false; +} + +/** 校验 URL 仅指向本机/私有网络;否则抛错。用于本地 provider 配置与探测。 */ +export function assertSafeLocalUrl(rawUrl: string): void { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Invalid URL: ${rawUrl}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Unsupported scheme: ${url.protocol}`); + } + let host = url.hostname.toLowerCase(); + // 去掉 IPv6 字面量的方括号 + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + + if (host === "localhost") return; + if (host === "::1") return; // IPv6 loopback + if (host.startsWith("fe80:")) return; // IPv6 link-local (本机) + if (isPrivateIpv4(host)) return; + + throw new Error(`Refusing non-local provider URL: ${rawUrl}`); +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd apps/gateway && bun test src/lib/ssrf.test.ts` +Expected: PASS(6 tests) + +- [ ] **Step 5: 在探测前做校验并禁止重定向** + +在 `apps/gateway/src/lib/local-providers.ts` 顶部 import 区(第 6 行 `import { loadSetting, saveSetting } from "./db";` 之后)加入: + +```ts +import { assertSafeLocalUrl } from "./ssrf"; +``` + +将 `probeLocalProvider`(`:50-76`)的 fetch 调用改为先校验、并禁止跟随重定向。具体把: + +```ts +export async function probeLocalProvider(state: LocalProviderState): Promise { + try { + const url = `${state.baseUrl}/models`; + const headers: Record = {}; + if (state.apiKey) headers["Authorization"] = `Bearer ${state.apiKey}`; + const res = await fetch(url, { + method: "GET", + headers, + signal: AbortSignal.timeout(2000), + }); +``` + +改为: + +```ts +export async function probeLocalProvider(state: LocalProviderState): Promise { + try { + assertSafeLocalUrl(state.baseUrl); // H2-sec: 拒绝非本地/私有地址 + const url = `${state.baseUrl}/models`; + const headers: Record = {}; + if (state.apiKey) headers["Authorization"] = `Bearer ${state.apiKey}`; + const res = await fetch(url, { + method: "GET", + headers, + redirect: "error", // H2-sec: 禁止跟随重定向,防绕过白名单 + signal: AbortSignal.timeout(2000), + }); +``` + +(校验失败会抛错,落入既有的 `catch` 分支,将 provider 标记为离线 —— 行为安全且无需新增分支。) + +- [ ] **Step 6: 在保存 URL 时拒绝非法地址** + +将 `updateLocalProviderUrl`(`:140-151`)开头: + +```ts +export async function updateLocalProviderUrl(name: string, baseUrl: string, apiKey?: string): Promise { + const lp = localProviders.find((p) => p.name === name); + if (!lp) return undefined; + lp.baseUrl = baseUrl.replace(/\/+$/, ""); +``` + +改为: + +```ts +export async function updateLocalProviderUrl(name: string, baseUrl: string, apiKey?: string): Promise { + const lp = localProviders.find((p) => p.name === name); + if (!lp) return undefined; + const normalized = baseUrl.replace(/\/+$/, ""); + assertSafeLocalUrl(normalized); // H2-sec: 保存前校验,非法地址直接抛错 + lp.baseUrl = normalized; +``` + +- [ ] **Step 7: 在 API 层把校验错误转为 400** + +将 `apps/gateway/src/routes/api.ts:198-216` 的 handler 体包一层 try/catch。把: + +```ts +app.put("/local-providers/:name/url", async (c) => { + const name = c.req.param("name"); + const body = await c.req.json<{ baseUrl: string; apiKey?: string }>(); + if (!body.baseUrl?.trim()) return c.json({ error: "baseUrl is required" }, 400); + + const updated = await updateLocalProviderUrl(name, body.baseUrl.trim(), body.apiKey?.trim()); + if (!updated) return c.json({ error: "Unknown local provider" }, 404); +``` + +改为: + +```ts +app.put("/local-providers/:name/url", async (c) => { + const name = c.req.param("name"); + const body = await c.req.json<{ baseUrl: string; apiKey?: string }>(); + if (!body.baseUrl?.trim()) return c.json({ error: "baseUrl is required" }, 400); + + let updated; + try { + updated = await updateLocalProviderUrl(name, body.baseUrl.trim(), body.apiKey?.trim()); + } catch (e) { + return c.json({ error: (e as Error).message }, 400); // H2-sec: 非法 baseUrl + } + if (!updated) return c.json({ error: "Unknown local provider" }, 404); +``` + +- [ ] **Step 8: 运行网关测试套件确认无回归** + +Run: `cd apps/gateway && bun test` +Expected: 全部 PASS。 + +- [ ] **Step 9: Commit** + +```bash +git add apps/gateway/src/lib/ssrf.ts apps/gateway/src/lib/ssrf.test.ts \ + apps/gateway/src/lib/local-providers.ts apps/gateway/src/routes/api.ts +git commit -m "fix(gateway): SSRF allowlist + no-redirect for local providers (H2-sec)" +``` + +--- + +## Task 6: 云端 CORS 不为 no-origin 返回通配(H3-sec) + +**Files:** +- Modify: `apps/cloud-gateway/src/index.ts:48-52` + +**背景:** 浏览器请求总会带 `Origin`;无 `Origin` 的是非浏览器客户端(Tauri/curl/服务端),它们不受 CORS 约束,因此无需也不应回 `*`。返回 `null` 即不下发 `Access-Control-Allow-Origin`,对非浏览器客户端零影响,同时去掉通配。 + +- [ ] **Step 1: 修改 origin 回调** + +将 `apps/cloud-gateway/src/index.ts:48-52`: + +```ts + origin: (origin) => { + // Non-browser requests (Tauri desktop, curl, server-to-server) + if (!origin) return "*"; + return ALLOWED_ORIGINS.includes(origin) ? origin : null; + }, +``` + +改为: + +```ts + origin: (origin) => { + // H3-sec: 非浏览器请求(无 Origin)不下发 ACAO —— CORS 仅约束浏览器, + // 浏览器请求必带 Origin,故无需通配 + if (!origin) return null; + return ALLOWED_ORIGINS.includes(origin) ? origin : null; + }, +``` + +- [ ] **Step 2: 手动验证(若本地可起云端)或代码评审确认** + +若环境具备(已配 `DATABASE_URL` 等),Run: +```bash +cd apps/cloud-gateway && bun run src/index.ts & sleep 3 ; \ + curl -s -D - -o /dev/null http://localhost:8787/health -H "Origin: https://evil.example" | grep -i access-control-allow-origin ; \ + pkill -f "bun run src/index.ts" +``` +Expected: 不出现 `access-control-allow-origin: *`(对未授权 Origin 无该响应头)。 +若无法起服务,改为代码评审确认改动已生效。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/cloud-gateway/src/index.ts +git commit -m "fix(cloud): do not reflect ACAO '*' for no-origin requests (H3-sec)" +``` + +--- + +## Task 7: 网关 AES-256-GCM secrets 模块(M4-sec 基础) + +**Files:** +- Create: `apps/gateway/src/lib/secrets.ts` +- Create: `apps/gateway/src/lib/secrets.test.ts` + +- [ ] **Step 1: 写失败测试** + +Create `apps/gateway/src/lib/secrets.test.ts`: + +```ts +import { test, expect, beforeEach } from "bun:test"; + +const KEY_HEX = "0".repeat(64); // 32 字节全零,仅测试用 + +beforeEach(() => { + process.env.ROUTEBOX_DB_KEY = KEY_HEX; +}); + +test("encrypt then decrypt round-trips", async () => { + const { encryptSecret, decryptSecret } = await import("./secrets"); + const plain = "sk-test-1234567890"; + const enc = encryptSecret(plain); + expect(enc.startsWith("enc:v1:")).toBe(true); + expect(enc).not.toContain(plain); + expect(decryptSecret(enc)).toBe(plain); +}); + +test("decrypt passes through legacy plaintext", async () => { + const { decryptSecret } = await import("./secrets"); + expect(decryptSecret("sk-legacy-plaintext")).toBe("sk-legacy-plaintext"); +}); + +test("encrypt is non-deterministic (random IV)", async () => { + const { encryptSecret } = await import("./secrets"); + expect(encryptSecret("same")).not.toBe(encryptSecret("same")); +}); + +test("without key, encrypt is a passthrough (dev fallback)", async () => { + delete process.env.ROUTEBOX_DB_KEY; + const { encryptSecret } = await import("./secrets"); + expect(encryptSecret("plain")).toBe("plain"); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd apps/gateway && bun test src/lib/secrets.test.ts` +Expected: FAIL —— `Cannot find module './secrets'`。 + +- [ ] **Step 3: 实现 secrets.ts** + +Create `apps/gateway/src/lib/secrets.ts`: + +```ts +// --------------------------------------------------------------------------- +// Secrets — AES-256-GCM 静态加密(provider key / auth token) +// 主密钥来自 ROUTEBOX_DB_KEY 环境变量(由 Tauri 从 keychain 注入,64 hex = 32 bytes) +// --------------------------------------------------------------------------- + +import crypto from "crypto"; + +const PREFIX = "enc:v1:"; + +function getKey(): Buffer | null { + const hex = process.env.ROUTEBOX_DB_KEY; + if (!hex || hex.length !== 64) return null; + return Buffer.from(hex, "hex"); +} + +/** 加密;无密钥时(本地 dev)透传明文,保证可用性。 */ +export function encryptSecret(plain: string): string { + const key = getKey(); + if (!key) return plain; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`; +} + +/** 解密;非 `enc:v1:` 前缀视为旧明文直接返回(向后兼容)。 */ +export function decryptSecret(stored: string): string { + if (!stored.startsWith(PREFIX)) return stored; + const key = getKey(); + if (!key) throw new Error("ROUTEBOX_DB_KEY required to decrypt stored secret"); + const body = stored.slice(PREFIX.length); + const [ivHex, tagHex, ctHex] = body.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const ct = Buffer.from(ctHex, "hex"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd apps/gateway && bun test src/lib/secrets.test.ts` +Expected: PASS(4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/gateway/src/lib/secrets.ts apps/gateway/src/lib/secrets.test.ts +git commit -m "feat(gateway): add AES-256-GCM secrets module (M4-sec)" +``` + +--- + +## Task 8: 网关 provider key 与 token 落库加密(C2c + M4-sec) + +**Files:** +- Modify: `apps/gateway/src/lib/db.ts:249-263` +- Modify: `apps/gateway/src/lib/auth.ts:1-24` +- Test: `apps/gateway/src/lib/db.test.ts` (Create) + +- [ ] **Step 1: 写失败测试(provider key 加密落库)** + +Create `apps/gateway/src/lib/db.test.ts`: + +```ts +import { test, expect, beforeAll } from "bun:test"; + +// 使用独立临时 DB + 加密密钥,避免污染真实库 +process.env.ROUTEBOX_DB_KEY = "1".repeat(64); +process.env.ROUTEBOX_DB_PATH = "/tmp/routebox-test-db.sqlite"; + +import { Database } from "bun:sqlite"; +import { saveProviderKey, loadProviderKey, loadAllProviderKeys } from "./db"; + +test("provider key is stored encrypted but reads back as plaintext", () => { + saveProviderKey("OpenAI", "sk-secret-abc123"); + + // 读 API 返回明文 + const row = loadProviderKey("OpenAI"); + expect(row?.api_key).toBe("sk-secret-abc123"); + + // 直接查底层表,值应为密文(不含明文) + const raw = new Database(process.env.ROUTEBOX_DB_PATH!).query( + "SELECT api_key FROM provider_keys WHERE provider_name = ?", + ).get("OpenAI") as { api_key: string }; + expect(raw.api_key.startsWith("enc:v1:")).toBe(true); + expect(raw.api_key).not.toContain("sk-secret-abc123"); +}); + +test("loadAllProviderKeys decrypts every row", () => { + saveProviderKey("Anthropic", "sk-ant-xyz789"); + const all = loadAllProviderKeys(); + const anth = all.find((k) => k.provider_name === "Anthropic"); + expect(anth?.api_key).toBe("sk-ant-xyz789"); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd apps/gateway && rm -f /tmp/routebox-test-db.sqlite* && bun test src/lib/db.test.ts` +Expected: FAIL —— `raw.api_key.startsWith("enc:v1:")` 为 false(当前明文存储)。 + +- [ ] **Step 3: 在 db.ts 读写处加解密** + +在 `apps/gateway/src/lib/db.ts` 顶部 import 区(第 6 行 `import type { RequestRecord } from "./metrics";` 之后)加入: + +```ts +import { encryptSecret, decryptSecret } from "./secrets"; +``` + +将 `saveProviderKey` / `loadProviderKey` / `loadAllProviderKeys`(`:249-263`): + +```ts +export function saveProviderKey(name: string, apiKey: string) { + upsertProviderKey.run({ $name: name, $key: apiKey, $now: Date.now() }); +} + +export function removeProviderKey(name: string) { + deleteProviderKeyStmt.run(name); +} + +export function loadProviderKey(name: string): ProviderKeyRow | null { + return (getProviderKeyStmt.get(name) as ProviderKeyRow | null) ?? null; +} + +export function loadAllProviderKeys(): ProviderKeyRow[] { + return getAllProviderKeysStmt.all() as ProviderKeyRow[]; +} +``` + +改为: + +```ts +export function saveProviderKey(name: string, apiKey: string) { + upsertProviderKey.run({ $name: name, $key: encryptSecret(apiKey), $now: Date.now() }); +} + +export function removeProviderKey(name: string) { + deleteProviderKeyStmt.run(name); +} + +export function loadProviderKey(name: string): ProviderKeyRow | null { + const row = (getProviderKeyStmt.get(name) as ProviderKeyRow | null) ?? null; + if (row) row.api_key = decryptSecret(row.api_key); + return row; +} + +export function loadAllProviderKeys(): ProviderKeyRow[] { + const rows = getAllProviderKeysStmt.all() as ProviderKeyRow[]; + for (const r of rows) r.api_key = decryptSecret(r.api_key); + return rows; +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd apps/gateway && rm -f /tmp/routebox-test-db.sqlite* && bun test src/lib/db.test.ts` +Expected: PASS(2 tests) + +- [ ] **Step 5: 加密持久化的 auth token(C2c)** + +将 `apps/gateway/src/lib/auth.ts:1-24` 的 import 与 `resolveToken`: + +```ts +import { createMiddleware } from "hono/factory"; +import { loadSetting, saveSetting } from "./db"; +import crypto from "crypto"; + +function resolveToken(): string { + // 1. Environment variable takes priority + const envToken = process.env.ROUTEBOX_TOKEN; + if (envToken) { + return envToken; + } + + // 2. Try loading from DB (persisted from a previous startup) + const dbToken = loadSetting("routebox_token"); + if (dbToken) { + console.log(" Auth token loaded from database."); + return dbToken; + } + + // 3. Generate a new random token and persist it + const newToken = `rb_${crypto.randomBytes(24).toString("hex")}`; + saveSetting("routebox_token", newToken); + console.log(" Generated new auth token (saved to database)."); + return newToken; +} +``` + +改为: + +```ts +import { createMiddleware } from "hono/factory"; +import { loadSetting, saveSetting } from "./db"; +import { encryptSecret, decryptSecret } from "./secrets"; +import crypto from "crypto"; + +function resolveToken(): string { + // 1. Environment variable takes priority (Tauri 从 keychain 注入) + const envToken = process.env.ROUTEBOX_TOKEN; + if (envToken) { + return envToken; + } + + // 2. Try loading from DB (persisted from a previous standalone startup) + const dbToken = loadSetting("routebox_token"); + if (dbToken) { + console.log(" Auth token loaded from database."); + return decryptSecret(dbToken); // C2c: 库内为密文(无密钥时 decrypt 透传旧明文) + } + + // 3. Generate a new random token and persist it (encrypted) + const newToken = `rb_${crypto.randomBytes(24).toString("hex")}`; + saveSetting("routebox_token", encryptSecret(newToken)); // C2c + console.log(" Generated new auth token (saved to database)."); + return newToken; +} +``` + +- [ ] **Step 6: 回归与确认** + +Run: `cd apps/gateway && rm -f /tmp/routebox-test-db.sqlite* && bun test` +Expected: 全部 PASS(含 auth.test.ts、secrets.test.ts、db.test.ts 及既有测试)。 + +- [ ] **Step 7: Commit** + +```bash +git add apps/gateway/src/lib/db.ts apps/gateway/src/lib/auth.ts apps/gateway/src/lib/db.test.ts +git commit -m "feat(gateway): encrypt provider keys and auth token at rest (C2c, M4-sec)" +``` + +--- + +## Task 9: 云端 AES-256-GCM 工具 + 环境校验(M4-sec) + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/crypto.ts` +- Create: `apps/cloud-gateway/src/lib/crypto.test.ts` +- Modify: `apps/cloud-gateway/src/lib/env.ts:7-30` + +- [ ] **Step 1: 写失败测试** + +Create `apps/cloud-gateway/src/lib/crypto.test.ts`: + +```ts +import { test, expect, beforeEach } from "bun:test"; + +const KEY_HEX = "a".repeat(64); + +beforeEach(() => { + process.env.PROVIDER_KEY_ENCRYPTION_KEY = KEY_HEX; +}); + +test("encrypt then decrypt round-trips", async () => { + const { encryptSecret, decryptSecret } = await import("./crypto"); + const plain = "sk-cloud-secret-001"; + const enc = encryptSecret(plain); + expect(enc.startsWith("enc:v1:")).toBe(true); + expect(enc).not.toContain(plain); + expect(decryptSecret(enc)).toBe(plain); +}); + +test("decrypt passes through legacy plaintext", async () => { + const { decryptSecret } = await import("./crypto"); + expect(decryptSecret("sk-legacy")).toBe("sk-legacy"); +}); + +test("sha256Hex still works", async () => { + const { sha256Hex } = await import("./crypto"); + expect(await sha256Hex("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); +}); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd apps/cloud-gateway && bun test src/lib/crypto.test.ts` +Expected: FAIL —— `encryptSecret is not a function`。 + +- [ ] **Step 3: 在 crypto.ts 新增 AES-256-GCM** + +将 `apps/cloud-gateway/src/lib/crypto.ts` 全文替换为(保留既有 `sha256Hex`,新增 import 与两个函数): + +```ts +// --------------------------------------------------------------------------- +// Shared cryptographic utilities +// --------------------------------------------------------------------------- + +import nodeCrypto from "node:crypto"; + +const PREFIX = "enc:v1:"; + +/** Compute SHA-256 hash and return as lowercase hex string */ +export async function sha256Hex(text: string): Promise { + const data = new TextEncoder().encode(text); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function getKey(): Buffer | null { + const hex = process.env.PROVIDER_KEY_ENCRYPTION_KEY; + if (!hex || hex.length !== 64) return null; + return Buffer.from(hex, "hex"); +} + +/** AES-256-GCM 加密 provider key;无密钥(非生产)时透传明文。 */ +export function encryptSecret(plain: string): string { + const key = getKey(); + if (!key) return plain; + const iv = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`; +} + +/** 解密;非 `enc:v1:` 前缀视为旧明文直接返回(向后兼容)。 */ +export function decryptSecret(stored: string): string { + if (!stored.startsWith(PREFIX)) return stored; + const key = getKey(); + if (!key) throw new Error("PROVIDER_KEY_ENCRYPTION_KEY required to decrypt stored secret"); + const body = stored.slice(PREFIX.length); + const [ivHex, tagHex, ctHex] = body.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const ct = Buffer.from(ctHex, "hex"); + const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd apps/cloud-gateway && bun test src/lib/crypto.test.ts` +Expected: PASS(3 tests) + +- [ ] **Step 5: 生产环境强制要求加密密钥** + +将 `apps/cloud-gateway/src/lib/env.ts:26-30`(`Enforce minimum secret length` 块之后)新增校验。在该块之后插入: + +```ts + // M4-sec: provider key 静态加密密钥 —— 生产必须配置且为 64 hex(32 字节) + const encKey = process.env.PROVIDER_KEY_ENCRYPTION_KEY; + if (process.env.NODE_ENV === "production") { + if (!encKey) { + log.fatal("missing_env_vars", { missing: ["PROVIDER_KEY_ENCRYPTION_KEY"] }); + process.exit(1); + } + if (encKey.length !== 64 || !/^[0-9a-fA-F]+$/.test(encKey)) { + log.fatal("insecure_encryption_key", { reason: "must be 64 hex chars (32 bytes)" }); + process.exit(1); + } + } else if (!encKey) { + log.warn("encryption_key_absent", { + message: "PROVIDER_KEY_ENCRYPTION_KEY not set — provider keys stored as plaintext (dev only)", + }); + } +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/cloud-gateway/src/lib/crypto.ts apps/cloud-gateway/src/lib/crypto.test.ts apps/cloud-gateway/src/lib/env.ts +git commit -m "feat(cloud): AES-256-GCM secret helpers + enforce encryption key in prod (M4-sec)" +``` + +--- + +## Task 10: 云端 provider key 落库加密(M4-sec) + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/provider-config.ts` + +**说明:** `maskKey` 接收明文返回 `...xxxx`。加密后,落库存密文;凡是读出 `api_key` 用于「掩码展示」或「实际请求」处,先 `decryptSecret`。 + +- [ ] **Step 1: import 加解密** + +在 `apps/cloud-gateway/src/lib/provider-config.ts` 顶部 import 区(第 6 行 `import { log } from "./logger";` 之后)加入: + +```ts +import { encryptSecret, decryptSecret } from "./crypto"; +``` + +- [ ] **Step 2: 创建时加密落库** + +将 `createProviderKey`(`:55-59`)的 INSERT: + +```ts + const [row] = await sql` + INSERT INTO provider_keys (provider_name, api_key, base_url, label) + VALUES (${providerName}, ${apiKey}, ${baseUrl ?? null}, ${label ?? null}) + RETURNING id, provider_name, api_key, base_url, label, is_active, created_at + `; +``` + +改为: + +```ts + const [row] = await sql` + INSERT INTO provider_keys (provider_name, api_key, base_url, label) + VALUES (${providerName}, ${encryptSecret(apiKey)}, ${baseUrl ?? null}, ${label ?? null}) + RETURNING id, provider_name, api_key, base_url, label, is_active, created_at + `; +``` + +注意返回对象的 `maskedKey: maskKey(row.api_key ...)` 此时 `row.api_key` 是密文。将 `createProviderKey` 返回块里的: + +```ts + maskedKey: maskKey(row.api_key as string), +``` + +改为(此处用入参明文掩码,避免对密文取后 4 位): + +```ts + maskedKey: maskKey(apiKey), +``` + +- [ ] **Step 3: 更新 apiKey 时加密** + +将 `updateProviderKey` 中(`:86-88`): + +```ts + if (updates.apiKey !== undefined) { + await sql`UPDATE provider_keys SET api_key = ${updates.apiKey}, updated_at = now() WHERE id = ${id}`; + } +``` + +改为: + +```ts + if (updates.apiKey !== undefined) { + await sql`UPDATE provider_keys SET api_key = ${encryptSecret(updates.apiKey)}, updated_at = now() WHERE id = ${id}`; + } +``` + +- [ ] **Step 4: 读取/列表/装载时解密** + +`listProviderKeys`(`:38-46`)、`updateProviderKey` 末尾的 SELECT 回显(`:105-113`)、`loadDbProviderKeys`(`:149-157`)都需要在用 `api_key` 前解密。 + +(a) `listProviderKeys` 的 map 中 `maskedKey: maskKey(r.api_key as string)` 改为: + +```ts + maskedKey: maskKey(decryptSecret(r.api_key as string)), +``` + +(b) `updateProviderKey` 末尾 SELECT 后的返回块 `maskedKey: maskKey(row.api_key as string)` 改为: + +```ts + maskedKey: maskKey(decryptSecret(row.api_key as string)), +``` + +(c) `loadDbProviderKeys` 的 `configs.push({ ... apiKey: r.api_key as string ... })` 改为: + +```ts + apiKey: decryptSecret(r.api_key as string), +``` + +- [ ] **Step 5: 类型检查/启动验证** + +Run: `cd apps/cloud-gateway && bun build src/lib/provider-config.ts --target=bun --outdir=/tmp/rb-typecheck 2>&1 | head` (或在已配置 env 时 `bun test`) +Expected: 无类型/语法错误。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/cloud-gateway/src/lib/provider-config.ts +git commit -m "feat(cloud): encrypt provider keys at rest, decrypt on read (M4-sec)" +``` + +--- + +## Task 11: Tauri 注入网关加密密钥(支撑 C2c/M4-sec 生产路径) + +**Files:** +- Modify: `apps/desktop/src-tauri/src/keychain.rs` +- Modify: `apps/desktop/src-tauri/src/commands.rs:165-173` 和 `:264-268` + +**说明:** 网关生产环境需要 `ROUTEBOX_DB_KEY`。复用现有 keychain 模式:首次生成 32 字节随机密钥存入 keychain,启动网关时作为 env 注入。 + +- [ ] **Step 1: keychain 增加 DB key 存取** + +在 `apps/desktop/src-tauri/src/keychain.rs:5`(`const CLOUD_TOKEN_KEY ...` 之后)加入常量: + +```rust +const DB_KEY_KEY: &str = "db_encryption_key"; +``` + +并在文件末尾(`delete_cloud_token` 之后)追加: + +```rust +pub fn store_db_key(key: &str) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, DB_KEY_KEY).map_err(|e| e.to_string())?; + entry.set_password(key).map_err(|e| e.to_string()) +} + +pub fn get_db_key() -> Result, String> { + let entry = Entry::new(SERVICE_NAME, DB_KEY_KEY).map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(key) => Ok(Some(key)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } +} +``` + +- [ ] **Step 2: commands.rs 生成/读取 DB key** + +在 `apps/desktop/src-tauri/src/commands.rs:165-173`(token 解析块)之后,紧接着加入 DB key 解析: + +```rust + // M4-sec: 获取或生成 provider-key 静态加密密钥(64 hex = 32 bytes) + let db_key = keychain::get_db_key() + .ok() + .flatten() + .unwrap_or_else(|| { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).expect("failed to generate db key"); + let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + let _ = keychain::store_db_key(&hex); + hex + }); +``` + +- [ ] **Step 3: 启动网关时注入 env** + +将 `apps/desktop/src-tauri/src/commands.rs:265-266`: + +```rust + .env("ROUTEBOX_TOKEN", &token) + .env("ROUTEBOX_DB_PATH", db_path.to_string_lossy().to_string()) +``` + +改为: + +```rust + .env("ROUTEBOX_TOKEN", &token) + .env("ROUTEBOX_DB_KEY", &db_key) + .env("ROUTEBOX_DB_PATH", db_path.to_string_lossy().to_string()) +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd apps/desktop/src-tauri && cargo check` +Expected: 编译通过(`getrandom` 已是依赖,见 `generate_token`)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src-tauri/src/keychain.rs apps/desktop/src-tauri/src/commands.rs +git commit -m "feat(desktop): provision ROUTEBOX_DB_KEY from keychain for at-rest encryption (M4-sec)" +``` + +--- + +## Task 12: Tauri entitlements 收窄与说明(H4-sec) + +**Files:** +- Modify: `apps/desktop/src-tauri/Entitlements.plist` + +**说明:** 完整开启 App Sandbox 会破坏「spawn bun 子进程 + 访问真实 $HOME」,属较大改造,列入后续 backlog。本任务只做诚实记录:为每个 entitlement 标注用途,确认 `network.server` 是 loopback 监听所必需,移除/确认无用项。`files.user-selected.read-write` 若当前无文件选择交互可移除——执行者需先 grep 确认。 + +- [ ] **Step 1: 确认是否使用文件选择能力** + +Run: `grep -rn "dialog\|FilePicker\|open(\|save(" apps/desktop/src apps/desktop/src-tauri/src | grep -iv "onClose\|openPanel\|window" | head` +Expected: 据结果判断 `files.user-selected.read-write` 是否仍被用到;若无任何文件选择交互,下一步将其移除,否则保留。 + +- [ ] **Step 2: 加注释并按上一步结论调整** + +将 `apps/desktop/src-tauri/Entitlements.plist` 的 `` 内容改为(若 Step 1 判定文件选择仍需要,则保留该项并仅加注释;以下示例为「不需要、移除」的版本): + +```xml + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + com.apple.security.network.server + + +``` + +(若 Step 1 表明仍需文件选择,则在 `` 前保留: +```xml + + com.apple.security.files.user-selected.read-write + +``` +) + +- [ ] **Step 3: 验证 plist 合法** + +Run: `plutil -lint apps/desktop/src-tauri/Entitlements.plist` +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src-tauri/Entitlements.plist +git commit -m "chore(desktop): document/narrow Tauri entitlements (H4-sec)" +``` + +--- + +## Final Verification + +- [ ] **网关全量测试** + +Run: `cd apps/gateway && find . -path ./node_modules -prune -o -name '._*' -delete ; bun test` +Expected: 全部 PASS。 + +- [ ] **云端全量测试(若 env 具备)** + +Run: `cd apps/cloud-gateway && bun test` +Expected: 全部 PASS(或仅因缺少 DATABASE_URL/Redis 等基础设施跳过的集成测试,非本次改动导致的失败)。 + +- [ ] **Rust 编译** + +Run: `cd apps/desktop/src-tauri && cargo check` +Expected: 通过。 + +- [ ] **逐项核对验收(对照 spec Phase 0)** + - C1 端口绑定 127.0.0.1(Task 1 Step 2 已验证) + - C2a 日志掩码(Task 3 Step 2) + - C2b stdout 丢弃(Task 4) + - C2c token 密文落库(Task 8 Step 5) + - C3 常量时间比较(Task 2) + - H2-sec SSRF 拒绝元数据/公网地址(Task 5) + - H3-sec CORS 无通配(Task 6) + - H4-sec entitlements 已说明/收窄(Task 12) + - M4-sec provider key 两端密文落库(Task 8、Task 10) + +--- + +## Self-Review notes(作者自检) + +- **Spec 覆盖:** Phase 0 表中 C1/C2a/C2b/C2c/C3/H2-sec/H3-sec/H4-sec/M4-sec 全部对应到 Task 1–12,无遗漏。主密钥策略(云端 env、本地 keychain)在 Task 9/11 落实。 +- **类型一致性:** 两端加解密函数命名统一为 `encryptSecret`/`decryptSecret`;密文格式 `enc:v1:::` 两端一致;`ProviderKeyRow.api_key`(网关)在读路径被原地解密,类型不变。 +- **向后兼容:** 解密对非 `enc:v1:` 前缀透传,既有明文 key/token 不会损坏;dev 无密钥时加密透传,保证本地可用。 +- **未决项(实现者注意):** Task 12 Step 1 的 grep 结论决定 `files.user-selected.read-write` 去留;Task 6 Step 2 的端口(示例用 8787)以云端实际监听端口为准。 From fd02b266233d65a4cc0667e4a1bcedd8428b9c2c Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:38:22 +0800 Subject: [PATCH 03/62] fix(gateway): bind to 127.0.0.1 to prevent LAN exposure (C1) --- apps/gateway/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts index c06e8b3..c994438 100644 --- a/apps/gateway/src/index.ts +++ b/apps/gateway/src/index.ts @@ -126,6 +126,7 @@ process.on("SIGINT", () => { console.log("Shutting down..."); process.exit(0); } export default { port, + hostname: "127.0.0.1", // C1: 仅监听 loopback,禁止局域网访问本地代理 fetch: app.fetch, websocket, }; From ee7ee6a8cf49b0ecb06f1ae25f37984715fef440 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:40:36 +0800 Subject: [PATCH 04/62] fix(gateway): constant-time token comparison (C3) --- apps/gateway/src/lib/auth.test.ts | 21 +++++++++++++++++++++ apps/gateway/src/lib/auth.ts | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 apps/gateway/src/lib/auth.test.ts diff --git a/apps/gateway/src/lib/auth.test.ts b/apps/gateway/src/lib/auth.test.ts new file mode 100644 index 0000000..e46214f --- /dev/null +++ b/apps/gateway/src/lib/auth.test.ts @@ -0,0 +1,21 @@ +import { test, expect } from "bun:test"; + +// 在导入被测模块前设置一个固定 token,使 resolveToken 走 env 分支。 +// 使用动态 import,确保该赋值在 auth.ts 顶层求值(捕获 ROUTEBOX_TOKEN)之前生效, +// 覆盖 test-preload.ts 里设置的默认值。 +process.env.ROUTEBOX_TOKEN = "rb_testtoken_constant_time"; + +const { verifyToken } = await import("./auth"); + +test("verifyToken accepts the correct token", () => { + expect(verifyToken("rb_testtoken_constant_time")).toBe(true); +}); + +test("verifyToken rejects a wrong token of equal length", () => { + expect(verifyToken("rb_testtoken_constant_XXXX")).toBe(false); +}); + +test("verifyToken rejects a token of different length without throwing", () => { + expect(verifyToken("short")).toBe(false); + expect(verifyToken("")).toBe(false); +}); diff --git a/apps/gateway/src/lib/auth.ts b/apps/gateway/src/lib/auth.ts index f0765f8..e67e949 100644 --- a/apps/gateway/src/lib/auth.ts +++ b/apps/gateway/src/lib/auth.ts @@ -29,7 +29,11 @@ const ROUTEBOX_TOKEN = resolveToken(); console.log(` ROUTEBOX_TOKEN=${ROUTEBOX_TOKEN}`); export function verifyToken(token: string): boolean { - return token === ROUTEBOX_TOKEN; + // C3: 常量时间比较,避免 token 计时侧信道 + const a = Buffer.from(token); + const b = Buffer.from(ROUTEBOX_TOKEN); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } export const authMiddleware = createMiddleware(async (c, next) => { From f6f99cfc8570fbf8a962b354404395b4d879c54a Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:42:04 +0800 Subject: [PATCH 05/62] fix(gateway): mask auth token in startup logs (C2a) --- apps/gateway/src/lib/auth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/lib/auth.ts b/apps/gateway/src/lib/auth.ts index e67e949..d8cc803 100644 --- a/apps/gateway/src/lib/auth.ts +++ b/apps/gateway/src/lib/auth.ts @@ -25,8 +25,11 @@ function resolveToken(): string { const ROUTEBOX_TOKEN = resolveToken(); -// Print token on startup so the user can configure their clients -console.log(` ROUTEBOX_TOKEN=${ROUTEBOX_TOKEN}`); +// C2a: 不在日志中打印完整 token;只显示掩码前缀供识别 +const masked = ROUTEBOX_TOKEN.length > 10 + ? `${ROUTEBOX_TOKEN.slice(0, 6)}…${ROUTEBOX_TOKEN.slice(-4)}` + : "****"; +console.log(` ROUTEBOX_TOKEN=${masked} (full token in Settings / keychain)`); export function verifyToken(token: string): boolean { // C3: 常量时间比较,避免 token 计时侧信道 From 100067c109241ebec08b73e6af6d154953acc19a Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:43:38 +0800 Subject: [PATCH 06/62] fix(desktop): discard gateway stdout to prevent leak/deadlock (C2b) --- apps/desktop/src-tauri/src/commands.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 47e56c8..9877731 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -266,7 +266,9 @@ pub async fn spawn_gateway( .env("ROUTEBOX_DB_PATH", db_path.to_string_lossy().to_string()) .env("HOME", &real_home) .env("PATH", &child_path) - .stdout(std::process::Stdio::piped()) + // C2b: 丢弃 gateway stdout —— 诊断走 stderr(下方会读取); + // 不保留无人读取的管道,避免凭据泄露与管道缓冲写满导致的死锁 + .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| { From 7113bd3a214acfcbf2fd593e0596a707fb057e57 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:46:49 +0800 Subject: [PATCH 07/62] fix(gateway): SSRF allowlist + no-redirect for local providers (H2-sec) --- apps/gateway/src/lib/local-providers.ts | 7 ++++- apps/gateway/src/lib/ssrf.test.ts | 32 +++++++++++++++++++ apps/gateway/src/lib/ssrf.ts | 41 +++++++++++++++++++++++++ apps/gateway/src/routes/api.ts | 7 ++++- 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/lib/ssrf.test.ts create mode 100644 apps/gateway/src/lib/ssrf.ts diff --git a/apps/gateway/src/lib/local-providers.ts b/apps/gateway/src/lib/local-providers.ts index 97054a7..8c14a4e 100644 --- a/apps/gateway/src/lib/local-providers.ts +++ b/apps/gateway/src/lib/local-providers.ts @@ -4,6 +4,7 @@ import type { ProviderConfig } from "./providers"; import { loadSetting, saveSetting } from "./db"; +import { assertSafeLocalUrl } from "./ssrf"; export interface LocalProviderState { name: string; @@ -49,12 +50,14 @@ export function initLocalProviders() { export async function probeLocalProvider(state: LocalProviderState): Promise { try { + assertSafeLocalUrl(state.baseUrl); // H2-sec: 拒绝非本地/私有地址 const url = `${state.baseUrl}/models`; const headers: Record = {}; if (state.apiKey) headers["Authorization"] = `Bearer ${state.apiKey}`; const res = await fetch(url, { method: "GET", headers, + redirect: "error", // H2-sec: 禁止跟随重定向,防绕过白名单 signal: AbortSignal.timeout(2000), }); if (!res.ok) { @@ -140,7 +143,9 @@ export function getLocalProviderForModel(model: string): ProviderConfig | undefi export async function updateLocalProviderUrl(name: string, baseUrl: string, apiKey?: string): Promise { const lp = localProviders.find((p) => p.name === name); if (!lp) return undefined; - lp.baseUrl = baseUrl.replace(/\/+$/, ""); + const normalized = baseUrl.replace(/\/+$/, ""); + assertSafeLocalUrl(normalized); // H2-sec: 保存前校验,非法地址直接抛错 + lp.baseUrl = normalized; saveSetting(`localProvider:${name}:baseUrl`, lp.baseUrl); if (apiKey !== undefined) { lp.apiKey = apiKey; diff --git a/apps/gateway/src/lib/ssrf.test.ts b/apps/gateway/src/lib/ssrf.test.ts new file mode 100644 index 0000000..3880df1 --- /dev/null +++ b/apps/gateway/src/lib/ssrf.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test"; +import { assertSafeLocalUrl } from "./ssrf"; + +test("allows loopback hosts", () => { + expect(() => assertSafeLocalUrl("http://localhost:11434/v1")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://127.0.0.1:1234/v1")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://[::1]:8080/v1")).not.toThrow(); +}); + +test("allows RFC1918 private ranges", () => { + expect(() => assertSafeLocalUrl("http://192.168.1.50:11434")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://10.0.0.5:1234")).not.toThrow(); + expect(() => assertSafeLocalUrl("http://172.16.4.4:1234")).not.toThrow(); +}); + +test("rejects cloud metadata address", () => { + expect(() => assertSafeLocalUrl("http://169.254.169.254/latest/meta-data")).toThrow(); +}); + +test("rejects public hosts", () => { + expect(() => assertSafeLocalUrl("http://example.com/v1")).toThrow(); + expect(() => assertSafeLocalUrl("https://api.openai.com/v1")).toThrow(); +}); + +test("rejects non-http(s) schemes", () => { + expect(() => assertSafeLocalUrl("file:///etc/passwd")).toThrow(); + expect(() => assertSafeLocalUrl("gopher://127.0.0.1")).toThrow(); +}); + +test("rejects malformed urls", () => { + expect(() => assertSafeLocalUrl("not a url")).toThrow(); +}); diff --git a/apps/gateway/src/lib/ssrf.ts b/apps/gateway/src/lib/ssrf.ts new file mode 100644 index 0000000..9e04c43 --- /dev/null +++ b/apps/gateway/src/lib/ssrf.ts @@ -0,0 +1,41 @@ +// --------------------------------------------------------------------------- +// SSRF guard — 本地 provider 的 baseUrl 必须指向 loopback 或私有网段 +// --------------------------------------------------------------------------- + +/** 判断一个 IPv4 字符串是否属于 loopback / 私有 / link-local 网段 */ +function isPrivateIpv4(host: string): boolean { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const oct = m.slice(1).map(Number); + if (oct.some((n) => n > 255)) return false; + const [a, b] = oct; + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + // 169.254.0.0/16 (link-local, 含云元数据 169.254.169.254) 一律拒绝 + return false; +} + +/** 校验 URL 仅指向本机/私有网络;否则抛错。用于本地 provider 配置与探测。 */ +export function assertSafeLocalUrl(rawUrl: string): void { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Invalid URL: ${rawUrl}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`Unsupported scheme: ${url.protocol}`); + } + let host = url.hostname.toLowerCase(); + // 去掉 IPv6 字面量的方括号 + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + + if (host === "localhost") return; + if (host === "::1") return; // IPv6 loopback + if (host.startsWith("fe80:")) return; // IPv6 link-local (本机) + if (isPrivateIpv4(host)) return; + + throw new Error(`Refusing non-local provider URL: ${rawUrl}`); +} diff --git a/apps/gateway/src/routes/api.ts b/apps/gateway/src/routes/api.ts index 46acaf3..02b9cca 100644 --- a/apps/gateway/src/routes/api.ts +++ b/apps/gateway/src/routes/api.ts @@ -200,7 +200,12 @@ app.put("/local-providers/:name/url", async (c) => { const body = await c.req.json<{ baseUrl: string; apiKey?: string }>(); if (!body.baseUrl?.trim()) return c.json({ error: "baseUrl is required" }, 400); - const updated = await updateLocalProviderUrl(name, body.baseUrl.trim(), body.apiKey?.trim()); + let updated; + try { + updated = await updateLocalProviderUrl(name, body.baseUrl.trim(), body.apiKey?.trim()); + } catch (e) { + return c.json({ error: (e as Error).message }, 400); // H2-sec: 非法 baseUrl + } if (!updated) return c.json({ error: "Unknown local provider" }, 404); metrics.syncProviders(); From acfe2108481623c2bf0fdb95b2e763fa54497772 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:50:23 +0800 Subject: [PATCH 08/62] harden(gateway): no-redirect on forwarding + document SSRF limitation + regression tests (H2-sec) --- apps/gateway/src/lib/ssrf.test.ts | 19 +++++++++++++++++++ apps/gateway/src/lib/ssrf.ts | 3 ++- apps/gateway/src/routes/proxy.ts | 2 ++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/lib/ssrf.test.ts b/apps/gateway/src/lib/ssrf.test.ts index 3880df1..3f06661 100644 --- a/apps/gateway/src/lib/ssrf.test.ts +++ b/apps/gateway/src/lib/ssrf.test.ts @@ -30,3 +30,22 @@ test("rejects non-http(s) schemes", () => { test("rejects malformed urls", () => { expect(() => assertSafeLocalUrl("not a url")).toThrow(); }); + +test("blocks numeric-encoded metadata addresses (URL-normalized)", () => { + // 169.254.169.254 in decimal / hex must still be rejected + expect(() => assertSafeLocalUrl("http://2852039166/")).toThrow(); + expect(() => assertSafeLocalUrl("http://0xa9fea9fe/")).toThrow(); +}); + +test("ignores userinfo when deciding host", () => { + expect(() => assertSafeLocalUrl("http://localhost@evil.com/")).toThrow(); +}); + +test("blocks IPv4-mapped IPv6 metadata", () => { + expect(() => assertSafeLocalUrl("http://[::ffff:169.254.169.254]/")).toThrow(); +}); + +test("allows numeric-encoded loopback", () => { + // 2130706433 === 127.0.0.1 + expect(() => assertSafeLocalUrl("http://2130706433/")).not.toThrow(); +}); diff --git a/apps/gateway/src/lib/ssrf.ts b/apps/gateway/src/lib/ssrf.ts index 9e04c43..c6f1685 100644 --- a/apps/gateway/src/lib/ssrf.ts +++ b/apps/gateway/src/lib/ssrf.ts @@ -17,7 +17,8 @@ function isPrivateIpv4(host: string): boolean { return false; } -/** 校验 URL 仅指向本机/私有网络;否则抛错。用于本地 provider 配置与探测。 */ +/** 校验 URL 仅指向本机/私有网络;否则抛错。用于本地 provider 配置与探测。 + * 注意:基于 hostname 静态判断,不做 DNS 解析,故不防御「域名解析到内网 IP」的 DNS rebinding。 */ export function assertSafeLocalUrl(rawUrl: string): void { let url: URL; try { diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index 56635dc..27a1dd7 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -86,6 +86,7 @@ async function forwardOpenAI( method: "POST", headers, body: JSON.stringify(body), + redirect: "error", signal: AbortSignal.timeout(provider.isLocal ? 120_000 : 30_000), }); } @@ -103,6 +104,7 @@ async function forwardAnthropic( "anthropic-version": "2023-06-01", }, body: JSON.stringify(anthropicBody), + redirect: "error", signal: AbortSignal.timeout(30_000), }); } From 9a8d54a2ac33dd5060be20665e3dd649275011f3 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:51:05 +0800 Subject: [PATCH 09/62] fix(cloud): do not reflect ACAO '*' for no-origin requests (H3-sec) --- apps/cloud-gateway/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cloud-gateway/src/index.ts b/apps/cloud-gateway/src/index.ts index 959dcb9..3816f61 100644 --- a/apps/cloud-gateway/src/index.ts +++ b/apps/cloud-gateway/src/index.ts @@ -46,8 +46,9 @@ app.use( "*", cors({ origin: (origin) => { - // Non-browser requests (Tauri desktop, curl, server-to-server) - if (!origin) return "*"; + // H3-sec: 非浏览器请求(无 Origin)不下发 ACAO —— CORS 仅约束浏览器, + // 浏览器请求必带 Origin,故无需通配 + if (!origin) return null; return ALLOWED_ORIGINS.includes(origin) ? origin : null; }, allowHeaders: ["Content-Type", "Authorization"], From 6ca123dcb46f63cbf998a74c240342dfbe3aa2ad Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:52:10 +0800 Subject: [PATCH 10/62] feat(gateway): add AES-256-GCM secrets module (M4-sec) Co-Authored-By: Claude Fable 5 --- apps/gateway/src/lib/secrets.test.ts | 32 ++++++++++++++++++++++ apps/gateway/src/lib/secrets.ts | 40 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 apps/gateway/src/lib/secrets.test.ts create mode 100644 apps/gateway/src/lib/secrets.ts diff --git a/apps/gateway/src/lib/secrets.test.ts b/apps/gateway/src/lib/secrets.test.ts new file mode 100644 index 0000000..34365af --- /dev/null +++ b/apps/gateway/src/lib/secrets.test.ts @@ -0,0 +1,32 @@ +import { test, expect, beforeEach } from "bun:test"; + +const KEY_HEX = "0".repeat(64); // 32 字节全零,仅测试用 + +beforeEach(() => { + process.env.ROUTEBOX_DB_KEY = KEY_HEX; +}); + +test("encrypt then decrypt round-trips", async () => { + const { encryptSecret, decryptSecret } = await import("./secrets"); + const plain = "sk-test-1234567890"; + const enc = encryptSecret(plain); + expect(enc.startsWith("enc:v1:")).toBe(true); + expect(enc).not.toContain(plain); + expect(decryptSecret(enc)).toBe(plain); +}); + +test("decrypt passes through legacy plaintext", async () => { + const { decryptSecret } = await import("./secrets"); + expect(decryptSecret("sk-legacy-plaintext")).toBe("sk-legacy-plaintext"); +}); + +test("encrypt is non-deterministic (random IV)", async () => { + const { encryptSecret } = await import("./secrets"); + expect(encryptSecret("same")).not.toBe(encryptSecret("same")); +}); + +test("without key, encrypt is a passthrough (dev fallback)", async () => { + delete process.env.ROUTEBOX_DB_KEY; + const { encryptSecret } = await import("./secrets"); + expect(encryptSecret("plain")).toBe("plain"); +}); diff --git a/apps/gateway/src/lib/secrets.ts b/apps/gateway/src/lib/secrets.ts new file mode 100644 index 0000000..3a5679a --- /dev/null +++ b/apps/gateway/src/lib/secrets.ts @@ -0,0 +1,40 @@ +// --------------------------------------------------------------------------- +// Secrets — AES-256-GCM 静态加密(provider key / auth token) +// 主密钥来自 ROUTEBOX_DB_KEY 环境变量(由 Tauri 从 keychain 注入,64 hex = 32 bytes) +// --------------------------------------------------------------------------- + +import crypto from "crypto"; + +const PREFIX = "enc:v1:"; + +function getKey(): Buffer | null { + const hex = process.env.ROUTEBOX_DB_KEY; + if (!hex || hex.length !== 64) return null; + return Buffer.from(hex, "hex"); +} + +/** 加密;无密钥时(本地 dev)透传明文,保证可用性。 */ +export function encryptSecret(plain: string): string { + const key = getKey(); + if (!key) return plain; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`; +} + +/** 解密;非 `enc:v1:` 前缀视为旧明文直接返回(向后兼容)。 */ +export function decryptSecret(stored: string): string { + if (!stored.startsWith(PREFIX)) return stored; + const key = getKey(); + if (!key) throw new Error("ROUTEBOX_DB_KEY required to decrypt stored secret"); + const body = stored.slice(PREFIX.length); + const [ivHex, tagHex, ctHex] = body.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const ct = Buffer.from(ctHex, "hex"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); +} From d03be6089350bc68a064cd2d5d17a7df60fa4850 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:56:05 +0800 Subject: [PATCH 11/62] feat(gateway): encrypt provider keys and auth token at rest (C2c, M4-sec) Co-Authored-By: Claude Fable 5 --- apps/gateway/src/lib/auth.ts | 5 +++-- apps/gateway/src/lib/db.test.ts | 32 ++++++++++++++++++++++++++++++++ apps/gateway/src/lib/db.ts | 11 ++++++++--- 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 apps/gateway/src/lib/db.test.ts diff --git a/apps/gateway/src/lib/auth.ts b/apps/gateway/src/lib/auth.ts index d8cc803..5828ae5 100644 --- a/apps/gateway/src/lib/auth.ts +++ b/apps/gateway/src/lib/auth.ts @@ -1,5 +1,6 @@ import { createMiddleware } from "hono/factory"; import { loadSetting, saveSetting } from "./db"; +import { encryptSecret, decryptSecret } from "./secrets"; import crypto from "crypto"; function resolveToken(): string { @@ -13,12 +14,12 @@ function resolveToken(): string { const dbToken = loadSetting("routebox_token"); if (dbToken) { console.log(" Auth token loaded from database."); - return dbToken; + return decryptSecret(dbToken); // C2c: 库内为密文(无密钥时 decrypt 透传旧明文) } // 3. Generate a new random token and persist it const newToken = `rb_${crypto.randomBytes(24).toString("hex")}`; - saveSetting("routebox_token", newToken); + saveSetting("routebox_token", encryptSecret(newToken)); // C2c console.log(" Generated new auth token (saved to database)."); return newToken; } diff --git a/apps/gateway/src/lib/db.test.ts b/apps/gateway/src/lib/db.test.ts new file mode 100644 index 0000000..629c705 --- /dev/null +++ b/apps/gateway/src/lib/db.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test"; +import { Database } from "bun:sqlite"; + +const TEST_DB = "/tmp/routebox-test-db.sqlite"; + +// 必须在 import db 之前设置(db.ts 在模块加载时按此路径打开 SQLite) +process.env.ROUTEBOX_DB_KEY = "1".repeat(64); +process.env.ROUTEBOX_DB_PATH = TEST_DB; + +const { saveProviderKey, loadProviderKey, loadAllProviderKeys } = await import("./db"); + +test("provider key is stored encrypted but reads back as plaintext", () => { + saveProviderKey("OpenAI", "sk-secret-abc123"); + + // 读 API 返回明文 + const row = loadProviderKey("OpenAI"); + expect(row?.api_key).toBe("sk-secret-abc123"); + + // 直接查底层表,值应为密文(不含明文) + const raw = new Database(TEST_DB).query( + "SELECT api_key FROM provider_keys WHERE provider_name = ?", + ).get("OpenAI") as { api_key: string }; + expect(raw.api_key.startsWith("enc:v1:")).toBe(true); + expect(raw.api_key).not.toContain("sk-secret-abc123"); +}); + +test("loadAllProviderKeys decrypts every row", () => { + saveProviderKey("Anthropic", "sk-ant-xyz789"); + const all = loadAllProviderKeys(); + const anth = all.find((k) => k.provider_name === "Anthropic"); + expect(anth?.api_key).toBe("sk-ant-xyz789"); +}); diff --git a/apps/gateway/src/lib/db.ts b/apps/gateway/src/lib/db.ts index f52b02c..3bdbab9 100644 --- a/apps/gateway/src/lib/db.ts +++ b/apps/gateway/src/lib/db.ts @@ -4,6 +4,7 @@ import { Database } from "bun:sqlite"; import type { RequestRecord } from "./metrics"; +import { encryptSecret, decryptSecret } from "./secrets"; const DB_PATH = process.env.ROUTEBOX_DB_PATH || "routebox.db"; @@ -247,7 +248,7 @@ export interface ProviderKeyRow { } export function saveProviderKey(name: string, apiKey: string) { - upsertProviderKey.run({ $name: name, $key: apiKey, $now: Date.now() }); + upsertProviderKey.run({ $name: name, $key: encryptSecret(apiKey), $now: Date.now() }); } export function removeProviderKey(name: string) { @@ -255,11 +256,15 @@ export function removeProviderKey(name: string) { } export function loadProviderKey(name: string): ProviderKeyRow | null { - return (getProviderKeyStmt.get(name) as ProviderKeyRow | null) ?? null; + const row = (getProviderKeyStmt.get(name) as ProviderKeyRow | null) ?? null; + if (row) row.api_key = decryptSecret(row.api_key); + return row; } export function loadAllProviderKeys(): ProviderKeyRow[] { - return getAllProviderKeysStmt.all() as ProviderKeyRow[]; + const rows = getAllProviderKeysStmt.all() as ProviderKeyRow[]; + for (const r of rows) r.api_key = decryptSecret(r.api_key); + return rows; } export function updateProviderKeyValidation(name: string) { From 7597dc93058b9cc7cff593a9ec5bce6b92c8c631 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:57:41 +0800 Subject: [PATCH 12/62] feat(cloud): AES-256-GCM secret helpers + enforce encryption key in prod (M4-sec) Co-Authored-By: Claude Fable 5 --- apps/cloud-gateway/src/lib/crypto.test.ts | 28 ++++++++++++++++++ apps/cloud-gateway/src/lib/crypto.ts | 36 +++++++++++++++++++++++ apps/cloud-gateway/src/lib/env.ts | 17 +++++++++++ 3 files changed, 81 insertions(+) create mode 100644 apps/cloud-gateway/src/lib/crypto.test.ts diff --git a/apps/cloud-gateway/src/lib/crypto.test.ts b/apps/cloud-gateway/src/lib/crypto.test.ts new file mode 100644 index 0000000..1aa128b --- /dev/null +++ b/apps/cloud-gateway/src/lib/crypto.test.ts @@ -0,0 +1,28 @@ +import { test, expect, beforeEach } from "bun:test"; + +const KEY_HEX = "a".repeat(64); + +beforeEach(() => { + process.env.PROVIDER_KEY_ENCRYPTION_KEY = KEY_HEX; +}); + +test("encrypt then decrypt round-trips", async () => { + const { encryptSecret, decryptSecret } = await import("./crypto"); + const plain = "sk-cloud-secret-001"; + const enc = encryptSecret(plain); + expect(enc.startsWith("enc:v1:")).toBe(true); + expect(enc).not.toContain(plain); + expect(decryptSecret(enc)).toBe(plain); +}); + +test("decrypt passes through legacy plaintext", async () => { + const { decryptSecret } = await import("./crypto"); + expect(decryptSecret("sk-legacy")).toBe("sk-legacy"); +}); + +test("sha256Hex still works", async () => { + const { sha256Hex } = await import("./crypto"); + expect(await sha256Hex("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); +}); diff --git a/apps/cloud-gateway/src/lib/crypto.ts b/apps/cloud-gateway/src/lib/crypto.ts index 8b72c5c..cf42dbb 100644 --- a/apps/cloud-gateway/src/lib/crypto.ts +++ b/apps/cloud-gateway/src/lib/crypto.ts @@ -2,6 +2,10 @@ // Shared cryptographic utilities // --------------------------------------------------------------------------- +import nodeCrypto from "node:crypto"; + +const PREFIX = "enc:v1:"; + /** Compute SHA-256 hash and return as lowercase hex string */ export async function sha256Hex(text: string): Promise { const data = new TextEncoder().encode(text); @@ -9,3 +13,35 @@ export async function sha256Hex(text: string): Promise { const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } + +function getKey(): Buffer | null { + const hex = process.env.PROVIDER_KEY_ENCRYPTION_KEY; + if (!hex || hex.length !== 64) return null; + return Buffer.from(hex, "hex"); +} + +/** AES-256-GCM 加密 provider key;无密钥(非生产)时透传明文。 */ +export function encryptSecret(plain: string): string { + const key = getKey(); + if (!key) return plain; + const iv = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`; +} + +/** 解密;非 `enc:v1:` 前缀视为旧明文直接返回(向后兼容)。 */ +export function decryptSecret(stored: string): string { + if (!stored.startsWith(PREFIX)) return stored; + const key = getKey(); + if (!key) throw new Error("PROVIDER_KEY_ENCRYPTION_KEY required to decrypt stored secret"); + const body = stored.slice(PREFIX.length); + const [ivHex, tagHex, ctHex] = body.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const ct = Buffer.from(ctHex, "hex"); + const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); +} diff --git a/apps/cloud-gateway/src/lib/env.ts b/apps/cloud-gateway/src/lib/env.ts index f584163..bd2ee09 100644 --- a/apps/cloud-gateway/src/lib/env.ts +++ b/apps/cloud-gateway/src/lib/env.ts @@ -29,6 +29,23 @@ export function validateEnv(): void { process.exit(1); } + // M4-sec: provider key 静态加密密钥 —— 生产必须配置且为 64 hex(32 字节) + const encKey = process.env.PROVIDER_KEY_ENCRYPTION_KEY; + if (process.env.NODE_ENV === "production") { + if (!encKey) { + log.fatal("missing_env_vars", { missing: ["PROVIDER_KEY_ENCRYPTION_KEY"] }); + process.exit(1); + } + if (encKey.length !== 64 || !/^[0-9a-fA-F]+$/.test(encKey)) { + log.fatal("insecure_encryption_key", { reason: "must be 64 hex chars (32 bytes)" }); + process.exit(1); + } + } else if (!encKey) { + log.warn("encryption_key_absent", { + message: "PROVIDER_KEY_ENCRYPTION_KEY not set — provider keys stored as plaintext (dev only)", + }); + } + // Warn if no LLM provider API keys are configured const providerKeys = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", From 35475ecb608499c322794b9529c58dcb7cf440b6 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:59:21 +0800 Subject: [PATCH 13/62] feat(cloud): encrypt provider keys at rest, decrypt on read (M4-sec) --- apps/cloud-gateway/src/lib/provider-config.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/cloud-gateway/src/lib/provider-config.ts b/apps/cloud-gateway/src/lib/provider-config.ts index efac3cd..16f7080 100644 --- a/apps/cloud-gateway/src/lib/provider-config.ts +++ b/apps/cloud-gateway/src/lib/provider-config.ts @@ -4,6 +4,7 @@ import { sql } from "./db-cloud"; import { log } from "./logger"; +import { encryptSecret, decryptSecret } from "./crypto"; import { PROVIDER_REGISTRY, initCloudProviders, @@ -38,7 +39,7 @@ export async function listProviderKeys(): Promise { return rows.map((r) => ({ id: r.id as string, providerName: r.provider_name as string, - maskedKey: maskKey(r.api_key as string), + maskedKey: maskKey(decryptSecret(r.api_key as string)), baseUrl: r.base_url as string | null, label: r.label as string | null, isActive: r.is_active as boolean, @@ -54,13 +55,13 @@ export async function createProviderKey( ): Promise { const [row] = await sql` INSERT INTO provider_keys (provider_name, api_key, base_url, label) - VALUES (${providerName}, ${apiKey}, ${baseUrl ?? null}, ${label ?? null}) + VALUES (${providerName}, ${encryptSecret(apiKey)}, ${baseUrl ?? null}, ${label ?? null}) RETURNING id, provider_name, api_key, base_url, label, is_active, created_at `; return { id: row.id as string, providerName: row.provider_name as string, - maskedKey: maskKey(row.api_key as string), + maskedKey: maskKey(apiKey), baseUrl: row.base_url as string | null, label: row.label as string | null, isActive: row.is_active as boolean, @@ -84,7 +85,7 @@ export async function updateProviderKey( // Use individual updates to avoid SQL injection with dynamic columns if (updates.apiKey !== undefined) { - await sql`UPDATE provider_keys SET api_key = ${updates.apiKey}, updated_at = now() WHERE id = ${id}`; + await sql`UPDATE provider_keys SET api_key = ${encryptSecret(updates.apiKey)}, updated_at = now() WHERE id = ${id}`; } if (updates.baseUrl !== undefined) { await sql`UPDATE provider_keys SET base_url = ${updates.baseUrl}, updated_at = now() WHERE id = ${id}`; @@ -105,7 +106,7 @@ export async function updateProviderKey( return { id: row.id as string, providerName: row.provider_name as string, - maskedKey: maskKey(row.api_key as string), + maskedKey: maskKey(decryptSecret(row.api_key as string)), baseUrl: row.base_url as string | null, label: row.label as string | null, isActive: row.is_active as boolean, @@ -149,7 +150,7 @@ export async function loadDbProviderKeys(): Promise { configs.push({ name: providerName, baseUrl: (r.base_url as string) || tmpl.defaultBaseUrl, - apiKey: r.api_key as string, + apiKey: decryptSecret(r.api_key as string), prefixes: tmpl.prefixes, format: tmpl.format, authHeader: tmpl.authHeader, From 671f97a5ce27b68fca6126cceeafaa61361ad155 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:01:16 +0800 Subject: [PATCH 14/62] feat(desktop): provision ROUTEBOX_DB_KEY from keychain for at-rest encryption (M4-sec) --- apps/desktop/src-tauri/src/commands.rs | 13 +++++++++++++ apps/desktop/src-tauri/src/keychain.rs | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 9877731..f4e433f 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -172,6 +172,18 @@ pub async fn spawn_gateway( t }); + // M4-sec: 获取或生成 provider-key 静态加密密钥(64 hex = 32 bytes) + let db_key = keychain::get_db_key() + .ok() + .flatten() + .unwrap_or_else(|| { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).expect("failed to generate db key"); + let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + let _ = keychain::store_db_key(&hex); + hex + }); + let bun_path = which_bun().ok_or_else(|| { eprintln!("[RouteBox] bun not found in any known path"); "bun not found. Install from https://bun.sh or add to PATH".to_string() @@ -263,6 +275,7 @@ pub async fn spawn_gateway( .current_dir(entry.parent().unwrap_or(&resource_dir)) .env("PORT", gateway_port.to_string()) .env("ROUTEBOX_TOKEN", &token) + .env("ROUTEBOX_DB_KEY", &db_key) .env("ROUTEBOX_DB_PATH", db_path.to_string_lossy().to_string()) .env("HOME", &real_home) .env("PATH", &child_path) diff --git a/apps/desktop/src-tauri/src/keychain.rs b/apps/desktop/src-tauri/src/keychain.rs index 721ddab..c0aadc7 100644 --- a/apps/desktop/src-tauri/src/keychain.rs +++ b/apps/desktop/src-tauri/src/keychain.rs @@ -3,6 +3,7 @@ use keyring::Entry; const SERVICE_NAME: &str = "com.routebox.app"; const TOKEN_KEY: &str = "api_token"; const CLOUD_TOKEN_KEY: &str = "cloud_api_token"; +const DB_KEY_KEY: &str = "db_encryption_key"; pub fn store_token(token: &str) -> Result<(), String> { let entry = Entry::new(SERVICE_NAME, TOKEN_KEY).map_err(|e| e.to_string())?; @@ -49,3 +50,17 @@ pub fn delete_cloud_token() -> Result<(), String> { Err(e) => Err(e.to_string()), } } + +pub fn store_db_key(key: &str) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, DB_KEY_KEY).map_err(|e| e.to_string())?; + entry.set_password(key).map_err(|e| e.to_string()) +} + +pub fn get_db_key() -> Result, String> { + let entry = Entry::new(SERVICE_NAME, DB_KEY_KEY).map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(key) => Ok(Some(key)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } +} From 55637e30247e93483ba1d77d8e466f1aac573870 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:02:40 +0800 Subject: [PATCH 15/62] chore(desktop): document/narrow Tauri entitlements (H4-sec) Co-Authored-By: Claude Fable 5 --- apps/desktop/src-tauri/Entitlements.plist | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Entitlements.plist b/apps/desktop/src-tauri/Entitlements.plist index 6bc2ac1..94c154c 100644 --- a/apps/desktop/src-tauri/Entitlements.plist +++ b/apps/desktop/src-tauri/Entitlements.plist @@ -2,13 +2,15 @@ + com.apple.security.app-sandbox + com.apple.security.network.client + com.apple.security.network.server - com.apple.security.files.user-selected.read-write - From e8d267ef175c6167d9c58b98ba522535985002a4 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:07:17 +0800 Subject: [PATCH 16/62] harden: skip undecryptable provider keys at startup instead of crashing (M4-sec) --- apps/cloud-gateway/src/lib/provider-config.ts | 30 +++++++++++-------- apps/gateway/src/lib/db.ts | 12 ++++++-- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/cloud-gateway/src/lib/provider-config.ts b/apps/cloud-gateway/src/lib/provider-config.ts index 16f7080..51d74c2 100644 --- a/apps/cloud-gateway/src/lib/provider-config.ts +++ b/apps/cloud-gateway/src/lib/provider-config.ts @@ -144,18 +144,24 @@ export async function loadDbProviderKeys(): Promise { continue; } - const idx = counterByProvider.get(providerName) ?? 0; - counterByProvider.set(providerName, idx + 1); - - configs.push({ - name: providerName, - baseUrl: (r.base_url as string) || tmpl.defaultBaseUrl, - apiKey: decryptSecret(r.api_key as string), - prefixes: tmpl.prefixes, - format: tmpl.format, - authHeader: tmpl.authHeader, - instanceId: `${providerName}:db:${idx}`, - }); + try { + const apiKey = decryptSecret(r.api_key as string); + const idx = counterByProvider.get(providerName) ?? 0; + counterByProvider.set(providerName, idx + 1); + + configs.push({ + name: providerName, + baseUrl: (r.base_url as string) || tmpl.defaultBaseUrl, + apiKey, + prefixes: tmpl.prefixes, + format: tmpl.format, + authHeader: tmpl.authHeader, + instanceId: `${providerName}:db:${idx}`, + }); + } catch { + log.warn("provider_key_decrypt_failed", { providerName }); + continue; + } } if (configs.length > 0) { diff --git a/apps/gateway/src/lib/db.ts b/apps/gateway/src/lib/db.ts index 3bdbab9..dc772cb 100644 --- a/apps/gateway/src/lib/db.ts +++ b/apps/gateway/src/lib/db.ts @@ -263,8 +263,16 @@ export function loadProviderKey(name: string): ProviderKeyRow | null { export function loadAllProviderKeys(): ProviderKeyRow[] { const rows = getAllProviderKeysStmt.all() as ProviderKeyRow[]; - for (const r of rows) r.api_key = decryptSecret(r.api_key); - return rows; + const out: ProviderKeyRow[] = []; + for (const r of rows) { + try { + r.api_key = decryptSecret(r.api_key); + out.push(r); + } catch { + console.warn(` Skipping provider key for "${r.provider_name}": decryption failed (missing/incorrect ROUTEBOX_DB_KEY?)`); + } + } + return out; } export function updateProviderKeyValidation(name: string) { From d2d68863acadbde296cfa0d947389532f238e06d Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:32:45 +0800 Subject: [PATCH 17/62] =?UTF-8?q?docs:=20add=20Phase=201=20plan=20?= =?UTF-8?q?=E2=80=94=20packages/llm-core=20(mechanism-only=20extraction)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share types + pricing algorithm + alias resolver; each app keeps its own registry list and pricing table (data is legitimately divergent, not drift). Zero behavior change. Adapter/SSE unification deferred to Phase 2 where they're fixed with tests. Co-Authored-By: Claude Fable 5 --- .../2026-06-11-routebox-phase1-llm-core.md | 778 ++++++++++++++++++ 1 file changed, 778 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase1-llm-core.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase1-llm-core.md b/docs/superpowers/plans/2026-06-11-routebox-phase1-llm-core.md new file mode 100644 index 0000000..868809a --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase1-llm-core.md @@ -0,0 +1,778 @@ +# RouteBox Phase 1 — `packages/llm-core` 共享包(机制层)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 建立 `packages/llm-core` 共享包,把两端**真正相同的机制**(定价算法、共享类型、alias 解析)收敛为单一来源,两端各自传入自己的数据 —— 行为零变化,并为 Phase 2/3 提供落脚点。 + +**Architecture:** 关键发现——gateway 与 cloud-gateway 的 `PROVIDER_REGISTRY`、`MODEL_PRICING` 是**真实不同的数据/策略**(gateway 7 个 provider 的本地 BYOK + 列表价用于成本展示;cloud 10 个 provider 的池化服务 + 议价用于计费),不是简单拷贝漂移。因此 Phase 1 只共享**机制**(纯函数 + 类型),数据(registry 列表、pricing 表)留在各 app。adapter(`toAnthropicRequest`)与 SSE 转换器在两端已**行为性分叉**(cloud 的精简适配器丢 tools/images、cloud 的 SSE 含 H4 修复),它们的统一 = Phase 2 的 bug 修复,本期不动。 + +**Tech Stack:** TypeScript + Bun(`bun test`,`bun build` 验证解析/打包)、pnpm workspace。共享包为**源码包**(无构建步骤,Bun 直接跑 TS),通过各 app tsconfig 的 `paths` 别名解析(沿用仓库已有的 `@gateway/*` 模式),磁盘安全、无需 `pnpm install`。 + +--- + +## 范围边界(本期做 / 不做) + +**做(零行为变化):** +- 新建 `packages/llm-core` 源码包 + workspace 接线 + tsconfig 路径解析。 +- 共享 **类型**:`ProviderFormat`、`ProviderTemplate`、`ModelPricing`。 +- 共享 **定价算法**:`pricingForModel(model, table, opts?)`、`calculateCost(...)` —— 数据由各 app 注入。 +- 共享 **alias 解析**:`resolveAlias(model, table)`。 +- 两端 `providers.ts` / `proxy.ts` / `key-pool.ts` 改为 import 共享机制 + 薄包装(保留各自数据与全部调用点签名,diff 最小)。 + +**不做(留待后续 Phase):** +- 统一 `PROVIDER_REGISTRY` 列表或 prefix(真实不同,合并会改路由)。 +- 统一 `MODEL_PRICING` 数字(真实不同,合并会改计费/成本展示)。 +- Anthropic 适配器(`toAnthropicRequest`/`fromAnthropicResponse`)统一 —— Phase 2 随 SSE/流式修复一起做(cloud 精简版丢 tools/images 是 Phase 2 的 bug)。 +- SSE 转换器统一 —— Phase 2(H4 流式修复)。 +- provider 匹配(longest-prefix + cloud 的 round-robin/熔断 + gateway 的 local/DB 合并)—— 两端逻辑不同,本期不抽。 + +--- + +## File Structure + +| 文件 | 责任 | 操作 | +|------|------|------| +| `pnpm-workspace.yaml` | workspace 包含 packages/* | Modify | +| `packages/llm-core/package.json` | 包声明(源码包) | Create | +| `packages/llm-core/tsconfig.json` | 包 tsconfig | Create | +| `packages/llm-core/src/index.ts` | barrel 导出 | Create | +| `packages/llm-core/src/types.ts` | ProviderFormat/ProviderTemplate/ModelPricing | Create | +| `packages/llm-core/src/pricing.ts` | pricingForModel/calculateCost(数据注入) | Create | +| `packages/llm-core/src/pricing.test.ts` | 定价算法单测 | Create | +| `packages/llm-core/src/aliases.ts` | resolveAlias(数据注入) | Create | +| `packages/llm-core/src/aliases.test.ts` | alias 单测 | Create | +| `apps/gateway/tsconfig.json` | 加 `@routebox/llm-core` 路径 | Modify | +| `apps/gateway/src/lib/providers.ts` | import 核心定价/alias/类型 + 薄包装 | Modify | +| `apps/cloud-gateway/tsconfig.json` | 加 `@routebox/llm-core` 路径 | Modify | +| `apps/cloud-gateway/src/routes/proxy.ts` | pricingFor/calculateCost 改用核心 | Modify | +| `apps/cloud-gateway/src/lib/key-pool.ts` | ProviderTemplate 类型改用核心 | Modify | + +**执行者必读:** +- 测试:`cd packages/llm-core && bun test`;`cd apps/gateway && bun test `;`cd apps/cloud-gateway && bun test `。 +- 解析/打包验证:`bun build --target=bun --outdir=/tmp/`(能 bundle 即说明 import 解析成功,无需 typecheck 工具链)。 +- 磁盘:`/Volumes/ROG_500GB` 卷接近满,**不要运行 `pnpm install`**(本期靠 tsconfig paths,无需安装);也不要为腾空间删用户数据。忽略 `non-monotonic index` git 警告。 +- gateway 测试套件有**预存的多文件隔离问题**(`db.test.ts`/`proxy.test.ts` 单跑通过、合跑失败,源于模块级单例 + bun preload),非本期引入;验证一律**单文件**运行。 +- 提交前清 AppleDouble:`find . -path ./node_modules -prune -o -name '._*' -delete`。 + +--- + +## Task 1: 脚手架 `packages/llm-core` + workspace 接线 + 解析冒烟 + +**Files:** +- Modify: `pnpm-workspace.yaml` +- Create: `packages/llm-core/package.json`, `packages/llm-core/tsconfig.json`, `packages/llm-core/src/index.ts` +- Modify: `apps/gateway/tsconfig.json` +- Create: `packages/llm-core/src/__smoke.test.ts`(临时,本任务末删除) + +- [ ] **Step 1: workspace 包含 packages/*** + +把 `pnpm-workspace.yaml`: +```yaml +packages: + - apps/* + +ignoredBuiltDependencies: + - esbuild +``` +改为: +```yaml +packages: + - apps/* + - packages/* + +ignoredBuiltDependencies: + - esbuild +``` + +- [ ] **Step 2: 创建 `packages/llm-core/package.json`** + +```json +{ + "name": "@routebox/llm-core", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + } +} +``` + +- [ ] **Step 3: 创建 `packages/llm-core/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["bun"] + }, + "include": ["src"] +} +``` + +- [ ] **Step 4: 创建 `packages/llm-core/src/index.ts`(初始仅一个冒烟导出)** + +```ts +// --------------------------------------------------------------------------- +// @routebox/llm-core — 两端共享的 LLM 机制(类型 / 定价算法 / alias) +// 数据(registry 列表、pricing 表)由各 app 注入,不在本包内。 +// --------------------------------------------------------------------------- + +/** 冒烟用,确认跨包解析可用;Task 2 起被真实导出替换。 */ +export const LLM_CORE_VERSION = "1.0.0"; +``` + +- [ ] **Step 5: 给 gateway tsconfig 加路径别名** + +`apps/gateway/tsconfig.json` 当前没有 `paths`。把: +```json + "esModuleInterop": true, + "types": ["bun"] + }, + "include": ["src"] +``` +改为: +```json + "esModuleInterop": true, + "types": ["bun"], + "paths": { + "@routebox/llm-core": ["../../packages/llm-core/src/index.ts"] + } + }, + "include": ["src"] +``` + +- [ ] **Step 6: 冒烟测试 —— 确认 Bun 能经 tsconfig paths 跨包解析** + +Create `packages/llm-core/src/__smoke.test.ts`: +```ts +import { test, expect } from "bun:test"; +import { LLM_CORE_VERSION } from "./index"; + +test("package resolves locally", () => { + expect(LLM_CORE_VERSION).toBe("1.0.0"); +}); +``` +Also create a temporary resolution probe in the gateway to confirm the `@routebox/llm-core` alias resolves at runtime. Create `apps/gateway/src/__smoke.test.ts`: +```ts +import { test, expect } from "bun:test"; +import { LLM_CORE_VERSION } from "@routebox/llm-core"; + +test("gateway resolves @routebox/llm-core via tsconfig paths", () => { + expect(LLM_CORE_VERSION).toBe("1.0.0"); +}); +``` +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test src/__smoke.test.ts +cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun test src/__smoke.test.ts +``` +Expected: both PASS. +**If the gateway probe FAILS to resolve `@routebox/llm-core`:** Bun isn't honoring tsconfig `paths` here. STOP and report — the fallback is to add `"@routebox/llm-core": "workspace:*"` to `apps/gateway/package.json` dependencies and run `pnpm install` (only viable if disk space allows). Do not silently switch approaches; report so the controller decides. + +- [ ] **Step 7: 删除两个 `__smoke.test.ts` 探针** + +```bash +rm apps/gateway/src/__smoke.test.ts packages/llm-core/src/__smoke.test.ts +``` +(`index.ts` 的 `LLM_CORE_VERSION` 保留——无害,且作为包非空的占位,Task 2 会在其旁追加真实导出。) + +- [ ] **Step 8: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add pnpm-workspace.yaml packages/llm-core/package.json packages/llm-core/tsconfig.json packages/llm-core/src/index.ts apps/gateway/tsconfig.json +git commit -m "feat(llm-core): scaffold shared package + tsconfig path resolution (Phase 1)" +``` + +--- + +## Task 2: 共享类型 `types.ts` + +**Files:** +- Create: `packages/llm-core/src/types.ts` +- Modify: `packages/llm-core/src/index.ts` + +- [ ] **Step 1: 创建 `packages/llm-core/src/types.ts`** + +类型与两端现有定义**逐字段一致**(gateway `providers.ts` 的 `ProviderTemplate`、cloud `key-pool.ts` 的 `ProviderTemplate` 同形;`ModelPricing` 即两端 `{ input; output }`)。 + +```ts +// --------------------------------------------------------------------------- +// 共享类型 —— 与两端现有定义逐字段一致(纯类型,运行时零影响) +// --------------------------------------------------------------------------- + +/** Provider API 形态 —— 除 Anthropic 外都讲 OpenAI 协议 */ +export type ProviderFormat = "openai" | "anthropic"; + +/** 静态 provider 模板(元数据,不含 key)—— gateway 与 cloud 同形 */ +export interface ProviderTemplate { + name: string; + envKey: string; + baseUrlEnvKey: string; + defaultBaseUrl: string; + prefixes: string[]; + format: ProviderFormat; + /** 自定义 auth header 名(默认 "Authorization" + "Bearer " 前缀) */ + authHeader?: string; +} + +/** 每 100 万 token 的输入/输出价格 */ +export interface ModelPricing { + input: number; + output: number; +} +``` + +- [ ] **Step 2: 从 index 导出** + +把 `packages/llm-core/src/index.ts` 末尾追加: +```ts + +export type { ProviderFormat, ProviderTemplate, ModelPricing } from "./types"; +``` + +- [ ] **Step 3: 解析验证** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun build src/index.ts --target=bun --outdir=/tmp/llmcore-t2` +Expected: bundle 成功,无错误。 + +- [ ] **Step 4: Commit** + +```bash +git add packages/llm-core/src/types.ts packages/llm-core/src/index.ts +git commit -m "feat(llm-core): shared types (ProviderFormat, ProviderTemplate, ModelPricing)" +``` + +--- + +## Task 3: 共享定价算法 `pricing.ts`(TDD) + +**Files:** +- Create: `packages/llm-core/src/pricing.ts`, `packages/llm-core/src/pricing.test.ts` +- Modify: `packages/llm-core/src/index.ts` + +**关键不变量:** 核心函数必须能**化简为两端各自当前行为**: +- gateway `pricingForModel(model, providerName?)`:顺序 = free-provider(Ollama/LM Studio)→ provider 专属覆盖 → 精确表命中 → 前缀命中 → `{1,3}`。 +- cloud `pricingFor(model)`:精确表命中 → 前缀命中 → `{1,3}`(无 free/覆盖)。 + +- [ ] **Step 1: 写失败测试 `packages/llm-core/src/pricing.test.ts`** + +```ts +import { test, expect } from "bun:test"; +import { pricingForModel, calculateCost } from "./pricing"; +import type { ModelPricing } from "./types"; + +const TABLE: Record = { + "gpt-4o": { input: 2.5, output: 10 }, + "claude-sonnet-4-20250514": { input: 3, output: 15 }, +}; + +test("exact table hit", () => { + expect(pricingForModel("gpt-4o", TABLE)).toEqual({ input: 2.5, output: 10 }); +}); + +test("prefix match (longest key that prefixes the model)", () => { + expect(pricingForModel("gpt-4o-2024-08-06", TABLE)).toEqual({ input: 2.5, output: 10 }); +}); + +test("default fallback {1,3} when no match", () => { + expect(pricingForModel("totally-unknown", TABLE)).toEqual({ input: 1, output: 3 }); +}); + +test("custom fallback honored", () => { + expect(pricingForModel("unknown", TABLE, { fallback: { input: 0.5, output: 0.5 } })) + .toEqual({ input: 0.5, output: 0.5 }); +}); + +test("free providers short-circuit to zero", () => { + expect(pricingForModel("gpt-4o", TABLE, { providerName: "Ollama", freeProviders: ["Ollama", "LM Studio"] })) + .toEqual({ input: 0, output: 0 }); +}); + +test("provider-specific override beats table", () => { + const overrides = { "FLock.io": { "gpt-4o": { input: 9, output: 9 } } }; + expect(pricingForModel("gpt-4o", TABLE, { providerName: "FLock.io", providerOverrides: overrides })) + .toEqual({ input: 9, output: 9 }); +}); + +test("override only applies to the named provider", () => { + const overrides = { "FLock.io": { "gpt-4o": { input: 9, output: 9 } } }; + expect(pricingForModel("gpt-4o", TABLE, { providerName: "OpenAI", providerOverrides: overrides })) + .toEqual({ input: 2.5, output: 10 }); +}); + +test("calculateCost uses (in*input + out*output)/1e6", () => { + // 1000 in * 2.5 + 2000 out * 10 = 2500 + 20000 = 22500 ; /1e6 = 0.0225 + expect(calculateCost("gpt-4o", 1000, 2000, TABLE)).toBeCloseTo(0.0225, 10); +}); + +test("reduces to cloud behavior (no opts): exact then prefix then {1,3}", () => { + expect(pricingForModel("claude-sonnet-4-20250514", TABLE)).toEqual({ input: 3, output: 15 }); + expect(pricingForModel("nope", TABLE)).toEqual({ input: 1, output: 3 }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test src/pricing.test.ts` +Expected: FAIL —— `Cannot find module './pricing'`。 + +- [ ] **Step 3: 实现 `packages/llm-core/src/pricing.ts`** + +```ts +// --------------------------------------------------------------------------- +// 定价算法 —— 纯函数,定价表由调用方注入(数据留在各 app) +// --------------------------------------------------------------------------- + +import type { ModelPricing } from "./types"; + +export interface PricingOptions { + /** 当前 provider 名,用于 free / override 判定 */ + providerName?: string; + /** provider 专属价格覆盖:{ [providerName]: { [model]: ModelPricing } } */ + providerOverrides?: Record>; + /** 视为免费($0)的 provider 名(如本地 Ollama / LM Studio) */ + freeProviders?: string[]; + /** 无命中时的兜底价(默认 { input: 1, output: 3 }) */ + fallback?: ModelPricing; +} + +/** + * 解析某模型的价格。判定顺序: + * free-provider → provider 专属覆盖 → 精确表命中 → 前缀命中 → fallback。 + * 不传 opts 时即「精确 → 前缀 → {1,3}」,与 cloud 当前 `pricingFor` 等价。 + */ +export function pricingForModel( + model: string, + table: Record, + opts: PricingOptions = {}, +): ModelPricing { + const { providerName, providerOverrides, freeProviders, fallback = { input: 1, output: 3 } } = opts; + + if (providerName && freeProviders?.includes(providerName)) { + return { input: 0, output: 0 }; + } + if (providerName && providerOverrides?.[providerName]?.[model]) { + return providerOverrides[providerName][model]; + } + if (table[model]) return table[model]; + for (const [key, val] of Object.entries(table)) { + if (model.startsWith(key)) return val; + } + return fallback; +} + +/** 由 token 数算成本(USD)。公式与两端一致。 */ +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + table: Record, + opts?: PricingOptions, +): number { + const p = pricingForModel(model, table, opts); + return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; +} +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test src/pricing.test.ts` +Expected: PASS(9 tests) + +- [ ] **Step 5: 从 index 导出** + +`packages/llm-core/src/index.ts` 追加: +```ts +export { pricingForModel, calculateCost, type PricingOptions } from "./pricing"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/llm-core/src/pricing.ts packages/llm-core/src/pricing.test.ts packages/llm-core/src/index.ts +git commit -m "feat(llm-core): data-injected pricing algorithm (pricingForModel, calculateCost)" +``` + +--- + +## Task 4: 共享 alias 解析 `aliases.ts`(TDD) + +**Files:** +- Create: `packages/llm-core/src/aliases.ts`, `packages/llm-core/src/aliases.test.ts` +- Modify: `packages/llm-core/src/index.ts` + +- [ ] **Step 1: 写失败测试 `packages/llm-core/src/aliases.test.ts`** + +```ts +import { test, expect } from "bun:test"; +import { resolveAlias } from "./aliases"; + +test("maps a known alias", () => { + expect(resolveAlias("claude-haiku", { "claude-haiku": "claude-haiku-4-20250514" })) + .toBe("claude-haiku-4-20250514"); +}); + +test("passes through unknown model", () => { + expect(resolveAlias("gpt-4o", { "claude-haiku": "x" })).toBe("gpt-4o"); +}); + +test("empty table is identity (cloud behavior)", () => { + expect(resolveAlias("anything", {})).toBe("anything"); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test src/aliases.test.ts` +Expected: FAIL —— module not found。 + +- [ ] **Step 3: 实现 `packages/llm-core/src/aliases.ts`** + +```ts +// --------------------------------------------------------------------------- +// Model alias 解析 —— 别名表由调用方注入(gateway 有表,cloud 传空表) +// --------------------------------------------------------------------------- + +/** 把用户给的模型名按别名表解析为规范 ID;无命中则原样返回。 */ +export function resolveAlias(model: string, aliases: Record): string { + return aliases[model] ?? model; +} +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test src/aliases.test.ts` +Expected: PASS(3 tests) + +- [ ] **Step 5: 从 index 导出 + 完整包测试** + +`packages/llm-core/src/index.ts` 追加: +```ts +export { resolveAlias } from "./aliases"; +``` +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test` +Expected: PASS(pricing 9 + aliases 3 = 12 tests)。 + +- [ ] **Step 6: Commit** + +```bash +git add packages/llm-core/src/aliases.ts packages/llm-core/src/aliases.test.ts packages/llm-core/src/index.ts +git commit -m "feat(llm-core): data-injected alias resolver" +``` + +--- + +## Task 5: gateway 改用共享机制(薄包装,零行为变化) + +**Files:** +- Modify: `apps/gateway/src/lib/providers.ts` + +**策略:** 保留 gateway 的全部数据(`MODEL_PRICING`、`PROVIDER_MODEL_PRICING`、`MODEL_ALIASES`、`PROVIDER_REGISTRY`)与所有导出名,把**算法/类型**换成从核心 import,导出薄包装。这样 gateway 其余文件(proxy.ts 等)对 `./providers` 的 import 全部不变。 + +- [ ] **Step 1: 顶部 import 核心** + +`apps/gateway/src/lib/providers.ts` 第 5 行(`import { getLocalProviderForModel, ... } from "./local-providers";`)之后,加: +```ts +import { + pricingForModel as corePricingForModel, + calculateCost as coreCalculateCost, + resolveAlias as coreResolveAlias, + type ModelPricing, + type ProviderTemplate as CoreProviderTemplate, +} from "@routebox/llm-core"; +``` + +- [ ] **Step 2: `ProviderTemplate` 接口改为复用核心类型** + +把本地接口定义(约 121-130 行): +```ts +export interface ProviderTemplate { + name: string; + envKey: string; + baseUrlEnvKey: string; + defaultBaseUrl: string; + prefixes: string[]; + format: "openai" | "anthropic"; + /** Custom auth header name (default: "Authorization" with "Bearer " prefix) */ + authHeader?: string; +} +``` +替换为(保持导出名 `ProviderTemplate` 不变,供本文件 `PROVIDER_REGISTRY: ProviderTemplate[]` 及其它文件使用): +```ts +export type ProviderTemplate = CoreProviderTemplate; +``` + +- [ ] **Step 3: `pricingForModel` 改为薄包装(行为不变)** + +把现有实现(约 316-332 行): +```ts +export function pricingForModel(model: string, providerName?: string): { input: number; output: number } { + // Local providers are always free + if (providerName === "Ollama" || providerName === "LM Studio") { + return { input: 0, output: 0 }; + } + // Check provider-specific pricing override first + if (providerName && PROVIDER_MODEL_PRICING[providerName]?.[model]) { + return PROVIDER_MODEL_PRICING[providerName][model]; + } + if (MODEL_PRICING[model]) return MODEL_PRICING[model]; + // try prefix match (e.g. "gpt-4o-2024-08-06" → "gpt-4o") + for (const [key, val] of Object.entries(MODEL_PRICING)) { + if (model.startsWith(key)) return val; + } + return { input: 1, output: 3 }; // fallback estimate +} +``` +替换为: +```ts +export function pricingForModel(model: string, providerName?: string): ModelPricing { + return corePricingForModel(model, MODEL_PRICING, { + providerName, + providerOverrides: PROVIDER_MODEL_PRICING, + freeProviders: ["Ollama", "LM Studio"], + fallback: { input: 1, output: 3 }, + }); +} +``` + +- [ ] **Step 4: `calculateCost` 改为薄包装** + +把现有实现(约 334-343 行): +```ts +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + providerName?: string, +): number { + const p = pricingForModel(model, providerName); + return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; +} +``` +替换为: +```ts +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + providerName?: string, +): number { + return coreCalculateCost(model, inputTokens, outputTokens, MODEL_PRICING, { + providerName, + providerOverrides: PROVIDER_MODEL_PRICING, + freeProviders: ["Ollama", "LM Studio"], + fallback: { input: 1, output: 3 }, + }); +} +``` + +- [ ] **Step 5: `resolveModelAlias` 改为薄包装** + +把(约 41-44 行): +```ts +/** Resolve a user-provided model name to the canonical model ID */ +export function resolveModelAlias(model: string): string { + return MODEL_ALIASES[model] ?? model; +} +``` +替换为: +```ts +/** Resolve a user-provided model name to the canonical model ID */ +export function resolveModelAlias(model: string): string { + return coreResolveAlias(model, MODEL_ALIASES); +} +``` +注意:`MODEL_ALIASES` 定义在该函数下方(约 26-39 行),JS 函数体在调用时才求值,提升后仍可访问,无需移动。 + +- [ ] **Step 6: 解析/打包验证** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun build src/lib/providers.ts --target=bun --outdir=/tmp/gw-providers` +Expected: bundle 成功(证明 `@routebox/llm-core` 解析正常、无类型/语法错误)。 + +- [ ] **Step 7: 行为回归 —— 单文件运行相关测试** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/providers.test.ts +bun test src/lib/router.test.ts +bun test src/routes/proxy.test.ts +``` +Expected: 各自全部 PASS(单跑;`providers.test.ts` 覆盖 pricing/registry,`proxy.test.ts` 用到 calculateCost)。若某测试断言了具体价格/成本数值,结果必须与改动前**完全一致**(零行为变化)。 + +- [ ] **Step 8: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/lib/providers.ts +git commit -m "refactor(gateway): use @routebox/llm-core pricing/alias/types (no behavior change)" +``` + +--- + +## Task 6: cloud-gateway 改用共享机制(薄包装,零行为变化) + +**Files:** +- Modify: `apps/cloud-gateway/tsconfig.json` +- Modify: `apps/cloud-gateway/src/routes/proxy.ts` +- Modify: `apps/cloud-gateway/src/lib/key-pool.ts` + +- [ ] **Step 1: cloud tsconfig 加路径别名** + +`apps/cloud-gateway/tsconfig.json` 已有 `paths` 块。把: +```json + "paths": { + "@gateway/*": ["../gateway/src/*"] + } +``` +改为: +```json + "paths": { + "@gateway/*": ["../gateway/src/*"], + "@routebox/llm-core": ["../../packages/llm-core/src/index.ts"] + } +``` + +- [ ] **Step 2: proxy.ts import 核心定价** + +`apps/cloud-gateway/src/routes/proxy.ts` 顶部 import 区加入: +```ts +import { pricingForModel, calculateCost as coreCalculateCost, type ModelPricing } from "@routebox/llm-core"; +``` +(`MODEL_PRICING` 数据保留在本文件,见下。) + +- [ ] **Step 3: `pricingFor` / `calculateCost` 改为薄包装** + +把(约 90-101 行): +```ts +export function pricingFor(model: string): { input: number; output: number } { + if (MODEL_PRICING[model]) return MODEL_PRICING[model]; + for (const [key, val] of Object.entries(MODEL_PRICING)) { + if (model.startsWith(key)) return val; + } + return { input: 1, output: 3 }; +} + +export function calculateCost(model: string, inputTokens: number, outputTokens: number): number { + const p = pricingFor(model); + return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; +} +``` +替换为: +```ts +export function pricingFor(model: string): ModelPricing { + return pricingForModel(model, MODEL_PRICING, { fallback: { input: 1, output: 3 } }); +} + +export function calculateCost(model: string, inputTokens: number, outputTokens: number): number { + return coreCalculateCost(model, inputTokens, outputTokens, MODEL_PRICING, { fallback: { input: 1, output: 3 } }); +} +``` +注意:`MODEL_PRICING` 的类型注解可保留为原样或改为 `Record`(等价)。不要改动表内任何数字。`getModelUserPrice`/`calculateUserCostCents`(其它计费函数)**不在本任务范围**,保持不变——它们内部调用 `pricingFor`,已自动走新实现且行为一致。 + +- [ ] **Step 4: key-pool.ts `ProviderTemplate` 改用核心类型** + +`apps/cloud-gateway/src/lib/key-pool.ts` 顶部 import 区(`import { getCircuitBreaker } from "./circuit-breaker";` 之后)加: +```ts +import type { ProviderTemplate } from "@routebox/llm-core"; +``` +然后删除本地的接口定义(约 21-29 行): +```ts +interface ProviderTemplate { + name: string; + envKey: string; + baseUrlEnvKey: string; + defaultBaseUrl: string; + prefixes: string[]; + format: "openai" | "anthropic"; + authHeader?: string; +} +``` +(`PROVIDER_REGISTRY: ProviderTemplate[]` 现在引用 import 的类型;形状一致,运行时无变化。`CloudProviderConfig` 接口保留不动——它含 `instanceId`,是 cloud 专属。) + +- [ ] **Step 5: 解析/打包验证** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun build src/routes/proxy.ts --target=bun --outdir=/tmp/cl-proxy +bun build src/lib/key-pool.ts --target=bun --outdir=/tmp/cl-keypool +``` +Expected: 两个都 bundle 成功。 + +- [ ] **Step 6: 行为回归测试** + +Run(这些不需要 DATABASE_URL/Redis 的单测应直接跑;需要基础设施的集成测试若因环境跳过/报错属正常,只看与本改动相关的): +``` +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/key-pool.test.ts +bun test src/lib/metrics.test.ts +bun test src/lib/credits.test.ts +``` +Expected: 各自 PASS(或仅因缺基础设施而非本改动失败——需逐一确认失败与本改动无关)。若有断言具体价格/成本的测试,数值必须与改动前一致。 + +- [ ] **Step 7: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/tsconfig.json apps/cloud-gateway/src/routes/proxy.ts apps/cloud-gateway/src/lib/key-pool.ts +git commit -m "refactor(cloud): use @routebox/llm-core pricing/types (no behavior change)" +``` + +--- + +## Task 7: Final —— 全量验证 + 零行为变化确认 + 终审 + +- [ ] **Step 1: llm-core 包全测** + +Run: `cd /Volumes/ROG_500GB/RouteBox/packages/llm-core && bun test` +Expected: 12 PASS。 + +- [ ] **Step 2: 两端入口能 bundle(解析闭环)** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun build src/index.ts --target=bun --outdir=/tmp/gw-entry +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway && bun build src/index.ts --target=bun --outdir=/tmp/cl-entry +``` +Expected: 两个入口都成功 bundle(证明全图 `@routebox/llm-core` import 解析正常)。 + +- [ ] **Step 3: gateway 相关单测复跑(单文件)** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/providers.test.ts && bun test src/lib/router.test.ts && bun test src/routes/proxy.test.ts +``` +Expected: 全 PASS。 + +- [ ] **Step 4: 零行为变化核对** + +对照改动前后,确认:gateway `pricingForModel`/`calculateCost`/`resolveModelAlias` 与 cloud `pricingFor`/`calculateCost` 的输出在所有现有测试下数值一致;无 registry/pricing 数据被改动(`git diff 188274a..HEAD -- '*providers.ts' '*proxy.ts' '*key-pool.ts'` 中应只见 import/包装替换,无数字增删)。 + +- [ ] **Step 5: 派终审 reviewer** + +对整个 Phase 1 commit 范围做一次代码评审,重点:(a) 核心定价函数确实化简为两端原行为;(b) 两端数据(registry/pricing 数字)未被改动;(c) 无新增 any/类型漏洞;(d) tsconfig 路径解析方案稳健。 + +--- + +## Self-Review notes(作者自检) + +- **范围覆盖:** 用户确认的「只共享机制、数据留应用侧」全部落地——类型(Task 2)、定价(Task 3)、alias(Task 4)、两端接线(Task 5/6)。adapter/SSE/registry 统一显式排除,留 Phase 2。 +- **零行为变化论证:** 核心 `pricingForModel` 判定顺序 = gateway 原顺序(free→override→exact→prefix→fallback);cloud 不传 opts 时退化为 exact→prefix→{1,3} = cloud 原行为。`calculateCost` 公式逐字一致。`resolveAlias` 对空表为恒等 = cloud 原行为。两端数据表与 registry **不改一个数字**。 +- **类型一致性:** 核心导出名 `pricingForModel`/`calculateCost`/`resolveAlias`/`ProviderTemplate`/`ModelPricing`/`PricingOptions` 在 Task 2-4 定义,Task 5/6 import 使用一致;gateway 以 `as` 别名避免与本地包装重名。 +- **磁盘/解析风险:** 采用 tsconfig paths(无需 pnpm install),Task 1 Step 6 设了解析冒烟门;若 Bun 不认 paths,已给出 workspace-dep 回退并要求 STOP 上报。 +- **测试隔离:** 已知 gateway 多文件合跑问题,全程单文件验证;cloud 集成测试缺基础设施属环境因素,需区分。 From 2f969b7216c625efc9073eca40c132232a9dbe63 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:34:58 +0800 Subject: [PATCH 18/62] feat(llm-core): scaffold shared package + tsconfig path resolution (Phase 1) --- apps/gateway/tsconfig.json | 5 ++++- packages/llm-core/package.json | 15 +++++++++++++++ packages/llm-core/src/index.ts | 7 +++++++ packages/llm-core/tsconfig.json | 12 ++++++++++++ pnpm-workspace.yaml | 1 + 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 packages/llm-core/package.json create mode 100644 packages/llm-core/src/index.ts create mode 100644 packages/llm-core/tsconfig.json diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json index 9babbf0..bf77c46 100644 --- a/apps/gateway/tsconfig.json +++ b/apps/gateway/tsconfig.json @@ -7,7 +7,10 @@ "skipLibCheck": true, "resolveJsonModule": true, "esModuleInterop": true, - "types": ["bun"] + "types": ["bun"], + "paths": { + "@routebox/llm-core": ["../../packages/llm-core/src/index.ts"] + } }, "include": ["src"] } diff --git a/packages/llm-core/package.json b/packages/llm-core/package.json new file mode 100644 index 0000000..8cb190f --- /dev/null +++ b/packages/llm-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@routebox/llm-core", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts new file mode 100644 index 0000000..2564ea3 --- /dev/null +++ b/packages/llm-core/src/index.ts @@ -0,0 +1,7 @@ +// --------------------------------------------------------------------------- +// @routebox/llm-core — 两端共享的 LLM 机制(类型 / 定价算法 / alias) +// 数据(registry 列表、pricing 表)由各 app 注入,不在本包内。 +// --------------------------------------------------------------------------- + +/** 冒烟用,确认跨包解析可用;Task 2 起被真实导出替换。 */ +export const LLM_CORE_VERSION = "1.0.0"; diff --git a/packages/llm-core/tsconfig.json b/packages/llm-core/tsconfig.json new file mode 100644 index 0000000..67da98a --- /dev/null +++ b/packages/llm-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2161579..fe60d2b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - packages/* ignoredBuiltDependencies: - esbuild From 7eec32b0b49d4792a40b896e41091c775a67c357 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:40:35 +0800 Subject: [PATCH 19/62] =?UTF-8?q?test(gateway):=20fix=20suite=20isolation?= =?UTF-8?q?=20=E2=80=94=20call-time=20token=20read=20+=20raw=20key=20test?= =?UTF-8?q?=20accessor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyToken reads ROUTEBOX_TOKEN at call time so sibling test files can't leak a stale token across the shared module singleton; db.ts gains a test-only __rawProviderKeyForTest so encryption assertions read the real DB singleton regardless of import order. Full gateway suite now passes 63/0 (was order-dependent). --- apps/gateway/src/lib/auth.test.ts | 17 +++++++++------- apps/gateway/src/lib/auth.ts | 6 +++++- apps/gateway/src/lib/db.test.ts | 29 +++++++++++++-------------- apps/gateway/src/lib/db.ts | 14 +++++++++++++ apps/gateway/src/routes/proxy.test.ts | 3 +++ 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/apps/gateway/src/lib/auth.test.ts b/apps/gateway/src/lib/auth.test.ts index e46214f..41149e3 100644 --- a/apps/gateway/src/lib/auth.test.ts +++ b/apps/gateway/src/lib/auth.test.ts @@ -1,11 +1,14 @@ -import { test, expect } from "bun:test"; +import { test, expect, beforeAll, afterAll } from "bun:test"; +import { verifyToken } from "./auth"; -// 在导入被测模块前设置一个固定 token,使 resolveToken 走 env 分支。 -// 使用动态 import,确保该赋值在 auth.ts 顶层求值(捕获 ROUTEBOX_TOKEN)之前生效, -// 覆盖 test-preload.ts 里设置的默认值。 -process.env.ROUTEBOX_TOKEN = "rb_testtoken_constant_time"; - -const { verifyToken } = await import("./auth"); +// verifyToken 在调用时读取 ROUTEBOX_TOKEN,故在本文件的测试运行前设置固定 token, +// 运行后恢复默认值(test-preload.ts 中的 "test-token"),避免污染其它测试文件的共享环境。 +beforeAll(() => { + process.env.ROUTEBOX_TOKEN = "rb_testtoken_constant_time"; +}); +afterAll(() => { + process.env.ROUTEBOX_TOKEN = "test-token"; +}); test("verifyToken accepts the correct token", () => { expect(verifyToken("rb_testtoken_constant_time")).toBe(true); diff --git a/apps/gateway/src/lib/auth.ts b/apps/gateway/src/lib/auth.ts index 5828ae5..cbe1d0d 100644 --- a/apps/gateway/src/lib/auth.ts +++ b/apps/gateway/src/lib/auth.ts @@ -33,9 +33,13 @@ const masked = ROUTEBOX_TOKEN.length > 10 console.log(` ROUTEBOX_TOKEN=${masked} (full token in Settings / keychain)`); export function verifyToken(token: string): boolean { + // 期望值在启动时已解析并记录/持久化(见上)。这里在调用时再读一次 + // ROUTEBOX_TOKEN 环境变量:生产环境该变量在运行期保持不变,故等价于启动值; + // 测试环境下各测试文件在发起请求前设置 ROUTEBOX_TOKEN,从而不依赖模块加载顺序。 + const expected = process.env.ROUTEBOX_TOKEN || ROUTEBOX_TOKEN; // C3: 常量时间比较,避免 token 计时侧信道 const a = Buffer.from(token); - const b = Buffer.from(ROUTEBOX_TOKEN); + const b = Buffer.from(expected); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } diff --git a/apps/gateway/src/lib/db.test.ts b/apps/gateway/src/lib/db.test.ts index 629c705..eb0407b 100644 --- a/apps/gateway/src/lib/db.test.ts +++ b/apps/gateway/src/lib/db.test.ts @@ -1,13 +1,14 @@ -import { test, expect } from "bun:test"; -import { Database } from "bun:sqlite"; +import { test, expect, beforeAll } from "bun:test"; +import { saveProviderKey, loadProviderKey, loadAllProviderKeys, __rawProviderKeyForTest } from "./db"; -const TEST_DB = "/tmp/routebox-test-db.sqlite"; - -// 必须在 import db 之前设置(db.ts 在模块加载时按此路径打开 SQLite) -process.env.ROUTEBOX_DB_KEY = "1".repeat(64); -process.env.ROUTEBOX_DB_PATH = TEST_DB; - -const { saveProviderKey, loadProviderKey, loadAllProviderKeys } = await import("./db"); +// db.ts 的 Database 句柄是模块级单例,其路径由最先导入它的文件决定(可能是 :memory:)。 +// 因此本测试不按固定路径另开 DB,而是通过 __rawProviderKeyForTest 读取同一单例底层表的 +// 原始值——与 DB 路径及测试导入顺序无关。 +// encryptSecret/decryptSecret 在调用时读取 ROUTEBOX_DB_KEY;secrets.test.ts 会在其末个用例 +// 删除该变量,故在本文件用例运行前用 beforeAll 重新设置,确保加解密用同一密钥。 +beforeAll(() => { + process.env.ROUTEBOX_DB_KEY = "1".repeat(64); +}); test("provider key is stored encrypted but reads back as plaintext", () => { saveProviderKey("OpenAI", "sk-secret-abc123"); @@ -16,12 +17,10 @@ test("provider key is stored encrypted but reads back as plaintext", () => { const row = loadProviderKey("OpenAI"); expect(row?.api_key).toBe("sk-secret-abc123"); - // 直接查底层表,值应为密文(不含明文) - const raw = new Database(TEST_DB).query( - "SELECT api_key FROM provider_keys WHERE provider_name = ?", - ).get("OpenAI") as { api_key: string }; - expect(raw.api_key.startsWith("enc:v1:")).toBe(true); - expect(raw.api_key).not.toContain("sk-secret-abc123"); + // 底层表中的值应为密文(不含明文) + const raw = __rawProviderKeyForTest("OpenAI"); + expect(raw?.startsWith("enc:v1:")).toBe(true); + expect(raw).not.toContain("sk-secret-abc123"); }); test("loadAllProviderKeys decrypts every row", () => { diff --git a/apps/gateway/src/lib/db.ts b/apps/gateway/src/lib/db.ts index dc772cb..e7dfc57 100644 --- a/apps/gateway/src/lib/db.ts +++ b/apps/gateway/src/lib/db.ts @@ -138,6 +138,10 @@ const updateProviderKeyValidationStmt = db.prepare(` UPDATE provider_keys SET validated_at = ? WHERE provider_name = ? `); +const rawProviderKeyStmt = db.prepare(` + SELECT api_key FROM provider_keys WHERE provider_name = ? +`); + // ── Request by ID ─────────────────────────────────────────────────────────── const getRequestByIdStmt = db.prepare(` @@ -279,6 +283,16 @@ export function updateProviderKeyValidation(name: string) { updateProviderKeyValidationStmt.run(Date.now(), name); } +/** + * Test-only: read the raw (still-encrypted) api_key directly from the table, + * bypassing decryption. Lets tests assert at-rest encryption against the actual + * module-level Database singleton, independent of its path or test import order. + * Not used in production. + */ +export function __rawProviderKeyForTest(name: string): string | undefined { + return (rawProviderKeyStmt.get(name) as { api_key: string } | undefined)?.api_key; +} + // ── Request by ID ─────────────────────────────────────────────────────────── export function loadRequestById(id: string): RequestRecord | null { diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index ec6a6b3..8ceeae1 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -3,6 +3,9 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; let mockServer: ReturnType; beforeAll(() => { + // Self-contained auth: verifyToken reads ROUTEBOX_TOKEN at call time, so a + // sibling test file (e.g. auth.test.ts) cannot leave a stale token behind. + process.env.ROUTEBOX_TOKEN = "test-token"; mockServer = Bun.serve({ port: 19999, fetch(req: Request) { From 985b8b04955c35aae9d3edebdcf87e71149ac478 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:41:47 +0800 Subject: [PATCH 20/62] feat(llm-core): shared types (ProviderFormat, ProviderTemplate, ModelPricing) --- packages/llm-core/src/index.ts | 2 ++ packages/llm-core/src/types.ts | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/llm-core/src/types.ts diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts index 2564ea3..67a6108 100644 --- a/packages/llm-core/src/index.ts +++ b/packages/llm-core/src/index.ts @@ -5,3 +5,5 @@ /** 冒烟用,确认跨包解析可用;Task 2 起被真实导出替换。 */ export const LLM_CORE_VERSION = "1.0.0"; + +export type { ProviderFormat, ProviderTemplate, ModelPricing } from "./types"; diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts new file mode 100644 index 0000000..8cb45f3 --- /dev/null +++ b/packages/llm-core/src/types.ts @@ -0,0 +1,24 @@ +// --------------------------------------------------------------------------- +// 共享类型 —— 与两端现有定义逐字段一致(纯类型,运行时零影响) +// --------------------------------------------------------------------------- + +/** Provider API 形态 —— 除 Anthropic 外都讲 OpenAI 协议 */ +export type ProviderFormat = "openai" | "anthropic"; + +/** 静态 provider 模板(元数据,不含 key)—— gateway 与 cloud 同形 */ +export interface ProviderTemplate { + name: string; + envKey: string; + baseUrlEnvKey: string; + defaultBaseUrl: string; + prefixes: string[]; + format: ProviderFormat; + /** 自定义 auth header 名(默认 "Authorization" + "Bearer " 前缀) */ + authHeader?: string; +} + +/** 每 100 万 token 的输入/输出价格 */ +export interface ModelPricing { + input: number; + output: number; +} From 3ee24394566c2e8328d3b3511347c6cc348eb316 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:42:33 +0800 Subject: [PATCH 21/62] feat(llm-core): data-injected pricing algorithm (pricingForModel, calculateCost) --- packages/llm-core/src/index.ts | 1 + packages/llm-core/src/pricing.test.ts | 43 ++++++++++++++++++++++ packages/llm-core/src/pricing.ts | 53 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 packages/llm-core/src/pricing.test.ts create mode 100644 packages/llm-core/src/pricing.ts diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts index 67a6108..5399b35 100644 --- a/packages/llm-core/src/index.ts +++ b/packages/llm-core/src/index.ts @@ -7,3 +7,4 @@ export const LLM_CORE_VERSION = "1.0.0"; export type { ProviderFormat, ProviderTemplate, ModelPricing } from "./types"; +export { pricingForModel, calculateCost, type PricingOptions } from "./pricing"; diff --git a/packages/llm-core/src/pricing.test.ts b/packages/llm-core/src/pricing.test.ts new file mode 100644 index 0000000..f06f59f --- /dev/null +++ b/packages/llm-core/src/pricing.test.ts @@ -0,0 +1,43 @@ +import { test, expect } from "bun:test"; +import { pricingForModel, calculateCost } from "./pricing"; +import type { ModelPricing } from "./types"; + +const TABLE: Record = { + "gpt-4o": { input: 2.5, output: 10 }, + "claude-sonnet-4-20250514": { input: 3, output: 15 }, +}; + +test("exact table hit", () => { + expect(pricingForModel("gpt-4o", TABLE)).toEqual({ input: 2.5, output: 10 }); +}); +test("prefix match (longest key that prefixes the model)", () => { + expect(pricingForModel("gpt-4o-2024-08-06", TABLE)).toEqual({ input: 2.5, output: 10 }); +}); +test("default fallback {1,3} when no match", () => { + expect(pricingForModel("totally-unknown", TABLE)).toEqual({ input: 1, output: 3 }); +}); +test("custom fallback honored", () => { + expect(pricingForModel("unknown", TABLE, { fallback: { input: 0.5, output: 0.5 } })) + .toEqual({ input: 0.5, output: 0.5 }); +}); +test("free providers short-circuit to zero", () => { + expect(pricingForModel("gpt-4o", TABLE, { providerName: "Ollama", freeProviders: ["Ollama", "LM Studio"] })) + .toEqual({ input: 0, output: 0 }); +}); +test("provider-specific override beats table", () => { + const overrides = { "FLock.io": { "gpt-4o": { input: 9, output: 9 } } }; + expect(pricingForModel("gpt-4o", TABLE, { providerName: "FLock.io", providerOverrides: overrides })) + .toEqual({ input: 9, output: 9 }); +}); +test("override only applies to the named provider", () => { + const overrides = { "FLock.io": { "gpt-4o": { input: 9, output: 9 } } }; + expect(pricingForModel("gpt-4o", TABLE, { providerName: "OpenAI", providerOverrides: overrides })) + .toEqual({ input: 2.5, output: 10 }); +}); +test("calculateCost uses (in*input + out*output)/1e6", () => { + expect(calculateCost("gpt-4o", 1000, 2000, TABLE)).toBeCloseTo(0.0225, 10); +}); +test("reduces to cloud behavior (no opts): exact then prefix then {1,3}", () => { + expect(pricingForModel("claude-sonnet-4-20250514", TABLE)).toEqual({ input: 3, output: 15 }); + expect(pricingForModel("nope", TABLE)).toEqual({ input: 1, output: 3 }); +}); diff --git a/packages/llm-core/src/pricing.ts b/packages/llm-core/src/pricing.ts new file mode 100644 index 0000000..1ea2182 --- /dev/null +++ b/packages/llm-core/src/pricing.ts @@ -0,0 +1,53 @@ +// --------------------------------------------------------------------------- +// 定价算法 —— 纯函数,定价表由调用方注入(数据留在各 app) +// --------------------------------------------------------------------------- + +import type { ModelPricing } from "./types"; + +export interface PricingOptions { + /** 当前 provider 名,用于 free / override 判定 */ + providerName?: string; + /** provider 专属价格覆盖:{ [providerName]: { [model]: ModelPricing } } */ + providerOverrides?: Record>; + /** 视为免费($0)的 provider 名(如本地 Ollama / LM Studio) */ + freeProviders?: string[]; + /** 无命中时的兜底价(默认 { input: 1, output: 3 }) */ + fallback?: ModelPricing; +} + +/** + * 解析某模型的价格。判定顺序: + * free-provider → provider 专属覆盖 → 精确表命中 → 前缀命中 → fallback。 + * 不传 opts 时即「精确 → 前缀 → {1,3}」,与 cloud 当前 `pricingFor` 等价。 + */ +export function pricingForModel( + model: string, + table: Record, + opts: PricingOptions = {}, +): ModelPricing { + const { providerName, providerOverrides, freeProviders, fallback = { input: 1, output: 3 } } = opts; + + if (providerName && freeProviders?.includes(providerName)) { + return { input: 0, output: 0 }; + } + if (providerName && providerOverrides?.[providerName]?.[model]) { + return providerOverrides[providerName][model]; + } + if (table[model]) return table[model]; + for (const [key, val] of Object.entries(table)) { + if (model.startsWith(key)) return val; + } + return fallback; +} + +/** 由 token 数算成本(USD)。公式与两端一致。 */ +export function calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + table: Record, + opts?: PricingOptions, +): number { + const p = pricingForModel(model, table, opts); + return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; +} From 3e51be41d19d76552e6b8d045aaa027e2284ddb2 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:43:20 +0800 Subject: [PATCH 22/62] feat(llm-core): data-injected alias resolver --- packages/llm-core/src/aliases.test.ts | 13 +++++++++++++ packages/llm-core/src/aliases.ts | 8 ++++++++ packages/llm-core/src/index.ts | 1 + 3 files changed, 22 insertions(+) create mode 100644 packages/llm-core/src/aliases.test.ts create mode 100644 packages/llm-core/src/aliases.ts diff --git a/packages/llm-core/src/aliases.test.ts b/packages/llm-core/src/aliases.test.ts new file mode 100644 index 0000000..112a08c --- /dev/null +++ b/packages/llm-core/src/aliases.test.ts @@ -0,0 +1,13 @@ +import { test, expect } from "bun:test"; +import { resolveAlias } from "./aliases"; + +test("maps a known alias", () => { + expect(resolveAlias("claude-haiku", { "claude-haiku": "claude-haiku-4-20250514" })) + .toBe("claude-haiku-4-20250514"); +}); +test("passes through unknown model", () => { + expect(resolveAlias("gpt-4o", { "claude-haiku": "x" })).toBe("gpt-4o"); +}); +test("empty table is identity (cloud behavior)", () => { + expect(resolveAlias("anything", {})).toBe("anything"); +}); diff --git a/packages/llm-core/src/aliases.ts b/packages/llm-core/src/aliases.ts new file mode 100644 index 0000000..969fb40 --- /dev/null +++ b/packages/llm-core/src/aliases.ts @@ -0,0 +1,8 @@ +// --------------------------------------------------------------------------- +// Model alias 解析 —— 别名表由调用方注入(gateway 有表,cloud 传空表) +// --------------------------------------------------------------------------- + +/** 把用户给的模型名按别名表解析为规范 ID;无命中则原样返回。 */ +export function resolveAlias(model: string, aliases: Record): string { + return aliases[model] ?? model; +} diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts index 5399b35..6e036d3 100644 --- a/packages/llm-core/src/index.ts +++ b/packages/llm-core/src/index.ts @@ -8,3 +8,4 @@ export const LLM_CORE_VERSION = "1.0.0"; export type { ProviderFormat, ProviderTemplate, ModelPricing } from "./types"; export { pricingForModel, calculateCost, type PricingOptions } from "./pricing"; +export { resolveAlias } from "./aliases"; From 6bda204da2f1df0ec81cfcd9cdab06973306f4b3 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:45:03 +0800 Subject: [PATCH 23/62] refactor(gateway): use @routebox/llm-core pricing/alias/types (no behavior change) Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/lib/providers.ts | 50 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/apps/gateway/src/lib/providers.ts b/apps/gateway/src/lib/providers.ts index 3e99320..8c2e378 100644 --- a/apps/gateway/src/lib/providers.ts +++ b/apps/gateway/src/lib/providers.ts @@ -3,6 +3,13 @@ // --------------------------------------------------------------------------- import { getLocalProviderForModel, getLocalProviderConfigs, localProviders } from "./local-providers"; +import { + pricingForModel as corePricingForModel, + calculateCost as coreCalculateCost, + resolveAlias as coreResolveAlias, + type ModelPricing, + type ProviderTemplate as CoreProviderTemplate, +} from "@routebox/llm-core"; export interface ProviderConfig { name: string; @@ -40,7 +47,7 @@ export const MODEL_ALIASES: Record = { /** Resolve a user-provided model name to the canonical model ID */ export function resolveModelAlias(model: string): string { - return MODEL_ALIASES[model] ?? model; + return coreResolveAlias(model, MODEL_ALIASES); } // Pricing per 1 M tokens { input, output } — matches spec exactly @@ -118,16 +125,7 @@ export const MODEL_TIERS: Record = { // Static provider registry — metadata only, no API keys // --------------------------------------------------------------------------- -export interface ProviderTemplate { - name: string; - envKey: string; - baseUrlEnvKey: string; - defaultBaseUrl: string; - prefixes: string[]; - format: "openai" | "anthropic"; - /** Custom auth header name (default: "Authorization" with "Bearer " prefix) */ - authHeader?: string; -} +export type ProviderTemplate = CoreProviderTemplate; export const PROVIDER_REGISTRY: ProviderTemplate[] = [ { @@ -314,21 +312,13 @@ export function providersForModel(model: string): ProviderConfig[] { } /** Lookup pricing — checks provider-specific overrides, then global, then prefix match */ -export function pricingForModel(model: string, providerName?: string): { input: number; output: number } { - // Local providers are always free - if (providerName === "Ollama" || providerName === "LM Studio") { - return { input: 0, output: 0 }; - } - // Check provider-specific pricing override first - if (providerName && PROVIDER_MODEL_PRICING[providerName]?.[model]) { - return PROVIDER_MODEL_PRICING[providerName][model]; - } - if (MODEL_PRICING[model]) return MODEL_PRICING[model]; - // try prefix match (e.g. "gpt-4o-2024-08-06" → "gpt-4o") - for (const [key, val] of Object.entries(MODEL_PRICING)) { - if (model.startsWith(key)) return val; - } - return { input: 1, output: 3 }; // fallback estimate +export function pricingForModel(model: string, providerName?: string): ModelPricing { + return corePricingForModel(model, MODEL_PRICING, { + providerName, + providerOverrides: PROVIDER_MODEL_PRICING, + freeProviders: ["Ollama", "LM Studio"], + fallback: { input: 1, output: 3 }, + }); } /** Calculate cost in USD from token counts */ @@ -338,8 +328,12 @@ export function calculateCost( outputTokens: number, providerName?: string, ): number { - const p = pricingForModel(model, providerName); - return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; + return coreCalculateCost(model, inputTokens, outputTokens, MODEL_PRICING, { + providerName, + providerOverrides: PROVIDER_MODEL_PRICING, + freeProviders: ["Ollama", "LM Studio"], + fallback: { input: 1, output: 3 }, + }); } // --------------------------------------------------------------------------- From 40dba72216236bfefe1d4dfbd5d61e55f7aa8880 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:46:30 +0800 Subject: [PATCH 24/62] refactor(cloud): use @routebox/llm-core pricing/types (no behavior change) --- apps/cloud-gateway/src/lib/key-pool.ts | 11 +---------- apps/cloud-gateway/src/routes/proxy.ts | 12 ++++-------- apps/cloud-gateway/tsconfig.json | 3 ++- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/apps/cloud-gateway/src/lib/key-pool.ts b/apps/cloud-gateway/src/lib/key-pool.ts index be24082..3563daa 100644 --- a/apps/cloud-gateway/src/lib/key-pool.ts +++ b/apps/cloud-gateway/src/lib/key-pool.ts @@ -5,6 +5,7 @@ import { log } from "./logger"; import { getCircuitBreaker } from "./circuit-breaker"; +import type { ProviderTemplate } from "@routebox/llm-core"; export interface CloudProviderConfig { name: string; @@ -18,16 +19,6 @@ export interface CloudProviderConfig { instanceId: string; } -interface ProviderTemplate { - name: string; - envKey: string; - baseUrlEnvKey: string; - defaultBaseUrl: string; - prefixes: string[]; - format: "openai" | "anthropic"; - authHeader?: string; -} - export const PROVIDER_REGISTRY: ProviderTemplate[] = [ { name: "OpenAI", diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index edbc355..f963ca5 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -18,6 +18,7 @@ import { getCircuitBreaker } from "../lib/circuit-breaker"; import { sql } from "../lib/db-cloud"; import { deductCredits, recordCloudRequest } from "../lib/credits"; import { getMarkupForPlan } from "../lib/polar"; +import { pricingForModel, calculateCost as coreCalculateCost, type ModelPricing } from "@routebox/llm-core"; import { getRegistryEntry, getActiveModels } from "../lib/model-registry"; import { resolveStrategy } from "../lib/routing-config"; import { checkDailyQuota, decrementDailyQuota } from "../lib/quota"; @@ -87,17 +88,12 @@ export function resolveAlias(model: string): string { return MODEL_ALIASES[model] ?? model; } -export function pricingFor(model: string): { input: number; output: number } { - if (MODEL_PRICING[model]) return MODEL_PRICING[model]; - for (const [key, val] of Object.entries(MODEL_PRICING)) { - if (model.startsWith(key)) return val; - } - return { input: 1, output: 3 }; +export function pricingFor(model: string): ModelPricing { + return pricingForModel(model, MODEL_PRICING, { fallback: { input: 1, output: 3 } }); } export function calculateCost(model: string, inputTokens: number, outputTokens: number): number { - const p = pricingFor(model); - return (inputTokens * p.input + outputTokens * p.output) / 1_000_000; + return coreCalculateCost(model, inputTokens, outputTokens, MODEL_PRICING, { fallback: { input: 1, output: 3 } }); } /** Effective pricing for a model+plan combination. diff --git a/apps/cloud-gateway/tsconfig.json b/apps/cloud-gateway/tsconfig.json index 5204aee..753b469 100644 --- a/apps/cloud-gateway/tsconfig.json +++ b/apps/cloud-gateway/tsconfig.json @@ -9,7 +9,8 @@ "esModuleInterop": true, "types": ["bun"], "paths": { - "@gateway/*": ["../gateway/src/*"] + "@gateway/*": ["../gateway/src/*"], + "@routebox/llm-core": ["../../packages/llm-core/src/index.ts"] } }, "include": ["src"] From b728d8ecee0ce9b0cb69846140f67a34fe0ebd4d Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:49:29 +0800 Subject: [PATCH 25/62] chore(llm-core): drop unused LLM_CORE_VERSION smoke-test placeholder --- packages/llm-core/src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts index 6e036d3..ff54fc6 100644 --- a/packages/llm-core/src/index.ts +++ b/packages/llm-core/src/index.ts @@ -3,9 +3,6 @@ // 数据(registry 列表、pricing 表)由各 app 注入,不在本包内。 // --------------------------------------------------------------------------- -/** 冒烟用,确认跨包解析可用;Task 2 起被真实导出替换。 */ -export const LLM_CORE_VERSION = "1.0.0"; - export type { ProviderFormat, ProviderTemplate, ModelPricing } from "./types"; export { pricingForModel, calculateCost, type PricingOptions } from "./pricing"; export { resolveAlias } from "./aliases"; From 6fd3a8fa63a62c35780a1d9f15e0d1ec81a52609 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:58:47 +0800 Subject: [PATCH 26/62] =?UTF-8?q?docs:=20add=20Phase=202a=20plan=20?= =?UTF-8?q?=E2=80=94=20gateway=20routing/correctness=20(H1,=20M4,=20H3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 fallback-route bug (catch-retry records wrong provider), M4 (4xx fallback returned as 200), H3 (provider never recovers after 3-strike down). TDD where cleanly testable; H3 via pure computeProviderUp helper to avoid mutating the shared metrics singleton. Streaming robustness (H2/H4/M8 + SSE extraction) deferred to Phase 2b. Co-Authored-By: Claude Fable 5 --- ...11-routebox-phase2a-routing-correctness.md | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase2a-routing-correctness.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase2a-routing-correctness.md b/docs/superpowers/plans/2026-06-11-routebox-phase2a-routing-correctness.md new file mode 100644 index 0000000..3bb4db4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase2a-routing-correctness.md @@ -0,0 +1,331 @@ +# RouteBox Phase 2a — 网关路由/正确性 bug Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复本地网关三个路由/正确性 bug —— fallback 重试成功后记错 provider(H1)、provider 三振后永不恢复(H3)、fallback 收到 4xx 仍当成功返回 200(M4),全部 TDD/带回归测试,不触碰流式结构。 + +**Architecture:** 改动集中在 `apps/gateway`:`routes/proxy.ts`(H1、M4 的重试控制流)与 `lib/metrics.ts`(H3 的 provider 健康判定)。H3 把「是否可用」抽成纯函数 `computeProviderUp(failStreak, lastFailure, now)` 便于无副作用单测,并加入恢复冷却(half-open)。 + +**Tech Stack:** TypeScript + Bun(`bun test`,gateway 全套现为 63/0)。测试用既有 `proxy.test.ts` 的 mock-server 夹具(`Bun.serve` on :19999)+ 新建 `metrics.test.ts`。 + +**Phase 2 拆分说明:** Phase 2 整体偏大,本计划是 **2a**(网关本地路由正确性,隔离、可测、低风险)。流式健壮性(H2 超时杀流、H4 断连/溢出、M8 abort 判定)与 SSE/适配器抽取到 `llm-core` 是 **2b**,另出计划——因为它需要把云端的流式修复抽成共享转换器,体量与风险都更大。 + +--- + +## 背景:三个 bug 的精确定位(已读码确认) + +- **H1** `apps/gateway/src/routes/proxy.ts:535-564` 的 catch-重试路径:网络错误后 fallback 成功时只设了局部变量 `retriedProvider/retriedModel`,**没更新 `route`**。而后续 `:636-638` 的 `finalProvider/finalModel/finalIsFallback` 全部读 `route`(原失败 provider)。后果:流式分支按错的 `finalProvider.format` 选转换器(Anthropic→OpenAI fallback 会用错转换器产出乱流)、`recordRequest` 记错模型/provider、`X-RouteBox-Provider` 头撒谎。对照:5xx 重试路径 `:590` 用 `Object.assign(route, …)` 是对的,catch 路径漏了。 +- **M4** `apps/gateway/src/routes/proxy.ts:587`:`if (retryRes.ok || retryRes.status < 500)` 把 fallback 的 4xx 当成功,落入正常处理 → 记为 success/fallback、以 HTTP 200 把错误体返回客户端。应仅 `retryRes.ok` 才继续。 +- **H3** `apps/gateway/src/lib/metrics.ts:375-383` `isProviderUp` 返回 `failStreak < DOWN_FAIL_STREAK(=3)`;`failStreak` 只在成功记录时归零(`:176`)。但 router(`router.ts:105,238` 等)不会选 down 的 provider,故它永无机会成功 → 一次 3 连失败后永久禁用,直到重启。`getStats`(`:280`)也用同一判据。修复:加入恢复冷却——距 `lastFailure` 超过 `PROVIDER_RECOVERY_MS` 后重新视为可用(half-open),成功则 `failStreak` 归零、再失败则刷新 `lastFailure` 重新冷却。 + +**不在本期(留 2b):** H2(`forward` 的 `AbortSignal.timeout(30_000)` 杀长流)、H4(溢出后 enqueue 崩溃 + 断连不取消上游)、M8(client/timeout abort 不应记 provider down)。H3 的恢复冷却已先行缓解 M8 最坏后果(永久禁用 → 暂时)。 + +--- + +## File Structure + +| 文件 | 责任 | 操作 | +|------|------|------| +| `apps/gateway/src/lib/metrics.ts` | provider 健康判定 + 恢复 | Modify(加 `PROVIDER_RECOVERY_MS`、`computeProviderUp`,改 `isProviderUp`/getStats) | +| `apps/gateway/src/lib/metrics.test.ts` | `computeProviderUp` 纯函数单测 | Create | +| `apps/gateway/src/routes/proxy.ts` | 重试控制流 | Modify(H1 catch-success 更新 route;M4 仅 ok 续) | +| `apps/gateway/src/routes/proxy.test.ts` | H1 集成测试 + M4 best-effort | Modify | + +**执行者必读:** +- 测试:`cd apps/gateway && bun test `;全套 `bun test`(现为 63/0)。提交前 `find . -path ./node_modules -prune -o -name '._*' -delete`。忽略 `non-monotonic index` git 警告。分支 `fix/audit-remediation`。 +- `metrics` 是模块级单例,跨 test 文件共享;**不要**写会把某 provider 永久标 down 的测试(会污染同进程内 `proxy.test.ts`)。H3 因此用纯函数 `computeProviderUp` 单测,不碰单例。 +- H1 集成测试需把某 provider 的 `baseUrl` 临时指向死端口;务必 `try/finally` 还原,避免泄漏到其它测试。 + +--- + +## Task 1: H3 —— provider 恢复冷却(纯函数 + TDD) + +**Files:** +- Modify: `apps/gateway/src/lib/metrics.ts` +- Create: `apps/gateway/src/lib/metrics.test.ts` + +- [ ] **Step 1: 写失败测试 `apps/gateway/src/lib/metrics.test.ts`** + +```ts +import { test, expect } from "bun:test"; +import { computeProviderUp, DOWN_FAIL_STREAK, PROVIDER_RECOVERY_MS } from "./metrics"; + +const T = 1_000_000; // 固定基准时间 + +test("healthy provider (failStreak below threshold) is up", () => { + expect(computeProviderUp(0, 0, T)).toBe(true); + expect(computeProviderUp(DOWN_FAIL_STREAK - 1, T, T)).toBe(true); +}); + +test("provider at/above fail threshold is down right after failure", () => { + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T)).toBe(false); + expect(computeProviderUp(DOWN_FAIL_STREAK + 5, T, T)).toBe(false); +}); + +test("downed provider recovers (half-open) after recovery cooldown", () => { + // 距 lastFailure 不足冷却时间 → 仍 down + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS - 1)).toBe(false); + // 达到冷却 → 重新可用,允许探测 + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS)).toBe(true); + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS + 5000)).toBe(true); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun test src/lib/metrics.test.ts` +Expected: FAIL —— `computeProviderUp`/`PROVIDER_RECOVERY_MS` 未导出。 + +- [ ] **Step 3: 在 metrics.ts 加常量与纯函数** + +在 `apps/gateway/src/lib/metrics.ts` 的 `const DOWN_FAIL_STREAK = 3;`(第 57 行)之后加入: +```ts +/** 标记 down 后,距上次失败多久重新视为可用(half-open 探测) */ +export const PROVIDER_RECOVERY_MS = 60_000; +``` +并把 `const DOWN_FAIL_STREAK = 3;` 改为 `export const DOWN_FAIL_STREAK = 3;`(测试需要导入)。 + +在文件中合适的模块作用域(类定义之外,例如紧接上述常量之后)加入纯函数: +```ts +/** + * 判定 provider 是否可用(纯函数,便于测试)。 + * failStreak 未达阈值 → 可用;达阈值后,距 lastFailure 超过恢复冷却 → 重新可用(half-open)。 + */ +export function computeProviderUp(failStreak: number, lastFailure: number, now: number): boolean { + if (failStreak < DOWN_FAIL_STREAK) return true; + return now - lastFailure >= PROVIDER_RECOVERY_MS; +} +``` + +- [ ] **Step 4: 让 `isProviderUp` 与 getStats 复用纯函数** + +把 `isProviderUp`(约 `:375-383`)的结尾: +```ts + const ps = this.providerState.get(name); + if (!ps) return false; + return ps.failStreak < DOWN_FAIL_STREAK; + } +``` +改为: +```ts + const ps = this.providerState.get(name); + if (!ps) return false; + return computeProviderUp(ps.failStreak, ps.lastFailure, Date.now()); + } +``` + +把 `getStats` 里(约 `:280`): +```ts + const isUp = ps.failStreak < DOWN_FAIL_STREAK; +``` +改为: +```ts + const isUp = computeProviderUp(ps.failStreak, ps.lastFailure, Date.now()); +``` + +(成功时 `failStreak` 归零的逻辑 `:176` 已存在,无需改;恢复后被选中→成功即彻底复位,失败则 `record()`/`markProviderDown` 刷新 `lastFailure` 重新冷却。) + +- [ ] **Step 5: 运行确认通过** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun test src/lib/metrics.test.ts` +Expected: PASS(3 tests) + +- [ ] **Step 6: 全套回归(确认未破坏既有)** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; bun test` +Expected: 全 PASS(原 63 + 新 3 = 66)。 + +- [ ] **Step 7: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/lib/metrics.ts apps/gateway/src/lib/metrics.test.ts +git commit -m "fix(gateway): provider recovery cooldown so 3-strike-down self-heals (H3)" +``` + +--- + +## Task 2: M4 —— fallback 仅在 ok 时续,否则返回上游错误状态 + +**Files:** +- Modify: `apps/gateway/src/routes/proxy.ts` + +- [ ] **Step 1: 收紧 5xx 重试的成功判据** + +把 `apps/gateway/src/routes/proxy.ts:587` 起的块: +```ts + const retryRes = await forward(fallback.provider, body); + if (retryRes.ok || retryRes.status < 500) { + // Retry succeeded — continue with this response + res = retryRes; + Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); + // Fall through to normal response handling below + } else { +``` +改为(仅 2xx 才视为成功;4xx/5xx 都走错误返回): +```ts + const retryRes = await forward(fallback.provider, body); + if (retryRes.ok) { + // Retry succeeded — continue with this response + res = retryRes; + Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); + // Fall through to normal response handling below + } else { +``` +其余(else 分支记 error 并返回 502/上游错误)保持不变 —— 现在 4xx 也会走该分支,把真实状态/错误体返回,而不是伪装成 200 成功。 + +- [ ] **Step 2: 解析/打包验证** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && bun build src/routes/proxy.ts --target=bun --outdir=/tmp/gw-proxy-m4` +Expected: bundle 成功。 + +- [ ] **Step 3: best-effort 集成测试(若可行)** + +说明:用当前测试 provider 配置(每个显式模型仅一个 provider),5xx→跨 provider→4xx 这一具体分支很难确定性触发(`selectRoute(requestedModel,"quality_first")` 多半返回同一 canonical → 不重试)。因此: +- 先尝试构造:在 `proxy.test.ts` 的 mock 中按 `body.model` 返回 503(主)与 400(备),并通过 `metrics.markProviderDown` 让 canonical 暂时不可用以迫使跨 provider 选择;在 try/finally 中恢复(对该 provider record 一次成功以复位 failStreak,或依赖 H3 冷却)。 +- 若经合理尝试无法确定性触发该分支(routing 不产生跨 provider fallback),**不要**伪造测试。改为在测试文件中加一条注释记录该分支的局限,并在报告里说明「M4 经代码审查验证:`retryRes.ok || status<500` → `retryRes.ok`,仅收窄成功集合,4xx 改走既有 else 返回上游状态;无新风险」。 + +具体若可行,加入此测试到 `proxy.test.ts` 的 describe 块内(按需调整 model 名使其路由到可控 provider): +```ts + test("M4: fallback returning 4xx is surfaced as an error, not a 200", async () => { + // 该测试依赖 mock 按 model 返回 503(主)/400(备)且 routing 产生跨 provider fallback。 + // 若当前 provider 配置无法触发跨 provider 重试,跳过并依赖代码审查(见计划 Task 2 Step 3)。 + // 实现者:仅在能确定性触发时保留断言,否则删除此 test 并在报告说明。 + }); +``` + +- [ ] **Step 4: 全套回归** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; bun test` +Expected: 全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/routes/proxy.ts apps/gateway/src/routes/proxy.test.ts +git commit -m "fix(gateway): fallback only succeeds on 2xx; 4xx returns upstream error (M4)" +``` + +--- + +## Task 3: H1 —— catch-重试成功后更新 route(集成测试) + +**Files:** +- Modify: `apps/gateway/src/routes/proxy.ts` +- Modify: `apps/gateway/src/routes/proxy.test.ts` + +- [ ] **Step 1: 写失败的集成测试** + +在 `apps/gateway/src/routes/proxy.test.ts` 的 `describe("POST /v1/chat/completions", ...)` 内加入。思路:把 Anthropic 的 `baseUrl` 临时指向死端口,使请求 `claude-*` 时主 provider fetch 抛错 → catch 重试经 `quality_first` 选到一个 OpenAI 形态 provider(指向 mock)成功。修复前:`finalProvider` 仍是 Anthropic(format=anthropic),响应被 `fromAnthropicResponse` 误处理且 `X-RouteBox-Provider: Anthropic`;修复后:头部为实际成功的 OpenAI 形态 provider,且响应正常。 + +```ts + test("H1: network-error fallback records the provider that actually served it", async () => { + const { providers } = await import("../lib/providers"); + const anthropic = providers.find((p) => p.name === "Anthropic"); + if (!anthropic) { + // 无 Anthropic provider(env 未配)→ 跳过 + return; + } + const originalBaseUrl = anthropic.baseUrl; + anthropic.baseUrl = "http://127.0.0.1:1/v1"; // 死端口 → fetch 抛错 + try { + const res = await proxyRequest({ + model: "claude-3-haiku-20240307", + messages: [{ role: "user", content: "Hello" }], + }); + // 重试成功 → 200,且不再自称 Anthropic + expect(res.status).toBe(200); + const served = res.headers.get("X-RouteBox-Provider"); + expect(served).not.toBe("Anthropic"); + const json = await res.json() as any; + // 来自 OpenAI 形态 mock 的正常响应(未被 Anthropic 转换器破坏) + expect(json.choices[0].message.content).toBe("Hello from mock!"); + expect(json._routebox.provider).toBe(served!.toLowerCase()); + expect(json._routebox.is_fallback).toBe(true); + } finally { + anthropic.baseUrl = originalBaseUrl; + } + }); +``` +注意:此测试会因网络错误对 Anthropic 调用 `markProviderDown`(失败计 1 次,<3,不会 down);为稳妥,测试末尾(finally 内,恢复 baseUrl 后)无需额外复位。若担心顺序影响,可在 finally 里对 Anthropic 记一次成功——但通常 1 次失败无害,优先保持测试简单。 + +- [ ] **Step 2: 运行确认失败(暴露 bug)** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; bun test src/routes/proxy.test.ts -t "H1"` +Expected: FAIL —— 修复前 `X-RouteBox-Provider` 仍为 `Anthropic`(或响应被 Anthropic 转换器破坏),断言不通过。 +若该测试因环境未配 Anthropic 而直接 return 通过,STOP 上报:需要另寻可触发 catch-重试且跨 format 的配置;不要在缺少有效断言时继续。 + +- [ ] **Step 3: 修复 catch-重试成功路径** + +把 `apps/gateway/src/routes/proxy.ts:546-557`(catch 内的 fallback 重试): +```ts + try { + body.model = fallback.model; + if (isStream && fallback.provider.format === "openai" && !fallback.provider.isLocal) { + body.stream_options = { include_usage: true }; + } + res = await forward(fallback.provider, body); + retriedProvider = fallback.provider; + retriedModel = fallback.model; + } catch { + metrics.markProviderDown(fallback.provider.name); + } +``` +改为(成功后把 `route` 更新为实际服务的 provider/model,与 5xx 路径一致,使后续 `finalProvider/finalModel/finalIsFallback` 正确): +```ts + try { + body.model = fallback.model; + if (isStream && fallback.provider.format === "openai" && !fallback.provider.isLocal) { + body.stream_options = { include_usage: true }; + } + res = await forward(fallback.provider, body); + Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); + retriedProvider = fallback.provider; + retriedModel = fallback.model; + } catch { + metrics.markProviderDown(fallback.provider.name); + } +``` +(保留 `retriedProvider/retriedModel` 不动 —— 它们仍被 `:567-569` 的 `activeProvider/activeModel/activeIsFallback` 用于 `!res.ok` 错误分支;现在 `route` 与它们一致。) + +- [ ] **Step 4: 运行确认通过** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; bun test src/routes/proxy.test.ts -t "H1"` +Expected: PASS。 + +- [ ] **Step 5: 全套回归** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; bun test` +Expected: 全 PASS。 + +- [ ] **Step 6: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/routes/proxy.ts apps/gateway/src/routes/proxy.test.ts +git commit -m "fix(gateway): update route on network-error fallback so served provider is recorded (H1)" +``` + +--- + +## Task 4: Final —— 全量回归 + 终审 + +- [ ] **Step 1: gateway 全套** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; find . -name '._*' -delete 2>/dev/null; bun test` +Expected: 全 PASS(≥66,取决于 M4 测试是否保留)。 + +- [ ] **Step 2: 派终审 reviewer** + +对 Phase 2a commit 范围做评审,重点:(a) H1 修复后 catch-成功与 5xx-成功两条路径对 `route` 的更新一致,`finalProvider/finalModel/finalIsFallback` 在所有路径都指向实际服务者;(b) M4 收窄后 4xx 不再被当成功,且 else 分支正确返回上游状态;(c) H3 `computeProviderUp` 逻辑正确、`isProviderUp`/getStats 都已切换、成功复位路径完好;(d) 无新增竞态或回归。 + +--- + +## Self-Review notes(作者自检) + +- **范围:** 本期仅 H1/M4/H3,均 gateway 本地、隔离可测;H2/H4/M8 明确留 2b(需 SSE 抽取到 llm-core,体量/风险更大)。 +- **零副作用测试:** H3 用纯函数 `computeProviderUp` 单测,绝不把单例 provider 永久标 down;H1 集成测试用 try/finally 还原 baseUrl。两者都不会破坏现在 63/0 的全套。 +- **M4 诚实性:** 该 bug 的具体重试分支在当前单 provider/模型配置下难以确定性触发;计划要求实现者要么给出能触发的确定性测试,要么删测并在报告里以代码审查论证(改动仅收窄成功集合,无新风险)——不得伪造测试。 +- **H1 论证:** 修复使 catch-重试成功路径与既有 5xx 路径(`:590`)对 `route` 的处理一致;`finalProvider/finalModel/finalIsFallback`(`:636-638`)随之正确,流式转换器选型、计费、响应头同时被修正。 +- **依赖:** 不依赖 Phase 2b;可独立合并。 From ed633ccba38ce643640fca94383ac4e57a7f95a6 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:03:55 +0800 Subject: [PATCH 27/62] fix(gateway): provider recovery cooldown so 3-strike-down self-heals (H3) Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/lib/metrics.test.ts | 20 ++++++++++++++++++++ apps/gateway/src/lib/metrics.ts | 18 +++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 apps/gateway/src/lib/metrics.test.ts diff --git a/apps/gateway/src/lib/metrics.test.ts b/apps/gateway/src/lib/metrics.test.ts new file mode 100644 index 0000000..6cd30e4 --- /dev/null +++ b/apps/gateway/src/lib/metrics.test.ts @@ -0,0 +1,20 @@ +import { test, expect } from "bun:test"; +import { computeProviderUp, DOWN_FAIL_STREAK, PROVIDER_RECOVERY_MS } from "./metrics"; + +const T = 1_000_000; // 固定基准时间 + +test("healthy provider (failStreak below threshold) is up", () => { + expect(computeProviderUp(0, 0, T)).toBe(true); + expect(computeProviderUp(DOWN_FAIL_STREAK - 1, T, T)).toBe(true); +}); + +test("provider at/above fail threshold is down right after failure", () => { + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T)).toBe(false); + expect(computeProviderUp(DOWN_FAIL_STREAK + 5, T, T)).toBe(false); +}); + +test("downed provider recovers (half-open) after recovery cooldown", () => { + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS - 1)).toBe(false); + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS)).toBe(true); + expect(computeProviderUp(DOWN_FAIL_STREAK, T, T + PROVIDER_RECOVERY_MS + 5000)).toBe(true); +}); diff --git a/apps/gateway/src/lib/metrics.ts b/apps/gateway/src/lib/metrics.ts index 76c2372..74fd1dd 100644 --- a/apps/gateway/src/lib/metrics.ts +++ b/apps/gateway/src/lib/metrics.ts @@ -54,7 +54,19 @@ interface ProviderState { const MAX_LOG = 200; const MAX_SPARKLINE = 30; const LATENCY_WINDOW = 20; -const DOWN_FAIL_STREAK = 3; +export const DOWN_FAIL_STREAK = 3; + +/** 标记 down 后,距上次失败多久重新视为可用(half-open 探测) */ +export const PROVIDER_RECOVERY_MS = 60_000; + +/** + * 判定 provider 是否可用(纯函数,便于测试)。 + * failStreak 未达阈值 → 可用;达阈值后,距 lastFailure 超过恢复冷却 → 重新可用(half-open)。 + */ +export function computeProviderUp(failStreak: number, lastFailure: number, now: number): boolean { + if (failStreak < DOWN_FAIL_STREAK) return true; + return now - lastFailure >= PROVIDER_RECOVERY_MS; +} class MetricsStore { private log: RequestRecord[] = []; @@ -277,7 +289,7 @@ class MetricsStore { const avgLatency = ps.latencySamples.length ? Math.round(ps.latencySamples.reduce((a, b) => a + b, 0) / ps.latencySamples.length) : 0; - const isUp = ps.failStreak < DOWN_FAIL_STREAK; + const isUp = computeProviderUp(ps.failStreak, ps.lastFailure, Date.now()); providerSnapshots.push({ name: p.name, latency: avgLatency, @@ -379,7 +391,7 @@ class MetricsStore { const ps = this.providerState.get(name); if (!ps) return false; - return ps.failStreak < DOWN_FAIL_STREAK; + return computeProviderUp(ps.failStreak, ps.lastFailure, Date.now()); } /** Sync provider state when providers array changes (after rebuildProviders) */ From 59a2965b461e40ea007a369f9b673c691f7779f2 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:07:34 +0800 Subject: [PATCH 28/62] fix(gateway): fallback only succeeds on 2xx; 4xx returns upstream error (M4) Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/routes/proxy.test.ts | 58 +++++++++++++++++++++++++++ apps/gateway/src/routes/proxy.ts | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index 8ceeae1..6825658 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -15,6 +15,25 @@ beforeAll(() => { const authEcho = req.headers.get("authorization") || ""; const litellmEcho = req.headers.get("x-litellm-api-key") || ""; return req.json().then((body: any) => { + // Deterministic failure injection (M4 test): only triggered when a + // request carries the sentinel marker in its first message, so other + // tests using the same models are unaffected. The mock returns a + // per-model HTTP status read from the marker's status map. + const firstContent = typeof body.messages?.[0]?.content === "string" + ? body.messages[0].content + : ""; + const failMatch = firstContent.match(/__M4_FAIL__:(\{.*\})/); + if (failMatch) { + const statusMap = JSON.parse(failMatch[1]) as Record; + const forced = statusMap[body.model]; + if (forced) { + return Response.json( + { error: { message: `mock forced ${forced} for ${body.model}` } }, + { status: forced }, + ); + } + } + if (body.stream) { // Streaming response const encoder = new TextEncoder(); @@ -212,6 +231,45 @@ describe("POST /v1/chat/completions", () => { expect(json._litellm).toBe("test-flock"); expect(json._auth).toBe(""); }); + + test("M4: primary 5xx → fallback 4xx returns upstream error, NOT 200", async () => { + const { metrics } = await import("../lib/metrics"); + + // Arrange: prime OpenAI to failStreak=2 (still UP, threshold is 3) so the + // top-level route picks OpenAI (isFallback:false). The handler's single + // markProviderDown inside the 5xx branch then trips OpenAI to DOWN, so the + // in-branch selectRoute("gpt-4o","quality_first") falls back to FLock.io + // (kimi-k2-thinking) — a DIFFERENT provider — and retries it. + metrics.markProviderDown("OpenAI"); + metrics.markProviderDown("OpenAI"); + + try { + // Mock: gpt-4o (primary, OpenAI) → 503; kimi-k2-thinking (retry, FLock) → 400. + const marker = `__M4_FAIL__:${JSON.stringify({ "gpt-4o": 503, "kimi-k2-thinking": 400 })}`; + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: marker }], + }); + + // The fallback returned 4xx (not 2xx), so it must NOT be treated as a + // success. Pre-fix (retryRes.ok || retryRes.status < 500) returned the + // 400 body as a 200. Post-fix the error branch returns 502. + expect(res.status).toBe(502); + expect(res.status).not.toBe(200); + const json = await res.json() as any; + expect(json.error?.type).toBe("upstream_error"); + } finally { + // Restore shared singleton state: reset both providers to healthy so + // sibling tests (and other test files) see them UP. + const reset = (provider: string, model: string) => + metrics.record({ + timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, + totalTokens: 0, cost: 0, latencyMs: 1, status: "success", + }); + reset("OpenAI", "gpt-4o"); + reset("FLock.io", "kimi-k2-thinking"); + } + }); }); describe("GET /health", () => { diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index 27a1dd7..8eb550e 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -584,7 +584,7 @@ app.post("/chat/completions", async (c) => { body.stream_options = { include_usage: true }; } const retryRes = await forward(fallback.provider, body); - if (retryRes.ok || retryRes.status < 500) { + if (retryRes.ok) { // Retry succeeded — continue with this response res = retryRes; Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); From f520ca89abcf070a65afe95ff65ad93c04ff4a1b Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:12:35 +0800 Subject: [PATCH 29/62] fix(gateway): update route on network-error fallback so served provider is recorded (H1) Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/routes/proxy.test.ts | 39 +++++++++++++++++++++++++++ apps/gateway/src/routes/proxy.ts | 1 + 2 files changed, 40 insertions(+) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index 6825658..bca9f3c 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -270,6 +270,45 @@ describe("POST /v1/chat/completions", () => { reset("FLock.io", "kimi-k2-thinking"); } }); + + test("H1: network-error cross-provider fallback records the provider that actually served it", async () => { + const { providers } = await import("../lib/providers"); + const { metrics } = await import("../lib/metrics"); + const anthropic = providers.find((p) => p.name === "Anthropic"); + if (!anthropic) return; // env didn't configure Anthropic — skip + const originalBaseUrl = anthropic.baseUrl; + anthropic.baseUrl = "http://127.0.0.1:1/v1"; // dead port → fetch throws + // Prime Anthropic to failStreak=2 (still up); the handler's single in-catch + // markProviderDown then trips it to 3=down, forcing a cross-provider fallback. + metrics.markProviderDown("Anthropic"); + metrics.markProviderDown("Anthropic"); + try { + const res = await proxyRequest({ + model: "claude-sonnet-4-20250514", + messages: [{ role: "user", content: "Hello" }], + }); + expect(res.status).toBe(200); + const served = res.headers.get("X-RouteBox-Provider"); + expect(served).not.toBe("Anthropic"); + const json = await res.json() as any; + expect(json.choices[0].message.content).toBe("Hello from mock!"); + expect(json._routebox.provider).toBe(served!.toLowerCase()); + expect(json._routebox.is_fallback).toBe(true); + } finally { + anthropic.baseUrl = originalBaseUrl; + // Restore shared singleton state: reset Anthropic (primed down) and the + // served fallback provider to healthy using the same success-recording + // mechanism the M4 test uses, so sibling tests see all providers UP. + const reset = (provider: string, model: string) => + metrics.record({ + timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, + totalTokens: 0, cost: 0, latencyMs: 1, status: "success", + }); + reset("Anthropic", "claude-sonnet-4-20250514"); + reset("OpenAI", "gpt-4o"); + reset("FLock.io", "kimi-k2-thinking"); + } + }); }); describe("GET /health", () => { diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index 8eb550e..b1a2c9f 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -549,6 +549,7 @@ app.post("/chat/completions", async (c) => { body.stream_options = { include_usage: true }; } res = await forward(fallback.provider, body); + Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); retriedProvider = fallback.provider; retriedModel = fallback.model; } catch { From 6ab891e56e21a2b1e2cca720f3d8eef54b7bc3bb Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:16:44 +0800 Subject: [PATCH 30/62] test(gateway): add required id field to reset() RequestRecord (tsc hygiene) --- apps/gateway/src/routes/proxy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index bca9f3c..6936374 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -263,7 +263,7 @@ describe("POST /v1/chat/completions", () => { // sibling tests (and other test files) see them UP. const reset = (provider: string, model: string) => metrics.record({ - timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, + id: crypto.randomUUID(), timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, latencyMs: 1, status: "success", }); reset("OpenAI", "gpt-4o"); @@ -301,7 +301,7 @@ describe("POST /v1/chat/completions", () => { // mechanism the M4 test uses, so sibling tests see all providers UP. const reset = (provider: string, model: string) => metrics.record({ - timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, + id: crypto.randomUUID(), timestamp: Date.now(), provider, model, inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, latencyMs: 1, status: "success", }); reset("Anthropic", "claude-sonnet-4-20250514"); From 052a62884b1721de7ec97d796c70131507592b7f Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:29:12 +0800 Subject: [PATCH 31/62] =?UTF-8?q?docs:=20add=20Phase=202b=20plan=20?= =?UTF-8?q?=E2=80=94=20streaming=20robustness=20(H2,=20H4,=20M8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix both gateways' streaming bugs in place (no shared-transformer extraction, since the two have diverged in capability): gateway gains guarded enqueue / once-latch / no-crash overflow / idle timer / TTFB timeout / client-abort propagation (H4, H2, M8); cloud clears its overall request timeout once streaming begins (H2). Transformer extraction to llm-core deferred to a later maintenance refactor. Co-Authored-By: Claude Fable 5 --- ...1-routebox-phase2b-streaming-robustness.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase2b-streaming-robustness.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase2b-streaming-robustness.md b/docs/superpowers/plans/2026-06-11-routebox-phase2b-streaming-robustness.md new file mode 100644 index 0000000..1003a8f --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase2b-streaming-robustness.md @@ -0,0 +1,368 @@ +# RouteBox Phase 2b — 流式健壮性 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 就地修复两端的流式健壮性问题 —— 网关流式转换器崩溃/卡死/不可取消(H4)、总超时杀健康长流(H2)、客户端断连不传播、abort 被误记为 provider down(M8);云端清除流式开始后仍在跑的整体超时(H2)。两端各自保留现有能力(网关的 tool 流式、云端的健壮性),不做共享转换器抽取(列为后续维护重构)。 + +**Architecture:** 改动集中在 `apps/gateway/src/routes/proxy.ts`(两个 SSE 转换器 + forward/handler 的超时与 abort 接线)与 `apps/cloud-gateway/src/routes/proxy.ts`(整体超时的清除时机)。网关移植云端已验证的模式:guarded enqueue、`callOnDone` once-latch、空闲计时器(`reader.cancel`)、溢出不崩溃、断流 token 估算;并把 `forward` 的「整段 fetch 超时」改为「首字节超时(连接阶段)+ 流开始后交给空闲计时器」,同时把客户端 `c.req.raw.signal` 传播到上游。 + +**Tech Stack:** TypeScript + Bun(`bun test`,gateway 现 68/0)。流式超时常量做成 env 可覆盖,便于测试用小值确定性触发。 + +**Phase 2 拆分:** 这是 Phase 2 的第二半(2a 已完成路由/正确性 H1/M4/H3)。SSE 转换器/Anthropic 适配器抽取到 `llm-core` 经评估为「能力分叉的大合并」,本期**不做**,留作后续纯维护重构。 + +--- + +## 背景:bug 精确定位(已读码确认) + +**网关 `apps/gateway/src/routes/proxy.ts`:** +- **H4-崩溃**:`anthropicStreamToOpenAI`(`:177-179`)与 `openaiStreamPassthrough`(`:324-327`)在缓冲溢出时 `controller.error(...)` 后 `break`,但执行继续到循环后的 `pushChunk(meta)`/`controller.close()`(`:282-294`、`:373-388`)→ 在已 error 的 controller 上 enqueue 抛错 → `start()` 异步 reject(未处理),且 `onDone` 不执行 → 请求不被记录。 +- **H4-不可取消 + enqueue 不安全**:`pushChunk`/`controller.enqueue` 未 try/catch(客户端断开后 enqueue 抛错);无 `cancel()`、不监听 `c.req.raw.signal`,客户端断开后上游 reader 仍读完整响应(token 照烧)。 +- **H2-超时杀流**:`forwardOpenAI`/`forwardAnthropic`(`:90`、`:108`)用 `AbortSignal.timeout(30_000)`(本地 120_000)覆盖整段 fetch(含流式 body 消费);任何总耗时 >30s 的流式生成被中断为 `stream_error`。 +- **M8**:catch(`:538`)对任何 throw(含上面的 30s timeout abort、客户端 abort)都 `markProviderDown`;慢但健康、或客户端主动断开,都被记为 provider 故障。 + +**云端 `apps/cloud-gateway/src/routes/proxy.ts`:** +- **H2**:整体 `requestTimeout = setTimeout(abort, REQUEST_TIMEOUT_MS=60_000)`(`:848`)只在 `onDone`(`:1070`,流结束后)清除 → 健康长流在 60s 被 abort。云端已有空闲计时器(`STREAM_IDLE_TIMEOUT_MS`)覆盖「卡死流」场景,故整体超时应在**流开始时**清除。 + +**参考实现:** 云端 `anthropicStreamToOpenAI`/`openaiStreamPassthrough`(`cloud-gateway/src/routes/proxy.ts:228-470`)已含 guarded `push`/`enqueue`、`callOnDone` once-latch(`doneCalled`)、`resetIdleTimer`(`reader.cancel`)、`resolveOutputTokens`、溢出推 error 事件后 `break`(不 `controller.error`)。网关移植这些模式,但**保留**网关版的 tool_use 流式逻辑(云端版没有)。 + +--- + +## File Structure + +| 文件 | 责任 | 操作 | +|------|------|------| +| `apps/gateway/src/routes/proxy.ts` | SSE 转换器 + forward/handler 超时/abort | Modify | +| `apps/gateway/src/routes/proxy.test.ts` | 溢出不崩溃 + once-latch + 长流不被杀 测试 | Modify | +| `apps/cloud-gateway/src/routes/proxy.ts` | 流开始时清除整体超时 | Modify | + +**执行者必读:** +- 测试:`cd apps/gateway && bun test`(现 68/0);提交前 `find . -path ./node_modules -prune -o -name '._*' -delete`。忽略 `non-monotonic index` 警告。分支 `fix/audit-remediation`。 +- gateway 全套同进程跑,`metrics` 单例共享 —— 新测试若动 provider 健康态须 finally 复位(参考 proxy.test.ts 既有 M4/H1 测试的 `reset()`)。 +- 流式超时常量做成 env 可覆盖(`ROUTEBOX_STREAM_IDLE_MS`),测试用小值(如 200ms)确定性触发空闲取消,避免 30s 等待。 + +--- + +## Task 1: 网关转换器健壮化(H4 + 空闲计时器) + +**Files:** +- Modify: `apps/gateway/src/routes/proxy.ts` +- Modify: `apps/gateway/src/routes/proxy.test.ts` + +对 **两个** 转换器 `anthropicStreamToOpenAI`、`openaiStreamPassthrough` 应用同一组结构性改动,保留各自现有的 chunk 发射逻辑(尤其 anthropic 版的 tool_use 处理)。 + +- [ ] **Step 1: 加空闲超时常量** + +在 `apps/gateway/src/routes/proxy.ts:24`(`const MAX_STREAM_BUFFER = ...`)之后加入: +```ts +const STREAM_IDLE_TIMEOUT_MS = Number(process.env.ROUTEBOX_STREAM_IDLE_MS) || 30_000; // 无数据超过此时长则关闭流 +``` + +- [ ] **Step 2: 写失败测试(溢出不崩溃 + 记录)** + +先扩展 mock,使其能按请求产出一个「超大无换行」流以触发溢出。在 `apps/gateway/src/routes/proxy.test.ts` 的 mock streaming 分支(`if (body.stream)`)开头,按 sentinel 产出不同流。找到 `if (body.stream) {` 块,在其内最前面加入: +```ts + // 溢出测试:首条 user 消息含 __OVERFLOW__ 时,产出一个 >1MB 的无换行块 + const firstContent = Array.isArray(body.messages) && typeof body.messages[0]?.content === "string" + ? body.messages[0].content as string : ""; + if (firstContent.includes("__OVERFLOW__")) { + const huge = "x".repeat(1024 * 1024 + 10); + const ovStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: {"choices":[{"delta":{"content":"${huge}"}}]}`)); + controller.close(); + }, + }); + return new Response(ovStream, { headers: { "Content-Type": "text/event-stream" } }); + } +``` +然后在 `describe("POST /v1/chat/completions", ...)` 内加测试: +```ts + test("H4: stream buffer overflow does not crash; emits error event and [DONE]", async () => { + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "__OVERFLOW__ please" }], + stream: true, + }); + expect(res.status).toBe(200); + const text = await res.text(); + // 不崩溃:仍能读到完整响应体,含溢出错误事件与结束标记 + expect(text).toContain("stream_overflow"); + expect(text).toContain("[DONE]"); + }); +``` + +- [ ] **Step 3: 运行确认失败** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; find . -name '._*' -delete 2>/dev/null; bun test src/routes/proxy.test.ts -t "H4"` +Expected: FAIL —— 当前溢出路径 `controller.error` 后继续 enqueue 抛错,响应体不含 `stream_overflow`/`[DONE]`(或流以异常中断)。 + +- [ ] **Step 4: 健壮化 `anthropicStreamToOpenAI`** + +在该函数顶部的状态变量区(`let toolCallIndex = -1;` 之后,约 `:162`)加入: +```ts + let streamedChars = 0; + let doneCalled = false; + let idleTimer: ReturnType | null = null; + const resolveOutputTokens = () => (outputTokens > 0 ? outputTokens : Math.ceil(streamedChars / 4)); + const callOnDone = (usage: { input: number; output: number }) => { + if (doneCalled) return; + doneCalled = true; + if (idleTimer) clearTimeout(idleTimer); + onDone(usage); + }; +``` +把内部的 `function pushChunk(data: string)`(`:168-170`)改为 guarded: +```ts + function pushChunk(data: string) { + try { controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } catch { /* closed */ } + } + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + reader.cancel().catch(() => {}); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: resolveOutputTokens() }); + }, STREAM_IDLE_TIMEOUT_MS); + }; + resetIdleTimer(); +``` +在 `while (true)` 循环里、`if (done) break;` 之后加入 `resetIdleTimer();`。 +在文本增量处理(`text_delta` 分支,`:221` 附近,push content 之前)加入累计:`streamedChars += (evt.delta.text as string).length;` +把溢出处理(`:177-180`): +```ts + if (buffer.length > MAX_STREAM_BUFFER) { + controller.error(new Error("Stream buffer overflow")); + break; + } +``` +改为(推错误事件后 break,**不**调用 `controller.error`,使循环后的收尾安全执行): +```ts + if (buffer.length > MAX_STREAM_BUFFER) { + pushChunk(JSON.stringify({ + error: { message: "Stream buffer overflow — response truncated", type: "server_error", code: "stream_overflow" }, + })); + break; + } +``` +把 `finally { reader.releaseLock(); }` 之后的收尾段(`:279-295`)改为:在 `pushChunk([DONE])`/`controller.close()` 前后用 `resolveOutputTokens()` 计算 meta,并把 `controller.close()` 包 try/catch、把 `onDone(...)` 换成 `callOnDone(...)`。具体: +```ts + if (idleTimer) clearTimeout(idleTimer); + + const finalOutput = resolveOutputTokens(); + const totalTokens = inputTokens + finalOutput; + const metaCost = calculateCost(model, inputTokens, finalOutput, streamMeta.provider); + pushChunk(JSON.stringify({ + object: "routebox.meta", + provider: streamMeta.provider.toLowerCase(), + model, + requested_model: streamMeta.requestedModel, + usage: { prompt_tokens: inputTokens, completion_tokens: finalOutput, total_tokens: totalTokens }, + cost: metaCost, + latency_ms: Math.round(performance.now() - streamMeta.startMs), + is_fallback: streamMeta.isFallback, + })); + pushChunk("[DONE]"); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: finalOutput }); +``` + +- [ ] **Step 5: 健壮化 `openaiStreamPassthrough`(同样的五处)** + +在状态变量区(`let metaInjected = false;` 之后,约 `:311`)加入相同的 `streamedChars/doneCalled/idleTimer/resolveOutputTokens/callOnDone`。 +把内部 enqueue 包一层 guarded helper:在 `const reader = upstream.getReader();`/`const encoder = ...` 之后加入: +```ts + const enqueue = (data: Uint8Array) => { try { controller.enqueue(data); } catch { /* closed */ } }; + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + reader.cancel().catch(() => {}); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: resolveOutputTokens() }); + }, STREAM_IDLE_TIMEOUT_MS); + }; + resetIdleTimer(); +``` +把该函数内所有 `controller.enqueue(...)` 调用替换为 `enqueue(...)`。 +在 `if (done) break;` 之后加 `resetIdleTimer();`。 +在解析 chunk 累计内容处(`:351-355` 的 try 内,解析出 delta 后)加入:`const _d = chunk.choices?.[0]?.delta; if (_d?.content) streamedChars += (_d.content as string).length;`(放在已有的 usage 累计旁)。 +把溢出处理(`:324-327`): +```ts + if (buffer.length > MAX_STREAM_BUFFER) { + controller.error(new Error("Stream buffer overflow")); + break; + } +``` +改为: +```ts + if (buffer.length > MAX_STREAM_BUFFER) { + enqueue(encoder.encode(`data: ${JSON.stringify({ error: { message: "Stream buffer overflow — response truncated", type: "server_error", code: "stream_overflow" } })}\n\n`)); + break; + } +``` +收尾段(`:369-389`):在 fallback meta 注入前加 `if (idleTimer) clearTimeout(idleTimer);`;把 `metaInjected` 分支里的 token 用 `resolveOutputTokens()`;把 `controller.close()` 包 try/catch;`onDone(...)` 换 `callOnDone(...)`。注意保留既有的 `[DONE]`/meta 注入逻辑结构,仅替换 enqueue→guarded、close→try/catch、onDone→callOnDone、output token→resolveOutputTokens。 + +- [ ] **Step 6: 运行 H4 测试确认通过 + 既有流式测试仍过** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; find . -name '._*' -delete 2>/dev/null; bun test src/routes/proxy.test.ts` +Expected: 全 PASS(含新 H4 测试 + 既有 "streaming: returns SSE with [DONE]")。 + +- [ ] **Step 7: 全套回归 + 提交** + +Run: `bun test`(期望全 PASS)。 +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/routes/proxy.ts apps/gateway/src/routes/proxy.test.ts +git commit -m "fix(gateway): harden SSE transformers — guarded enqueue, once-latch, no-crash overflow, idle timer (H4)" +``` + +--- + +## Task 2: 网关 forward/handler 超时与 abort 重构(H2 + 断连传播 + M8) + +**Files:** +- Modify: `apps/gateway/src/routes/proxy.ts` + +目标:连接/首字节阶段有超时(防卡在握手),但流式 body 一旦开始就交给 Task 1 的空闲计时器,不再被整段超时杀死;客户端断开传播到上游;客户端 abort 不记 provider down(M8)。 + +- [ ] **Step 1: `forward` 系列接受外部 AbortSignal** + +把 `forwardOpenAI`(`:68-92`)、`forwardAnthropic`(`:94-110`)、`forward`(`:112-119`)的签名各加一个可选 `signal?: AbortSignal`,并把 fetch 里的 `signal: AbortSignal.timeout(...)` 改为 `signal: signal ?? AbortSignal.timeout(provider.isLocal ? 120_000 : 30_000)`(forwardOpenAI)/`signal: signal ?? AbortSignal.timeout(30_000)`(forwardAnthropic)。`forward(provider, body, signal)` 透传 signal 给两者。保留 `redirect: "error"`。 + +- [ ] **Step 2: handler 里建 AbortController + 首字节超时 + 断连传播** + +在主 handler `app.post("/chat/completions", ...)` 里、`const startMs = performance.now();`(`:530`)之后、`let res: Response;` 之前,加入: +```ts + // 客户端断连 → 取消上游;首字节阶段超时,流开始后交给空闲计时器 + const clientSignal = c.req.raw.signal; + const abortController = new AbortController(); + const upstreamSignal = abortController.signal; + const onClientAbort = () => abortController.abort(); + clientSignal?.addEventListener("abort", onClientAbort, { once: true }); + const CONNECT_TIMEOUT_MS = Number(process.env.ROUTEBOX_CONNECT_TIMEOUT_MS) || 30_000; + let connectTimer: ReturnType | null = setTimeout(() => abortController.abort(), CONNECT_TIMEOUT_MS); + const clearConnectTimer = () => { if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } }; +``` + +- [ ] **Step 3: 所有 `forward(...)` 调用传入 `upstreamSignal`** + +把 handler 内三处 `await forward(provider, body)` / `await forward(fallback.provider, body)`(`:536`、`:551`、`:586`)都改为传入 `upstreamSignal`,例如 `await forward(provider, body, upstreamSignal)`。 + +- [ ] **Step 4: M8 —— 客户端 abort 不记 provider down** + +把首个 catch(`:537-538`): +```ts + } catch (err) { + metrics.markProviderDown(provider.name); +``` +改为(客户端主动断开时不计 provider 故障): +```ts + } catch (err) { + if (!clientSignal?.aborted) metrics.markProviderDown(provider.name); +``` +(catch 内 fallback 失败的 `markProviderDown(fallback.provider.name)`、以及 5xx 路径的 markProviderDown 同理可加 `if (!clientSignal?.aborted)` 守卫——对每处 markProviderDown 调用加同一守卫。) + +- [ ] **Step 5: 流式开始时清除连接超时;非流式在读完后清除** + +在「Streaming response」分支(`:641`,`if (isStream && res!.body) {` 内,构造 stream 之前)加入 `clearConnectTimer();`(此后由转换器空闲计时器治理;客户端断连仍经 `upstreamSignal` 传播)。 +在「Non-streaming response」分支(`:678`,`const latencyMs = ...` 之前)加入: +```ts + clearConnectTimer(); + clientSignal?.removeEventListener("abort", onClientAbort); +``` +另外,流式分支返回前无需移除断连监听(断连要继续传播);但 `onClientAbort` 用了 `{ once: true }`,触发后自动移除,无泄漏。 + +- [ ] **Step 6: 验证 —— 长流不再被整段超时杀死(用小超时值确定性测试)** + +在 `proxy.test.ts` 加测试:把 `ROUTEBOX_CONNECT_TIMEOUT_MS` 设为很小(如 300ms),mock 在首字节后延迟 >300ms 再发后续 chunk,断言流仍完整(不被连接超时中断)。由于 env 在模块加载时读入 handler 内是每请求读取(`Number(process.env...)` 在 handler 体内)——确认 Step 2 的常量是在 handler **体内**读取 env(每请求求值),以便测试可在请求前 `process.env.ROUTEBOX_CONNECT_TIMEOUT_MS = "300"`。给出测试: +```ts + test("H2: streaming response is not killed by the connect timeout once data flows", async () => { + process.env.ROUTEBOX_CONNECT_TIMEOUT_MS = "300"; + try { + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "__SLOWSTREAM__" }], + stream: true, + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain("[DONE]"); + expect(text).toContain("world"); // 慢 chunk 仍送达 + } finally { + delete process.env.ROUTEBOX_CONNECT_TIMEOUT_MS; + } + }); +``` +并在 mock streaming 分支加 sentinel `__SLOWSTREAM__`:首 chunk 立即发,第二个 chunk 用 `await Bun.sleep(500)` 后发(>300ms 连接超时),最后 `[DONE]`。若 Step 2 把 `CONNECT_TIMEOUT_MS` 写成模块级常量(只读一次),改为 handler 体内读取,确保测试可控。 + +- [ ] **Step 7: 全套回归 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; find . -name '._*' -delete 2>/dev/null; bun test` +Expected: 全 PASS。 +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/routes/proxy.ts apps/gateway/src/routes/proxy.test.ts +git commit -m "fix(gateway): TTFB timeout + client-abort propagation; stream not killed by total timeout; abort not marked down (H2, M8)" +``` + +--- + +## Task 3: 云端整体超时在流开始时清除(H2) + +**Files:** +- Modify: `apps/cloud-gateway/src/routes/proxy.ts` + +- [ ] **Step 1: 流式分支开始处清除整体超时** + +在「Streaming response」分支(`:1051`,`if (isStream && res.body) {` 内最前面)加入: +```ts + // H2: 流已开始 —— 清除整体请求超时,改由转换器空闲计时器治理; + // 客户端断连仍经 abortController 传播到上游 + clearTimeout(requestTimeout); +``` +`onDone`(`:1070`)里既有的 `clearTimeout(requestTimeout)` 保留(幂等,无害)。客户端断连监听 `onClientAbort` 仍需在 `onDone` 里移除(已有,`:1071`),不动。 + +- [ ] **Step 2: 验证 bundle** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway && bun build src/routes/proxy.ts --target=bun --outdir=/tmp/cl-proxy-h2` +Expected: bundle 成功。 + +- [ ] **Step 3: 回归(不需基础设施的单测)** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway && find . -name '._*' -delete 2>/dev/null; bun test src/lib/key-pool.test.ts src/lib/metrics.test.ts src/lib/credits.test.ts` +Expected: PASS(若有 proxy 相关单测也跑;需基础设施的集成测试环境缺失属正常)。 + +- [ ] **Step 4: 提交** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/routes/proxy.ts +git commit -m "fix(cloud): clear overall request timeout once streaming begins so long streams aren't killed (H2)" +``` + +--- + +## Task 4: Final —— 全量回归 + 终审 + +- [ ] **Step 1: 两端测试** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox/apps/gateway && rm -f /tmp/routebox-test-db.sqlite* 2>/dev/null; find . -name '._*' -delete 2>/dev/null; bun test +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway && bun test src/lib/key-pool.test.ts src/lib/metrics.test.ts src/lib/credits.test.ts +``` +Expected: gateway 全 PASS;cloud 上述单测 PASS。 + +- [ ] **Step 2: 派终审 reviewer** + +重点:(a) 两个网关转换器的五处健壮化都到位(guarded enqueue、once-latch、溢出不崩溃且收尾安全、空闲计时器、resolveOutputTokens),且**保留** anthropic 版的 tool_use 流式逻辑;(b) forward/handler 的连接超时在流开始时清除、客户端断连经 `upstreamSignal` 传播到所有 forward 调用、所有 markProviderDown 加了 `!clientSignal?.aborted` 守卫(M8);(c) 云端整体超时在流开始时清除且 onDone 清除仍幂等;(d) 无新增竞态(idleTimer 与正常收尾的双重 callOnDone 被 once-latch 吸收);(e) 既有流式行为/meta 注入不变。 + +--- + +## Self-Review notes(作者自检) + +- **范围:** 仅就地修两端流式 bug(H2/H4/M8 网关 + H2 云端),不抽共享转换器(能力分叉的大合并,留后续维护重构)。两端各自保留现有能力。 +- **once-latch 必要性:** 加空闲计时器后,「空闲触发的 callOnDone」与「正常收尾的 callOnDone」可能都跑;`doneCalled` 确保 onDone(记账)只执行一次。 +- **H2 设计:** 连接/首字节阶段有超时(防握手卡死),`await forward` 拿到 headers 即 `clearConnectTimer`;流式 body 此后由转换器空闲计时器(无数据 30s)治理,健康长流不再被杀。非流式仍在读完后清除。常量在 handler 体内读 env,测试可用小值确定性触发。 +- **M8:** 对每处 `markProviderDown` 加 `!clientSignal?.aborted` 守卫——客户端主动断开不算 provider 故障;真实连接失败/首字节超时仍计入(配合 2a 的 H3 恢复冷却,不会永久禁用)。 +- **测试可行性:** 溢出(确定性,sentinel)、长流不被连接超时杀(小超时 + 慢 mock chunk)可确定性测;真正的 30s 空闲取消不做实时等待测试(常量已可 env 覆盖,留给手动/后续)。 +- **依赖:** 依赖 2a 已合入(同一 proxy.ts);与 Phase 3 无耦合。 From f5ca8ad0cf61249163b52a81764f28b79e6420ba Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:35:56 +0800 Subject: [PATCH 32/62] fix(gateway): harden SSE transformers for overflow and idle timeout (H4) --- apps/gateway/src/routes/proxy.test.ts | 24 +++++++ apps/gateway/src/routes/proxy.ts | 96 +++++++++++++++++++++------ 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index 6936374..0eb7660 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -35,6 +35,18 @@ beforeAll(() => { } if (body.stream) { + const firstContent = Array.isArray(body.messages) && typeof body.messages[0]?.content === "string" + ? body.messages[0].content as string : ""; + if (firstContent.includes("__OVERFLOW__")) { + const huge = "x".repeat(1024 * 1024 + 10); + const ovStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: {"choices":[{"delta":{"content":"${huge}"}}]}`)); + controller.close(); + }, + }); + return new Response(ovStream, { headers: { "Content-Type": "text/event-stream" } }); + } // Streaming response const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -183,6 +195,18 @@ describe("POST /v1/chat/completions", () => { expect(text).toContain("world"); }); + test("H4: stream buffer overflow does not crash; emits error event and [DONE]", async () => { + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "__OVERFLOW__ please" }], + stream: true, + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain("stream_overflow"); + expect(text).toContain("[DONE]"); + }); + test("401 without auth", async () => { const res = await gateway.fetch(new Request("http://localhost/v1/chat/completions", { method: "POST", diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index b1a2c9f..cf0a661 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -22,6 +22,7 @@ import { braveSearch, formatSearchContext, isSearchEnabled } from "../lib/brave- const app = new Hono(); const MAX_STREAM_BUFFER = 1024 * 1024; // 1 MB — reject malformed streams that never emit newlines +const STREAM_IDLE_TIMEOUT_MS = Number(process.env.ROUTEBOX_STREAM_IDLE_MS) || 30_000; // 无数据超过此时长则关闭流 // ── In-memory rate limiter: 60 requests per minute per auth token ──────── @@ -160,22 +161,44 @@ function anthropicStreamToOpenAI( let currentToolCallName = ""; let currentToolCallInput = ""; let toolCallIndex = -1; + let streamedChars = 0; + let doneCalled = false; + let idleTimer: ReturnType | null = null; + const resolveOutputTokens = () => (outputTokens > 0 ? outputTokens : Math.ceil(streamedChars / 4)); + const callOnDone = (usage: { input: number; output: number }) => { + if (doneCalled) return; + doneCalled = true; + if (idleTimer) clearTimeout(idleTimer); + onDone(usage); + }; return new ReadableStream({ async start(controller) { const reader = upstream.getReader(); function pushChunk(data: string) { - controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + try { controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } catch { /* closed */ } } + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + reader.cancel().catch(() => {}); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: resolveOutputTokens() }); + }, STREAM_IDLE_TIMEOUT_MS); + }; + resetIdleTimer(); try { while (true) { const { done, value } = await reader.read(); if (done) break; + resetIdleTimer(); buffer += decoder.decode(value, { stream: true }); if (buffer.length > MAX_STREAM_BUFFER) { - controller.error(new Error("Stream buffer overflow")); + pushChunk(JSON.stringify({ + error: { message: "Stream buffer overflow — response truncated", type: "server_error", code: "stream_overflow" }, + })); break; } @@ -219,6 +242,7 @@ function anthropicStreamToOpenAI( } } else if (evt.type === "content_block_delta") { if (evt.delta?.type === "text_delta" && evt.delta?.text) { + streamedChars += (evt.delta.text as string).length; pushChunk(JSON.stringify({ id: messageId, object: "chat.completion.chunk", @@ -277,22 +301,24 @@ function anthropicStreamToOpenAI( } // Inject routebox.meta before [DONE] - const totalTokens = inputTokens + outputTokens; - const metaCost = calculateCost(model, inputTokens, outputTokens, streamMeta.provider); + if (idleTimer) clearTimeout(idleTimer); + const finalOutput = resolveOutputTokens(); + const totalTokens = inputTokens + finalOutput; + const metaCost = calculateCost(model, inputTokens, finalOutput, streamMeta.provider); pushChunk(JSON.stringify({ object: "routebox.meta", provider: streamMeta.provider.toLowerCase(), model, requested_model: streamMeta.requestedModel, - usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: totalTokens }, + usage: { prompt_tokens: inputTokens, completion_tokens: finalOutput, total_tokens: totalTokens }, cost: metaCost, latency_ms: Math.round(performance.now() - streamMeta.startMs), is_fallback: streamMeta.isFallback, })); pushChunk("[DONE]"); - controller.close(); - onDone({ input: inputTokens, output: outputTokens }); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: finalOutput }); }, }); } @@ -309,20 +335,45 @@ function openaiStreamPassthrough( let inputTokens = 0; let outputTokens = 0; let metaInjected = false; + let streamedChars = 0; + let doneCalled = false; + let idleTimer: ReturnType | null = null; + const resolveOutputTokens = () => (outputTokens > 0 ? outputTokens : Math.ceil(streamedChars / 4)); + const callOnDone = (usage: { input: number; output: number }) => { + if (doneCalled) return; + doneCalled = true; + if (idleTimer) clearTimeout(idleTimer); + onDone(usage); + }; return new ReadableStream({ async start(controller) { const reader = upstream.getReader(); const encoder = new TextEncoder(); + const enqueue = (data: Uint8Array) => { + try { controller.enqueue(data); } catch { /* closed */ } + }; + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + reader.cancel().catch(() => {}); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: resolveOutputTokens() }); + }, STREAM_IDLE_TIMEOUT_MS); + }; + resetIdleTimer(); try { while (true) { const { done, value } = await reader.read(); if (done) break; + resetIdleTimer(); // Parse for usage and intercept [DONE] to inject _routebox buffer += decoder.decode(value, { stream: true }); if (buffer.length > MAX_STREAM_BUFFER) { - controller.error(new Error("Stream buffer overflow")); + enqueue(encoder.encode(`data: ${JSON.stringify({ + error: { message: "Stream buffer overflow — response truncated", type: "server_error", code: "stream_overflow" }, + })}\n\n`)); break; } const lines = buffer.split("\n"); @@ -334,7 +385,7 @@ function openaiStreamPassthrough( // Inject routebox.meta before [DONE] const totalTok = inputTokens + outputTokens; const metaCost = calculateCost(streamMeta.requestedModel, inputTokens, outputTokens, streamMeta.provider); - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + enqueue(encoder.encode(`data: ${JSON.stringify({ object: "routebox.meta", provider: streamMeta.provider.toLowerCase(), model: streamMeta.requestedModel, @@ -344,7 +395,7 @@ function openaiStreamPassthrough( latency_ms: Math.round(performance.now() - streamMeta.startMs), is_fallback: streamMeta.isFallback, })}\n\n`)); - controller.enqueue(encoder.encode(`${line}\n\n`)); + enqueue(encoder.encode(`${line}\n\n`)); metaInjected = true; } else { try { @@ -353,40 +404,45 @@ function openaiStreamPassthrough( inputTokens = chunk.usage.prompt_tokens ?? inputTokens; outputTokens = chunk.usage.completion_tokens ?? outputTokens; } + const delta = chunk.choices?.[0]?.delta; + if (delta?.content) streamedChars += (delta.content as string).length; } catch { /* skip */ } - controller.enqueue(encoder.encode(`${line}\n\n`)); + enqueue(encoder.encode(`${line}\n\n`)); } } else if (line.trim()) { // Pass through non-data lines (e.g. event: lines) - controller.enqueue(encoder.encode(`${line}\n`)); + enqueue(encoder.encode(`${line}\n`)); } } } } catch (err) { // Send SSE error event to the client const errorMessage = err instanceof Error ? err.message : "Stream read error"; - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: { message: errorMessage, type: "stream_error" } })}\n\n`)); + enqueue(encoder.encode(`data: ${JSON.stringify({ error: { message: errorMessage, type: "stream_error" } })}\n\n`)); } finally { reader.releaseLock(); } + if (idleTimer) clearTimeout(idleTimer); // Fallback: if stream ended without [DONE], inject meta now (e.g. local LM Studio) if (!metaInjected) { - const totalTok = inputTokens + outputTokens; - const metaCost = calculateCost(streamMeta.requestedModel, inputTokens, outputTokens, streamMeta.provider); - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + const finalOutput = resolveOutputTokens(); + const totalTok = inputTokens + finalOutput; + const metaCost = calculateCost(streamMeta.requestedModel, inputTokens, finalOutput, streamMeta.provider); + enqueue(encoder.encode(`data: ${JSON.stringify({ object: "routebox.meta", provider: streamMeta.provider.toLowerCase(), model: streamMeta.requestedModel, requested_model: streamMeta.requestedModel, - usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: totalTok }, + usage: { prompt_tokens: inputTokens, completion_tokens: finalOutput, total_tokens: totalTok }, cost: metaCost, latency_ms: Math.round(performance.now() - streamMeta.startMs), is_fallback: streamMeta.isFallback, })}\n\n`)); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + enqueue(encoder.encode("data: [DONE]\n\n")); + outputTokens = finalOutput; } - controller.close(); - onDone({ input: inputTokens, output: outputTokens }); + try { controller.close(); } catch { /* already closed */ } + callOnDone({ input: inputTokens, output: resolveOutputTokens() }); }, }); } From 5e5dee260887b2d89b5126230f23558c5f562a32 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:37:38 +0800 Subject: [PATCH 33/62] fix(gateway): use connect timeout and propagate client abort for streams (H2, M8) --- apps/gateway/src/routes/proxy.test.ts | 41 +++++++++++++++++++++ apps/gateway/src/routes/proxy.ts | 51 +++++++++++++++++++++------ 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index 0eb7660..7425c82 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -47,6 +47,23 @@ beforeAll(() => { }); return new Response(ovStream, { headers: { "Content-Type": "text/event-stream" } }); } + if (firstContent.includes("__SLOWSTREAM__")) { + const encoder = new TextEncoder(); + const slowStream = new ReadableStream({ + async start(controller) { + const chunk1 = { id: "chatcmpl-slow", object: "chat.completion.chunk", model: body.model, choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }] }; + const chunk2 = { id: "chatcmpl-slow", object: "chat.completion.chunk", model: body.model, choices: [{ index: 0, delta: { content: " world" }, finish_reason: null }] }; + const chunk3 = { id: "chatcmpl-slow", object: "chat.completion.chunk", model: body.model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk1)}\n\n`)); + await Bun.sleep(120); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk2)}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk3)}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(slowStream, { headers: { "Content-Type": "text/event-stream" } }); + } // Streaming response const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -207,6 +224,30 @@ describe("POST /v1/chat/completions", () => { expect(text).toContain("[DONE]"); }); + test("H2: streaming response is not killed by the connect timeout once data flows", async () => { + const originalTimeout = AbortSignal.timeout; + (AbortSignal as any).timeout = () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + return controller.signal; + }; + process.env.ROUTEBOX_CONNECT_TIMEOUT_MS = "50"; + try { + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "__SLOWSTREAM__ please" }], + stream: true, + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain("[DONE]"); + expect(text).toContain("world"); + } finally { + (AbortSignal as any).timeout = originalTimeout; + delete process.env.ROUTEBOX_CONNECT_TIMEOUT_MS; + } + }); + test("401 without auth", async () => { const res = await gateway.fetch(new Request("http://localhost/v1/chat/completions", { method: "POST", diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index cf0a661..2749a08 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -69,6 +69,7 @@ function checkRateLimit(token: string): { allowed: boolean; retryAfterMs: number async function forwardOpenAI( provider: ProviderConfig, body: OpenAIChatRequest, + signal?: AbortSignal, ): Promise { const headers: Record = { "Content-Type": "application/json", @@ -88,13 +89,14 @@ async function forwardOpenAI( headers, body: JSON.stringify(body), redirect: "error", - signal: AbortSignal.timeout(provider.isLocal ? 120_000 : 30_000), + signal: signal ?? AbortSignal.timeout(provider.isLocal ? 120_000 : 30_000), }); } async function forwardAnthropic( provider: ProviderConfig, body: OpenAIChatRequest, + signal?: AbortSignal, ): Promise { const anthropicBody = toAnthropicRequest(body); return fetch(`${provider.baseUrl}/messages`, { @@ -106,17 +108,18 @@ async function forwardAnthropic( }, body: JSON.stringify(anthropicBody), redirect: "error", - signal: AbortSignal.timeout(30_000), + signal: signal ?? AbortSignal.timeout(30_000), }); } async function forward( provider: ProviderConfig, body: OpenAIChatRequest, + signal?: AbortSignal, ): Promise { return provider.format === "anthropic" - ? forwardAnthropic(provider, body) - : forwardOpenAI(provider, body); + ? forwardAnthropic(provider, body, signal) + : forwardOpenAI(provider, body, signal); } // ── Extract usage from provider response (non-stream) ─────────────────────── @@ -584,14 +587,31 @@ app.post("/chat/completions", async (c) => { } const startMs = performance.now(); + const clientSignal = c.req.raw.signal; + const abortController = new AbortController(); + const upstreamSignal = abortController.signal; + const onClientAbort = () => abortController.abort(); + clientSignal?.addEventListener("abort", onClientAbort, { once: true }); + const CONNECT_TIMEOUT_MS = Number(process.env.ROUTEBOX_CONNECT_TIMEOUT_MS) || 30_000; + let connectTimer: ReturnType | null = setTimeout(() => abortController.abort(), CONNECT_TIMEOUT_MS); + const clearConnectTimer = () => { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + }; + const cleanupUpstreamSignal = () => { + clearConnectTimer(); + clientSignal?.removeEventListener("abort", onClientAbort); + }; let res: Response; let retriedProvider: ProviderConfig | undefined; let retriedModel: string | undefined; try { - res = await forward(provider, body); + res = await forward(provider, body, upstreamSignal); } catch (err) { - metrics.markProviderDown(provider.name); + if (!clientSignal?.aborted) metrics.markProviderDown(provider.name); const latencyMs = Math.round(performance.now() - startMs); recordRequest(requestedModel, model, provider.name, 0, 0, 0, latencyMs, "error"); @@ -604,16 +624,17 @@ app.post("/chat/completions", async (c) => { if (isStream && fallback.provider.format === "openai" && !fallback.provider.isLocal) { body.stream_options = { include_usage: true }; } - res = await forward(fallback.provider, body); + res = await forward(fallback.provider, body, upstreamSignal); Object.assign(route, { provider: fallback.provider, model: fallback.model, isFallback: true }); retriedProvider = fallback.provider; retriedModel = fallback.model; } catch { - metrics.markProviderDown(fallback.provider.name); + if (!clientSignal?.aborted) metrics.markProviderDown(fallback.provider.name); } } } if (!res!) { + cleanupUpstreamSignal(); return c.json({ error: { message: `Provider ${provider.name} unreachable`, type: "server_error" }, }, 502); @@ -632,7 +653,7 @@ app.post("/chat/completions", async (c) => { // Auto-retry on 5xx with a different provider if (errStatus >= 500 && !activeIsFallback) { - metrics.markProviderDown(activeProvider.name); + if (!clientSignal?.aborted) metrics.markProviderDown(activeProvider.name); const fallback = selectRoute(requestedModel, "quality_first"); if (fallback && fallback.provider.name !== activeProvider.name) { try { @@ -640,7 +661,7 @@ app.post("/chat/completions", async (c) => { if (isStream && fallback.provider.format === "openai" && !fallback.provider.isLocal) { body.stream_options = { include_usage: true }; } - const retryRes = await forward(fallback.provider, body); + const retryRes = await forward(fallback.provider, body, upstreamSignal); if (retryRes.ok) { // Retry succeeded — continue with this response res = retryRes; @@ -648,6 +669,7 @@ app.post("/chat/completions", async (c) => { // Fall through to normal response handling below } else { // Retry also failed + cleanupUpstreamSignal(); recordRequest(requestedModel, activeModel, activeProvider.name, 0, 0, 0, latencyMs, "error"); return c.json({ error: { @@ -658,7 +680,8 @@ app.post("/chat/completions", async (c) => { }, 502); } } catch { - metrics.markProviderDown(fallback.provider.name); + if (!clientSignal?.aborted) metrics.markProviderDown(fallback.provider.name); + cleanupUpstreamSignal(); recordRequest(requestedModel, activeModel, activeProvider.name, 0, 0, 0, latencyMs, "error"); return c.json({ error: { @@ -668,6 +691,7 @@ app.post("/chat/completions", async (c) => { }, 502); } } else { + cleanupUpstreamSignal(); recordRequest(requestedModel, activeModel, activeProvider.name, 0, 0, 0, latencyMs, "error"); return c.json({ error: { @@ -678,6 +702,7 @@ app.post("/chat/completions", async (c) => { }, 502); } } else { + cleanupUpstreamSignal(); recordRequest(requestedModel, activeModel, activeProvider.name, 0, 0, 0, latencyMs, "error"); return c.json({ error: { @@ -696,6 +721,7 @@ app.post("/chat/completions", async (c) => { // ── Streaming response ── if (isStream && res!.body) { + clearConnectTimer(); const streamMetaObj: StreamMeta = { provider: finalProvider.name, requestedModel, @@ -704,6 +730,7 @@ app.post("/chat/completions", async (c) => { }; const stream = finalProvider.format === "anthropic" ? anthropicStreamToOpenAI(res!.body, finalModel, streamMetaObj, (usage) => { + cleanupUpstreamSignal(); const latencyMs = Math.round(performance.now() - startMs); recordRequest( requestedModel, finalModel, finalProvider.name, @@ -712,6 +739,7 @@ app.post("/chat/completions", async (c) => { ); }) : openaiStreamPassthrough(res!.body, streamMetaObj, (usage) => { + cleanupUpstreamSignal(); const latencyMs = Math.round(performance.now() - startMs); recordRequest( requestedModel, finalModel, finalProvider.name, @@ -733,6 +761,7 @@ app.post("/chat/completions", async (c) => { } // ── Non-streaming response ── + cleanupUpstreamSignal(); const latencyMs = Math.round(performance.now() - startMs); const json = await res!.json() as Record; From f3334cfb8752e0ba151c37e5ce573f42a1b42764 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:39:02 +0800 Subject: [PATCH 34/62] fix(cloud): clear request timeout when streaming begins (H2) --- .../src/routes/proxy-e2e.test.ts | 64 +++++++++++++++++++ apps/cloud-gateway/src/routes/proxy.ts | 2 + 2 files changed, 66 insertions(+) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index f154a8a..fa38b87 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -55,6 +55,7 @@ mock.module("../lib/routing-config", () => ({ mock.module("../lib/quota", () => ({ checkDailyQuota: async () => ({ allowed: true, remaining: Infinity, resetAt: new Date() }), incrementDailyQuota: async () => {}, + decrementDailyQuota: async () => {}, })); mock.module("../lib/provider-config", () => ({ @@ -422,6 +423,69 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("T5: Streaming full chain", () => { + test("H2: clears overall request timeout when streaming response begins", async () => { + const app = createApp({ userPlan: "pro" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + const encoder = new TextEncoder(); + mockFetch(async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + `data: ${JSON.stringify({ + id: "chatcmpl-stream", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: "Hi" }, finish_reason: null }], + })}\n\n`, + )); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }); + + const requestTimer = { type: "request-timeout" }; + let requestTimeoutCleared = false; + const origSetTimeout = globalThis.setTimeout; + const origClearTimeout = globalThis.clearTimeout; + // @ts-ignore - intercept only the route-level 60s request timeout. + globalThis.setTimeout = (fn: () => void, ms?: number, ...args: unknown[]) => { + if (ms === 60_000) return requestTimer as any; + return origSetTimeout(fn as any, ms as any, ...(args as any[])); + }; + // @ts-ignore - clearTimeout accepts the sentinel returned above. + globalThis.clearTimeout = (timer?: unknown) => { + if (timer === requestTimer) { + requestTimeoutCleared = true; + return; + } + return origClearTimeout(timer as any); + }; + + let res: Response | undefined; + try { + res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...CHAT_BODY, stream: true }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + expect(requestTimeoutCleared).toBe(true); + } finally { + globalThis.setTimeout = origSetTimeout; + globalThis.clearTimeout = origClearTimeout; + await res?.body?.cancel().catch(() => {}); + } + }); + test("SSE stream with routebox.meta and deductCredits called", async () => { const app = createApp({ userPlan: "pro" }); diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index f963ca5..aa5f82c 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -1050,6 +1050,8 @@ app.post("/chat/completions", creditsCheck, async (c) => { // ── Streaming response ── if (isStream && res.body) { + // Stream has started; the transformer idle timer handles stuck streams. + clearTimeout(requestTimeout); const streamMetaObj: StreamMeta = { provider: activeProvider.name, requestedModel, From 0c76d981744847646034b6272fd3ee109c8d2a57 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:40:47 +0800 Subject: [PATCH 35/62] fix(gateway): estimate stream meta tokens when usage is omitted --- apps/gateway/src/routes/proxy.test.ts | 27 +++++++++++++++++++++++++++ apps/gateway/src/routes/proxy.ts | 8 +++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/gateway/src/routes/proxy.test.ts b/apps/gateway/src/routes/proxy.test.ts index 7425c82..29417ce 100644 --- a/apps/gateway/src/routes/proxy.test.ts +++ b/apps/gateway/src/routes/proxy.test.ts @@ -64,6 +64,18 @@ beforeAll(() => { }); return new Response(slowStream, { headers: { "Content-Type": "text/event-stream" } }); } + if (firstContent.includes("__NO_USAGE_STREAM__")) { + const encoder = new TextEncoder(); + const noUsageStream = new ReadableStream({ + start(controller) { + const chunk = { id: "chatcmpl-no-usage", object: "chat.completion.chunk", model: body.model, choices: [{ index: 0, delta: { content: "estimated tokens" }, finish_reason: null }] }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(noUsageStream, { headers: { "Content-Type": "text/event-stream" } }); + } // Streaming response const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -248,6 +260,21 @@ describe("POST /v1/chat/completions", () => { } }); + test("streaming: estimates routebox.meta tokens when provider omits usage", async () => { + const res = await proxyRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "__NO_USAGE_STREAM__ please" }], + stream: true, + }); + expect(res.status).toBe(200); + const text = await res.text(); + const metaLine = text.split("\n").find((line) => line.startsWith("data: {") && line.includes("routebox.meta")); + expect(metaLine).toBeTruthy(); + const meta = JSON.parse(metaLine!.slice("data: ".length)); + expect(meta.usage.completion_tokens).toBeGreaterThan(0); + expect(meta.cost).toBeGreaterThan(0); + }); + test("401 without auth", async () => { const res = await gateway.fetch(new Request("http://localhost/v1/chat/completions", { method: "POST", diff --git a/apps/gateway/src/routes/proxy.ts b/apps/gateway/src/routes/proxy.ts index 2749a08..5f0da38 100644 --- a/apps/gateway/src/routes/proxy.ts +++ b/apps/gateway/src/routes/proxy.ts @@ -386,19 +386,21 @@ function openaiStreamPassthrough( if (line.startsWith("data: ")) { if (line.includes("[DONE]")) { // Inject routebox.meta before [DONE] - const totalTok = inputTokens + outputTokens; - const metaCost = calculateCost(streamMeta.requestedModel, inputTokens, outputTokens, streamMeta.provider); + const finalOutput = resolveOutputTokens(); + const totalTok = inputTokens + finalOutput; + const metaCost = calculateCost(streamMeta.requestedModel, inputTokens, finalOutput, streamMeta.provider); enqueue(encoder.encode(`data: ${JSON.stringify({ object: "routebox.meta", provider: streamMeta.provider.toLowerCase(), model: streamMeta.requestedModel, requested_model: streamMeta.requestedModel, - usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: totalTok }, + usage: { prompt_tokens: inputTokens, completion_tokens: finalOutput, total_tokens: totalTok }, cost: metaCost, latency_ms: Math.round(performance.now() - streamMeta.startMs), is_fallback: streamMeta.isFallback, })}\n\n`)); enqueue(encoder.encode(`${line}\n\n`)); + outputTokens = finalOutput; metaInjected = true; } else { try { From 7a0105aa265af1eedf07387bc7dd2a5d386250e0 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:46:04 +0800 Subject: [PATCH 36/62] =?UTF-8?q?docs:=20add=20Phase=203a=20plan=20?= =?UTF-8?q?=E2=80=94=20cloud=20accounting=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ox-phase3a-cloud-accounting-correctness.md | 750 ++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase3a-cloud-accounting-correctness.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase3a-cloud-accounting-correctness.md b/docs/superpowers/plans/2026-06-11-routebox-phase3a-cloud-accounting-correctness.md new file mode 100644 index 0000000..24753ac --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase3a-cloud-accounting-correctness.md @@ -0,0 +1,750 @@ +# RouteBox Phase 3a — Cloud Accounting Correctness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the cloud gateway accounting bugs where routed/fallback requests are billed and recorded against the requested model instead of the actually served model, quota increments are not rolled back on upstream 4xx, and Prometheus model labels can grow without bound or emit unsafe values. + +**Architecture:** Keep the changes local to cloud gateway request accounting. The proxy route will track `activeModel` beside `activeProvider`, compute pricing after routing succeeds, and use a bounded metric model label helper for all provider/token metric labels. Quota rollback stays in the proxy control flow because `checkDailyQuota` performs the increment before any upstream call. + +**Tech Stack:** TypeScript + Bun (`bun test`), Hono route tests using existing module mocks, PostgreSQL mocked by `src/test-setup.ts`. + +--- + +## Scope + +**Included in Phase 3a:** +- **M1:** Account, bill, and expose metadata for the actual served model when scoring/fallback rewrites `body.model`. +- **L5:** Roll back the daily quota increment when the provider returns a non-retryable upstream 4xx before a request is served. +- **M5:** Bound cloud metrics `model` labels to known routed model IDs or `"other"`, and escape Prometheus label values. + +**Deferred to later Phase 3 slices:** +- **M6:** Payment/bonus idempotency database constraints and migrations. +- **M2:** Transaction-scoped advisory migration lock. +- **M7-code:** Gateway `getStats()` delta baseline behavior. + +## File Structure + +| File | Responsibility | Operation | +|------|----------------|-----------| +| `apps/cloud-gateway/src/routes/proxy.ts` | Cloud routing, pricing, quota rollback, metric labels | Modify | +| `apps/cloud-gateway/src/routes/proxy-e2e.test.ts` | End-to-end route accounting and quota rollback tests | Modify | +| `apps/cloud-gateway/src/routes/proxy.test.ts` | Pure helper tests for bounded metric labels | Modify | +| `apps/cloud-gateway/src/lib/metrics.ts` | Prometheus label escaping | Modify | +| `apps/cloud-gateway/src/lib/metrics.test.ts` | Label escaping regression test | Modify | + +**Execution notes:** +- Run cloud tests from `apps/cloud-gateway`. +- `src/routes/proxy-e2e.test.ts` already owns module mocks for scoring, metrics, model registry, quota, credits, key pool, and fetch; extend those mocks instead of adding a second test fixture. +- `src/test-setup.ts` preloads `db-cloud` and `credits` mocks. Do not bypass it. +- Clean AppleDouble files before commits: `find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null`. + +--- + +## Task 1: M5 — Escape Prometheus Label Values + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/metrics.ts` +- Modify: `apps/cloud-gateway/src/lib/metrics.test.ts` + +- [ ] **Step 1: Write the failing label escaping test** + +Add this test inside the existing `describe("incCounter", ...)` block in `apps/cloud-gateway/src/lib/metrics.test.ts`: + +```ts + test("escapes label values for Prometheus text format", () => { + incCounter("test_counter_escaped", { model: 'bad"slash\\line\nnext' }); + const output = serialize(); + expect(output).toContain('test_counter_escaped{model="bad\\"slash\\\\line\\nnext"} 1'); + }); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/metrics.test.ts -t "escapes label values" +``` + +Expected: FAIL because `labelKey()` currently interpolates raw label values. + +- [ ] **Step 3: Implement label escaping** + +In `apps/cloud-gateway/src/lib/metrics.ts`, replace `labelKey()` with: + +```ts +function escapeLabelValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/"/g, '\\"'); +} + +function labelKey(labels: Record): string { + const entries = Object.entries(labels).sort(([a], [b]) => + a.localeCompare(b), + ); + if (entries.length === 0) return ""; + return entries.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(","); +} +``` + +- [ ] **Step 4: Run metrics tests and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/metrics.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/lib/metrics.ts apps/cloud-gateway/src/lib/metrics.test.ts +git commit -m "fix(cloud): escape Prometheus label values" +``` + +--- + +## Task 2: M5 — Bound Proxy Metric Model Labels + +**Files:** +- Modify: `apps/cloud-gateway/src/routes/proxy.ts` +- Modify: `apps/cloud-gateway/src/routes/proxy.test.ts` +- Modify: `apps/cloud-gateway/src/routes/proxy-e2e.test.ts` + +- [ ] **Step 1: Write pure helper tests** + +In `apps/cloud-gateway/src/routes/proxy.test.ts`, add `metricModelLabel` to the import list: + +```ts + metricModelLabel, +``` + +Then add this block after the `resolveAlias` tests: + +```ts +// ── metricModelLabel ─────────────────────────────────────────────────────── + +describe("metricModelLabel", () => { + test("returns known served model IDs unchanged", () => { + expect(metricModelLabel("kimi-k2.5", new Set(["kimi-k2.5", "minimax-m2.5"]))).toBe("kimi-k2.5"); + }); + + test("collapses unknown model IDs to other", () => { + expect(metricModelLabel("minimax-m2.5-user-supplied-variant", new Set(["kimi-k2.5"]))).toBe("other"); + }); +}); +``` + +- [ ] **Step 2: Run the helper tests and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy.test.ts -t "metricModelLabel" +``` + +Expected: FAIL because `metricModelLabel` is not exported. + +- [ ] **Step 3: Make e2e metrics mock observable** + +In `apps/cloud-gateway/src/routes/proxy-e2e.test.ts`, add a metric call collector near the existing `deductCalls` and `recordCalls` declarations: + +```ts +let metricCounterCalls: unknown[][] = []; +``` + +Change the `../lib/metrics` mock to record `incCounter` calls: + +```ts +mock.module("../lib/metrics", () => ({ + incCounter: (...args: unknown[]) => { + metricCounterCalls.push(args); + }, + observeHistogram: () => {}, + incGauge: () => {}, + decGauge: () => {}, +})); +``` + +In `beforeEach()`, reset it: + +```ts + metricCounterCalls = []; +``` + +- [ ] **Step 4: Write the e2e bounded label regression** + +Add this test after the existing non-streaming T4 success test in `apps/cloud-gateway/src/routes/proxy-e2e.test.ts`: + +```ts + test("provider metrics use bounded model label for unregistered model IDs", async () => { + const app = createApp({ userPlan: "pro" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async () => + new Response(JSON.stringify(PROVIDER_JSON_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "minimax-user-supplied-variant", + messages: [{ role: "user", content: "Hello" }], + }), + }); + + expect(res.status).toBe(200); + const providerRequestMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_requests_total" && (labels as any).status === "200", + ); + expect(providerRequestMetric).toBeTruthy(); + expect((providerRequestMetric![1] as any).model).toBe("other"); + }); +``` + +- [ ] **Step 5: Run the e2e bounded label test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts -t "bounded model label" +``` + +Expected: FAIL because `provider_requests_total` currently uses raw `requestedModel`. + +- [ ] **Step 6: Implement metric label helpers** + +In `apps/cloud-gateway/src/routes/proxy.ts`, add this exported helper near the pricing helpers: + +```ts +export function metricModelLabel(model: string, knownModelIds: Iterable): string { + const known = new Set(knownModelIds); + return known.has(model) ? model : "other"; +} +``` + +Inside the `/chat/completions` handler, after `let totalAttempts = 0;`, add: + +```ts + const knownMetricModels = new Set(); + if (!isAutoRoute) { + const entry = await getRegistryEntry(requestedModel); + if (entry?.modelId) knownMetricModels.add(entry.modelId); + } + for (const candidate of scoredCandidates) { + knownMetricModels.add(candidate.modelId); + } +``` + +Before each provider attempt, compute the model actually sent to that provider: + +```ts + const servedModel = scored._scoredModelId ?? requestedModel; + const metricModel = metricModelLabel(servedModel, knownMetricModels); +``` + +Then replace each `model: requestedModel` in provider request/token metric labels in `apps/cloud-gateway/src/routes/proxy.ts` with `model: metricModel` when it is inside the provider attempt loop, and with `model: activeMetricModel` after success. Task 3 introduces `activeMetricModel`; for this task, add: + +```ts + let activeMetricModel = metricModelLabel(requestedModel, knownMetricModels); +``` + +When `rawRes.ok`, set: + +```ts + activeMetricModel = metricModel; +``` + +Use `activeMetricModel` for the streaming/non-streaming `provider_tokens_total` metric labels and for `stream_aborted_total`. + +- [ ] **Step 7: Run proxy helper/e2e tests and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy.test.ts -t "metricModelLabel" +bun test src/routes/proxy-e2e.test.ts -t "bounded model label" +``` + +Expected: both PASS. + +- [ ] **Step 8: Run broader cloud proxy tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy.test.ts src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/routes/proxy.ts apps/cloud-gateway/src/routes/proxy.test.ts apps/cloud-gateway/src/routes/proxy-e2e.test.ts +git commit -m "fix(cloud): bound provider metric model labels" +``` + +--- + +## Task 3: M1 — Bill and Record the Actual Served Model + +**Files:** +- Modify: `apps/cloud-gateway/src/routes/proxy.ts` +- Modify: `apps/cloud-gateway/src/routes/proxy-e2e.test.ts` + +- [ ] **Step 1: Make scoring mock configurable** + +In `apps/cloud-gateway/src/routes/proxy-e2e.test.ts`, add near the top: + +```ts +let mockScoredCandidates: any[] = []; +``` + +Change the scoring mock from a fixed empty array to: + +```ts +mock.module("../lib/scoring-engine", () => ({ + scoreAndRank: async () => mockScoredCandidates, +})); +``` + +In `beforeEach()`, reset: + +```ts + mockScoredCandidates = []; +``` + +- [ ] **Step 2: Write the failing actual-served-model test** + +Add this test in the T4 non-streaming describe block: + +```ts + test("M1: scoring fallback bills and records the actual served model", async () => { + const app = createApp({ userPlan: "pro" }); + + const providerConfig = { + name: "TestProvider", + instanceId: "test-1", + baseUrl: "http://localhost:9999", + apiKey: "test-key", + format: "openai", + prefixes: ["minimax-", "kimi-"], + }; + mockScoredCandidates = [ + { + modelId: "kimi-k2.5", + providerConfigs: [providerConfig], + isFallback: true, + totalScore: 0.99, + }, + ]; + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async (_url, init) => { + const providerBody = JSON.parse(init!.body as string); + expect(providerBody.model).toBe("kimi-k2.5"); + return new Response(JSON.stringify({ + ...PROVIDER_JSON_RESPONSE, + model: providerBody.model, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(200); + const body = await res.json() as any; + + expect(body._routebox.routed_model).toBe("kimi-k2.5"); + expect(body._routebox.requested_model).toBe("minimax-m2.5"); + expect(body._routebox.is_fallback).toBe(true); + + expect(deductCalls).toHaveLength(1); + expect((deductCalls[0][2] as any).model).toBe("kimi-k2.5"); + + expect(recordCalls).toHaveLength(1); + expect(recordCalls[0][1]).toBe("kimi-k2.5"); + + const providerRequestMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_requests_total" && (labels as any).status === "200", + ); + expect((providerRequestMetric![1] as any).model).toBe("kimi-k2.5"); + }); +``` + +- [ ] **Step 3: Run the test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts -t "actual served model" +``` + +Expected: FAIL because `_routebox`, `deductCredits`, and `recordCloudRequest` currently use `requestedModel`. + +- [ ] **Step 4: Track the active served model** + +In `apps/cloud-gateway/src/routes/proxy.ts`, after: + +```ts + let activeProvider: CloudProviderConfig | undefined; +``` + +add: + +```ts + let activeModel = requestedModel; +``` + +Inside the provider loop, after `const servedModel = scored._scoredModelId ?? requestedModel;`, make sure provider body rewrite uses the same value: + +```ts + if (scored._scoredModelId) { + providerBody.model = servedModel; + if (scored._isScoredFallback) isFallback = true; + } +``` + +When `rawRes.ok`, set: + +```ts + activeModel = servedModel; + isFallback = providerIdx > 0 || scored._isScoredFallback === true; +``` + +This replaces the existing success-path assignment: + +```ts + isFallback = providerIdx > 0; +``` + +Without this change, a scored fallback served by the first provider config is accounted against the right model but still reports `is_fallback: false`. + +- [ ] **Step 5: Compute pricing after routing succeeds** + +Remove the pre-routing pricing line: + +```ts + const modelPricing = await getModelUserPrice(requestedModel, userPlan); +``` + +After the `if (!res || !activeProvider) { ... }` block and before the retry metrics block, add: + +```ts + const modelPricing = await getModelUserPrice(activeModel, userPlan); +``` + +- [ ] **Step 6: Use `activeModel` for served-model accounting and metadata** + +In the streaming `onDone` callback, replace: + +```ts +model: requestedModel +deductCredits(userId, costCents, { model: requestedModel, ... }) +recordCloudRequest(userId, requestedModel, ...) +``` + +with `activeModel` for cost/accounting records and metric labels: + +```ts +model: activeMetricModel +deductCredits(userId, costCents, { model: activeModel, ... }) +recordCloudRequest(userId, activeModel, ...) +``` + +When creating `streamMetaObj`, pass the served model as the transformer model and preserve the original request separately: + +```ts + const streamMetaObj: StreamMeta = { + provider: activeProvider.name, + requestedModel: activeModel, + startMs, + isFallback, + autoRouted: isAutoRoute, + originalRequestedModel: isAutoRoute ? "auto" : originalRequestedModel, + }; +``` + +Use `activeModel` in the transformer calls: + +```ts + ? anthropicStreamToOpenAI(res.body, activeModel, streamMetaObj, onDone) + : openaiStreamPassthrough(res.body, streamMetaObj, onDone); +``` + +In the non-streaming branch: + +- `providerCost = calculateCost(activeModel, inputTokens, outputTokens)` +- `deductCredits(... { model: activeModel, ... })` +- `recordCloudRequest(userId, activeModel, ...)` +- Anthropic transformed response `model: activeModel` +- `_routebox.routed_model: activeModel` +- `_routebox.requested_model: isAutoRoute ? "auto" : originalRequestedModel` +- `"X-RouteBox-Model": activeModel` + +- [ ] **Step 7: Run the actual-served-model test and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts -t "actual served model" +``` + +Expected: PASS. + +- [ ] **Step 8: Run proxy tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy.test.ts src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/routes/proxy.ts apps/cloud-gateway/src/routes/proxy-e2e.test.ts +git commit -m "fix(cloud): account for actual served model after routing fallback" +``` + +--- + +## Task 4: L5 — Roll Back Starter Quota on Upstream 4xx + +**Files:** +- Modify: `apps/cloud-gateway/src/routes/proxy.ts` +- Modify: `apps/cloud-gateway/src/routes/proxy-e2e.test.ts` + +- [ ] **Step 1: Make quota mock observable** + +In `apps/cloud-gateway/src/routes/proxy-e2e.test.ts`, add near other call collectors: + +```ts +let decrementQuotaCalls: unknown[][] = []; +``` + +Change the quota mock to record decrement calls: + +```ts +mock.module("../lib/quota", () => ({ + checkDailyQuota: async () => ({ allowed: true, remaining: Infinity, resetAt: new Date() }), + incrementDailyQuota: async () => {}, + decrementDailyQuota: async (...args: unknown[]) => { + decrementQuotaCalls.push(args); + }, +})); +``` + +Reset in `beforeEach()`: + +```ts + decrementQuotaCalls = []; +``` + +- [ ] **Step 2: Write the failing quota rollback test** + +Add this test near the 4xx/5xx e2e tests: + +```ts +describe("L5: Upstream 4xx quota rollback", () => { + test("starter quota is decremented when provider returns non-retryable 4xx", async () => { + const app = createApp({ userPlan: "starter" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async () => + new Response(JSON.stringify({ error: { message: "bad request" } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + })); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(400); + expect(decrementQuotaCalls).toEqual([["test-user", "minimax-m2.5"]]); + expect(deductCalls).toHaveLength(0); + expect(recordCalls).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 3: Run the test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts -t "quota is decremented" +``` + +Expected: FAIL because non-retryable 4xx returns without `decrementDailyQuota`. + +- [ ] **Step 4: Implement quota rollback helper** + +In `apps/cloud-gateway/src/routes/proxy.ts`, after the request timeout setup, add: + +```ts + const rollbackQuota = (model: string) => { + if (userPlan === "starter") decrementDailyQuota(userId, model).catch(() => {}); + }; +``` + +Replace the all-providers-exhausted rollback: + +```ts + decrementDailyQuota(userId, requestedModel).catch(() => {}); +``` + +with: + +```ts + rollbackQuota(requestedModel); +``` + +In the non-retryable 4xx branch, before `return c.json(...)`, add: + +```ts + rollbackQuota(requestedModel); +``` + +Use `requestedModel` because quota was checked/incremented before provider scoring rewrites. For `auto`, the earlier auto branch updates `requestedModel` to the selected model before quota check, so the same variable is correct. + +- [ ] **Step 5: Run rollback test and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts -t "quota is decremented" +``` + +Expected: PASS. + +- [ ] **Step 6: Run proxy e2e suite** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/routes/proxy.ts apps/cloud-gateway/src/routes/proxy-e2e.test.ts +git commit -m "fix(cloud): roll back starter quota on upstream 4xx" +``` + +--- + +## Task 5: Final Verification + +**Files:** no production edits. + +- [ ] **Step 1: Run cloud route and metric tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +find . -name '._*' -delete 2>/dev/null +bun test src/lib/metrics.test.ts src/routes/proxy.test.ts src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run planned cloud regression set** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/key-pool.test.ts src/lib/metrics.test.ts src/lib/credits.test.ts src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Bundle proxy route** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun build src/routes/proxy.ts --target=bun --outdir=/tmp/cl-proxy-phase3a +``` + +Expected: bundle succeeds. + +- [ ] **Step 4: Whitespace check** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox +git diff --check HEAD~4..HEAD +``` + +Expected: no output, exit 0. + +- [ ] **Step 5: Final review** + +Use `superpowers:requesting-code-review` if explicit subagent delegation is available/authorized; otherwise perform a local review of: + +- every `provider_requests_total`, `provider_tokens_total`, and `stream_aborted_total` label uses bounded `activeMetricModel` / `metricModel`; +- every billing/accounting path after success uses `activeModel`; +- `_routebox.requested_model` preserves the user request while `_routebox.routed_model` shows the served model; +- 4xx returns call `rollbackQuota(requestedModel)` before returning; +- Prometheus label escaping covers `"`, `\`, and newline. + +If review finds changes, fix with TDD and rerun the relevant tests before finalizing. From 9b9853fa5d6e4e18c331781a641c2b1834fa3c06 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:49:17 +0800 Subject: [PATCH 37/62] fix(cloud): escape Prometheus label values --- apps/cloud-gateway/src/lib/metrics.test.ts | 6 ++++++ apps/cloud-gateway/src/lib/metrics.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/cloud-gateway/src/lib/metrics.test.ts b/apps/cloud-gateway/src/lib/metrics.test.ts index 5054fbd..b172897 100644 --- a/apps/cloud-gateway/src/lib/metrics.test.ts +++ b/apps/cloud-gateway/src/lib/metrics.test.ts @@ -43,6 +43,12 @@ describe("incCounter", () => { const output = serialize(); expect(output).toContain('test_counter_4{a="2",z="1"} 1'); }); + + test("escapes label values for Prometheus text format", () => { + incCounter("test_counter_escaped", { model: 'bad"slash\\line\nnext' }); + const output = serialize(); + expect(output).toContain('test_counter_escaped{model="bad\\"slash\\\\line\\nnext"} 1'); + }); }); describe("Histogram", () => { diff --git a/apps/cloud-gateway/src/lib/metrics.ts b/apps/cloud-gateway/src/lib/metrics.ts index e23bfe8..a12d7fb 100644 --- a/apps/cloud-gateway/src/lib/metrics.ts +++ b/apps/cloud-gateway/src/lib/metrics.ts @@ -77,12 +77,19 @@ export function decGauge(name: string, amount = 1): void { // ── Serialization (Prometheus text exposition format) ──────────────────────── +function escapeLabelValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/"/g, '\\"'); +} + function labelKey(labels: Record): string { const entries = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b), ); if (entries.length === 0) return ""; - return entries.map(([k, v]) => `${k}="${v}"`).join(","); + return entries.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(","); } function labelStr(key: string): string { From 66f466f3e2c32a4c0608c3adb63c81a145bccd7e Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:55:47 +0800 Subject: [PATCH 38/62] fix(cloud): bound provider metric model labels --- .../src/routes/proxy-e2e.test.ts | 37 ++++++++++++++++++- apps/cloud-gateway/src/routes/proxy.test.ts | 13 +++++++ apps/cloud-gateway/src/routes/proxy.ts | 35 +++++++++++++----- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index fa38b87..11a14b6 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -10,6 +10,7 @@ import type { CloudEnv } from "../types"; let deductCalls: unknown[][] = []; let recordCalls: unknown[][] = []; +let metricCounterCalls: unknown[][] = []; let mockGetBalanceInfo = async (_userId: string) => ({ balance_cents: 5000, bonus_cents: 0, @@ -88,7 +89,9 @@ mock.module("../lib/key-pool", () => ({ })); mock.module("../lib/metrics", () => ({ - incCounter: () => {}, + incCounter: (...args: unknown[]) => { + metricCounterCalls.push(args); + }, observeHistogram: () => {}, incGauge: () => {}, decGauge: () => {}, @@ -208,6 +211,7 @@ beforeEach(() => { globalThis.__dbMockSqlCalls = []; deductCalls = []; recordCalls = []; + metricCounterCalls = []; mockGetBalanceInfo = async () => ({ balance_cents: 5000, bonus_cents: 0, @@ -416,6 +420,37 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { expect(recordCalls[0][1]).toBe("minimax-m2.5"); expect(recordCalls[0][2]).toBe("TestProvider"); }); + + test("provider metrics use bounded model label for unregistered model IDs", async () => { + const app = createApp({ userPlan: "pro" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async () => + new Response(JSON.stringify(PROVIDER_JSON_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "minimax-user-supplied-variant", + messages: [{ role: "user", content: "Hello" }], + }), + }); + + expect(res.status).toBe(200); + const providerRequestMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_requests_total" && (labels as any).status === "200", + ); + expect(providerRequestMetric).toBeTruthy(); + expect((providerRequestMetric![1] as any).model).toBe("other"); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/cloud-gateway/src/routes/proxy.test.ts b/apps/cloud-gateway/src/routes/proxy.test.ts index e728636..097366e 100644 --- a/apps/cloud-gateway/src/routes/proxy.test.ts +++ b/apps/cloud-gateway/src/routes/proxy.test.ts @@ -9,6 +9,7 @@ import { describe, test, expect, spyOn } from "bun:test"; const { resolveAlias, + metricModelLabel, pricingFor, calculateCost, calculateUserCostCents, @@ -28,6 +29,18 @@ describe("resolveAlias", () => { }); }); +// ── metricModelLabel ─────────────────────────────────────────────────────── + +describe("metricModelLabel", () => { + test("returns known served model IDs unchanged", () => { + expect(metricModelLabel("kimi-k2.5", new Set(["kimi-k2.5", "minimax-m2.5"]))).toBe("kimi-k2.5"); + }); + + test("collapses unknown model IDs to other", () => { + expect(metricModelLabel("minimax-m2.5-user-supplied-variant", new Set(["kimi-k2.5"]))).toBe("other"); + }); +}); + // ── pricingFor ────────────────────────────────────────────────────────────── describe("pricingFor", () => { diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index aa5f82c..4e94b87 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -88,6 +88,11 @@ export function resolveAlias(model: string): string { return MODEL_ALIASES[model] ?? model; } +export function metricModelLabel(model: string, knownModelIds: Iterable): string { + const known = new Set(knownModelIds); + return known.has(model) ? model : "other"; +} + export function pricingFor(model: string): ModelPricing { return pricingForModel(model, MODEL_PRICING, { fallback: { input: 1, output: 3 } }); } @@ -854,6 +859,15 @@ app.post("/chat/completions", creditsCheck, async (c) => { let activeProviderLatencyMs = 0; let isFallback = false; let totalAttempts = 0; + const knownMetricModels = new Set(); + if (!isAutoRoute) { + const entry = await getRegistryEntry(requestedModel); + if (entry?.modelId) knownMetricModels.add(entry.modelId); + } + for (const candidate of scoredCandidates) { + knownMetricModels.add(candidate.modelId); + } + let activeMetricModel = metricModelLabel(requestedModel, knownMetricModels); for (let providerIdx = 0; providerIdx < providerChain.length; providerIdx++) { const provider = providerChain[providerIdx]!; @@ -878,6 +892,8 @@ app.post("/chat/completions", creditsCheck, async (c) => { providerBody.model = scored._scoredModelId; if (scored._isScoredFallback) isFallback = true; } + const servedModel = scored._scoredModelId ?? requestedModel; + const metricModel = metricModelLabel(servedModel, knownMetricModels); if (isStream && provider.format === "openai") { providerBody.stream_options = { include_usage: true }; } @@ -897,7 +913,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { cb.onSuccess(); incCounter("provider_requests_total", { provider: provider.name, - model: requestedModel, + model: metricModel, status: "200", }); observeHistogram("provider_request_duration_ms", providerLatencyMs, { @@ -908,6 +924,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { activeProvider = provider; activeProviderLatencyMs = providerLatencyMs; isFallback = providerIdx > 0; + activeMetricModel = metricModel; break; // exit retry loop } else if (!isRetryableStatus(rawRes.status)) { @@ -915,7 +932,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { const errBody = await rawRes.text().catch(() => ""); incCounter("provider_requests_total", { provider: provider.name, - model: requestedModel, + model: metricModel, status: String(rawRes.status), }); observeHistogram("provider_request_duration_ms", providerLatencyMs, { @@ -945,7 +962,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { cb.onFailure(); incCounter("provider_requests_total", { provider: provider.name, - model: requestedModel, + model: metricModel, status: String(rawRes.status), }); observeHistogram("provider_request_duration_ms", providerLatencyMs, { @@ -980,7 +997,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { cb.onFailure(); incCounter("provider_requests_total", { provider: provider.name, - model: requestedModel, + model: metricModel, status: "error", }); observeHistogram("provider_request_duration_ms", providerLatencyMs, { @@ -1078,14 +1095,14 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Track if client aborted if (clientSignal?.aborted && !wasAborted) { - incCounter("stream_aborted_total", { provider: finalProvider.name, model: requestedModel }); + incCounter("stream_aborted_total", { provider: finalProvider.name, model: activeMetricModel }); } const costCents = calculateUserCostCents(usage.input, usage.output, modelPricing); // Token metrics - incCounter("provider_tokens_total", { provider: finalProvider.name, model: requestedModel, direction: "input" }, usage.input); - incCounter("provider_tokens_total", { provider: finalProvider.name, model: requestedModel, direction: "output" }, usage.output); + incCounter("provider_tokens_total", { provider: finalProvider.name, model: activeMetricModel, direction: "input" }, usage.input); + incCounter("provider_tokens_total", { provider: finalProvider.name, model: activeMetricModel, direction: "output" }, usage.output); // Deduct credits (even for partial streams — bill consumed tokens) if (costCents > 0) { @@ -1161,8 +1178,8 @@ app.post("/chat/completions", creditsCheck, async (c) => { const providerCost = calculateCost(requestedModel, inputTokens, outputTokens); // Token metrics - incCounter("provider_tokens_total", { provider: activeProvider.name, model: requestedModel, direction: "input" }, inputTokens); - incCounter("provider_tokens_total", { provider: activeProvider.name, model: requestedModel, direction: "output" }, outputTokens); + incCounter("provider_tokens_total", { provider: activeProvider.name, model: activeMetricModel, direction: "input" }, inputTokens); + incCounter("provider_tokens_total", { provider: activeProvider.name, model: activeMetricModel, direction: "output" }, outputTokens); // Deduct credits if (costCents > 0) { From 728a25a530f5678bbc87dc494e95d83ced793297 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:02:08 +0800 Subject: [PATCH 39/62] fix(cloud): bound retry metric model labels --- .../src/routes/proxy-e2e.test.ts | 29 +++++++++++++++++-- apps/cloud-gateway/src/routes/proxy.ts | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index 11a14b6..d7dd152 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -429,11 +429,17 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { [], // disabled model check ]; - mockFetch(async () => - new Response(JSON.stringify(PROVIDER_JSON_RESPONSE), { + let fetchCount = 0; + mockFetch(async () => { + fetchCount++; + if (fetchCount === 1) { + return new Response("retry me", { status: 500 }); + } + return new Response(JSON.stringify(PROVIDER_JSON_RESPONSE), { status: 200, headers: { "Content-Type": "application/json" }, - })); + }); + }); const res = await app.request("/chat/completions", { method: "POST", @@ -450,6 +456,23 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { ); expect(providerRequestMetric).toBeTruthy(); expect((providerRequestMetric![1] as any).model).toBe("other"); + + const retryMetric = metricCounterCalls.find( + ([name]) => name === "retry_attempts_total", + ); + expect(retryMetric).toBeTruthy(); + expect((retryMetric![1] as any).model).toBe("other"); + + const inputTokenMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_tokens_total" && (labels as any).direction === "input", + ); + const outputTokenMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_tokens_total" && (labels as any).direction === "output", + ); + expect(inputTokenMetric).toBeTruthy(); + expect(outputTokenMetric).toBeTruthy(); + expect((inputTokenMetric![1] as any).model).toBe("other"); + expect((outputTokenMetric![1] as any).model).toBe("other"); }); }); diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index 4e94b87..ed3b892 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -1060,7 +1060,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Track retry metrics if (totalAttempts > 1) { incCounter("retry_attempts_total", { - model: requestedModel, + model: activeMetricModel, final_provider: activeProvider.instanceId, }, totalAttempts - 1); } From d5661ec57f031269dc94557f3023377c91152bb2 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:09:37 +0800 Subject: [PATCH 40/62] fix(cloud): account for actual served model after routing fallback --- .../src/routes/proxy-e2e.test.ts | 66 ++++++++++++++++++- apps/cloud-gateway/src/routes/proxy.ts | 53 +++++++-------- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index d7dd152..36244dc 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -11,6 +11,7 @@ import type { CloudEnv } from "../types"; let deductCalls: unknown[][] = []; let recordCalls: unknown[][] = []; let metricCounterCalls: unknown[][] = []; +let mockScoredCandidates: any[] = []; let mockGetBalanceInfo = async (_userId: string) => ({ balance_cents: 5000, bonus_cents: 0, @@ -26,7 +27,7 @@ mock.module("../lib/model-registry", () => ({ })); mock.module("../lib/scoring-engine", () => ({ - scoreAndRank: async () => [], + scoreAndRank: async () => mockScoredCandidates, })); mock.module("../lib/circuit-breaker", () => ({ @@ -212,6 +213,7 @@ beforeEach(() => { deductCalls = []; recordCalls = []; metricCounterCalls = []; + mockScoredCandidates = []; mockGetBalanceInfo = async () => ({ balance_cents: 5000, bonus_cents: 0, @@ -421,6 +423,68 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { expect(recordCalls[0][2]).toBe("TestProvider"); }); + test("M1: scoring fallback bills and records the actual served model", async () => { + const app = createApp({ userPlan: "pro" }); + + const providerConfig = { + name: "TestProvider", + instanceId: "test-1", + baseUrl: "http://localhost:9999", + apiKey: "test-key", + format: "openai", + prefixes: ["minimax-", "kimi-"], + }; + mockScoredCandidates = [ + { + modelId: "kimi-k2.5", + providerConfigs: [providerConfig], + isFallback: true, + totalScore: 0.99, + }, + ]; + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async (_url, init) => { + const providerBody = JSON.parse(init!.body as string); + expect(providerBody.model).toBe("kimi-k2.5"); + return new Response(JSON.stringify({ + ...PROVIDER_JSON_RESPONSE, + model: providerBody.model, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(200); + const body = await res.json() as any; + + expect(body._routebox.routed_model).toBe("kimi-k2.5"); + expect(body._routebox.requested_model).toBe("minimax-m2.5"); + expect(body._routebox.is_fallback).toBe(true); + + expect(deductCalls).toHaveLength(1); + expect((deductCalls[0][2] as any).model).toBe("kimi-k2.5"); + + expect(recordCalls).toHaveLength(1); + expect(recordCalls[0][1]).toBe("kimi-k2.5"); + + const providerRequestMetric = metricCounterCalls.find( + ([name, labels]) => name === "provider_requests_total" && (labels as any).status === "200", + ); + expect((providerRequestMetric![1] as any).model).toBe("kimi-k2.5"); + }); + test("provider metrics use bounded model label for unregistered model IDs", async () => { const app = createApp({ userPlan: "pro" }); diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index ed3b892..2b6f06d 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -783,9 +783,6 @@ app.post("/chat/completions", creditsCheck, async (c) => { } } - // ── Pre-resolve effective pricing (after model is finalized) ───────────── - const modelPricing = await getModelUserPrice(requestedModel, userPlan); - let providerChain: CloudProviderConfig[]; if (scoredCandidates.length > 0) { @@ -856,6 +853,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { let lastError: { message: string; status?: number; body?: string } | undefined; let res: Response | undefined; let activeProvider: CloudProviderConfig | undefined; + let activeModel = requestedModel; let activeProviderLatencyMs = 0; let isFallback = false; let totalAttempts = 0; @@ -888,11 +886,11 @@ app.post("/chat/completions", creditsCheck, async (c) => { // If scoring engine selected a different model, rewrite the model ID const scored = provider as CloudProviderConfig & { _scoredModelId?: string; _isScoredFallback?: boolean }; + const servedModel = scored._scoredModelId ?? requestedModel; if (scored._scoredModelId) { - providerBody.model = scored._scoredModelId; + providerBody.model = servedModel; if (scored._isScoredFallback) isFallback = true; } - const servedModel = scored._scoredModelId ?? requestedModel; const metricModel = metricModelLabel(servedModel, knownMetricModels); if (isStream && provider.format === "openai") { providerBody.stream_options = { include_usage: true }; @@ -922,8 +920,9 @@ app.post("/chat/completions", creditsCheck, async (c) => { res = rawRes; activeProvider = provider; + activeModel = servedModel; activeProviderLatencyMs = providerLatencyMs; - isFallback = providerIdx > 0; + isFallback = providerIdx > 0 || scored._isScoredFallback === true; activeMetricModel = metricModel; break; // exit retry loop @@ -1057,6 +1056,8 @@ app.post("/chat/completions", creditsCheck, async (c) => { }, 502); } + const modelPricing = await getModelUserPrice(activeModel, userPlan); + // Track retry metrics if (totalAttempts > 1) { incCounter("retry_attempts_total", { @@ -1071,11 +1072,11 @@ app.post("/chat/completions", creditsCheck, async (c) => { clearTimeout(requestTimeout); const streamMetaObj: StreamMeta = { provider: activeProvider.name, - requestedModel, + requestedModel: activeModel, startMs, isFallback, autoRouted: isAutoRoute, - originalRequestedModel: isAutoRoute ? "auto" : undefined, + originalRequestedModel: isAutoRoute ? "auto" : originalRequestedModel, }; // Capture variables for the async onDone closure @@ -1107,19 +1108,19 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Deduct credits (even for partial streams — bill consumed tokens) if (costCents > 0) { const deductResult = await deductCredits(userId, costCents, { - model: requestedModel, + model: activeModel, provider: finalProvider.name, inputTokens: usage.input, outputTokens: usage.output, }).catch((err) => { log.error("deduct_credits_failed", { - requestId, userId, model: requestedModel, costCents, + requestId, userId, model: activeModel, costCents, error: err instanceof Error ? err.message : String(err), }); // H1: Persist pending deduction so it can be retried later sql` INSERT INTO pending_deductions (user_id, cost_cents, model, provider, input_tokens, output_tokens, request_id) - VALUES (${userId}, ${costCents}, ${requestedModel}, ${finalProvider.name}, ${usage.input}, ${usage.output}, ${requestId}) + VALUES (${userId}, ${costCents}, ${activeModel}, ${finalProvider.name}, ${usage.input}, ${usage.output}, ${requestId}) `.catch((dbErr) => { log.error("pending_deduction_persist_failed", { requestId, userId, costCents, @@ -1129,7 +1130,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { return null; }); if (deductResult && !deductResult.success) { - log.error("deduct_credits_insufficient", { requestId, userId, model: requestedModel, costCents }); + log.error("deduct_credits_insufficient", { requestId, userId, model: activeModel, costCents }); } } @@ -1138,18 +1139,18 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Record request const status = clientSignal?.aborted && !wasAborted ? "aborted" : "ok"; await recordCloudRequest( - userId, requestedModel, finalProvider.name, + userId, activeModel, finalProvider.name, usage.input, usage.output, costCents, latencyMs, status, ).catch((err) => { log.error("record_request_failed", { - requestId, userId, model: requestedModel, + requestId, userId, model: activeModel, error: err instanceof Error ? err.message : String(err), }); }); }; const stream = activeProvider.format === "anthropic" - ? anthropicStreamToOpenAI(res.body, requestedModel, streamMetaObj, onDone) + ? anthropicStreamToOpenAI(res.body, activeModel, streamMetaObj, onDone) : openaiStreamPassthrough(res.body, streamMetaObj, onDone); const streamHeaders: Record = { @@ -1157,7 +1158,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { "Cache-Control": "no-cache", "Connection": "keep-alive", "X-RouteBox-Provider": activeProvider.name, - "X-RouteBox-Model": requestedModel, + "X-RouteBox-Model": activeModel, }; if (isAutoRoute) streamHeaders["X-RouteBox-Auto-Routed"] = "true"; @@ -1175,7 +1176,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { const inputTokens = usage?.prompt_tokens ?? usage?.input_tokens ?? 0; const outputTokens = usage?.completion_tokens ?? usage?.output_tokens ?? 0; const costCents = calculateUserCostCents(inputTokens, outputTokens, modelPricing); - const providerCost = calculateCost(requestedModel, inputTokens, outputTokens); + const providerCost = calculateCost(activeModel, inputTokens, outputTokens); // Token metrics incCounter("provider_tokens_total", { provider: activeProvider.name, model: activeMetricModel, direction: "input" }, inputTokens); @@ -1184,18 +1185,18 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Deduct credits if (costCents > 0) { const deductResult = await deductCredits(userId, costCents, { - model: requestedModel, + model: activeModel, provider: activeProvider.name, inputTokens, outputTokens, }).catch((err) => { log.error("deduct_credits_failed", { - requestId, userId, model: requestedModel, costCents, + requestId, userId, model: activeModel, costCents, error: err instanceof Error ? err.message : String(err), }); return null; }); if (deductResult && !deductResult.success) { - log.error("deduct_credits_insufficient", { requestId, userId, model: requestedModel, costCents }); + log.error("deduct_credits_insufficient", { requestId, userId, model: activeModel, costCents }); } } @@ -1203,11 +1204,11 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Record request await recordCloudRequest( - userId, requestedModel, activeProvider.name, + userId, activeModel, activeProvider.name, inputTokens, outputTokens, costCents, latencyMs, "ok", ).catch((err) => { log.error("record_request_failed", { - requestId, userId, model: requestedModel, + requestId, userId, model: activeModel, error: err instanceof Error ? err.message : String(err), }); }); @@ -1221,7 +1222,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { id: (json as { id: string }).id, object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: requestedModel, + model: activeModel, choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens }, }; @@ -1229,8 +1230,8 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Inject _routebox metadata const routeboxMeta: Record = { - routed_model: requestedModel, - requested_model: isAutoRoute ? "auto" : requestedModel, + routed_model: activeModel, + requested_model: isAutoRoute ? "auto" : originalRequestedModel, provider: activeProvider.name.toLowerCase(), instance_id: activeProvider.instanceId, cost: providerCost, @@ -1244,7 +1245,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { const responseHeaders: Record = { "X-RouteBox-Provider": activeProvider.name, - "X-RouteBox-Model": requestedModel, + "X-RouteBox-Model": activeModel, }; if (isAutoRoute) responseHeaders["X-RouteBox-Auto-Routed"] = "true"; From 22862484428ab87010e7f4cb266692c7dc501fd8 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:17:12 +0800 Subject: [PATCH 41/62] test(cloud): cover served model billing regressions --- .../src/routes/proxy-e2e.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index 36244dc..98cec18 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -448,12 +448,26 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { [], // disabled model check ]; + const usage = { prompt_tokens: 100_000, completion_tokens: 50_000, total_tokens: 150_000 }; + const requestedModelCost = calculateUserCostCents( + usage.prompt_tokens, + usage.completion_tokens, + { ...pricingFor("minimax-m2.5"), markup: 1.08 }, + ); + const servedModelCost = calculateUserCostCents( + usage.prompt_tokens, + usage.completion_tokens, + { ...pricingFor("kimi-k2.5"), markup: 1.08 }, + ); + expect(servedModelCost).not.toBe(requestedModelCost); + mockFetch(async (_url, init) => { const providerBody = JSON.parse(init!.body as string); expect(providerBody.model).toBe("kimi-k2.5"); return new Response(JSON.stringify({ ...PROVIDER_JSON_RESPONSE, model: providerBody.model, + usage, }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -472,8 +486,11 @@ describe("T4: Non-streaming full chain (route + deduct)", () => { expect(body._routebox.routed_model).toBe("kimi-k2.5"); expect(body._routebox.requested_model).toBe("minimax-m2.5"); expect(body._routebox.is_fallback).toBe(true); + expect(body._routebox.user_cost_cents).toBe(servedModelCost); expect(deductCalls).toHaveLength(1); + expect(deductCalls[0][1]).toBe(servedModelCost); + expect(deductCalls[0][1]).not.toBe(requestedModelCost); expect((deductCalls[0][2] as any).model).toBe("kimi-k2.5"); expect(recordCalls).toHaveLength(1); @@ -677,6 +694,90 @@ describe("T5: Streaming full chain", () => { // recordCloudRequest should have been called expect(recordCalls.length).toBeGreaterThanOrEqual(1); }); + + test("streaming scoring fallback uses served-model metadata and accounting", async () => { + const app = createApp({ userPlan: "pro" }); + + const providerConfig = { + name: "TestProvider", + instanceId: "test-1", + baseUrl: "http://localhost:9999", + apiKey: "test-key", + format: "openai", + prefixes: ["minimax-", "kimi-"], + }; + mockScoredCandidates = [ + { + modelId: "kimi-k2.5", + providerConfigs: [providerConfig], + isFallback: true, + totalScore: 0.99, + }, + ]; + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + const encoder = new TextEncoder(); + mockFetch(async (_url, init) => { + const providerBody = JSON.parse(init!.body as string); + expect(providerBody.model).toBe("kimi-k2.5"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode( + `data: ${JSON.stringify({ + id: "chatcmpl-stream", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: "Hi" }, finish_reason: null }], + })}\n\n`, + )); + controller.enqueue(encoder.encode( + `data: ${JSON.stringify({ + id: "chatcmpl-stream", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 100_000, completion_tokens: 50_000, total_tokens: 150_000 }, + })}\n\n`, + )); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...CHAT_BODY, stream: true }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("X-RouteBox-Model")).toBe("kimi-k2.5"); + + const text = await res.text(); + const metaLine = text.split("\n").find( + (line) => line.startsWith("data: ") && line.includes("\"object\":\"routebox.meta\""), + ); + expect(metaLine).toBeTruthy(); + const streamMeta = JSON.parse(metaLine!.slice("data: ".length)); + expect(streamMeta.model).toBe("kimi-k2.5"); + expect(streamMeta.requested_model).toBe("minimax-m2.5"); + expect(streamMeta.is_fallback).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(deductCalls).toHaveLength(1); + expect((deductCalls[0][2] as any).model).toBe("kimi-k2.5"); + + expect(recordCalls).toHaveLength(1); + expect(recordCalls[0][1]).toBe("kimi-k2.5"); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From b8ea6ecdbcf3f06a6682fd28f5aa0d19f5f696a3 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:25:28 +0800 Subject: [PATCH 42/62] fix(cloud): roll back starter quota on upstream 4xx --- .../src/routes/proxy-e2e.test.ts | 34 ++++++++++++++++++- apps/cloud-gateway/src/routes/proxy.ts | 6 +++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index 98cec18..ebb6b04 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -10,6 +10,7 @@ import type { CloudEnv } from "../types"; let deductCalls: unknown[][] = []; let recordCalls: unknown[][] = []; +let decrementQuotaCalls: unknown[][] = []; let metricCounterCalls: unknown[][] = []; let mockScoredCandidates: any[] = []; let mockGetBalanceInfo = async (_userId: string) => ({ @@ -57,7 +58,9 @@ mock.module("../lib/routing-config", () => ({ mock.module("../lib/quota", () => ({ checkDailyQuota: async () => ({ allowed: true, remaining: Infinity, resetAt: new Date() }), incrementDailyQuota: async () => {}, - decrementDailyQuota: async () => {}, + decrementDailyQuota: async (...args: unknown[]) => { + decrementQuotaCalls.push(args); + }, })); mock.module("../lib/provider-config", () => ({ @@ -212,6 +215,7 @@ beforeEach(() => { globalThis.__dbMockSqlCalls = []; deductCalls = []; recordCalls = []; + decrementQuotaCalls = []; metricCounterCalls = []; mockScoredCandidates = []; mockGetBalanceInfo = async () => ({ @@ -844,6 +848,34 @@ describe("T7: All providers fail → 502", () => { }); }); +describe("L5: Upstream 4xx quota rollback", () => { + test("starter quota is decremented when provider returns non-retryable 4xx", async () => { + const app = createApp({ userPlan: "starter" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async () => + new Response(JSON.stringify({ error: { message: "bad request" } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + })); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(400); + expect(decrementQuotaCalls).toEqual([["test-user", "minimax-m2.5"]]); + expect(deductCalls).toHaveLength(0); + expect(recordCalls).toHaveLength(0); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // T8. scoreAndRank exception → degrades to prefix matching // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index 2b6f06d..8581125 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -848,6 +848,9 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Overall request timeout const requestTimeout = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); + const rollbackQuota = (model: string) => { + if (userPlan === "starter") decrementDailyQuota(userId, model).catch(() => {}); + }; // ── Retry + Fallback Loop ────────────────────────────────────────────────── let lastError: { message: string; status?: number; body?: string } | undefined; @@ -946,6 +949,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { status: rawRes.status, upstream: errBody.slice(0, 500), }); + rollbackQuota(requestedModel); return c.json({ error: { message: `Provider returned ${rawRes.status}`, @@ -1044,7 +1048,7 @@ app.post("/chat/completions", creditsCheck, async (c) => { clientSignal?.removeEventListener("abort", onClientAbort); incCounter("errors_total", { type: "all_providers_failed" }); // Roll back the atomic quota increment since the request completely failed - decrementDailyQuota(userId, requestedModel).catch(() => {}); + rollbackQuota(requestedModel); return c.json({ error: { message: lastError?.message ?? "All providers failed", From 9bc3dbf0b7ea0254f639ec8a19e922701e9afd4f Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:32:41 +0800 Subject: [PATCH 43/62] fix(cloud): log quota rollback failures --- .../src/routes/proxy-e2e.test.ts | 82 ++++++++++++++++++- apps/cloud-gateway/src/routes/proxy.ts | 10 ++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts index ebb6b04..82ab819 100644 --- a/apps/cloud-gateway/src/routes/proxy-e2e.test.ts +++ b/apps/cloud-gateway/src/routes/proxy-e2e.test.ts @@ -11,8 +11,12 @@ import type { CloudEnv } from "../types"; let deductCalls: unknown[][] = []; let recordCalls: unknown[][] = []; let decrementQuotaCalls: unknown[][] = []; +let logErrorCalls: unknown[][] = []; let metricCounterCalls: unknown[][] = []; let mockScoredCandidates: any[] = []; +let mockDecrementDailyQuota = async (...args: unknown[]) => { + decrementQuotaCalls.push(args); +}; let mockGetBalanceInfo = async (_userId: string) => ({ balance_cents: 5000, bonus_cents: 0, @@ -58,9 +62,7 @@ mock.module("../lib/routing-config", () => ({ mock.module("../lib/quota", () => ({ checkDailyQuota: async () => ({ allowed: true, remaining: Infinity, resetAt: new Date() }), incrementDailyQuota: async () => {}, - decrementDailyQuota: async (...args: unknown[]) => { - decrementQuotaCalls.push(args); - }, + decrementDailyQuota: async (...args: unknown[]) => mockDecrementDailyQuota(...args), })); mock.module("../lib/provider-config", () => ({ @@ -105,7 +107,9 @@ mock.module("../lib/logger", () => ({ log: { info: () => {}, warn: () => {}, - error: () => {}, + error: (...args: unknown[]) => { + logErrorCalls.push(args); + }, debug: () => {}, }, })); @@ -216,8 +220,12 @@ beforeEach(() => { deductCalls = []; recordCalls = []; decrementQuotaCalls = []; + logErrorCalls = []; metricCounterCalls = []; mockScoredCandidates = []; + mockDecrementDailyQuota = async (...args: unknown[]) => { + decrementQuotaCalls.push(args); + }; mockGetBalanceInfo = async () => ({ balance_cents: 5000, bonus_cents: 0, @@ -846,6 +854,33 @@ describe("T7: All providers fail → 502", () => { // Should have retried (1 original + 2 retries = 3 attempts) expect(fetchCount).toBe(3); }); + + test("starter quota is decremented when providers are exhausted", async () => { + const app = createApp({ userPlan: "starter" }); + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + let fetchCount = 0; + mockFetch(async () => { + fetchCount++; + return new Response("Internal Server Error", { status: 500 }); + }); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(502); + expect(fetchCount).toBe(3); + expect(decrementQuotaCalls).toEqual([["test-user", "minimax-m2.5"]]); + expect(deductCalls).toHaveLength(0); + expect(recordCalls).toHaveLength(0); + }); }); describe("L5: Upstream 4xx quota rollback", () => { @@ -874,6 +909,45 @@ describe("L5: Upstream 4xx quota rollback", () => { expect(deductCalls).toHaveLength(0); expect(recordCalls).toHaveLength(0); }); + + test("rollback failures are logged without masking upstream 4xx", async () => { + const app = createApp({ userPlan: "starter" }); + mockDecrementDailyQuota = async (...args: unknown[]) => { + decrementQuotaCalls.push(args); + throw new Error("quota database unavailable"); + }; + + // @ts-ignore + globalThis.__dbMockSqlResults = [ + [], // disabled model check + ]; + + mockFetch(async () => + new Response(JSON.stringify({ error: { message: "bad request" } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + })); + + const res = await app.request("/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(CHAT_BODY), + }); + + expect(res.status).toBe(400); + const body = await res.json() as any; + expect(body.error.code).toBe("upstream_error"); + await Promise.resolve(); + + expect(decrementQuotaCalls).toEqual([["test-user", "minimax-m2.5"]]); + const rollbackLog = logErrorCalls.find(([event]) => event === "quota_rollback_failed"); + expect(rollbackLog).toBeTruthy(); + const rollbackLogContext = rollbackLog![1] as any; + expect(rollbackLogContext.requestId).toBe("req-test"); + expect(rollbackLogContext.userId).toBe("test-user"); + expect(rollbackLogContext.model).toBe("minimax-m2.5"); + expect(rollbackLogContext.error).toBe("quota database unavailable"); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/apps/cloud-gateway/src/routes/proxy.ts b/apps/cloud-gateway/src/routes/proxy.ts index 8581125..3aea10d 100644 --- a/apps/cloud-gateway/src/routes/proxy.ts +++ b/apps/cloud-gateway/src/routes/proxy.ts @@ -849,7 +849,15 @@ app.post("/chat/completions", creditsCheck, async (c) => { // Overall request timeout const requestTimeout = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); const rollbackQuota = (model: string) => { - if (userPlan === "starter") decrementDailyQuota(userId, model).catch(() => {}); + if (userPlan !== "starter") return; + decrementDailyQuota(userId, model).catch((err) => { + log.error("quota_rollback_failed", { + requestId, + userId, + model, + error: err instanceof Error ? err.message : String(err), + }); + }); }; // ── Retry + Fallback Loop ────────────────────────────────────────────────── From d85586977bc92f3b4882a9edeacdd128aa0a23e5 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:48:30 +0800 Subject: [PATCH 44/62] =?UTF-8?q?docs:=20add=20Phase=203b=20plan=20?= =?UTF-8?q?=E2=80=94=20ledger=20migration=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...routebox-phase3b-ledger-migration-stats.md | 870 ++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase3b-ledger-migration-stats.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase3b-ledger-migration-stats.md b/docs/superpowers/plans/2026-06-11-routebox-phase3b-ledger-migration-stats.md new file mode 100644 index 0000000..99b5736 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase3b-ledger-migration-stats.md @@ -0,0 +1,870 @@ +# RouteBox Phase 3b — Ledger, Migration, Stats Correctness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the remaining Phase 3 correctness fixes: make credit/bonus ledger idempotency race-safe, run cloud migrations under a transaction-scoped advisory lock, and make local gateway stats deltas stable across multiple readers. + +**Architecture:** Keep each bug fix local to the owning module. Ledger idempotency is enforced by PostgreSQL partial unique indexes plus insert-claim flows in `credits.ts`; migration locking is wrapped in one transaction that owns `pg_advisory_xact_lock`; stats deltas are computed from fixed SQLite time windows instead of mutating read-time baselines. + +**Tech Stack:** TypeScript + Bun tests; PostgreSQL via `postgres` in `apps/cloud-gateway`; SQLite via `bun:sqlite` in `apps/gateway`. + +--- + +## Scope + +**Included in Phase 3b:** +- **M6:** Payment and bonus idempotency correctness. +- **M2:** Transaction-scoped advisory migration lock. +- **M7-code:** Stable local gateway `getStats()` deltas for multiple WebSocket/API readers. + +**Deferred:** +- UX Phase 4 and product Phase 5 items. +- Full webhook replay semantics beyond the ledger idempotency changes below. + +## File Structure + +| File | Responsibility | Operation | +|------|----------------|-----------| +| `apps/cloud-gateway/migrations/025_transaction_idempotency.sql` | Adds idempotency ledger columns/indexes | Create | +| `apps/cloud-gateway/src/lib/credits.ts` | Race-safe payment/bonus credit application | Modify | +| `apps/cloud-gateway/src/lib/credits.test.ts` | Ledger idempotency tests | Modify | +| `apps/cloud-gateway/src/test-setup.ts` | Adds transaction call observability for tests | Modify | +| `apps/cloud-gateway/src/lib/migration-runner.ts` | Testable transaction-scoped migration runner | Create | +| `apps/cloud-gateway/src/lib/migration-runner.test.ts` | Migration lock tests using a mocked SQL runner | Create | +| `apps/cloud-gateway/src/lib/db-cloud.ts` | Transaction-scoped migration lock runner | Modify | +| `apps/gateway/src/lib/db.ts` | Adds fixed-window aggregate query | Modify | +| `apps/gateway/src/lib/metrics.ts` | Uses DB windows for stats deltas, removes read-time baseline mutation | Modify | +| `apps/gateway/src/lib/metrics.test.ts` | Stable delta pure/unit regression tests | Modify | + +**Execution notes:** +- Work on the current branch `fix/audit-remediation`; do not create or switch branches. +- Clean AppleDouble files before commits: + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +``` + +- Run cloud tests from `apps/cloud-gateway`; run local gateway tests from `apps/gateway`. +- `apps/cloud-gateway/bunfig.toml` preloads `src/test-setup.ts`, which mocks `./lib/db-cloud`. Keep the migration-lock helper in a separate `migration-runner.ts` file so tests can import real code without fighting that preload mock. + +--- + +## Task 1: M6 — Race-Safe Transaction Idempotency Schema + +**Files:** +- Create: `apps/cloud-gateway/migrations/025_transaction_idempotency.sql` +- Modify: `apps/cloud-gateway/src/lib/credits.test.ts` + +- [ ] **Step 1: Write a migration existence/schema regression test** + +Add this block near the top of `apps/cloud-gateway/src/lib/credits.test.ts` after `beforeEach()`: + +```ts +// ── ledger idempotency migration ─────────────────────────────────────────── + +describe("transaction idempotency migration", () => { + test("adds idempotency_key and unique partial indexes", async () => { + const migration = await Bun.file( + new URL("../../migrations/025_transaction_idempotency.sql", import.meta.url), + ).text(); + + expect(migration).toContain("ADD COLUMN IF NOT EXISTS idempotency_key"); + expect(migration).toContain("idx_transactions_payment_ref_unique"); + expect(migration).toContain("WHERE payment_ref IS NOT NULL"); + expect(migration).toContain("idx_transactions_bonus_idempotency_unique"); + expect(migration).toContain("WHERE type = 'bonus' AND idempotency_key IS NOT NULL"); + }); +}); +``` + +- [ ] **Step 2: Run the migration test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts -t "transaction idempotency migration" +``` + +Expected: FAIL because `025_transaction_idempotency.sql` does not exist. + +- [ ] **Step 3: Add the idempotency migration** + +Create `apps/cloud-gateway/migrations/025_transaction_idempotency.sql`: + +```sql +-- --------------------------------------------------------------------------- +-- Migration 025: Transaction idempotency hardening +-- --------------------------------------------------------------------------- + +ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS idempotency_key TEXT; + +-- Preserve historical payment references as idempotency keys where possible. +UPDATE transactions +SET idempotency_key = payment_ref +WHERE idempotency_key IS NULL + AND payment_ref IS NOT NULL; + +-- Prevent concurrent duplicate deposits for the same provider payment/session. +CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_payment_ref_unique + ON transactions (payment_ref) + WHERE payment_ref IS NOT NULL; + +-- Prevent duplicate bonus application for the same user/reason key while still +-- allowing shared promo names across different users when callers choose that. +CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_bonus_idempotency_unique + ON transactions (user_id, type, idempotency_key) + WHERE type = 'bonus' AND idempotency_key IS NOT NULL; +``` + +- [ ] **Step 4: Run the migration test and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts -t "transaction idempotency migration" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/migrations/025_transaction_idempotency.sql apps/cloud-gateway/src/lib/credits.test.ts +git commit -m "fix(cloud): add transaction idempotency constraints" +``` + +--- + +## Task 2: M6 — Race-Safe `addCredits()` Deposit Idempotency + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/credits.ts` +- Modify: `apps/cloud-gateway/src/lib/credits.test.ts` +- Modify: `apps/cloud-gateway/src/test-setup.ts` + +- [ ] **Step 1: Make transaction calls observable in the test setup** + +In `apps/cloud-gateway/src/test-setup.ts`, add a global transaction call collector near the existing db mock globals: + +```ts +// @ts-ignore +globalThis.__dbMockTxCalls = [] as unknown[][]; +``` + +Inside the mocked `tx` function, before shifting `__dbMockTxResults`, record the bound values: + +```ts +// @ts-ignore +(globalThis.__dbMockTxCalls as unknown[][]).push(values); +``` + +In `apps/cloud-gateway/src/lib/credits.test.ts` `beforeEach()`, reset it: + +```ts + // @ts-ignore + globalThis.__dbMockTxCalls = []; +``` + +- [ ] **Step 2: Update the duplicate deposit test for insert-claim semantics** + +Replace the existing `addCredits > returns existing balance for duplicate payment ref` mock setup with: + +```ts + // @ts-ignore + globalThis.__dbMockTxResults = [ + [], // INSERT claim hit ON CONFLICT DO NOTHING + [{ balance_cents: 1500 }], // Current balance lookup + ]; +``` + +Keep the existing call and assertion: + +```ts + const newBalance = await credits.addCredits("user-1", 1000, "cs_duplicate"); + expect(newBalance).toBe(1500); +``` + +- [ ] **Step 3: Update the successful deposit test for insert-claim semantics** + +Replace the existing `addCredits > adds credits and returns new balance` mock setup with: + +```ts + // @ts-ignore + globalThis.__dbMockTxResults = [ + [{ id: "tx-claim" }], // INSERT deposit claim + [{ balance_cents: 2500 }], // UPDATE credits RETURNING + [], // UPDATE transaction balance_after_cents + ]; +``` + +After the balance assertion, add: + +```ts + // @ts-ignore + const txCalls = globalThis.__dbMockTxCalls as unknown[][]; + expect(txCalls[0]).toContain("cs_test_123"); + expect(txCalls[2]).toContain("tx-claim"); + expect(txCalls[2]).toContain(2500); +``` + +- [ ] **Step 4: Run deposit tests and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts -t "addCredits" +``` + +Expected: FAIL because `addCredits()` still does select-before-update/insert and does not use the insert-claim flow. + +- [ ] **Step 5: Implement race-safe `addCredits()`** + +Replace the body of `addCredits()` in `apps/cloud-gateway/src/lib/credits.ts` with: + +```ts +export async function addCredits( + userId: string, + amountCents: number, + paymentRef: string, + description?: string, +): Promise { + const result = await withTx(async (tx) => { + const desc = description ?? "Credit purchase"; + + const [claim] = await tx` + INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, + description, payment_ref, idempotency_key) + VALUES (${userId}, 'deposit', ${amountCents}, 0, ${desc}, ${paymentRef}, ${paymentRef}) + ON CONFLICT (payment_ref) WHERE payment_ref IS NOT NULL DO NOTHING + RETURNING id + `; + + if (!claim) { + const [current] = await tx` + SELECT balance_cents FROM credits WHERE user_id = ${userId} + `; + return (current?.balance_cents as number) ?? 0; + } + + const [row] = await tx` + UPDATE credits + SET balance_cents = balance_cents + ${amountCents}, + total_deposited_cents = total_deposited_cents + ${amountCents}, + updated_at = now() + WHERE user_id = ${userId} + RETURNING balance_cents + `; + + const newBalance = (row?.balance_cents as number) ?? 0; + + await tx` + UPDATE transactions + SET balance_after_cents = ${newBalance} + WHERE id = ${claim.id} + `; + + return newBalance; + }); + + return result; +} +``` + +- [ ] **Step 6: Run deposit tests and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts -t "addCredits" +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/lib/credits.ts apps/cloud-gateway/src/lib/credits.test.ts apps/cloud-gateway/src/test-setup.ts +git commit -m "fix(cloud): make deposit crediting idempotent" +``` + +--- + +## Task 3: M6 — Race-Safe Bonus Idempotency + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/credits.ts` +- Modify: `apps/cloud-gateway/src/lib/credits.test.ts` + +- [ ] **Step 1: Extend the local test type for idempotency keys** + +In `apps/cloud-gateway/src/lib/credits.test.ts`, change the `addBonusCredits` type in `__realCredits` to: + +```ts + addBonusCredits: (userId: string, bonusCents: number, reason: string, idempotencyKey?: string) => Promise; +``` + +- [ ] **Step 2: Add a duplicate bonus idempotency test** + +Add this test inside `describe("addBonusCredits", ...)`: + +```ts + test("returns current total without applying duplicate idempotency key", async () => { + // @ts-ignore + globalThis.__dbMockTxResults = [ + [], // INSERT bonus claim hit conflict + [{ balance_cents: 500, bonus_cents: 250 }], // Current balance lookup + ]; + + const total = await credits.addBonusCredits( + "user-1", + 100, + "subscription_welcome", + "sub_welcome_user-1_2026-06", + ); + + expect(total).toBe(750); + + // @ts-ignore + const txCalls = globalThis.__dbMockTxCalls as unknown[][]; + expect(txCalls).toHaveLength(2); + expect(txCalls[0]).toContain("sub_welcome_user-1_2026-06"); + }); +``` + +- [ ] **Step 3: Add a new bonus claim test** + +Add this test inside `describe("addBonusCredits", ...)`: + +```ts + test("stores idempotency key on new bonus transactions", async () => { + // @ts-ignore + globalThis.__dbMockTxResults = [ + [{ id: "bonus-tx" }], // INSERT bonus claim + [{ balance_cents: 500, bonus_cents: 350 }], // UPDATE credits RETURNING + [], // UPDATE transaction balance_after_cents + ]; + + const total = await credits.addBonusCredits( + "user-1", + 100, + "referral_welcome", + "ref_welcome_user-1", + ); + + expect(total).toBe(850); + + // @ts-ignore + const txCalls = globalThis.__dbMockTxCalls as unknown[][]; + expect(txCalls[0]).toContain("ref_welcome_user-1"); + expect(txCalls[2]).toContain("bonus-tx"); + expect(txCalls[2]).toContain(850); + }); +``` + +- [ ] **Step 4: Run bonus tests and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts -t "addBonusCredits" +``` + +Expected: FAIL because duplicate detection still uses `description LIKE` and no real `idempotency_key` column. + +- [ ] **Step 5: Implement insert-claim bonus idempotency** + +In `apps/cloud-gateway/src/lib/credits.ts`, replace the `idempotencyKey` branch at the start of `addBonusCredits()` with an insert-claim branch, and keep the existing non-idempotent path for calls without a key: + +```ts + const result = await withTx(async (tx) => { + const descriptions: Record = { + referral_welcome: "Referral welcome bonus", + referral_earning: "Referral earnings", + subscription_welcome: "Subscription welcome credits", + promo: "Promotional bonus", + }; + const desc = descriptions[reason]; + + if (idempotencyKey) { + const [claim] = await tx` + INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, + description, idempotency_key) + VALUES (${userId}, 'bonus', ${bonusCents}, 0, ${desc}, ${idempotencyKey}) + ON CONFLICT (user_id, type, idempotency_key) + WHERE type = 'bonus' AND idempotency_key IS NOT NULL + DO NOTHING + RETURNING id + `; + + if (!claim) { + const [current] = await tx` + SELECT balance_cents, bonus_cents FROM credits WHERE user_id = ${userId} + `; + return ((current?.balance_cents as number) ?? 0) + ((current?.bonus_cents as number) ?? 0); + } + + const [row] = await tx` + UPDATE credits + SET bonus_cents = bonus_cents + ${bonusCents}, + updated_at = now() + WHERE user_id = ${userId} + RETURNING balance_cents, bonus_cents + `; + + const newBalance = (row?.balance_cents as number) ?? 0; + const newBonus = (row?.bonus_cents as number) ?? 0; + const total = newBalance + newBonus; + + await tx` + UPDATE transactions + SET balance_after_cents = ${total} + WHERE id = ${claim.id} + `; + + return total; + } + + const [row] = await tx` + UPDATE credits + SET bonus_cents = bonus_cents + ${bonusCents}, + updated_at = now() + WHERE user_id = ${userId} + RETURNING balance_cents, bonus_cents + `; + + const newBalance = (row?.balance_cents as number) ?? 0; + const newBonus = (row?.bonus_cents as number) ?? 0; + + await tx` + INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) + VALUES (${userId}, 'bonus', ${bonusCents}, ${newBalance + newBonus}, ${desc}) + `; + + return newBalance + newBonus; + }); +``` + +Remove the old `description LIKE` duplicate check and the old ` [${idempotencyKey}]` suffix. + +- [ ] **Step 6: Run credits tests and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/lib/credits.ts apps/cloud-gateway/src/lib/credits.test.ts +git commit -m "fix(cloud): make bonus crediting idempotent" +``` + +--- + +## Task 4: M2 — Transaction-Scoped Migration Advisory Lock + +**Files:** +- Modify: `apps/cloud-gateway/src/lib/db-cloud.ts` +- Create: `apps/cloud-gateway/src/lib/migration-runner.ts` +- Create: `apps/cloud-gateway/src/lib/migration-runner.test.ts` + +- [ ] **Step 1: Add a testable migration runner helper test** + +Create `apps/cloud-gateway/src/lib/migration-runner.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { runMigrationsWithTransactionLock } from "./migration-runner"; + +describe("runMigrationsWithTransactionLock", () => { + test("runs migrations under a transaction-scoped advisory lock", async () => { + const events: string[] = []; + const files = ["001.sql", "002.sql"]; + const contents: Record = { + "001.sql": "SELECT 1;", + "002.sql": "SELECT 2;", + }; + + const sql = { + begin: async (fn: (tx: any) => Promise) => { + events.push("begin"); + const tx = Object.assign( + (strings: TemplateStringsArray) => { + events.push(strings.join("?")); + return Promise.resolve([{ locked: true }]); + }, + { + unsafe: async (statement: string) => { + events.push(`unsafe:${statement}`); + return []; + }, + }, + ); + await fn(tx); + events.push("commit"); + }, + }; + + await runMigrationsWithTransactionLock(sql as any, { + listFiles: () => files, + readFile: (file) => contents[file]!, + logApplied: (file) => events.push(`applied:${file}`), + }); + + expect(events).toEqual([ + "begin", + "SELECT pg_advisory_xact_lock(1) AS locked", + "unsafe:SELECT 1;", + "applied:001.sql", + "unsafe:SELECT 2;", + "applied:002.sql", + "commit", + ]); + }); +}); +``` + +- [ ] **Step 2: Run the migration-lock test and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/migration-runner.test.ts +``` + +Expected: FAIL because `runMigrationsWithTransactionLock` is not exported. + +- [ ] **Step 3: Add the transaction-lock helper** + +Create `apps/cloud-gateway/src/lib/migration-runner.ts`: + +```ts +export interface MigrationSources { + listFiles: () => string[]; + readFile: (file: string) => string; + logApplied?: (file: string) => void; +} + +type MigrationTx = { + (template: TemplateStringsArray, ...parameters: readonly unknown[]): Promise; + unsafe: (query: string) => Promise; +}; + +export interface MigrationDb { + begin: (fn: (tx: MigrationTx) => Promise) => Promise; +} + +export async function runMigrationsWithTransactionLock( + db: MigrationDb, + sources: MigrationSources, +): Promise { + await db.begin(async (tx) => { + await tx`SELECT pg_advisory_xact_lock(1) AS locked`; + + for (const file of sources.listFiles()) { + const migrationSql = sources.readFile(file); + await tx.unsafe(migrationSql); + sources.logApplied?.(file); + } + }); +} +``` + +- [ ] **Step 4: Update `initDatabase()` to use the helper** + +In `apps/cloud-gateway/src/lib/db-cloud.ts`, add: + +```ts +import { runMigrationsWithTransactionLock } from "./migration-runner"; +``` + +Then replace the existing session-lock block in `initDatabase()` with: + +```ts + await runMigrationsWithTransactionLock(sql as any, { + listFiles: () => readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(), + readFile: (file) => readFileSync(join(migrationsDir, file), "utf-8"), + logApplied: (file) => log.info("migration_applied", { file }), + }); + + log.info("database_ready"); +``` + +Remove the old `pg_try_advisory_lock`, wait, and manual unlock code. + +- [ ] **Step 5: Run migration-lock test and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/migration-runner.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Run cloud build-focused tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/migration-runner.test.ts src/lib/credits.test.ts +bun build src/lib/db-cloud.ts --target=bun --outdir=/tmp/cl-db-cloud-phase3b +``` + +Expected: PASS/build success. + +- [ ] **Step 7: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/src/lib/db-cloud.ts apps/cloud-gateway/src/lib/migration-runner.ts apps/cloud-gateway/src/lib/migration-runner.test.ts +git commit -m "fix(cloud): use transaction-scoped migration lock" +``` + +--- + +## Task 5: M7-code — Stable Stats Deltas From Fixed DB Windows + +**Files:** +- Modify: `apps/gateway/src/lib/db.ts` +- Modify: `apps/gateway/src/lib/metrics.ts` +- Modify: `apps/gateway/src/lib/metrics.test.ts` + +- [ ] **Step 1: Add pure delta helper tests** + +In `apps/gateway/src/lib/metrics.test.ts`, update the import: + +```ts +import { + calculateStatsDeltas, + computeProviderUp, + DOWN_FAIL_STREAK, + PROVIDER_RECOVERY_MS, +} from "./metrics"; +``` + +Add these tests after the provider recovery tests: + +```ts +test("calculateStatsDeltas compares current and previous fixed windows", () => { + expect(calculateStatsDeltas( + { requests: 15, tokens: 300, cost: 6, avg_latency: 0 }, + { requests: 10, tokens: 200, cost: 4, avg_latency: 0 }, + )).toEqual({ requestsDelta: 50, tokensDelta: 50, costDelta: 50 }); +}); + +test("calculateStatsDeltas is stable for repeated readers", () => { + const current = { requests: 15, tokens: 300, cost: 6, avg_latency: 0 }; + const previous = { requests: 10, tokens: 200, cost: 4, avg_latency: 0 }; + + const first = calculateStatsDeltas(current, previous); + const second = calculateStatsDeltas(current, previous); + + expect(second).toEqual(first); +}); + +test("calculateStatsDeltas returns zero when previous window is empty", () => { + expect(calculateStatsDeltas( + { requests: 15, tokens: 300, cost: 6, avg_latency: 0 }, + { requests: 0, tokens: 0, cost: 0, avg_latency: 0 }, + )).toEqual({ requestsDelta: 0, tokensDelta: 0, costDelta: 0 }); +}); +``` + +- [ ] **Step 2: Run metrics helper tests and verify RED** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/metrics.test.ts -t "calculateStatsDeltas" +``` + +Expected: FAIL because `calculateStatsDeltas` is not exported. + +- [ ] **Step 3: Add fixed-window DB query** + +In `apps/gateway/src/lib/db.ts`, add a prepared statement near `totalsStmt`: + +```ts +const totalsBetweenStmt = db.prepare(` + SELECT COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost), 0) as cost, COALESCE(AVG(latency_ms), 0) as avg_latency + FROM requests WHERE timestamp >= ? AND timestamp < ? +`); +``` + +Add this export after `queryTotals()`: + +```ts +export function queryTotalsBetween(startTs: number, endTs: number): TotalsRow { + const row = totalsBetweenStmt.get(startTs, endTs) as TotalsRow | null; + return row ?? { requests: 0, tokens: 0, cost: 0, avg_latency: 0 }; +} +``` + +- [ ] **Step 4: Update `metrics.ts` imports and add delta helper** + +In `apps/gateway/src/lib/metrics.ts`, add `queryTotalsBetween` to the db import list and add `type TotalsRow`: + +```ts + queryTotalsBetween, + type TotalsRow, +``` + +Add these constants/helpers near the other constants: + +```ts +export const STATS_DELTA_WINDOW_MS = 5 * 60_000; + +function percentDelta(current: number, previous: number): number { + return previous > 0 ? Math.round(((current - previous) / previous) * 100) : 0; +} + +export function calculateStatsDeltas(current: TotalsRow, previous: TotalsRow) { + return { + requestsDelta: percentDelta(current.requests, previous.requests), + tokensDelta: percentDelta(current.tokens, previous.tokens), + costDelta: percentDelta(current.cost, previous.cost), + }; +} +``` + +Remove the three `prevRequests`, `prevTokens`, and `prevCost` fields and their constructor assignments. + +- [ ] **Step 5: Replace read-mutating deltas in `getStats()`** + +In `getStats()`, replace the existing `// Deltas` block that calculates deltas from `prev*` and then mutates `prev*` with: + +```ts + // Deltas — compare fixed DB windows so multiple stats readers do not + // consume or reset each other's baseline. + const deltaEnd = Date.now(); + const deltaStart = deltaEnd - STATS_DELTA_WINDOW_MS; + const previousStart = deltaStart - STATS_DELTA_WINDOW_MS; + const currentTotals = queryTotalsBetween(deltaStart, deltaEnd); + const previousTotals = queryTotalsBetween(previousStart, deltaStart); + const { requestsDelta, tokensDelta, costDelta } = + calculateStatsDeltas(currentTotals, previousTotals); +``` + +Leave the returned field names unchanged. + +- [ ] **Step 6: Run metrics tests and verify GREEN** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/metrics.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run local gateway build/test smoke** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/metrics.test.ts src/lib/db.test.ts +bun build src/lib/metrics.ts --target=bun --outdir=/tmp/gw-metrics-phase3b +``` + +Expected: PASS/build success. + +- [ ] **Step 8: Commit** + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/gateway/src/lib/db.ts apps/gateway/src/lib/metrics.ts apps/gateway/src/lib/metrics.test.ts +git commit -m "fix(gateway): compute stats deltas from fixed windows" +``` + +--- + +## Task 6: Final Verification + +**Files:** no code changes unless verification reveals a bug. + +- [ ] **Step 1: Clean AppleDouble files** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +``` + +- [ ] **Step 2: Run cloud ledger/migration tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun test src/lib/credits.test.ts src/lib/migration-runner.test.ts src/lib/referrals.test.ts src/routes/proxy-e2e.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run local gateway metrics/db tests** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun test src/lib/metrics.test.ts src/lib/db.test.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Build touched entry modules** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway +bun build src/lib/credits.ts src/lib/db-cloud.ts --target=bun --outdir=/tmp/cl-phase3b + +cd /Volumes/ROG_500GB/RouteBox/apps/gateway +bun build src/lib/metrics.ts --target=bun --outdir=/tmp/gw-phase3b +``` + +Expected: build success. + +- [ ] **Step 5: Check git diff hygiene** + +Run: + +```bash +cd /Volumes/ROG_500GB/RouteBox +git diff --check 9bc3dbf..HEAD +git status --short --branch +``` + +Expected: no whitespace errors and clean working tree. From 58e926cc8dbf8581265f4d3a0d91f6372540b8d0 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:50:50 +0800 Subject: [PATCH 45/62] fix(cloud): add transaction idempotency constraints --- .../025_transaction_idempotency.sql | 23 +++++++++++++++++++ apps/cloud-gateway/src/lib/credits.test.ts | 16 +++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 apps/cloud-gateway/migrations/025_transaction_idempotency.sql diff --git a/apps/cloud-gateway/migrations/025_transaction_idempotency.sql b/apps/cloud-gateway/migrations/025_transaction_idempotency.sql new file mode 100644 index 0000000..0ab0e9d --- /dev/null +++ b/apps/cloud-gateway/migrations/025_transaction_idempotency.sql @@ -0,0 +1,23 @@ +-- --------------------------------------------------------------------------- +-- Migration 025: Transaction idempotency hardening +-- --------------------------------------------------------------------------- + +ALTER TABLE transactions + ADD COLUMN IF NOT EXISTS idempotency_key TEXT; + +-- Preserve historical payment references as idempotency keys where possible. +UPDATE transactions +SET idempotency_key = payment_ref +WHERE idempotency_key IS NULL + AND payment_ref IS NOT NULL; + +-- Prevent concurrent duplicate deposits for the same provider payment/session. +CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_payment_ref_unique + ON transactions (payment_ref) + WHERE payment_ref IS NOT NULL; + +-- Prevent duplicate bonus application for the same user/reason key while still +-- allowing shared promo names across different users when callers choose that. +CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_bonus_idempotency_unique + ON transactions (user_id, type, idempotency_key) + WHERE type = 'bonus' AND idempotency_key IS NOT NULL; diff --git a/apps/cloud-gateway/src/lib/credits.test.ts b/apps/cloud-gateway/src/lib/credits.test.ts index 9a4ba69..935d07b 100644 --- a/apps/cloud-gateway/src/lib/credits.test.ts +++ b/apps/cloud-gateway/src/lib/credits.test.ts @@ -27,6 +27,22 @@ beforeEach(() => { globalThis.__dbMockSqlCalls = []; }); +// ── ledger idempotency migration ─────────────────────────────────────────── + +describe("transaction idempotency migration", () => { + test("adds idempotency_key and unique partial indexes", async () => { + const migration = await Bun.file( + new URL("../../migrations/025_transaction_idempotency.sql", import.meta.url), + ).text(); + + expect(migration).toContain("ADD COLUMN IF NOT EXISTS idempotency_key"); + expect(migration).toContain("idx_transactions_payment_ref_unique"); + expect(migration).toContain("WHERE payment_ref IS NOT NULL"); + expect(migration).toContain("idx_transactions_bonus_idempotency_unique"); + expect(migration).toContain("WHERE type = 'bonus' AND idempotency_key IS NOT NULL"); + }); +}); + // ── getBalance ────────────────────────────────────────────────────────────── describe("getBalance", () => { From c74c06e936d5b9fedc7624639d4eb52c7fb40082 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:56:26 +0800 Subject: [PATCH 46/62] fix(cloud): handle legacy duplicate transaction refs --- .../025_transaction_idempotency.sql | 47 +++++++++++++++++++ apps/cloud-gateway/src/lib/credits.test.ts | 4 ++ 2 files changed, 51 insertions(+) diff --git a/apps/cloud-gateway/migrations/025_transaction_idempotency.sql b/apps/cloud-gateway/migrations/025_transaction_idempotency.sql index 0ab0e9d..66e8b74 100644 --- a/apps/cloud-gateway/migrations/025_transaction_idempotency.sql +++ b/apps/cloud-gateway/migrations/025_transaction_idempotency.sql @@ -5,12 +5,59 @@ ALTER TABLE transactions ADD COLUMN IF NOT EXISTS idempotency_key TEXT; +-- Clear legacy duplicate payment refs before enforcing uniqueness. Keep the +-- earliest ledger row for each ref and preserve later rows for audit history. +WITH duplicate_payment_refs AS ( + SELECT id + FROM ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY payment_ref ORDER BY created_at, id) AS rn + FROM transactions + WHERE payment_ref IS NOT NULL + ) ranked + WHERE rn > 1 +) +UPDATE transactions +SET payment_ref = NULL, + description = concat_ws( + ' ', + NULLIF(transactions.description, ''), + '[duplicate payment_ref cleared before idempotency index]' + ) +FROM duplicate_payment_refs +WHERE transactions.id = duplicate_payment_refs.id; + -- Preserve historical payment references as idempotency keys where possible. UPDATE transactions SET idempotency_key = payment_ref WHERE idempotency_key IS NULL AND payment_ref IS NOT NULL; +-- Clear legacy duplicate bonus idempotency keys before enforcing uniqueness. +-- Keep the earliest bonus row per user/key and preserve later rows for audit. +WITH duplicate_bonus_idempotency AS ( + SELECT id + FROM ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY user_id, type, idempotency_key ORDER BY created_at, id) AS rn + FROM transactions + WHERE type = 'bonus' + AND idempotency_key IS NOT NULL + ) ranked + WHERE rn > 1 +) +UPDATE transactions +SET idempotency_key = NULL, + description = concat_ws( + ' ', + NULLIF(transactions.description, ''), + '[duplicate bonus idempotency_key cleared before idempotency index]' + ) +FROM duplicate_bonus_idempotency +WHERE transactions.id = duplicate_bonus_idempotency.id; + -- Prevent concurrent duplicate deposits for the same provider payment/session. CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_payment_ref_unique ON transactions (payment_ref) diff --git a/apps/cloud-gateway/src/lib/credits.test.ts b/apps/cloud-gateway/src/lib/credits.test.ts index 935d07b..b35a4a2 100644 --- a/apps/cloud-gateway/src/lib/credits.test.ts +++ b/apps/cloud-gateway/src/lib/credits.test.ts @@ -36,8 +36,12 @@ describe("transaction idempotency migration", () => { ).text(); expect(migration).toContain("ADD COLUMN IF NOT EXISTS idempotency_key"); + expect(migration).toContain("ROW_NUMBER() OVER (PARTITION BY payment_ref"); + expect(migration).toContain("duplicate payment_ref cleared before idempotency index"); expect(migration).toContain("idx_transactions_payment_ref_unique"); expect(migration).toContain("WHERE payment_ref IS NOT NULL"); + expect(migration).toContain("ROW_NUMBER() OVER (PARTITION BY user_id, type, idempotency_key"); + expect(migration).toContain("duplicate bonus idempotency_key cleared before idempotency index"); expect(migration).toContain("idx_transactions_bonus_idempotency_unique"); expect(migration).toContain("WHERE type = 'bonus' AND idempotency_key IS NOT NULL"); }); From 0a45395872186a60ceafa3f6c4b58fe448bd3446 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:02:31 +0800 Subject: [PATCH 47/62] fix(cloud): make deposit crediting idempotent --- apps/cloud-gateway/src/lib/credits.test.ts | 18 +++++++++---- apps/cloud-gateway/src/lib/credits.ts | 30 +++++++++++++--------- apps/cloud-gateway/src/test-setup.ts | 4 +++ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/cloud-gateway/src/lib/credits.test.ts b/apps/cloud-gateway/src/lib/credits.test.ts index b35a4a2..42c0945 100644 --- a/apps/cloud-gateway/src/lib/credits.test.ts +++ b/apps/cloud-gateway/src/lib/credits.test.ts @@ -25,6 +25,8 @@ beforeEach(() => { globalThis.__dbMockTxResults = []; // @ts-ignore globalThis.__dbMockSqlCalls = []; + // @ts-ignore + globalThis.__dbMockTxCalls = []; }); // ── ledger idempotency migration ─────────────────────────────────────────── @@ -125,20 +127,26 @@ describe("addCredits", () => { test("adds credits and returns new balance", async () => { // @ts-ignore globalThis.__dbMockTxResults = [ - [], // Check duplicate session - [{ balance_cents: 2500 }], // UPDATE RETURNING - [], // INSERT transaction + [{ id: "tx-claim" }], // INSERT deposit claim + [{ balance_cents: 2500 }], // UPDATE credits RETURNING + [], // UPDATE transaction balance_after_cents ]; const newBalance = await credits.addCredits("user-1", 1000, "cs_test_123", "Top up"); expect(newBalance).toBe(2500); + + // @ts-ignore + const txCalls = globalThis.__dbMockTxCalls as unknown[][]; + expect(txCalls[0]).toContain("cs_test_123"); + expect(txCalls[2]).toContain("tx-claim"); + expect(txCalls[2]).toContain(2500); }); test("returns existing balance for duplicate payment ref", async () => { // @ts-ignore globalThis.__dbMockTxResults = [ - [{ id: "existing-tx" }], - [{ balance_cents: 1500 }], + [], // INSERT claim hit ON CONFLICT DO NOTHING + [{ balance_cents: 1500 }], // Current balance lookup ]; const newBalance = await credits.addCredits("user-1", 1000, "cs_duplicate"); diff --git a/apps/cloud-gateway/src/lib/credits.ts b/apps/cloud-gateway/src/lib/credits.ts index 52de7b6..817ae4a 100644 --- a/apps/cloud-gateway/src/lib/credits.ts +++ b/apps/cloud-gateway/src/lib/credits.ts @@ -96,14 +96,21 @@ export async function addCredits( description?: string, ): Promise { const result = await withTx(async (tx) => { - // Check for duplicate payment_ref to prevent double-crediting - const [existing] = await tx` - SELECT id FROM transactions - WHERE payment_ref = ${paymentRef} + const desc = description ?? "Credit purchase"; + + const [claim] = await tx` + INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, + description, payment_ref, idempotency_key) + VALUES (${userId}, 'deposit', ${amountCents}, 0, ${desc}, ${paymentRef}, ${paymentRef}) + ON CONFLICT (payment_ref) WHERE payment_ref IS NOT NULL DO NOTHING + RETURNING id `; - if (existing) { - const [row] = await tx`SELECT balance_cents FROM credits WHERE user_id = ${userId}`; - return (row?.balance_cents as number) ?? 0; + + if (!claim) { + const [current] = await tx` + SELECT balance_cents FROM credits WHERE user_id = ${userId} + `; + return (current?.balance_cents as number) ?? 0; } const [row] = await tx` @@ -115,13 +122,12 @@ export async function addCredits( RETURNING balance_cents `; - const newBalance = row.balance_cents as number; + const newBalance = (row?.balance_cents as number) ?? 0; await tx` - INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, - description, payment_ref) - VALUES (${userId}, 'deposit', ${amountCents}, ${newBalance}, - ${description ?? 'Credit purchase'}, ${paymentRef}) + UPDATE transactions + SET balance_after_cents = ${newBalance} + WHERE id = ${claim.id} `; return newBalance; diff --git a/apps/cloud-gateway/src/test-setup.ts b/apps/cloud-gateway/src/test-setup.ts index 82f3364..e56fecc 100644 --- a/apps/cloud-gateway/src/test-setup.ts +++ b/apps/cloud-gateway/src/test-setup.ts @@ -15,6 +15,8 @@ globalThis.__dbMockSqlResults = [] as unknown[]; globalThis.__dbMockTxResults = [] as unknown[]; // @ts-ignore globalThis.__dbMockSqlCalls = [] as unknown[][]; +// @ts-ignore +globalThis.__dbMockTxCalls = [] as unknown[][]; mock.module("./lib/db-cloud", () => ({ sql: (strings: TemplateStringsArray, ...values: unknown[]) => { @@ -33,6 +35,8 @@ mock.module("./lib/db-cloud", () => ({ }, withTx: async (fn: any) => { const tx = (strings: TemplateStringsArray, ...values: unknown[]) => { + // @ts-ignore + (globalThis.__dbMockTxCalls as unknown[][]).push(values); // @ts-ignore const txResult = (globalThis.__dbMockTxResults as unknown[]).shift(); if (txResult === undefined) { From 6e223be9ec260ac88cbd39bb005f69c05eb27839 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:02:53 +0800 Subject: [PATCH 48/62] =?UTF-8?q?docs:=20add=20Phase=204=20plan=20?= =?UTF-8?q?=E2=80=94=20cheap-high-value=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 wrong-tab onboarding/settings copy + real navigation, H2 two-step delete confirm + visible errors, H5 tappable gateway-failed banner, M5 pause balance polling while panel hidden, M6 provider startup retry, L6 web-search save error surface, L4 activity search by provider/status. Frontend-only, low-risk. M7 (pricing) dropped pending a price decision. Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-11-routebox-phase4-ux.md | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase4-ux.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase4-ux.md b/docs/superpowers/plans/2026-06-11-routebox-phase4-ux.md new file mode 100644 index 0000000..23ae5bc --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase4-ux.md @@ -0,0 +1,343 @@ +# RouteBox Phase 4 — 便宜高收益 UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复一批低风险、用户可感知的桌面端 UX 问题:引导/设置文案指错 tab(H1)、删除无确认且失败静默(H2)、网关失败无恢复入口(H5)、余额轮询不暂停(M5)、provider 列表启动期死路(M6)、Web Search key 保存失败静默(L6)、Activity 搜索只匹配 model(L4)。 + +**Architecture:** 纯前端,改动在 `apps/desktop/src`(React + Tauri)。无后端/网络协议变化。导航复用既有 `setActiveTab`(App.tsx 已向 HeroSection 传 `onGoToAccount={() => setActiveTab("account")}`);本期把同样的回调也接到 Settings。删除确认用组件内联两步状态(不引入全局确认框)。轮询暂停用 `document.hidden` + `visibilitychange`。 + +**Tech Stack:** TypeScript + React + Vite + Tauri。验证:`cd apps/desktop && bunx tsc --noEmit`(类型)+ `bun run test`(vitest,既有 3 个 UI 测试不可回归)。无新测试基础设施要求——这些是小改动,主要靠类型检查 + 评审 + 可选 preview 验证。 + +**已移出本期:** M7(Pro 价格 $9.90 vs $9.99)——价格未定,用户决定暂不动。 + +--- + +## File Structure + +| 文件 | 项 | 操作 | +|------|----|------| +| `apps/desktop/src/components/Onboarding.tsx` | H1 文案 Activity→Account | Modify | +| `apps/desktop/src/components/Settings.tsx` | H1 按钮跳转 + L6 错误提示 | Modify | +| `apps/desktop/src/App.tsx` | H1 给 Settings 传 onGoToAccount;M5 轮询暂停 | Modify | +| `apps/desktop/src/components/AccountPage.tsx` | H2 删除确认 + 错误提示 | Modify | +| `apps/desktop/src/components/ProviderKeyManager.tsx` | H2 删除确认;M6 启动期状态 | Modify | +| `apps/desktop/src/components/HeroSection.tsx` | H5 失败状态可点击打开设置 | Modify | +| `apps/desktop/src/hooks/useCloudAuth.ts` | M5 轮询暂停 | Modify | +| `apps/desktop/src/components/ActivityPage.tsx` | L4 搜索扩展 | Modify | + +**执行者必读:** +- 验证:`cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit`(应无新错误)+ `bun run test`(vitest 既有测试全过)。提交前 `find . -path ./node_modules -prune -o -name '._*' -delete`。忽略 `non-monotonic index` 警告。分支 `fix/audit-remediation`。 +- 这些是独立小改动;每个任务一个 commit。改动遵循各文件既有样式(Tailwind class、lucide 图标、`text-[11px]` 等)。 + +--- + +## Task 1: H1 —— 引导/设置文案指向正确的 Account tab + Settings 真跳转 + +**Files:** `Onboarding.tsx`、`Settings.tsx`、`App.tsx` + +- [ ] **Step 1: Onboarding 文案 Activity → Account** + +`apps/desktop/src/components/Onboarding.tsx:120-128`,把两处 `Activity` 改为 `Account`: +- `:121` `Go to the Activity tab to sign in...` → `...Account tab to sign in...` +- `:126` `Your gateway endpoint and API key are shown in the Activity tab...` → `...Account tab...` + +- [ ] **Step 2: Settings 增加导航回调 prop** + +`apps/desktop/src/components/Settings.tsx`: +- `:29-31` 接口: +```ts +interface SettingsProps { + onClose: () => void; +} +``` +改为: +```ts +interface SettingsProps { + onClose: () => void; + onGoToAccount?: () => void; +} +``` +- `:33` `export function Settings({ onClose }: SettingsProps) {` 改为 `export function Settings({ onClose, onGoToAccount }: SettingsProps) {` + +- [ ] **Step 3: Settings 两个按钮文案改 Account 并真跳转** + +`Settings.tsx:576-582` 与 `:587-593`,两个按钮当前 `onClick={onClose}` + 文案 "Go to Activity"/"Sign in via Activity"。改为先跳转再关闭,文案改 Account: +- 第一个:`onClick={() => { onGoToAccount?.(); onClose(); }}`,文案 `Go to Account` +- 第二个:`onClick={() => { onGoToAccount?.(); onClose(); }}`,文案 `Sign in via Account` + +- [ ] **Step 4: App.tsx 给 Settings 传 onGoToAccount** + +`apps/desktop/src/App.tsx:346` 的 ``,加 `onGoToAccount={() => setActiveTab("account")}`(与 `:293` HeroSection 用法一致)。READ 该处确认现有 props,保留 onClose 不动。 + +- [ ] **Step 5: 验证 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit && bun run test` +Expected: 类型无新错误;vitest 全过。 +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src/components/Onboarding.tsx apps/desktop/src/components/Settings.tsx apps/desktop/src/App.tsx +git commit -m "fix(desktop): onboarding/settings point to Account tab and actually navigate (H1-ux)" +``` + +--- + +## Task 2: H2 —— 删除 API key / provider key 两步确认 + 失败提示 + +**Files:** `AccountPage.tsx`、`ProviderKeyManager.tsx` + +当前:AccountPage `handleDelete`(`:80-85`)单击即吊销、失败仅 `console.warn`;ProviderKeyManager 删除按钮(`:411-417`)直接 `handleDeleteKey`(`:69-77`,有 `setError` 但删除按钮在非编辑态、error 不可见)。改为内联两步确认 + 可见错误。 + +- [ ] **Step 1: AccountPage 内联确认 + 错误** + +`apps/desktop/src/components/AccountPage.tsx`: +- 在组件状态区加:`const [confirmingDelete, setConfirmingDelete] = useState(null);` 和 `const [deleteError, setDeleteError] = useState(null);`(READ 文件顶部确认 `useState` 已 import)。 +- `handleDelete`(`:80-85`)改为surface错误: +```ts + const handleDelete = async (id: string) => { + try { + await api.cloudDeleteApiKey(id); + setConfirmingDelete(null); + setDeleteError(null); + await loadKeys(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete key"); + } + }; +``` +- 删除按钮(`:172-178`)改为两步:第一次点击设 `setConfirmingDelete(k.id)`,显示 "Delete?" 确认;再点确认才 `handleDelete(k.id)`。具体把该 ` + + + ) : ( + + )} +``` +- 在 key 列表附近(列表容器下方)渲染错误:`{deleteError &&

{deleteError}

}`(放在 `:180` 列表 `))}` 之后、容器闭合前的合适位置)。 + +- [ ] **Step 2: ProviderKeyManager 内联确认 + 让 error 可见** + +`apps/desktop/src/components/ProviderKeyManager.tsx`: +- 加状态:`const [confirmingDelete, setConfirmingDelete] = useState(null);` +- 删除按钮(`:411-417`,非编辑态显示 maskedKey + Trash 的那个)改为两步确认:第一次点击 `setConfirmingDelete(p.name)`,显示 "Delete?"/"Cancel";确认调用 `handleDeleteKey(p.name)`(成功后该组件已 `fetchRegistry`;在 `handleDeleteKey` 成功分支加 `setConfirmingDelete(null)`)。保持 `e.stopPropagation()`。 +- 确认 `error` 状态已在 UI 渲染(READ 组件,找 `error &&` 的展示;若删除错误未展示在非编辑态,把 `handleDeleteKey` 的错误也放到一个对用户可见的位置——例如 provider 行下方 `{error &&

{error}

}`)。 + +- [ ] **Step 3: 验证 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit && bun run test` +Expected: 类型无新错误;vitest 全过。 +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src/components/AccountPage.tsx apps/desktop/src/components/ProviderKeyManager.tsx +git commit -m "fix(desktop): two-step confirm + visible error for key deletion (H2-ux)" +``` + +--- + +## Task 3: H5 —— 网关失败状态可点击打开设置 + +**Files:** `HeroSection.tsx` + +当前 `gatewayState === "failed"` 只显示截断错误文本(`:105-109`),无操作入口。`onOpenSettings` 已是 prop。改为让失败区可点击打开 Settings(Start 在 Settings → Gateway 内)。 + +- [ ] **Step 1: 失败区改为按钮** + +`apps/desktop/src/components/HeroSection.tsx:105-109`: +```tsx + {gatewayState === "failed" && shortError && ( +
+

{shortError}

+
+ )} +``` +改为(整块可点击 → 打开设置,文案提示): +```tsx + {gatewayState === "failed" && ( + + )} +``` +(`AlertTriangle` 已 import,见 `:1`。`shortError` 变量保留使用。) + +- [ ] **Step 2: 验证 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit && bun run test` +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src/components/HeroSection.tsx +git commit -m "fix(desktop): gateway-failed banner is tappable to open Settings (H5-ux)" +``` + +--- + +## Task 4: M5 —— 余额轮询在面板隐藏时暂停 + +**Files:** `App.tsx`、`hooks/useCloudAuth.ts` + +两处轮询:App.tsx `:205`(30s)、useCloudAuth `:199`(10s),都无条件运行。菜单栏面板失焦即隐藏,不应后台空转。改为 `document.hidden` 时跳过,`visibilitychange` 恢复时刷新一次。 + +- [ ] **Step 1: App.tsx 轮询跳过 hidden** + +`apps/desktop/src/App.tsx:204-208`: +```ts + // Poll balance every 30s + const balanceInterval = setInterval(() => { + api.cloudGetBalance().then((res) => setCloudBalanceCents(res.total_cents)).catch(() => {}); + }, 30_000); + return () => clearInterval(balanceInterval); +``` +改为: +```ts + // Poll balance every 30s — skip while the panel is hidden (menu-bar app) + const fetchBalance = () => { + if (document.hidden) return; + api.cloudGetBalance().then((res) => setCloudBalanceCents(res.total_cents)).catch(() => {}); + }; + const balanceInterval = setInterval(fetchBalance, 30_000); + const onVisible = () => { if (!document.hidden) fetchBalance(); }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(balanceInterval); + document.removeEventListener("visibilitychange", onVisible); + }; +``` + +- [ ] **Step 2: useCloudAuth 轮询跳过 hidden** + +`apps/desktop/src/hooks/useCloudAuth.ts:196-201`(READ to confirm exact lines). The interval `setInterval(refreshBalance, 10_000)` should skip when hidden. Wrap: +```ts + // Poll balance every 10s when authenticated — skip while hidden + useEffect(() => { + ...existing auth guard... + const tick = () => { if (!document.hidden) refreshBalance(); }; + const timer = setInterval(tick, 10_000); + const onVisible = () => { if (!document.hidden) refreshBalance(); }; + document.addEventListener("visibilitychange", onVisible); + return () => { clearInterval(timer); document.removeEventListener("visibilitychange", onVisible); }; + }, [...existing deps...]); +``` +READ the actual effect (around `:196-205`) and adapt — keep the existing auth/`cancelled` guard and dependency array; only change the interval callback to skip on hidden and add the visibilitychange listener + cleanup. There is already a window-focus refresh (`:203` "Refresh balance on window focus") — leave it; the visibilitychange addition is complementary (focus ≠ visibility, but harmless overlap; refreshBalance is idempotent). + +- [ ] **Step 3: 验证 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit && bun run test` +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src/App.tsx apps/desktop/src/hooks/useCloudAuth.ts +git commit -m "fix(desktop): pause balance polling while panel hidden, refresh on show (M5-ux)" +``` + +--- + +## Task 5: M6 + L6 + L4 —— provider 启动期状态、Web Search 保存错误、Activity 搜索扩展 + +**Files:** `ProviderKeyManager.tsx`、`Settings.tsx`、`ActivityPage.tsx` + +- [ ] **Step 1: M6 —— ProviderKeyManager 启动期自动重试 + 区分状态** + +`apps/desktop/src/components/ProviderKeyManager.tsx`: +- `fetchRegistry`(`:31-44`)的 catch 当前完全静默且把 `loading` 关掉,导致空列表显示 "Connect to gateway to manage providers" 死路。改为:catch 时记录一个 `unreachable` 状态;并在组件挂载后、列表为空时,若网关可能还在启动,自动重试几次。最小实现:加状态 `const [fetchFailed, setFetchFailed] = useState(false);`,fetchRegistry 成功置 false、catch 置 true。再加一个有限自动重试 effect: +```ts + useEffect(() => { + if (!fetchFailed) return; + const t = setTimeout(() => { fetchRegistry(); }, 2000); + return () => clearTimeout(t); + }, [fetchFailed, fetchRegistry]); +``` +- 空状态文案(`:142-153`)区分"启动中"与"不可达":若 `fetchFailed`,文案 "Gateway not reachable yet — retrying…" + 保留手动 Refresh;否则原文案。(READ 实际空状态块并按内容修改。) + +- [ ] **Step 2: L6 —— Web Search key 保存失败提示** + +`apps/desktop/src/components/Settings.tsx` `handleSaveSearchKey`(`:296-307`)当前 `catch {}` 静默。加一个错误状态并展示: +- 在 Settings 组件状态区加 `const [searchError, setSearchError] = useState(null);` +- catch 改为 `catch (err) { setSearchError(err instanceof Error ? err.message : "Failed to save search key"); }`,try 成功分支加 `setSearchError(null);` +- 在 Web Search key 输入区附近渲染 `{searchError &&

{searchError}

}`(READ 该区块定位)。 +- (与 Budget save 的错误展示风格一致——参考 `:643-645` 既有 budget 错误模式。) + +- [ ] **Step 3: L4 —— Activity 搜索同时匹配 provider 与 status** + +`apps/desktop/src/components/ActivityPage.tsx:34-36`: +```ts + const filtered = search + ? requestLog.filter((e) => e.model.toLowerCase().includes(search.toLowerCase())) + : requestLog; +``` +改为(同时匹配 model/provider/status,字段缺失安全): +```ts + const filtered = search + ? requestLog.filter((e) => { + const q = search.toLowerCase(); + return ( + e.model?.toLowerCase().includes(q) || + e.provider?.toLowerCase().includes(q) || + String((e as { status?: unknown }).status ?? "").toLowerCase().includes(q) + ); + }) + : requestLog; +``` +READ the `requestLog` entry type to confirm `provider`/`status` fields exist; if a field doesn't exist on the type, drop that clause (don't invent fields). Keep `e.model` match as the baseline. + +- [ ] **Step 4: 验证 + 提交** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit && bun run test` +Expected: 类型无新错误;vitest 全过。 +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src/components/ProviderKeyManager.tsx apps/desktop/src/components/Settings.tsx apps/desktop/src/components/ActivityPage.tsx +git commit -m "fix(desktop): provider startup retry, web-search save error, activity search by provider/status (M6/L6/L4-ux)" +``` + +--- + +## Task 6: Final —— 类型/测试 + 终审 + +- [ ] **Step 1: 全量类型检查 + UI 测试** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && find . -name '._*' -delete 2>/dev/null; bunx tsc --noEmit && bun run test` +Expected: tsc 无新错误;vitest 全过。 + +- [ ] **Step 2: 派终审 reviewer** + +重点:(a) H1 文案与跳转——Settings 两个按钮确实 `onGoToAccount?.()` 后 `onClose()`,App 传入正确;(b) H2 两步确认逻辑无误(确认态/取消态/成功后清理),错误对用户可见;(c) H5 失败区可点击打开 Settings,`shortError` 仍正确;(d) M5 两处轮询都跳过 hidden 且 visibilitychange 监听有 cleanup,无重复/泄漏;(e) M6 自动重试有限且不无限循环,空状态文案区分清晰;(f) L6 错误展示、L4 搜索字段真实存在;(g) 无类型回退、无 scope creep。 + +- [ ] **Step 3: (可选)preview 验证** + +若 reviewer 或用户要求,可用 preview 工具起 Vite dev 验证渲染与交互(删除确认两步、失败 banner 点击、搜索)。非必须——这些是小改动,类型 + 评审为主。 + +--- + +## Self-Review notes(作者自检) + +- **范围:** 7 项(H1/H2/H5/M5/M6/L6/L4),纯前端、低风险。M7(价格)已按用户决定移出。 +- **导航复用:** Settings 的 `onGoToAccount` 复用 App 既有的 `setActiveTab("account")`(HeroSection 已在用),不新增导航机制。 +- **删除确认:** 用组件内联两步状态,不引入全局 Modal/确认框——最小侵入、零新依赖。错误从 `console.warn`/静默改为对用户可见。 +- **轮询暂停:** 仅加 `document.hidden` 跳过 + `visibilitychange` 刷新,不做完整的双轮询合并(合并更大、收益低),都带 cleanup 防泄漏。 +- **字段安全:** L4 搜索扩展要求实现者核对 `requestLog` 条目真实字段,缺失则不加该 clause,不臆造字段。 +- **验证:** 以 `tsc --noEmit` + 既有 vitest 为门;无新测试基础设施;preview 为可选。 +- **依赖:** 与后端各 Phase 无耦合,可独立合并。 From d5270f428f47e06ec173dfdec50ffcf487347254 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:04:49 +0800 Subject: [PATCH 49/62] fix(desktop): onboarding/settings point to Account tab and actually navigate (H1-ux) --- apps/desktop/src/App.tsx | 1 + apps/desktop/src/components/Onboarding.tsx | 4 ++-- apps/desktop/src/components/Settings.tsx | 11 ++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0c2c78d..99f252f 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -345,6 +345,7 @@ export function App() { {showSettings && ( setShowSettings(false)} + onGoToAccount={() => setActiveTab("account")} /> )} diff --git a/apps/desktop/src/components/Onboarding.tsx b/apps/desktop/src/components/Onboarding.tsx index 021172a..a3f2f9f 100644 --- a/apps/desktop/src/components/Onboarding.tsx +++ b/apps/desktop/src/components/Onboarding.tsx @@ -118,12 +118,12 @@ function CloudConfirmation({ onDismiss }: { onDismiss: () => void }) {

- Go to the Activity tab to sign in to your cloud account. + Go to the Account tab to sign in to your cloud account.

Your gateway endpoint and API key are shown in the{" "} - Activity{" "} + Account{" "} tab after signing in.

diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx index 9a7f003..30d3ef4 100644 --- a/apps/desktop/src/components/Settings.tsx +++ b/apps/desktop/src/components/Settings.tsx @@ -28,9 +28,10 @@ async function loadStore() { interface SettingsProps { onClose: () => void; + onGoToAccount?: () => void; } -export function Settings({ onClose }: SettingsProps) { +export function Settings({ onClose, onGoToAccount }: SettingsProps) { const [activeMode, setActiveMode] = useState(getGatewayMode); const [token, setToken] = useState(""); const [hasToken, setHasToken] = useState(false); @@ -574,10 +575,10 @@ export function Settings({ onClose }: SettingsProps) {

{cloudUser.plan} plan

@@ -585,10 +586,10 @@ export function Settings({ onClose }: SettingsProps) {

Not signed in

From 29539d0bfbe8dfa348376baba3e35207770367a1 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:07:02 +0800 Subject: [PATCH 50/62] test(desktop): add missing User/MessageSquare to lucide-react mock (fix pre-existing red suite) --- apps/desktop/src/__tests__/components.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/__tests__/components.test.tsx b/apps/desktop/src/__tests__/components.test.tsx index 5c765ad..2089140 100644 --- a/apps/desktop/src/__tests__/components.test.tsx +++ b/apps/desktop/src/__tests__/components.test.tsx @@ -11,6 +11,7 @@ vi.mock("lucide-react", async () => { Pause: icon, Play: icon, Coins: icon, DollarSign: icon, Sparkles: icon, Zap: icon, Loader2: icon, Trash2: icon, ChevronDown: icon, LayoutDashboard: icon, Route: icon, ScrollText: icon, Search: icon, + User: icon, MessageSquare: icon, Wallet: icon, Activity: icon, XCircle: icon, Key: icon, Shield: icon, AlertCircle: icon, BookOpen: icon, ArrowRight: icon, BarChart3: icon, Pin: icon, Ban: icon, Plus: icon, Square: icon, From ea0aa6a5d838e3f4ad07601b28e4908224b2e4b8 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:08:34 +0800 Subject: [PATCH 51/62] fix(desktop): two-step confirm + visible error for key deletion (H2-ux) Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/components/AccountPage.tsx | 40 ++++++++++++--- .../src/components/ProviderKeyManager.tsx | 50 +++++++++++++++---- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/AccountPage.tsx b/apps/desktop/src/components/AccountPage.tsx index 38e8902..a093693 100644 --- a/apps/desktop/src/components/AccountPage.tsx +++ b/apps/desktop/src/components/AccountPage.tsx @@ -52,6 +52,8 @@ function CloudApiKeySection() { const [copied, setCopied] = useState(false); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); + const [confirmingDelete, setConfirmingDelete] = useState(null); + const [deleteError, setDeleteError] = useState(null); const copiedTimerRef = useRef>(undefined); useEffect(() => () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }, []); @@ -80,8 +82,12 @@ function CloudApiKeySection() { const handleDelete = async (id: string) => { try { await api.cloudDeleteApiKey(id); + setConfirmingDelete(null); + setDeleteError(null); await loadKeys(); - } catch (err) { console.warn("Failed to delete API key:", err); } + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete key"); + } }; const handleRename = async (id: string) => { @@ -169,17 +175,35 @@ function CloudApiKeySection() { {k.name} )} - + {confirmingDelete === k.id ? ( +
+ + +
+ ) : ( + + )} ))} )} + {deleteError &&

{deleteError}

} ); } diff --git a/apps/desktop/src/components/ProviderKeyManager.tsx b/apps/desktop/src/components/ProviderKeyManager.tsx index 1b40be6..aff8db2 100644 --- a/apps/desktop/src/components/ProviderKeyManager.tsx +++ b/apps/desktop/src/components/ProviderKeyManager.tsx @@ -16,6 +16,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro const [keyInput, setKeyInput] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(null); const [successProvider, setSuccessProvider] = useState(null); const [editingLocalUrl, setEditingLocalUrl] = useState(null); const [localUrlInput, setLocalUrlInput] = useState(""); @@ -70,6 +71,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro try { await api.deleteProviderKey(name); await fetchRegistry(); + setConfirmingDelete(null); onProvidersChanged?.(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to delete"); @@ -408,15 +410,39 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro {p.maskedKey} - + {confirmingDelete === p.name ? ( +
+ + +
+ ) : ( + + )} )} @@ -476,6 +502,12 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro ); })} + {error && !editingProvider && !editingLocalUrl && ( +
+ + {error} +
+ )} ); From 5d31ee17d58791b8bde9f19a1ffa4483c61f3e31 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:09:59 +0800 Subject: [PATCH 52/62] fix(desktop): gateway-failed banner is tappable to open Settings (H5-ux) --- apps/desktop/src/components/HeroSection.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/HeroSection.tsx b/apps/desktop/src/components/HeroSection.tsx index 5fc6368..ff08567 100644 --- a/apps/desktop/src/components/HeroSection.tsx +++ b/apps/desktop/src/components/HeroSection.tsx @@ -102,10 +102,17 @@ export function HeroSection({ connected, stale, gatewayState, gatewayError, bala - {gatewayState === "failed" && shortError && ( -
-

{shortError}

-
+ {gatewayState === "failed" && ( + )} {/* Low balance warning (P8) */} {mode === "cloud" && balanceCents !== undefined && balanceCents < 100 && balanceCents >= 0 && ( From afcf78e309090b795ea291d899db3eb441879568 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:11:11 +0800 Subject: [PATCH 53/62] fix(desktop): pause balance polling while panel hidden, refresh on show (M5-ux) --- apps/desktop/src/App.tsx | 15 +++++++++++---- apps/desktop/src/hooks/useCloudAuth.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 99f252f..9a0ee11 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -201,11 +201,18 @@ export function App() { setCloudBalanceCents(res.total_cents); }).catch(() => {}); - // Poll balance every 30s - const balanceInterval = setInterval(() => { + // Poll balance every 30s — skip while the panel is hidden (menu-bar app) + const fetchBalance = () => { + if (document.hidden) return; api.cloudGetBalance().then((res) => setCloudBalanceCents(res.total_cents)).catch(() => {}); - }, 30_000); - return () => clearInterval(balanceInterval); + }; + const balanceInterval = setInterval(fetchBalance, 30_000); + const onVisible = () => { if (!document.hidden) fetchBalance(); }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(balanceInterval); + document.removeEventListener("visibilitychange", onVisible); + }; }, [tokenLoaded]); // Auto-show onboarding for first-run users (only needs tokenLoaded, not connected) diff --git a/apps/desktop/src/hooks/useCloudAuth.ts b/apps/desktop/src/hooks/useCloudAuth.ts index af400d8..12b14c9 100644 --- a/apps/desktop/src/hooks/useCloudAuth.ts +++ b/apps/desktop/src/hooks/useCloudAuth.ts @@ -196,8 +196,14 @@ export function useCloudAuth(onLoginSuccess?: () => void, showToast?: (msg: stri // Poll balance every 10s when authenticated useEffect(() => { if (!isCloud || !hasCloudToken) return; - const timer = setInterval(refreshBalance, 10_000); - return () => clearInterval(timer); + const tick = () => { if (!document.hidden) refreshBalance(); }; + const timer = setInterval(tick, 10_000); + const onVisible = () => { if (!document.hidden) refreshBalance(); }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; }, [isCloud, hasCloudToken, refreshBalance]); // Refresh balance on window focus (e.g. returning from checkout) From a53c6747e1291e76ce1d4e33e9f32823d06e7da7 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:13:04 +0800 Subject: [PATCH 54/62] fix(desktop): provider startup retry, web-search save error, activity search by provider/status (M6/L6/L4-ux) --- apps/desktop/src/components/ActivityPage.tsx | 9 ++++++++- .../desktop/src/components/ProviderKeyManager.tsx | 15 +++++++++++++-- apps/desktop/src/components/Settings.tsx | 7 ++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/ActivityPage.tsx b/apps/desktop/src/components/ActivityPage.tsx index c7db166..832b0c2 100644 --- a/apps/desktop/src/components/ActivityPage.tsx +++ b/apps/desktop/src/components/ActivityPage.tsx @@ -32,7 +32,14 @@ export function ActivityPage({ requestLog, onSelectEntry }: ActivityPageProps) { const autoScroll = useRef(true); const filtered = search - ? requestLog.filter((e) => e.model.toLowerCase().includes(search.toLowerCase())) + ? requestLog.filter((e) => { + const q = search.toLowerCase(); + return ( + e.model?.toLowerCase().includes(q) || + e.provider?.toLowerCase().includes(q) || + String((e as { status?: unknown }).status ?? "").toLowerCase().includes(q) + ); + }) : requestLog; useEffect(() => { diff --git a/apps/desktop/src/components/ProviderKeyManager.tsx b/apps/desktop/src/components/ProviderKeyManager.tsx index aff8db2..615ab25 100644 --- a/apps/desktop/src/components/ProviderKeyManager.tsx +++ b/apps/desktop/src/components/ProviderKeyManager.tsx @@ -28,6 +28,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro const [customApiKey, setCustomApiKey] = useState(""); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(null); + const [fetchFailed, setFetchFailed] = useState(false); const fetchRegistry = useCallback(async () => { try { @@ -37,8 +38,10 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro ]); setProviders(regRes.providers); setLocalProviders(localRes.providers); + setFetchFailed(false); } catch { - // silent — may not be connected yet + // may not be connected yet — flag for bounded auto-retry + setFetchFailed(true); } finally { setLoading(false); } @@ -48,6 +51,12 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro fetchRegistry(); }, [fetchRegistry]); + useEffect(() => { + if (!fetchFailed) return; + const t = setTimeout(() => { fetchRegistry(); }, 2000); + return () => clearTimeout(t); + }, [fetchFailed, fetchRegistry]); + const handleSaveKey = useCallback(async (name: string) => { if (!keyInput.trim()) return; setSaving(true); @@ -145,7 +154,9 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro return (

- Connect to gateway to manage providers + {fetchFailed + ? "Gateway not reachable yet — retrying…" + : "Connect to gateway to manage providers"}

)}
+ {searchError &&

{searchError}

} From 0a61b611cac817af08ade1dbc6c4da6fabe5fd4c Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:17:12 +0800 Subject: [PATCH 55/62] fix(desktop): make provider startup retry a bounded poll, honest copy after giving up (M6-ux) --- .../src/components/ProviderKeyManager.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/ProviderKeyManager.tsx b/apps/desktop/src/components/ProviderKeyManager.tsx index 615ab25..c1dfcc4 100644 --- a/apps/desktop/src/components/ProviderKeyManager.tsx +++ b/apps/desktop/src/components/ProviderKeyManager.tsx @@ -29,6 +29,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(null); const [fetchFailed, setFetchFailed] = useState(false); + const [retriesExhausted, setRetriesExhausted] = useState(false); const fetchRegistry = useCallback(async () => { try { @@ -39,6 +40,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro setProviders(regRes.providers); setLocalProviders(localRes.providers); setFetchFailed(false); + setRetriesExhausted(false); } catch { // may not be connected yet — flag for bounded auto-retry setFetchFailed(true); @@ -51,10 +53,22 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro fetchRegistry(); }, [fetchRegistry]); + // Bounded auto-retry while the gateway is still coming up (e.g. starting). + // Polls every 2s, stops on success (fetchFailed→false clears this effect) or + // after a cap, after which the user falls back to the manual Refresh button. useEffect(() => { if (!fetchFailed) return; - const t = setTimeout(() => { fetchRegistry(); }, 2000); - return () => clearTimeout(t); + let attempts = 0; + const id = setInterval(() => { + attempts += 1; + if (attempts > 10) { + setRetriesExhausted(true); + clearInterval(id); + return; + } + fetchRegistry(); + }, 2000); + return () => clearInterval(id); }, [fetchFailed, fetchRegistry]); const handleSaveKey = useCallback(async (name: string) => { @@ -154,7 +168,7 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro return (

- {fetchFailed + {fetchFailed && !retriesExhausted ? "Gateway not reachable yet — retrying…" : "Connect to gateway to manage providers"}

From 1a328294d5471d603fa1cce2e41f9ee0ea048eca Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:13:24 +0800 Subject: [PATCH 56/62] =?UTF-8?q?docs:=20add=20Phase=205=20plan=20?= =?UTF-8?q?=E2=80=94=20landing=20CTA,=20admin=20responsive,=20tray=20statu?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-11-routebox-phase5-product-items.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-routebox-phase5-product-items.md diff --git a/docs/superpowers/plans/2026-06-11-routebox-phase5-product-items.md b/docs/superpowers/plans/2026-06-11-routebox-phase5-product-items.md new file mode 100644 index 0000000..521aa92 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-routebox-phase5-product-items.md @@ -0,0 +1,250 @@ +# RouteBox Phase 5 — 大型产品项 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 收尾三个较大的产品项:落地页购买 CTA 死循环修复(轻量版)、Admin 面板移动端适配 + 去重、菜单栏托盘状态指示 + 菜单增强。 + +**Architecture:** 三块互相独立、技术栈不同: +- **落地页 CTA**:`apps/cloud-gateway/landing.html`(实际服务)+ `apps/landing/index.html`(同字节副本)——两份都改。 +- **Admin 响应式**:`apps/cloud-gateway/admin.html`(实际服务,0 个 media query)加响应式;删除无引用的死副本 `apps/cloud-gateway/admin/index.html`。 +- **托盘(Rust)**:`apps/desktop/src-tauri/src/tray.rs` + `lib.rs` + `commands.rs`;动态 tooltip 状态 + 状态菜单行 + Start/Stop + Copy Endpoint,由前端 `gatewayState` 变化经 Tauri command/event 驱动。**不新增图标资源**(状态用 tooltip + 菜单文字,不做彩色图标)。 + +**Tech Stack:** HTML/CSS(landing/admin)、Rust + Tauri 2(tray)、TypeScript(前端 invoke/listen)。 + +**⚠️ 验证限制(重要):** 本卷(外置盘)`cargo build` 不可靠/曾因磁盘失败,Rust 改动**只能 read-back 验证**(沿用 Phase 0 Rust 任务的做法)。托盘任务(T3)合并后**需用户在本机 `cd apps/desktop && pnpm tauri dev` 实跑确认**托盘行为——计划会显式标注。landing/admin 为静态文件,inspect/浏览器目视验证。 + +--- + +## File Structure + +| 文件 | 项 | 操作 | +|------|----|------| +| `apps/cloud-gateway/landing.html` | CTA 死循环 | Modify | +| `apps/landing/index.html` | CTA(同步副本) | Modify | +| `apps/cloud-gateway/admin.html` | 移动端响应式 | Modify | +| `apps/cloud-gateway/admin/index.html` | 死副本 | Delete | +| `apps/desktop/src-tauri/src/tray.rs` | 托盘状态/菜单 | Modify | +| `apps/desktop/src-tauri/src/commands.rs` | update_tray_status 命令 | Modify | +| `apps/desktop/src-tauri/src/lib.rs` | 注册命令 | Modify | +| `apps/desktop/src/App.tsx` | gatewayState 变化时 invoke + 监听 copy-endpoint | Modify | + +**执行者必读:** 提交前 `find . -path ./node_modules -prune -o -name '._*' -delete`。忽略 `non-monotonic index` 警告。分支 `fix/audit-remediation`。Rust 改动跑 `cargo check` 若磁盘失败则 read-back 验证并在报告说明。 + +--- + +## Task 1: 落地页购买 CTA 不再死循环(轻量版) + +**Files:** `apps/cloud-gateway/landing.html`、`apps/landing/index.html`(两份内容相同,改成一致) + +当前 "Get Pro"/"Get Max" 链接到 `https://api.routebox.dev`,而该域名 serve 的就是这张落地页本身 → 死循环。轻量修复:指向页面内 `#download` 锚点(已存在),并加说明性 tooltip(下载 App 后在 Account 标签升级)。 + +- [ ] **Step 1: 改两个购买 CTA(两份文件各改一处对应的 Get Pro / Get Max)** + +在 **两份文件** 中,把: +```html +Get Pro +``` +改为: +```html +Get Pro +``` +把: +```html +Get Max +``` +改为: +```html +Get Max +``` +仅改这两个购买 CTA 的 `href` 并加 `title`。**不要**动 footer 的 "API" 链接(`>API`,指向 api.routebox.dev 是合理的)或 og/twitter meta 里的 api.routebox.dev。用 grep 确认两份文件各只改了 2 处。 + +- [ ] **Step 2: 验证** + +Run: +``` +cd /Volumes/ROG_500GB/RouteBox +grep -n 'href="https://api.routebox.dev"' apps/cloud-gateway/landing.html apps/landing/index.html +``` +Expected: 不再有指向 api.routebox.dev 的 **购买按钮**(仅可能剩 footer 的 API 链接——确认那不是 btn-ember/btn-primary 购买按钮)。两份文件的 Get Pro/Get Max 现在 `href="#download"`。 + +- [ ] **Step 3: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/landing.html apps/landing/index.html +git commit -m "fix(landing): point Pro/Max CTAs to #download instead of looping to self (H3-ux)" +``` + +--- + +## Task 2: Admin 面板移动端响应式 + 删除死副本 + +**Files:** `apps/cloud-gateway/admin.html`(改);`apps/cloud-gateway/admin/index.html`(删) + +`admin.html` 0 个 media query:固定 220px `position:fixed` 侧栏 + `margin-left:220px` 主区 + 固定 480px 模态,手机上严重溢出。`admin/index.html` 是与 `admin.html` 字节相同的副本,且 `admin-page.ts` 只读 `admin.html`,故 `admin/index.html` 是死代码。 + +- [ ] **Step 1: 删除死副本** + +```bash +cd /Volumes/ROG_500GB/RouteBox +# 确认无引用 +grep -rn "admin/index.html" apps/cloud-gateway/src || echo "no references — safe to delete" +git rm apps/cloud-gateway/admin/index.html +``` +(若上面 grep 有任何引用,STOP 上报——不要删。) + +- [ ] **Step 2: 加响应式 CSS** + +READ `apps/cloud-gateway/admin.html` 的 `` 之前)追加移动端断点: +```css + @media (max-width: 768px) { + /* 侧栏改为顶部横向条,主区不再左缩进 */ + .sidebar { position: static; width: 100%; height: auto; display: flex; flex-wrap: wrap; gap: 4px; } + .main, [class*="main"] { margin-left: 0 !important; padding: 12px !important; } + /* 模态自适应宽度 */ + .modal, [class*="modal"] { width: calc(100vw - 24px) !important; max-width: 480px; } + /* 表格横向滚动,避免撑破视口 */ + table { display: block; overflow-x: auto; white-space: nowrap; } + } +``` +**实现者:** 上面的选择器是按审计描述的占位——你必须 READ admin.html 的真实 class 名/结构,把选择器替换为实际匹配侧栏/主区/模态/表格的选择器。目标:≤768px 时侧栏不再固定 220px 占位、主区无 220px 左缩进、模态不超出视口、宽表格可横向滚动。保持桌面端(>768px)外观完全不变(媒体查询只在窄屏生效)。 + +- [ ] **Step 3: 验证** + +Run: `grep -c "@media" /Volumes/ROG_500GB/RouteBox/apps/cloud-gateway/admin.html`(应 ≥1)。 +若环境允许起服务并用 preview 工具(`preview_resize` 到 ~390px)目视确认侧栏/主区/模态在窄屏不溢出;否则 read-back 确认选择器与真实结构匹配,并在报告说明未做浏览器目视。 + +- [ ] **Step 4: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/cloud-gateway/admin.html apps/cloud-gateway/admin/index.html +git commit -m "fix(admin): mobile-responsive layout + remove dead duplicate admin/index.html (M2-ux)" +``` + +--- + +## Task 3: 托盘状态指示 + 菜单增强(Rust;read-back 验证) + +**Files:** `apps/desktop/src-tauri/src/tray.rs`、`commands.rs`、`lib.rs`、`apps/desktop/src/App.tsx` + +当前托盘:静态 template 图标、静态 tooltip "RouteBox"、菜单仅 Open/Quit;无状态指示。目标(**不新增图标资源**): +- 动态 tooltip 反映状态(Running / Stopped / Failed)。 +- 菜单加一个禁用的状态行(显示当前状态)+ Start Gateway / Stop Gateway / Copy Endpoint。 +- 由前端 `gatewayState` 变化经 `update_tray_status` 命令驱动。 +- 顺手修 `tray.rs:15` 的 `default_window_icon().unwrap()` 潜在 panic(改为 `if let Some(icon)`)。 + +**前置 READ(实现者必做):** `commands.rs` 里 `start_gateway`/`stop_gateway` 的确切签名(参数、是否 async);`lib.rs` 里 `invoke_handler![...]` 注册列表与 tray 创建位置;App.tsx 里 `gatewayState` 状态与 `@tauri-apps/api` 的 invoke/event 用法(项目是否已用 `listen`)。按真实签名适配,以下为结构指引。 + +- [ ] **Step 1: tray.rs —— 用 id 建托盘、加菜单项、暴露更新函数** + +把 `create_tray` 改为:给 `TrayIconBuilder` 设 id(如 `.with_id("main")` 或 `TrayIconBuilder::with_id("main")`,按 Tauri 2 API),菜单加项:一个禁用状态行 `status`(`MenuItem::with_id(app, "status", "Gateway: …", false, None)`)、`start`、`stop`、`copy_endpoint`、保留 `open`/`quit`。`default_window_icon().unwrap()` 改为安全处理: +```rust + let builder = TrayIconBuilder::with_id("main") + .icon_as_template(true) + .tooltip("RouteBox") + .menu(&menu) + .show_menu_on_left_click(false); + let builder = match app.default_window_icon() { + Some(icon) => builder.icon(icon.clone()), + None => builder, + }; +``` +`on_menu_event` 增加分支: +```rust + "start" => { let h = app.clone(); tauri::async_runtime::spawn(async move { let _ = crate::commands::start_gateway(h, None).await; }); } + "stop" => { let h = app.clone(); tauri::async_runtime::spawn(async move { let _ = crate::commands::stop_gateway(h).await; }); } + "copy_endpoint" => { let _ = app.emit("tray://copy-endpoint", ()); } +``` +**实现者:** `start_gateway`/`stop_gateway` 的真实签名以 commands.rs 为准(参数个数/类型可能不同,例如 port 参数)——按实际签名调用。`app.emit` 需要 `use tauri::Emitter;`(Tauri 2)。保留既有 open/quit 与 `on_tray_icon_event` 左键逻辑不变。 + +- [ ] **Step 2: tray.rs —— 暴露 `update_tray(app, status)` 更新 tooltip + 状态行** + +新增模块函数: +```rust +pub fn update_tray(app: &tauri::AppHandle, status: &str) { + // status: "running" | "stopped" | "failed" | "starting" | ... + let (label, tip) = match status { + "running" => ("Gateway: Running", "RouteBox — Running"), + "starting" => ("Gateway: Starting…", "RouteBox — Starting…"), + "failed" => ("Gateway: Failed", "RouteBox — Gateway failed"), + _ => ("Gateway: Stopped", "RouteBox — Stopped"), + }; + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_tooltip(Some(tip)); + } + // 状态菜单行文字更新:需保存 MenuItem 句柄或经 app state 取回; + // 若 Tauri 2 无法按 id 取回 MenuItem,则仅更新 tooltip(状态行可省略或重建菜单)。 + let _ = label; // 实现者:能更新则更新状态行文字,否则去掉 status 菜单项,仅留 tooltip +} +``` +**实现者:** Tauri 2 更新 MenuItem 文字需持有 `MenuItem` 句柄(`set_text`)。若实现成本高,**退化为只更新 tooltip**(去掉 status 菜单行),保证 tooltip 状态可用——这是最小高价值子集。报告你采用的方案。 + +- [ ] **Step 3: commands.rs —— `update_tray_status` 命令** + +```rust +#[tauri::command] +pub fn update_tray_status(app: tauri::AppHandle, status: String) { + crate::tray::update_tray(&app, &status); +} +``` +(放在其它 `#[tauri::command]` 旁;`crate::tray` 路径按实际模块结构。) + +- [ ] **Step 4: lib.rs —— 注册命令** + +在 `invoke_handler(tauri::generate_handler![...])` 列表里加入 `commands::update_tray_status`(READ 现有列表,按相同风格追加;确认 `commands` 模块路径)。 + +- [ ] **Step 5: App.tsx —— 状态变化时 invoke + 监听 copy-endpoint** + +READ App.tsx 现有 `gatewayState` 与 Tauri import 方式(项目已用 `@tauri-apps/api/core` invoke / `@tauri-apps/api/event` listen 的话沿用;注意桌面/浏览器双模式——用 lazy import 包裹避免浏览器报错,参考既有 Tauri 调用模式)。 +- 加一个 effect:`gatewayState` 变化时 `invoke("update_tray_status", { status: gatewayState })`(失败静默,浏览器模式跳过)。 +- 加一个 effect:`listen("tray://copy-endpoint", () => { /* 复制 endpoint+token 到剪贴板 */ })`——复制逻辑复用 Account 页已有的 endpoint/token 与 `navigator.clipboard.writeText`;并 `showToast("Endpoint copied")`。READ 现有获取 endpoint/token 的方式(constants/api),按真实来源拼接。effect 卸载时 `unlisten`。 +**实现者:** 严格遵循项目既有的 Tauri 调用/双模式封装,不要引入新的全局 import 破坏浏览器构建。 + +- [ ] **Step 6: 验证(read-back + 尽力 cargo check)** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop/src-tauri && cargo check 2>&1 | tail -15` +- 若编译通过:报告通过。 +- 若因磁盘/环境失败:read-back 验证——确认 (a) tray.rs 用 id 建托盘且 `unwrap()` 已去除;(b) `update_tray` / `update_tray_status` / 命令注册三处签名与路径一致;(c) `start_gateway`/`stop_gateway` 调用与 commands.rs 真实签名匹配;(d) App.tsx invoke/listen 用了项目既有封装、不破坏浏览器构建(跑 `cd apps/desktop && bunx tsc --noEmit` 验证前端类型)。报告所用验证方式。 +前端类型必须过:`cd /Volumes/ROG_500GB/RouteBox/apps/desktop && bunx tsc --noEmit`(expect clean)。 + +- [ ] **Step 7: Commit** + +```bash +find . -path ./node_modules -prune -o -name '._*' -delete 2>/dev/null +git add apps/desktop/src-tauri/src/tray.rs apps/desktop/src-tauri/src/commands.rs apps/desktop/src-tauri/src/lib.rs apps/desktop/src/App.tsx +git commit -m "feat(desktop): tray status tooltip + Start/Stop/Copy-Endpoint menu, driven by gateway state (tray-ux)" +``` + +--- + +## Task 4: Final —— 验证 + 终审 + +- [ ] **Step 1: 前端类型 + 测试** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop && find . -name '._*' -delete 2>/dev/null; bunx tsc --noEmit && bun run test` +Expected: tsc 干净;vitest 全过(30/30)。 + +- [ ] **Step 2: Rust(尽力)** + +Run: `cd /Volumes/ROG_500GB/RouteBox/apps/desktop/src-tauri && cargo check 2>&1 | tail -10` +若磁盘失败,记录为环境限制,依赖 Task 3 的 read-back。 + +- [ ] **Step 3: 静态文件目视/grep** + +确认 landing 两份无购买 CTA 指向 api.routebox.dev;admin.html 有 media query 且 admin/index.html 已删。 + +- [ ] **Step 4: 派终审 reviewer** + +重点:(a) landing 两份一致且仅改购买 CTA;(b) admin 媒体查询选择器匹配真实结构、桌面端不受影响、死副本已删;(c) tray 用 id 建/取、`unwrap` 已去、命令注册与签名一致、前端 invoke/listen 不破坏浏览器构建;(d) **明确标注 Rust 未经 cargo 验证(若是),提示用户实跑 `pnpm tauri dev` 确认托盘**。 + +--- + +## Self-Review notes(作者自检) + +- **三项独立、技术栈不同**,可分别合并。landing/admin 静态、低风险、可目视;tray 是 Rust + 前端联动、风险最高且**本环境无法 cargo 验证**——计划已显式要求 read-back + 前端 tsc + 用户实跑确认。 +- **托盘不新增图标资源**:状态用 tooltip(+ 可选菜单状态行),彩色状态图标留作后续(需美术资源)。Copy Endpoint 经 Tauri event 让前端用 `navigator.clipboard` 完成,避免新增 clipboard 插件/权限。 +- **退化路径**:若 Tauri 2 更新 MenuItem 文字成本高,T3 退化为「仅动态 tooltip」——仍是高价值最小子集;实现者需报告所采方案。 +- **去重**:admin/index.html 确认无引用后删除(单一来源 admin.html);landing 两份保持同步(本期不强行合并来源,只同改 CTA)。 +- **签名风险**:T3 多处依赖 commands.rs/lib.rs 真实签名,计划要求实现者先 READ 再适配,不臆造。 +- **依赖**:与后端各 Phase 无耦合;App.tsx 的 tray invoke 与 Phase 4 的 gatewayState 不冲突。 From bc0f27434ca83493a9ec73a95d0db8f3dd24bd8d Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:14:28 +0800 Subject: [PATCH 57/62] fix(landing): point Pro/Max CTAs to #download instead of looping to self (H3-ux) Co-Authored-By: Claude Opus 4.8 --- apps/cloud-gateway/landing.html | 4 ++-- apps/landing/index.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cloud-gateway/landing.html b/apps/cloud-gateway/landing.html index 48b1f1d..dc41b20 100644 --- a/apps/cloud-gateway/landing.html +++ b/apps/cloud-gateway/landing.html @@ -562,7 +562,7 @@

Simple, transparent

500 req/min rate limit - Get Pro + Get Pro
@@ -592,7 +592,7 @@

Simple, transparent

Maximum savings - Get Max + Get Max diff --git a/apps/landing/index.html b/apps/landing/index.html index 48b1f1d..dc41b20 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -562,7 +562,7 @@

Simple, transparent

500 req/min rate limit - Get Pro + Get Pro @@ -592,7 +592,7 @@

Simple, transparent

Maximum savings - Get Max + Get Max From 72d73ced82e5516a39edf84d40a34123d14a1a3f Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:15:47 +0800 Subject: [PATCH 58/62] fix(admin): mobile-responsive layout + remove dead duplicate admin/index.html (M2-ux) --- apps/cloud-gateway/admin.html | 20 + apps/cloud-gateway/admin/index.html | 1764 --------------------------- 2 files changed, 20 insertions(+), 1764 deletions(-) delete mode 100644 apps/cloud-gateway/admin/index.html diff --git a/apps/cloud-gateway/admin.html b/apps/cloud-gateway/admin.html index 15bf578..524c429 100644 --- a/apps/cloud-gateway/admin.html +++ b/apps/cloud-gateway/admin.html @@ -110,6 +110,26 @@ .mt-2 { margin-top: 8px; } .mb-2 { margin-bottom: 8px; } .flex-between { display: flex; align-items: center; justify-content: space-between; } + + /* Mobile responsive (<=768px). Desktop (>768px) unchanged. */ + @media (max-width: 768px) { + .app { display: block; } + /* Sidebar becomes a horizontal top bar instead of a fixed 220px column. */ + .sidebar { position: static; width: 100%; height: auto; bottom: auto; padding: 12px 0; display: flex; flex-wrap: wrap; align-items: center; } + .sidebar h1 { width: 100%; padding: 0 16px 12px; } + .nav-group { display: flex; flex-wrap: wrap; padding: 4px 8px; } + .nav-group + .nav-group { margin-top: 0; border-top: none; padding-top: 4px; } + .nav-section { width: 100%; padding: 8px 16px 4px; } + .nav-item { margin-right: 0; border-radius: 6px; padding: 7px 12px; } + .nav-item.active { border-left: none; padding-left: 12px; } + /* Main content no longer offset by the fixed sidebar. */ + .main { margin-left: 0; padding: 16px; } + /* Modals and fixed-width boxes fit the viewport. */ + .modal { width: calc(100vw - 24px); max-width: 480px; } + .auth-box { width: calc(100vw - 24px); max-width: 380px; } + /* Wide tables scroll horizontally instead of overflowing. */ + table { display: block; overflow-x: auto; } + } diff --git a/apps/cloud-gateway/admin/index.html b/apps/cloud-gateway/admin/index.html deleted file mode 100644 index 15bf578..0000000 --- a/apps/cloud-gateway/admin/index.html +++ /dev/null @@ -1,1764 +0,0 @@ - - - - - - RouteBox Admin - - - - - - - -
-
-

RouteBox Admin

-

Sign in with your admin credentials

- - - -

-
-
- - - - - - - - - - - - - - -
- - - - From 002ea8a94a9d542dbffe75a5158cebae30814bfa Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:20:06 +0800 Subject: [PATCH 59/62] feat(desktop): tray status tooltip + menu actions driven by gateway state (tray-ux) Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src-tauri/src/commands.rs | 7 +++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/tray.rs | 63 +++++++++++++++++-- apps/desktop/src/App.tsx | 83 +++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index f4e433f..a49237b 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -398,6 +398,13 @@ pub async fn is_gateway_running(app: tauri::AppHandle) -> Result { } } +// ── Tray status ───────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn update_tray_status(app: tauri::AppHandle, status: String) { + crate::tray::update_tray(&app, &status); +} + fn generate_token() -> String { // C4: Use crypto-secure random bytes instead of time+PID let mut bytes = [0u8; 32]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fcf8019..5573568 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -124,6 +124,7 @@ pub fn run() { commands::spawn_gateway, commands::stop_gateway, commands::is_gateway_running, + commands::update_tray_status, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e01e14b..594c844 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,22 +1,47 @@ use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, Runtime, + Emitter, Manager, Runtime, }; use tauri_plugin_positioner::{Position, WindowExt}; pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { + let status_item = + MenuItem::with_id(app, "status", "Gateway: …", false, None::<&str>)?; + let start_item = MenuItem::with_id(app, "start", "Start Gateway", true, None::<&str>)?; + let stop_item = MenuItem::with_id(app, "stop", "Stop Gateway", true, None::<&str>)?; + let copy_endpoint_item = + MenuItem::with_id(app, "copy_endpoint", "Copy Endpoint", true, None::<&str>)?; let open_item = MenuItem::with_id(app, "open", "Open Panel", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; let quit_item = MenuItem::with_id(app, "quit", "Quit RouteBox", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; + let sep1 = PredefinedMenuItem::separator(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + let sep3 = PredefinedMenuItem::separator(app)?; + let menu = Menu::with_items( + app, + &[ + &status_item, + &sep1, + &start_item, + &stop_item, + ©_endpoint_item, + &sep2, + &open_item, + &sep3, + &quit_item, + ], + )?; - let _tray = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) + let builder = TrayIconBuilder::with_id("main") .icon_as_template(true) .tooltip("RouteBox") .menu(&menu) - .show_menu_on_left_click(false) + .show_menu_on_left_click(false); + let builder = match app.default_window_icon() { + Some(icon) => builder.icon(icon.clone()), + None => builder, + }; + let _tray = builder .on_menu_event(|app, event| match event.id().as_ref() { "open" => { if let Some(window) = app.get_webview_window("panel") { @@ -25,6 +50,17 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { let _ = window.set_focus(); } } + // spawn_gateway requires a port supplied by the frontend, so we emit + // events for the frontend to drive start/stop with the correct port. + "start" => { + let _ = app.emit("tray://start", ()); + } + "stop" => { + let _ = app.emit("tray://stop", ()); + } + "copy_endpoint" => { + let _ = app.emit("tray://copy-endpoint", ()); + } "quit" => { app.exit(0); } @@ -52,3 +88,18 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { Ok(()) } + +/// Update the tray tooltip to reflect the current gateway state. +/// Driven by the frontend via the `update_tray_status` command. +pub fn update_tray(app: &tauri::AppHandle, status: &str) { + let tip = match status { + "running" => "RouteBox — Running", + "starting" => "RouteBox — Starting…", + "checking" => "RouteBox — Checking…", + "failed" => "RouteBox — Gateway failed", + _ => "RouteBox — Stopped", + }; + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_tooltip(Some(tip)); + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9a0ee11..0e3fa1d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -15,7 +15,7 @@ import { AlertBanner } from "@/components/AlertBanner"; import { ToastContainer } from "@/components/ToastContainer"; import { useRealtimeStats } from "@/hooks/useRealtimeStats"; import { useToast } from "@/hooks/useToast"; -import { getGatewayUrl, setGatewayUrl, setAuthToken, setCloudAuthToken, setGatewayMode, getGatewayMode, getCloudAuthToken, getPortFromUrl, ROUTEBOX_CLOUD_URL } from "@/lib/constants"; +import { getGatewayUrl, setGatewayUrl, setAuthToken, setCloudAuthToken, setGatewayMode, getGatewayMode, getCloudAuthToken, getAuthToken, getPortFromUrl, ROUTEBOX_CLOUD_URL } from "@/lib/constants"; import { checkGatewayHealth, waitForGateway, isLocalGatewayUrl } from "@/lib/gateway-health"; import { api } from "@/lib/api"; import type { CloudAnnouncement } from "@/lib/api"; @@ -183,6 +183,87 @@ export function App() { if (gatewayState === "running") setGatewayRunningAt(Date.now()); }, [gatewayState]); + // Drive the menu-bar tray tooltip from the gateway state (desktop only; no-ops in browser) + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { invoke } = await import("@tauri-apps/api/core"); + if (!cancelled) await invoke("update_tray_status", { status: gatewayState }); + } catch { + // Browser mode or invoke unavailable — best-effort, swallow + } + })(); + return () => { cancelled = true; }; + }, [gatewayState]); + + // Handle tray menu actions (Start / Stop / Copy Endpoint) emitted from Rust. + // Desktop only — lazy import so the browser build never pulls in the Tauri API. + useEffect(() => { + let unlistenFns: Array<() => void> = []; + let mounted = true; + + (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + const { invoke } = await import("@tauri-apps/api/core"); + + const handlers: Array void>> = [ + listen("tray://start", async () => { + const url = getGatewayUrl(); + if (getGatewayMode() !== "local" || !isLocalGatewayUrl(url)) return; + setGatewayState("starting"); + setGatewayError(null); + try { + await invoke("spawn_gateway", { port: getPortFromUrl(url) }); + try { + const t = await invoke("get_token"); + if (t) { setAuthToken(t); setToken(t); } + } catch { /* token reload best-effort */ } + const healthy = await waitForGateway(url, 12_000, 500); + setGatewayState(healthy ? "running" : "failed"); + } catch (err) { + setGatewayState("failed"); + setGatewayError(err instanceof Error ? err.message : String(err)); + } + }), + listen("tray://stop", async () => { + try { + await invoke("stop_gateway"); + setGatewayState("idle"); + } catch (err) { + setGatewayError(err instanceof Error ? err.message : String(err)); + } + }), + listen("tray://copy-endpoint", async () => { + const token = getAuthToken(); + const endpoint = getGatewayUrl(); + const text = token ? `${endpoint}\nToken: ${token}` : endpoint; + try { + await navigator.clipboard.writeText(text); + showToast("Endpoint copied", "success"); + } catch { + // Clipboard unavailable — silently ignore + } + }), + ]; + + for (const p of handlers) { + const fn = await p; + if (mounted) unlistenFns.push(fn); + else fn(); + } + } catch { + // Browser mode — no Tauri event API + } + })(); + + return () => { + mounted = false; + unlistenFns.forEach((fn) => fn()); + }; + }, [showToast]); + // Fetch cloud announcement once token is loaded (cloud mode only) useEffect(() => { if (!tokenLoaded) return; From f01614720ddedd464cd638063d279b4ab48dcae0 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:37:40 +0800 Subject: [PATCH 60/62] fix(gateway): make bind host configurable so container deploy is reachable (C1 docker regression) --- apps/gateway/Dockerfile | 3 +++ apps/gateway/src/index.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index f740534..75ae7a3 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -18,6 +18,9 @@ COPY --from=build /app/src src/ COPY --from=build /app/package.json ./ ENV ROUTEBOX_DB_PATH=/data/routebox.db +# Bind all interfaces inside the container so the mapped port is reachable. +# (The desktop app leaves this unset → gateway defaults to 127.0.0.1.) +ENV ROUTEBOX_HOST=0.0.0.0 USER bun EXPOSE 3001 diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts index c994438..d4fbe98 100644 --- a/apps/gateway/src/index.ts +++ b/apps/gateway/src/index.ts @@ -126,7 +126,10 @@ process.on("SIGINT", () => { console.log("Shutting down..."); process.exit(0); } export default { port, - hostname: "127.0.0.1", // C1: 仅监听 loopback,禁止局域网访问本地代理 + // C1: default to loopback so the desktop-embedded gateway isn't LAN-exposed. + // In a container, set ROUTEBOX_HOST=0.0.0.0 so the mapped port is reachable — + // there the security boundary is the container/network, not the bind address. + hostname: process.env.ROUTEBOX_HOST || "127.0.0.1", fetch: app.fetch, websocket, }; From fc51b48eed51a9041437a5758b947768e092ea09 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:37:40 +0800 Subject: [PATCH 61/62] fix(cloud): cast static-file Response body for TS5.7; sync lockfile for resend (CI) --- apps/cloud-gateway/src/index.ts | 8 +++++-- pnpm-lock.yaml | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/cloud-gateway/src/index.ts b/apps/cloud-gateway/src/index.ts index 3816f61..6a44394 100644 --- a/apps/cloud-gateway/src/index.ts +++ b/apps/cloud-gateway/src/index.ts @@ -147,7 +147,9 @@ app.get("/static/*", async (c) => { const fileName = c.req.path.replace("/static/", ""); const result = serveStaticFile(fileName); if (!result) return c.notFound(); - return new Response(result.data, { + // result.data is a Uint8Array; cast to satisfy TS 5.7's generic-TypedArray + // BodyInit typing (valid BodyInit at runtime). + return new Response(result.data as BodyInit, { status: 200, headers: { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400, immutable" }, }); @@ -157,7 +159,9 @@ app.get("/static/*", async (c) => { app.get("/favicon.ico", async (c) => { const result = serveStaticFile("favicon.ico"); if (!result) return c.notFound(); - return new Response(result.data, { + // result.data is a Uint8Array; cast to satisfy TS 5.7's generic-TypedArray + // BodyInit typing (valid BodyInit at runtime). + return new Response(result.data as BodyInit, { status: 200, headers: { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400, immutable" }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b31d6df..efc121a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: postgres: specifier: ^3.4 version: 3.4.8 + resend: + specifier: ^6.9.3 + version: 6.12.4 devDependencies: '@types/bun': specifier: latest @@ -131,6 +134,12 @@ importers: specifier: latest version: 1.3.9 + packages/llm-core: + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.14 + packages: '@acemir/cssom@0.9.31': @@ -1108,6 +1117,9 @@ packages: '@types/bun@1.3.10': resolution: {integrity: sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/bun@1.3.9': resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} @@ -1292,6 +1304,9 @@ packages: bun-types@1.3.10: resolution: {integrity: sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bun-types@1.3.9: resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} @@ -1916,6 +1931,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2039,6 +2057,15 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resend@6.12.4: + resolution: {integrity: sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3211,6 +3238,10 @@ snapshots: dependencies: bun-types: 1.3.10 + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/bun@1.3.9': dependencies: bun-types: 1.3.9 @@ -3403,6 +3434,10 @@ snapshots: dependencies: '@types/node': 25.3.1 + bun-types@1.3.14: + dependencies: + '@types/node': 25.3.1 + bun-types@1.3.9: dependencies: '@types/node': 25.3.1 @@ -4217,6 +4252,8 @@ snapshots: picomatch@4.0.3: {} + postal-mime@2.7.4: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4380,6 +4417,11 @@ snapshots: transitivePeerDependencies: - supports-color + resend@6.12.4: + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 From 04d6c972baa9f4929718d0b4651e5619ff933649 Mon Sep 17 00:00:00 2001 From: createpjf <113523690+createpjf@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:45:36 +0800 Subject: [PATCH 62/62] fix(docker): build from repo root so @routebox/llm-core resolves in images Phase 1 made both gateways import @routebox/llm-core via tsconfig paths, but the images were built with context=apps/, so packages/llm-core was absent and the import crashed at runtime (gateway smoke test caught it; cloud's lenient '|| true' smoke masked the same break). Gateway now bundles to a self-contained file (like the desktop ships); cloud preserves the monorepo layout + copies packages/. Workflows build with context=root. --- .github/workflows/build.yml | 6 ++++-- .github/workflows/ci.yml | 6 +++--- apps/cloud-gateway/Dockerfile | 19 +++++++++++++------ apps/gateway/Dockerfile | 26 +++++++++++++++++--------- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5be77e7..6425425 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -103,7 +103,8 @@ jobs: - name: Build and push uses: docker/build-push-action@v6 with: - context: apps/gateway + context: . + file: apps/gateway/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -138,7 +139,8 @@ jobs: - name: Build and push uses: docker/build-push-action@v6 with: - context: apps/cloud-gateway + context: . + file: apps/cloud-gateway/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b705ea..6747a44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - uses: actions/checkout@v4 - name: Build Docker image - run: docker build -t routebox-gateway apps/gateway + run: docker build -t routebox-gateway -f apps/gateway/Dockerfile . - name: Smoke test container run: | @@ -82,7 +82,7 @@ jobs: -e ROUTEBOX_TOKEN=test \ -e OPENAI_API_KEY=sk-fake \ routebox-gateway - sleep 2 + sleep 3 curl -sf http://localhost:3001/health | grep -q '"status":"ok"' docker stop gw @@ -93,7 +93,7 @@ jobs: - uses: actions/checkout@v4 - name: Build Docker image - run: docker build -t routebox-cloud-gateway apps/cloud-gateway + run: docker build -t routebox-cloud-gateway -f apps/cloud-gateway/Dockerfile . - name: Smoke test container run: | diff --git a/apps/cloud-gateway/Dockerfile b/apps/cloud-gateway/Dockerfile index d1e26e1..51ac77f 100644 --- a/apps/cloud-gateway/Dockerfile +++ b/apps/cloud-gateway/Dockerfile @@ -1,3 +1,8 @@ +# Build context is the REPO ROOT (so packages/llm-core is available). +# CI: docker build -f apps/cloud-gateway/Dockerfile . +# The monorepo layout is preserved in the image so the runtime resolves +# @routebox/llm-core via tsconfig paths (../../packages/llm-core/src). + FROM oven/bun:1-alpine # Install curl for Docker health checks @@ -7,19 +12,21 @@ RUN apk add --no-cache curl RUN addgroup -g 1001 -S routebox && \ adduser -S routebox -u 1001 -G routebox -WORKDIR /app +WORKDIR /repo # Install deps -COPY package.json bun.lock* ./ -RUN bun install --frozen-lockfile --production +COPY apps/cloud-gateway/package.json apps/cloud-gateway/bun.lock* apps/cloud-gateway/ +RUN cd apps/cloud-gateway && bun install --frozen-lockfile --production -# Copy source -COPY . . +# Copy source + the shared workspace package (preserve relative layout) +COPY apps/cloud-gateway apps/cloud-gateway +COPY packages packages # Fix permissions and switch to non-root user -RUN chown -R routebox:routebox /app +RUN chown -R routebox:routebox /repo USER routebox +WORKDIR /repo/apps/cloud-gateway EXPOSE 3001 CMD ["bun", "run", "src/index.ts"] diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 75ae7a3..12d887e 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -1,11 +1,21 @@ -# ---- Build stage ---- +# Build context is the REPO ROOT (so packages/llm-core is available). +# CI: docker build -f apps/gateway/Dockerfile . + +# ---- Build stage: bundle the gateway into one self-contained file ---- FROM oven/bun:1 AS build -WORKDIR /app +WORKDIR /repo -COPY package.json bun.lock* ./ -RUN bun install --frozen-lockfile --production +# Gateway sources + its workspace dependency (resolved via tsconfig paths) +COPY apps/gateway/package.json apps/gateway/tsconfig.json apps/gateway/ +COPY apps/gateway/src apps/gateway/src +COPY packages/llm-core packages/llm-core -COPY src/ src/ +# Install deps (hono) so the bundler can resolve them, then bundle. +# `bun build` inlines hono + @routebox/llm-core (via tsconfig paths) into one file, +# so the runtime image needs no node_modules and no packages/ layout — mirrors how +# the desktop app ships its gateway bundle. +RUN cd apps/gateway && bun install +RUN cd apps/gateway && bun build src/index.ts --target=bun --outfile /app/index.js # ---- Runtime stage ---- FROM oven/bun:1-slim @@ -13,9 +23,7 @@ WORKDIR /app RUN mkdir -p /data && chown bun:bun /data -COPY --from=build /app/node_modules node_modules/ -COPY --from=build /app/src src/ -COPY --from=build /app/package.json ./ +COPY --from=build /app/index.js ./index.js ENV ROUTEBOX_DB_PATH=/data/routebox.db # Bind all interfaces inside the container so the mapped port is reachable. @@ -28,4 +36,4 @@ EXPOSE 3001 HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ CMD bun -e "fetch('http://localhost:3001/health').then(r=>{if(!r.ok)throw 1})" || exit 1 -CMD ["bun", "run", "src/index.ts"] +CMD ["bun", "run", "index.js"]