docs(server): cleanup ai context

This commit is contained in:
RainbowBird
2026-08-02 00:10:40 +08:00
parent 71dd65cb99
commit 4d6e61f77d
37 changed files with 1 additions and 7346 deletions
+1
View File
@@ -0,0 +1 @@
docs/ai-context
View File
-91
View File
@@ -1,91 +0,0 @@
# AIRI Server AI Context
这组文档面向后续 AI / 开发者协作,目标是让人快速回答四个问题:
1. 服务端是怎么启动和组装的
2. 每条 API / WS 请求最终落到哪个服务
3. 哪些状态以 Postgres 为真相源,哪些只是缓存或派生数据
4. 计费、充值、事件分发这些高风险链路有哪些约束
## 文档索引
- `architecture-overview.md`
- 入口、依赖注入、应用装配、核心边界
- `transport-and-routes.md`
- HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件
- `data-model-and-state.md`
- 主要表、状态归属、缓存边界(事件队列层已拆掉)
- `workers-and-runtime.md`
-`api` role、无后台 loop、运行时约束(admin grant / Stripe webhook 等都同步在请求线程)
- `redis-boundaries-and-pubsub.md`
- Redis key / channel 收口、Pub/Sub 边界、运行时校验约束
- `config-and-naming-conventions.md`
- `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO
- `billing-architecture.md`
- 计费链路专项说明,重点看 Flux ledger / Stripe 幂等
- `stripe-pricing.md`
- Flux 充值定价以 Stripe Product/Price 为单一真相源,多币种 / 缓存 / 运营操作
- `flux-meter.md`
- Sub-Flux 计量服务(TTS/STT 等)的债务账本机制与复用指南
- `observability-conventions.md`
- traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界,SemconvStability 迁移、Counter priming、Dashboard 变量陷阱
- `observability-metrics.md`
- 全量 metric 目录(按域分组:HTTP / Auth / Engagement / Revenue / GenAI / Email / Rate limit / Runtime),含名字、类型、Labels、落点
- `metrics-ownership.md`
- 指标分层规则:什么走 Grafana / 什么走 PostHog / 什么是 Postgres truth;含 7 题判定 Checklist、PostHog 事件命名约定、当前指标归属总表、PostHog 接入路线图
- `product-analytics-instrumentation.md`
- 面向社区 / 产品问题的埋点补充方案:上手激活、Provider 配置、TTS 音色、语音输入、反馈、看板和异常播报
- `product-analytics-dashboard-setup.md`
- 产品分析看板落地说明:PostHog insights、Grafana 产品事件面板、告警表达式;不含 Discord / QQ 同步和日报 / 周报脚本
- `auth-and-oidc.md`
- 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录
- `email-auth-resend.md`
- Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项
- `account-deletion.md`
- 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型
- `admin-flux-grants.md`
- Admin 批量发 FLUX(活动赠送):单一同步 POST,无 batch 表无后台 loop`adminGuard` 邮箱白名单 + 可选 `idempotencyKey`
- `account-ban.md`
- Admin 授权改 role-basedbetter-auth `admin` 插件,删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生端点(`user.banned`),`resolveRequestAuth` + userinfo guard 做 OIDC JWT 热路径立即生效;改余额(`setFlux`)保留自建;含 disabledPaths 端点收口与「dashboard 为何不上」的决策
- `verifications/email-auth.md`
- 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据
- `verifications/account-deletion.md`
- 账号注销端到端验证:what's verifiedschema/typecheck/units)和 what's pendinglive DB + Resend + Stripe trace
- `verifications/admin-flux-grants.md`
- Admin 同步发 FLUX 路径:同步 grant / dry-run / adminGuard 拒绝(架构刚从 batch 切换到同步,待重新实测)
- `verifications/flux-unbilled-exploit-fix.md`
- Unpaid-usage exploit 修补(commit `7267b0d6b`)的代码层验证 + 残余 gapTTS flux-meter 未适配 partial-debit+ follow-up 清单
- `verifications/flux-unbilled-reconciliation.md`
- 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议
- `verifications/admin-user-balance-ban.md`
- Admin role 鉴权 / 封禁热路径闸 / 改余额:role adminGuard、resolveRequestAuth+userinfo 封禁、setFlux 的真实 PGlite/Hono 执行证据(含 flux-grants 集成测试走 role),及 better-auth admin 端点本身待端到端实测
- `verifications/product-analytics-smoke.md`
- 产品分析埋点上线冒烟清单:PostHog journey events、Postgres TTS metadata、Grafana Product Analytics row、Prometheus label 安全边界
## 快速结论
- `apps/server/src/app.ts` 是唯一的 API 应用装配入口。
- 服务端采用 `Hono + injeca + Drizzle + Redis + better-auth`
- 路由层整体较薄,业务逻辑主要在 `src/services/`
- **Postgres 是所有余额与计费状态的唯一真相源**,Redis 只做缓存、KV、Pub/Sub。计费链路不再使用 Redis Streams。
- WebSocket 只用于聊天同步,跨实例广播依赖 Redis Pub/Sub。
- 对外 LLM 能力不是本地推理,而是转发到配置里的 gateway,再按 usage / fallback rate 扣 Flux。
## 修改代码前建议先看
- 改 API 入口或新增依赖:先看 `architecture-overview.md`
- 改某个接口行为:先看 `transport-and-routes.md`
- 改表结构、缓存或幂等:先看 `data-model-and-state.md`
- 想加任何"异步副作用 / 后台 loop":先看 `workers-and-runtime.md` 的"运行时修改建议"
- 改 Redis key、Pub/Sub 边界:先看 `redis-boundaries-and-pubsub.md`
- 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md`
- 改扣费、充值、Stripe:先看 `billing-architecture.md`
- 改 Flux 充值价格 / 多币种 / Stripe Product/Price:先看 `stripe-pricing.md`
- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md`
- 加新 metric / 找当前 metric 全量列表:先看 `observability-metrics.md`
- 决定新指标该走 Grafana 还是 PostHog:先看 `metrics-ownership.md`
- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md`
- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md`
- 改账号注销 / 业务 service 的 `deleteAllForUser`:先看 `account-deletion.md`
- 改 admin 发 FLUX 路径:先看 `admin-flux-grants.md`
- 改 TTS / STT / embedding 等 sub-Flux 计量:先看 `flux-meter.md`
@@ -1,61 +0,0 @@
# Admin Access (Role) + Account Ban + Balance Override
服务端的 admin 能力统一在 better-auth 内置 `admin` 插件的 **role** 体系下,不再用 `ADMIN_EMAILS` 环境变量白名单。
## 授权模型:role-based
- `auth.ts` 启用 `admin({ adminRoles: ['admin'] })`。它给 `user` 表加 `role / banned / banReason / banExpires`,给 `session``impersonatedBy`schema 手写进 `schemas/accounts.ts`,字段名与插件一致,迁移 `drizzle/0013_naive_groot.sql`)。
- 自建的 `/api/admin/*` 路由用 `middlewares/admin-guard.ts``adminGuard`:读 `c.get('user').role`,命中 `'admin'`(支持逗号分隔多角色)才放行,否则 401(无 user/ 403(无 admin role)。
- **没有 env 白名单,也没有自动 seed**。第一个 admin 手动设:`UPDATE "user" SET role = 'admin' WHERE email = '...';`。在那之前没人能访问任何 admin 端点(自建的和 better-auth 的都不行)。
## better-auth admin 端点(收敛后的 ban/unban 在这里)
- 账号封禁/解封用 better-auth 原生端点:`POST /api/auth/admin/ban-user``/api/auth/admin/unban-user`body 用 `userId`,可带 `banReason` / `banExpiresIn`)。调用者需要 admin role(插件内部 `hasPermission` 校验)。
- ban 会写 `user.banned = true``deleteSessions(userId)``banExpires` 到点后,插件在下次登录的 `session.create.before` 自动翻回 `banned = false`
- **危险端点用 `disabledPaths` 关掉**`auth.ts`):`create-user / update-user / set-role / set-user-password / remove-user / impersonate-user / stop-impersonating`。只留读 + ban/unban + session 管理子集(list-users / ban-user / unban-user / list-user-sessions / revoke-user-session(s) / get-user / has-permission)。
- `set-role` 也关了:role 授予走手动 DB,不开放 HTTP 提权面。
## 封禁怎么「立即生效」
admin 插件的封禁强制只在 `session.create.before`(拦新登录)。但 stage-web / electron / pocket 热路径带的是 oauthProvider 签的无状态 RS256 JWT`resolveJWTAccessToken` 只验签 + `findUserById`**不建 session、不查 session 行**,插件那个钩子根本不触发。
所以热路径的封禁判断自己做,落在 `resolveRequestAuth`(所有传输层唯一鉴权入口:`sessionMiddleware` / 两个 WebSocket / OIDC `get-session`):
- 解析出 user 后调 `isUserBannedNow(user)``libs/request-auth.ts`),命中返回 `null` → 上层当未鉴权(401)。
- `isUserBannedNow` 读的是 `user.banned``findUserById` 已经把整行 user 带回来了,**零额外查询**),并判 `banExpires`:过期的 ban 当未封禁。
- `findUserById` 的 TS 返回类型是 better-auth 基础 User,不含插件字段,但运行时整行都在 → `request-auth.ts` 有一处带 `// NOTICE:` 的 widen cast 拿回 `banned`
另外 `/api/auth/oauth2/userinfo` 单独加了一道 guard`routes/auth/index.ts`):`/api/auth/*` 绕过 `sessionMiddleware`,而 userinfo 只验签就返 profile,所以这里用 `resolveSessionIgnoringBan` + `isUserBannedNow` 拦被封用户的有效 JWT。`/oauth2/introspect` 要 confidential client(一方 client 全 public),无可达调用方,不补。
**封禁时撤销 OAuth 凭据**codex review 发现并修):admin 插件 `banUser` 只删 session,留着 `oauth_refresh_token` / `oauth_access_token`。oauthProvider 的 `/oauth2/token` refresh grant`@better-auth/oauth-provider/dist/index.mjs:718`)加载 user 但**不查 `banned`**,所以被封用户本可用现存 refresh token 换一个全新 JWT。那个新 JWT 在所有资源路径仍被 `isUserBannedNow` 挡住(拿不到实际访问),但为了从源头断掉,`auth.ts` 加了 `databaseHooks.user.update.after`:检测 `banned=true` 时删该用户的 oauth refresh/access token 行(`banUser` 通过 `updateUser` 写 banned,触发此 hook)。这样 refresh grant 自然失败。
## 余额设定(无 better-auth 对应物,保留自建)
改余额是 flux 领域操作,better-auth admin 没有,保留为自建路由 `POST /api/admin/users/balance`,用 `adminGuard`role)守。
- `AdminUsersService.setBalance` 把 selectoremail | userId 二选一,`requireSingleSelector` 强校验)解析成 userId`resolveUserByIdOrEmail`),再委托 `BillingService.setFlux`
- `setFlux` 单事务锁 `user_flux` 行 → 改余额 → 写 `flux_transaction`type `admin_set`,方向 + before/after + issuedBy 进 metadata)→ 提交后 `redis.del` 失效缓存(不是写新值,避免参与 credit/debit 已有的跨操作 cache 竞态,详见下)。
flux-grants`/api/admin/flux-grants`)和 router-config`/api/admin/config/router`)同理:保留自建,鉴权从 `adminGuard(env)` 换成 role-based `adminGuard`
## 相关文件
- `src/libs/auth.ts``admin()` 插件 + `disabledPaths`
- `src/schemas/accounts.ts` — user/session 上的 admin 插件字段
- `src/middlewares/admin-guard.ts` — role-based `adminGuard`
- `src/libs/request-auth.ts``isUserBannedNow` + 热路径封禁闸
- `src/routes/auth/index.ts``/oauth2/userinfo` 封禁 guard
- `src/routes/admin/users/index.ts``POST /balance`(自建)
- `src/services/domain/admin/users/index.ts``setBalance` 编排
- `src/services/domain/billing/billing-service.ts``setFlux`
## 余额缓存竞态(预先存在)
`creditFlux` / `debitFlux` / `setFlux` 的 Redis 写都在事务提交后、行锁外,无版本号。多实例并发下较慢的旧 SET 可能后到覆盖新值。这不是本次引入的,`setFlux``redis.del`(失效,对齐 `FluxService.deleteAllForUser`)而非 SET,至少不往里添 stale SET。彻底修需要给三个写统一加版本化缓存写,超出范围。`getFlux` 是 cache-asidestale 窗口下次 miss 自愈,Postgres 始终是真相。
## 已知边界 / 取舍
- **第一个 admin 必须手动设 role**(删了 `ADMIN_EMAILS`)。首次部署/新环境要手动 `UPDATE "user" SET role='admin'`
- **ban 是 userId 单维**:账号在时 userId ban 已堵死一切登录方式(邮箱、所有 OAuth 都 resolve 到同一 user)。不覆盖「账号被删后用同邮箱/OAuth 重注册」——被封用户登不进去也删不了号,该洞只在 admin 主动删号后出现
- **dashboard 没上**`@better-auth/infra``dash()` 是闭源 SaaS`dash.better-auth.com`)的服务端 SDK,会把认证/用户数据外发第三方、`/dash/execute-adapter` 远程驱动 DB,且 `DashOptions` 只有 `activityTracking`、塞不进我们的 flux 业务页面。决定:业务管理(flux / grants / router config)将来自建 admin UIuser/session 管理直接调 better-auth admin 端点,不引入外部 SaaS
- admin 插件的 ban-user 端点 + `session.create.before` 登录拦截属于库行为,未跑真实 better-auth 登录流端到端验(靠源码确认 + 我们的热路径闸有真实执行覆盖)。详见 `verifications/admin-user-balance-ban.md`
@@ -1,184 +0,0 @@
# Account Deletion
User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance.
## 决策摘要
| 决策点 | 选择 | 理由 |
|---|---|---|
| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 |
| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state |
| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" |
| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 |
| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) |
| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 |
| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) |
| Stripe 订阅 | **立即 cancel,不退款(v1** | 简单、对内部记账影响最小。条款需注明。后续可改 |
| Flux 余额 | **清零(userFlux.deletedAt** | 同上。后续可补退款逻辑 |
## 流程
```
用户在 settings/account 点 Delete
POST /api/auth/delete-user (Bearer) ← better-auth
sendDeleteAccountVerification → Resend ← 我们的 EmailService
↓ 用户收邮件,点链接
GET /api/auth/delete-user/callback?token=... ← better-auth 验 token
↓ token 有效
beforeDelete(user) ← UserDeletionService.softDeleteAll(userId)
├─ stripe (priority 10): stripeService.deleteAllForUser
│ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt
├─ flux (priority 20): fluxService.deleteAllForUser
│ → userFlux 打 deletedAt + redis cache 失效
├─ providers (priority 30): providerService.deleteAllForUser
│ → userProviderConfigs 打 deletedAt
├─ characters (priority 30): characterService.deleteAllForUser
│ → character / likes / bookmarks 打 deletedAt
└─ chats (priority 30): chatService.deleteAllForUser
→ chats / messages 打 deletedAt
internalAdapter.deleteUser(userId) ← user 行真删
↓ Postgres FK cascade
session/account/oauth_client/oauth_*_token/oauth_consent ← 真删
重定向到 callbackURL
```
## 架构:service own 自己的删除语义
每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。
依赖图:
```
auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...]
└─ 内部仅持有 { name, priority, softDelete } 列表,
softDelete 是对 service.deleteAllForUser 的 thin wrapper
```
auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。
```ts
// apps/server/src/services/domain/user-deletion/types.ts
export interface UserDeletionHandler {
name: string
/** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */
priority: number
softDelete: (ctx: UserDeletionContext) => Promise<void>
}
export interface UserDeletionService {
register: (handler: UserDeletionHandler) => void
softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise<void>
}
```
装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotentStripe sub 已 cancel 的再 cancel 是 no-opdeletedAt 已设置的再 update 是 no-op)。
## 加新业务模块的步骤
1. 在该 service 加 `async deleteAllForUser(userId: string)` 方法
2.`app.ts``userDeletionService` build 里加一行 `service.register({...})`
3. 完成
不需要:写新文件、改 service 接口、改 auth.ts、改 types.ts。
## 各业务 service 的 deleteAllForUser
| Service | priority | 内容 | 依赖 |
|---|---|---|---|
| **stripeService** | 10 | (1) 查 stripeSubscription where userId=? and status=active(2) Stripe API `subscriptions.cancel(id, { prorate: false })`(3) 4 张 `stripe_*` 表 update deletedAt=now() | DB, Stripe SDK (optional) |
| **fluxService** | 20 | (1) `update userFlux set deletedAt=now() where userId=?`(2) `redis del flux:balance:{userId}`(3) **不动** flux_transaction(账本审计) | DB, Redis |
| **providerService** | 30 | `update userProviderConfigs set deletedAt=now() where ownerId=?` | DB |
| **characterService** | 30 | (1) `character set deletedAt=now() where ownerId=? or creatorId=?`(2) `characterLikes/Bookmarks set deletedAt=now() where userId=?` | DB |
| **chatService** | 30 | 按 `chat.type` 分支:① `private`/`bot` 整 chat soft-delete + 该 user 发的 message soft-delete;② `group`/`channel` 只硬删该 user 的 `chat_members` 行,**user 发的 message 保留**给其他 member 维持对话上下文(sender 通过"user 行 hard-delete + senderId bare text 无 FK"自然匿名化,UI 拿 senderId lookup 不到 user 时渲染为 "Deleted User" | DB |
| llm_request_log | 不参与 | 独立 retention job 处理 | — |
## 业务查询的软删过滤
**所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描:
- `apps/server/src/services/domain/flux.ts` — getBalance / readBalance
- `apps/server/src/services/domain/characters.ts` — listCharacters
- `apps/server/src/services/domain/providers.ts` — listProviderConfigs
- `apps/server/src/services/domain/chats.ts` — listChats / listMessages
- `apps/server/src/services/domain/billing/billing-service.ts` — invoice / sub 查询
写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。
## Failure 模型
| 阶段失败 | 行为 | 后果 |
|---|---|---|
| sendDeleteAccountVerification | better-auth 抛 500 | 用户重试 |
| token 验失败/过期 | better-auth 返 404 | 用户重新发起 |
| Stripe handler 抛错 | 整个 beforeDelete 中止 → user 不删 | DB 状态保持原样,Stripe sub 状态可能已 cancel(罕见),下次重试 idempotent |
| Flux/其他 handler 抛错 | 同上中止 → user 不删 | 已经 cancel 的 Stripe sub 不会回滚(Stripe API 不支持 un-cancel),用户得重新订阅。**记录到 deletion_failure_log**telemetry / sentry alert |
| user 真删后 afterDelete 抛错 | user 已删,session 已 revoke,已无法回滚 | 仅 log,不影响用户体验 |
**没有补偿事务**。Multi-step soft-delete 失败的处置策略是:失败即中止,依赖 idempotency 让重试干净。
## Idempotency
- better-auth 的 verification token 一次性消费(`deleteVerificationByIdentifier`),点链接两次第二次会 404
- handler 全部用 `update where deletedAt is null` 守卫,重跑无副作用
- Stripe `subscriptions.cancel` 对已 cancel 的 sub 返回 200idempotent by spec
## 群聊匿名化("Deleted User"
群聊场景下 `messages.senderId` 故意是 **bare `text` 列没有 FK**,所以:
- better-auth hard-delete `user` 行后,`messages.senderId='abc123'` 字符串还在,但 `select * from "user" where id='abc123'` 空集
- name / email / avatar 全部跟 user 行一起没了
- senderId 还能 group by(同一 user 发的 message 仍可识别为同一来源),但**反查不到任何 PII**
- UI 路径:渲染 message sender 时 user lookup miss → 显示 "Deleted User" / "[已注销]"
**chatService.deleteAllForUser 不需要主动改 senderId**schema "bare text + 无 FK + auth user 行 hard-delete" 这三件事联合产出匿名化效果。
## 第三方 OAuth provider 端
better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 google/github 登录方式的关联),oauth_* 表通过 FK cascade 删干净。**第三方 OAuth provider 那边的 grant 不主动撤销** —— 跟 Stripe / Slack / Discord 等业界默认一致。User 真要彻底清,应该去 OAuth provider 自己的 dashboard(如 google.com/security)撤。
如果未来出现严格 GDPR 需求,可以加 best-effort 调 Google `/o/oauth2/revoke?token=...` —— 但需要保留 refresh token,且 endpoint 本身就是 best-effort。
## 不做项 (v1)
- ❌ 软删 → hard delete reaper job(业务表保留无限期,等首次清理需求驱动;llm_request_log 已有独立 retention
- ❌ 误删恢复(用户认知中删除即终态;UI 必须文案警示)
- ❌ Stripe / Flux 退款(条款里写明,后续按需补)
- ❌ 删除事件外发 Webhook / Slack 通知(用 telemetry 替代)
- ❌ Admin 手动触发 delete(后续 admin panel 任务)
- ❌ 主动撤销第三方 OAuth provider 端的 grant(业界默认不做,user 自助撤)
## 相关代码索引
- 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts`
- Auth schema (不改): `apps/server/src/schemas/accounts.ts`
- Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`)
- Email service: `apps/server/src/services/adapters/email.ts` (extend interface + Resend impl)
- Deletion scheduler: `apps/server/src/services/domain/user-deletion/` (registry only, no domain logic)
- 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts`
- UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO)
- UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue`
- i18n: `packages/i18n/src/locales/{en,zh}/settings/account.yaml`
## Verification
实测路径见 `docs/ai/context/verifications/account-deletion.md`(待补)。
最小路径:
1. 注册 user A
2. 创建一个 character,给 5 flux,订阅 active submock Stripe
3. UI 点 Delete → 收邮件 → 点链接
4. 验证:
- `select * from "user" where email='A'`
- `select * from session where user_id='A'`
- `select * from user_flux where user_id='A'` deleted_at 非空
- `select * from character where owner_id='A'` deleted_at 非空
- `select * from stripe_subscription where user_id='A'` deleted_at 非空,Stripe API 端 sub status=canceled
- `select * from flux_transaction where user_id='A'` 仍存在(账本审计)
5. 重新用 email A 注册成功(unique 约束已释放)
@@ -1,106 +0,0 @@
# Admin Flux Grants
Admin 一次性给若干用户发 FLUX(Beta 致谢、补偿、运营赠送等)的接口。整个流程**单一同步 HTTP 调用**搞定,没有 batch 表、没有状态机、没有后台 loop。
## 1. 背景
旧设计是 `flux_grant_batch` + `flux_grant_batch_recipient` 两张表 + 状态机 + 异步处理 + retry 端点 + advisory-lock poller~800 行代码。实际产品里 admin 发放频率"几周一次、几十个用户",过度工程。简化为:
- 一个 `POST /api/admin/flux-grants` 接口
- 同步处理:resolve emails → 顺序调 `creditFlux` → 返回每条的 outcome
- 审计走 `flux_transaction` 表(`type='promo'``metadata.description` / `metadata.idempotencyKey`
- 失败处理:admin 看响应里的 `failed[]`,自己再发一次(用 `idempotencyKey` 防止已成功的部分被双发)
## 2. 路由
`POST /api/admin/flux-grants?dryRun=true|false`
Auth`authGuard` + `adminGuard``ADMIN_EMAILS` allowlist + 验证邮箱)。
Body
```text
{
description: string, // 1..500 chars; 写入 flux_transaction.metadata.description
amount: number, // 1..MAX_GRANT_AMOUNT_PER_USER (10_000), 单人发放数量
emails: string[], // 1..MAX_EMAILS_PER_GRANT (200) 个 email
idempotencyKey?: string, // 可选,最长 100 chars。提供后每个 recipient 的
// requestId = `flux-grant:${idempotencyKey}:${userId}`
// 重发同 (key, recipients) 是 no-op;不提供则每次 grant
// 都会重发。
}
```
dry-run 响应:
```text
{ preview: { totalEmails, willGrant, willSkip: { notFound, userDeleted, duplicateInInput }, totalFluxToIssue, samples } }
```
实发响应:
```text
{
summary: { totalEmails, willGrant, willSkip, totalFluxToIssue, samples },
result: {
granted: [{ email, userId, fluxTransactionId, balanceAfter }],
skipped: [{ email, reason: 'duplicate_in_input' | 'not_found' | 'user_deleted' }],
failed: [{ email, userId, error }], // creditFlux 抛错时进这里
},
}
```
## 3. 处理流程(同步)
1. 路由层 valibot 校验 body
2. `service.resolveEmails(emails)`
- 输入小写化后 `IN (...)``user.email`(不能 wrap `LOWER()`,会 break unique index → seq scan
- 命中 user 后再查 `user_flux.deletedAt`
- 重复输入按出现顺序首条留下,后续标 `duplicate_in_input`
3. 对每个 `status='pending'` 的 recipient 顺序调 `BillingService.creditFlux({ userId, amount, type: 'promo', requestId, description, source: 'admin_promo', auditMetadata })`
4. 抛错记到 `result.failed[]`,循环继续;成功记到 `result.granted[]`
5. HTTP 返回完整 `result`
没有 sleep / throttle —— 200 个 recipient × 2050ms 单条 ≈ 410s,安心进 LB 30s 超时窗口。如果以后真的需要更大批量,先评估是否值得拆,再决定加 cap 还是引入异步。
## 4. 失败 / 恢复
| 故障 | 表现 | 恢复 |
|---|---|---|
| 单条 recipient `creditFlux` 抛错(DB blip 等) | 出现在 `result.failed[]` | admin 看响应,自己再发一次相同请求;如果用了 `idempotencyKey`,已 granted 的不会被双发,只重试 failed 的 |
| 整个请求超 LB 超时 | 客户端看到超时,部分 recipient 已扣账 | admin 用同 `idempotencyKey` 重发,已成功的直接幂等跳过 |
| Operator 输错邮箱 / 数量 | 先用 `?dryRun=true` 看 preview | 改完再去掉 dryRun |
## 5. 审计
- 每条成功 grant 在 `flux_transaction` 写一行(`type='promo'``metadata.description``metadata.issuedByUserId`、可选 `metadata.idempotencyKey`
- 没有专门的 admin 报表;用 `/api/v1/flux/history` 或直接 SQL 按 `metadata->>'description'` / `metadata->>'idempotencyKey'`
```sql
-- 看某次 grant 实际发了多少
SELECT user_id, amount, balance_after, created_at
FROM flux_transaction
WHERE metadata->>'idempotencyKey' = 'beta-2026-q2'
ORDER BY created_at;
```
## 6. 实现位置
- 路由:[`apps/server/src/routes/admin/flux-grants/index.ts`](apps/server/src/routes/admin/flux-grants/index.ts)
- Service[`apps/server/src/services/domain/admin/flux-grants/index.ts`](apps/server/src/services/domain/admin/flux-grants/index.ts)
- 单测:[`apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts`](apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts)
- adminGuard[`apps/server/src/middlewares/admin-guard.ts`](apps/server/src/middlewares/admin-guard.ts)
- 数据库:**没有**专门的表;唯一持久化是 `flux_transaction` ledger
- 已废弃:`flux_grant_batch` / `flux_grant_batch_recipient`drizzle migration `0011_superb_lady_deathstrike.sql` 删表)
## 7. 不做
- 不做 batch 状态机 / retry endpoint —— 同步响应里已经有 failed 列表,admin 看到失败就自己再发
- 不做异步处理 / 后台 loop —— 200 用户上限完全可以塞进一个 HTTP 请求
- 不做 dashboard 展示 —— 直接查 `flux_transaction` 即可
- 不做高并发 / 大批量 —— 这是 admin 工具不是 bulk import;超 200 就让 admin 拆请求
## 8. 已知不足
- **无 admin-side 失败留痕**`failed[]` 只在 HTTP 响应里返回一次,admin 关掉浏览器就没了。如果将来发现需要"上次失败的那批"持久化,再单独加一张 `admin_grant_attempt_log` 之类的,不要把它做回 batch 表。
- **`emails` 上限 200 是经验估算**:单 `creditFlux` 假设 20–50ms。如果实际生产数据显示更慢,下调上限。
@@ -1,163 +0,0 @@
# Server Architecture Overview
## 一句话总结
`apps/server` 是一个基于 `Hono` 的 Node 服务端,负责认证、角色/聊天/Provider 配置、Flux 余额、Stripe 充值和面向 gateway 的 LLM 代理。整体模式是:
- 路由层负责参数校验、鉴权、错误映射
- 服务层负责业务逻辑和数据库事务
- `Postgres` 负责持久化与账本真相
- `Redis` 负责缓存、配置 KV、Pub/Sub(不再使用 Streams
- `injeca` 负责把这些依赖组装成一个可启动应用
## 入口与装配
核心入口在 `src/app.ts`
- `createApp()`
- 初始化 logger
- 解析环境变量
- 初始化 OpenTelemetry
- 建立 Postgres / Redis 连接
- 执行数据库迁移
- 构建各个 service
- 注册路由和中间件
- `runApiServer()`
- 启动 HTTP 服务
- 注入 WebSocket
- 绑定 `uncaughtException` / `unhandledRejection`
CLI 入口在 `src/bin/run.ts`,只有一种角色:
- `api`HTTP/WS;没有常驻后台 loop,也没有 fire-and-forget 异步任务。admin flux grant 在 POST 请求线程内同步处理完返回;详见 `workers-and-runtime.md`
## 依赖注入结构
`app.ts` 使用 `injeca.provide()` 注册依赖,依赖关系大致如下:
- 基础设施
- `env`
- `otel`
- `db`
- `redis`
- `configKV`
- 服务
- `auth`
- `characterService`
- `providerService`
- `chatService`
- `stripeService`
- `fluxTransactionService`
- `fluxService`
- `requestLogService`
- `billingService`
- `adminFluxGrantsService`
- `ttsMeter`
- `userDeletionService`
- `emailService`
这个装配顺序说明了几个事实:
- `billingService` 依赖 `db + redis`
- `fluxService` 只读余额,不承担余额写入职责
- `auth` 直接绑定数据库 schema,不是外部独立服务
## 应用层边界
### 1. HTTP / WS 传输层
`src/routes/``src/middlewares/`
- 参数校验使用 `valibot`
- 用户身份来自 `sessionMiddleware``authGuard`
- 业务异常统一抛 `ApiError`
- 全局 `onError` 转成标准 JSON 错误响应
### 2. 业务服务层
`src/services/`
- `characters.ts`
- `chats.ts`
- `providers.ts`
- `flux.ts`
- `billing-service.ts`
- `stripe.ts`
这里是主要改动面。大多数业务改动都不应该直接写进 route handler。
### 3. 持久化层
`src/schemas/`
- Drizzle schema 基本覆盖了所有核心表
- 数据迁移由 `@proj-airi/server-schema` 提供
- `app.ts` 启动时会执行迁移
## 中间件与通用约束
全局中间件链路大致是:
1. `/api/*` 启用 CORS
2. `hono/logger`
3. 可选的 `otelMiddleware`
4. `sessionMiddleware`
5. `bodyLimit(1MB)`
6. 各 route 的局部 guard
需要记住的行为:
- WebSocket `/ws/chat``bodyLimit` 之前注册
- `sessionMiddleware` 不会阻断匿名请求,只是往 context 填 `user/session`
- `authGuard` 才会真正返回 401
- `rate-limit.ts` 目前是**内存限流**,不是分布式限流
## 错误模型
统一错误类型在 `src/utils/error.ts`
- `ApiError(statusCode, errorCode, message, details)`
约定:
- 业务层可以直接抛 `ApiError`
- 未知异常会被包装成 `500 INTERNAL_SERVER_ERROR`
- 参数错误、权限错误、余额不足都已有明确 helper
## 关键设计取舍
### Flux 读写分离
- `FluxService`
- 面向读取
- Redis cache-aside
- 新用户首次读取时初始化余额
- `BillingService`
- 面向写入
- debitFlux / credit 方法:事务内同步更新余额并写 `flux_transaction` ledger;事务提交后 best-effort 刷 Redis 余额缓存
这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`
### LLM/TTS 路由在进程内,而不是本地 provider 编排
`/api/v1/openai``services/domain/llm-router` 读取 `LLM_ROUTER_CONFIG` 后按 upstream 链路 + key rotator 直接调 providerOpenRouter、Azure Speech、阿里云 DashScope、火山引擎 等),不再依赖外部 knoway sidecar。因此:
- 服务端关心的是鉴权、限流、计费、日志、观测、上游路由与 key 健康
- 具体模型协议翻译由 `services/domain/llm-router``services/adapters/tts` 的 adapter 完成
### Redis 有多种职责,但都不是余额真相源
Redis 在这里同时承担:
- Flux 余额缓存
- 运行时配置 KV
- WebSocket 跨实例广播 Pub/Sub
- Sub-Flux 计量债务账本(TTS 字符等,TTL 抹零,详见 `flux-meter.md`
- TTS voices 上游响应缓存
但余额真相仍然在 Postgres。Redis Streams 已全部移除,详见 `redis-boundaries-and-pubsub.md` 的 NOTICE。
## 当前值得注意的实现信号
- `/api/v1/openai` 当前开放:`POST /chat/completions``POST /chat/completion``POST /audio/speech``GET /audio/voices``handleTranscription` 路由尚未挂载。
- `flux_grant_batch` schema 已被简化版 `admin-flux-grants` 取代,代码 + schema 都已清理。`drizzle/0011_open_unus.sql` 是 drop migration`DROP TABLE flux_grant_batch / flux_grant_batch_recipient CASCADE`,顺带清掉 6 个 index)。这条 DDL 是不可逆破坏,需要操作员在合适的部署窗口手动 `pnpm db:push` 推到 prod;只要 prod DB 还没 apply 0011,回滚 server image 不会丢数据。
@@ -1,307 +0,0 @@
# 认证与 OIDC Provider
## 一句话总结
Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC ProviderAuthorization Server**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。客户端直接持有 OIDC access token,并通过服务端统一的 Bearer 解析链路完成鉴权与 session 查询。
## 架构角色
```
┌─────────────────────────────────┐
│ 社交登录 IdP (Google, GitHub) │
└──────────────┬──────────────────┘
↓ OAuth 2.0
┌──────────────────────────────────────────────────┐
│ AIRI Server (better-auth OIDC Provider) │
│ │
│ /api/auth/oauth2/authorize ← PKCE 授权 │
│ /api/auth/oauth2/token ← Code 换 Token │
│ /api/auth/oidc/electron-callback ← 回调中继页 │
│ /api/auth/sign-in/social ← 社交登录入口 │
│ /sign-in ← 登录选择页 │
└──────────────┬──────────────────┬────────────────┘
↓ ↓
┌──────────┐ ┌──────────────┐
│ Stage Web │ │ Stage Electron│
│ /auth/ │ │ 127.0.0.1: │
│ callback │ │ {port}/ │
└──────────┘ │ callback │
└──────────────┘
```
## 核心组件
### Server 端
| 文件 | 职责 |
|------|------|
| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 |
| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、token auth 辅助路由、electron callback、well-known metadata、better-auth catch-all |
| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback |
| `src/routes/oidc/token-auth.ts` | Bearer token 辅助路由:`get-session``sign-out``list-sessions` |
| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) |
| `src/utils/origin.ts` | 可信来源配置:`localhost``127.0.0.1``airi.moeru.ai``capacitor://localhost` |
| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema |
| `src/libs/request-auth.ts` | 统一鉴权解析:优先读 better-auth session,再回退到受信任 OIDC access token |
### Client 端
| 文件 | 职责 |
|------|------|
| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 |
| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`applyOIDCTokens()` 持久化 token、`fetchSession()` 同步会话、自动刷新调度 |
| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user``session``token``refreshToken` 到 localStorage |
| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()``generateCodeChallenge()``generateState()` |
| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → 持久化 access token → `fetchSession()` → 跳转首页 |
| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 |
### Trusted Clients
| Client | ID 环境变量 | redirect_uri | 类型 |
|--------|------------|--------------|------|
| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web |
| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native |
| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native |
### 环境变量
```
# 社交 Provider
AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET
AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET
# Apple optional;启用时四项必须一起配置
AUTH_APPLE_CLIENT_ID, AUTH_APPLE_TEAM_ID
AUTH_APPLE_KEY_ID, AUTH_APPLE_PRIVATE_KEY_PEM
# iOS 原生 Sign in with Apple;逗号分隔,每项必须与一个 Xcode target 的 Bundle ID 一致
AUTH_APPLE_APP_BUNDLE_IDENTIFIERS=ai.moeru.airi-pocket,ai.moeru.airi-pro
# OIDC Trusted Clients(均 optional,不配则不注册)
# Web and Pocket are public clients (no secret, PKCE only)
OIDC_CLIENT_ID_WEB
OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON
OIDC_CLIENT_ID_POCKET
```
### iOS 原生 Sign in with Apple
iOS 使用 `ASAuthorizationAppleIDProvider` 获取 Apple identity token,然后直接调用 Better Auth 的社交登录接口;不要先请求授权 URL,也不需要打开系统浏览器:
```http
POST /api/auth/sign-in/social
Content-Type: application/json
{
"provider": "apple",
"idToken": {
"token": "<ASAuthorizationAppleIDCredential.identityToken UTF-8 JWT>",
"nonce": "<the exact value assigned to ASAuthorizationAppleIDRequest.nonce>"
}
}
```
首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取:
```json
{
"provider": "apple",
"idToken": {
"token": "<identity-token>",
"nonce": "<nonce>",
"user": {
"name": {
"firstName": "<given-name>",
"lastName": "<family-name>"
}
}
}
}
```
Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL
```json
{
"redirect": false,
"token": "<better-auth-session-token>",
"user": {}
}
```
客户端后续可将返回的 session token 作为 `Authorization: Bearer <token>` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。
## Token 层次
| Token | 用途 | 存储位置 | 生命周期 |
|-------|------|---------|---------|
| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 |
| OIDC Access Token (JWT) | 实际的 API 鉴权凭证(Bearer | localStorage `auth/v1/token` | 1 小时 TTL,自包含,不存数据库 |
| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期,rotation 机制 |
| Session 对象 | UI / API 所需的用户态快照 | `fetchSession()` 后保存在 auth store | 跟随 access token 可解析结果 |
**为什么现在可以直接用 OIDC access token** 因为服务端的 `resolveRequestAuth()` 已经统一支持两条路径:先走 `auth.api.getSession()` 解析 better-auth session;如果没有 session,再用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间,然后通过 `findUserById()` 补齐用户信息。对业务路由来说,拿到的仍然是统一的 `{ user, session }` 结构。
**测试环境登录绕过:** 设置 `TEST_AUTH_TOKEN` 后,业务 API 可以直接带 `Authorization: Bearer $TEST_AUTH_TOKEN` 进入 `resolveRequestAuth()`,无需走 UI 登录或 better-auth session。默认虚拟用户为 `test-user / test@example.com / Test User`,可用 `TEST_AUTH_USER_ID``TEST_AUTH_USER_EMAIL``TEST_AUTH_USER_NAME``TEST_AUTH_USER_ROLE` 覆盖;需要访问 `/api/admin/*` 时把 `TEST_AUTH_USER_ROLE=admin`。该 token 只接入业务鉴权链路,不改变 `/api/auth/*` better-auth 登录/OIDC 端点;生产环境保持 unset。
**JWT 签发条件:** 前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`),oauthProvider 据此签发 JWT 而非 opaque token。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。
**撤销策略:** JWT 1 小时 TTL + refresh token rotation。signout 时撤销 refresh tokenJWT 等自然过期。不使用 denylist 或 Redis。
**为什么不用 cookie** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。
## 登录流程
### Web 完整流程
```
Client (localhost:5173) Server (localhost:3000) Social IdP
│ │ │
1. signInOIDC() │ │
构建 PKCE (verifier + challenge) │ │
存 sessionStorage │ │
window.location → │ │
│ │ │
2. GET /api/auth/oauth2/authorize │ │
?response_type=code │ │
&client_id=airi-stage-web │ │
&redirect_uri=localhost:5173/auth/callback │ │
&code_challenge=xxx │ │
&provider=github │ │
│ │ │
│ 3. 用户未登录 │
│ 302 → /sign-in?...所有 OIDC 参数... │
│ │ │
│ 4. /sign-in 看到 provider=github │
│ 重建 callbackURL = /api/auth/oauth2/authorize?... │
│ 302 → /api/auth/sign-in/social │
│ ?provider=github │
│ &callbackURL={OIDC authorize URL} │
│ │ │
│ ──────── 302 to GitHub ───────────────► │
│ │ 5. 用户授权
│ │ ◄──────── callback ────────────────── │
│ │ │
│ 6. better-auth 创建 user + sessionserver cookie
│ 302 → callbackURL= OIDC authorize
│ │ │
│ 7. /api/auth/oauth2/authorize │
│ 用户已有 session → 签发 authorization code │
│ 302 → redirect_uri?code=xxx&state=xxx │
│ │ │
8. /auth/callback │ │
consumeFlowState() 恢复 PKCE │ │
验证 state 防 CSRF │ │
│ │ │
9. POST /api/auth/oauth2/token ──────────────► │ │
(code + code_verifier + client_id + resource) │ │
◄──── { access_token (JWT), refresh_token } ─ │ │
│ │ │
10. GET /api/auth/get-session ─────────────────► │ │
(Bearer: access_token) │ │
◄──── { user, session } ──────────────────── │ │
│ │ │
11. 写入 authStore → 跳转首页 │ │
```
**关键设计:callbackURL 传递 OIDC 参数**
`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type``client_id``redirect_uri``code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server sessionOIDC 流程继续签发 code。
### Electron 特殊处理
Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`
- 固定端口范围:19721-19725,按顺序尝试
- 收到回调后立即关闭 server
- 5 分钟超时安全机制
**服务端回调中继**
Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。
好处:
- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL
- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL)
- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *`
端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`
### Bearer 鉴权解析
服务端通过 `src/libs/request-auth.ts` 解析请求头:
1. 先调用 `auth.api.getSession({ headers })`,支持标准 better-auth session / cookie / Bearer session token
2. 如果没有命中,读取 `Authorization: Bearer <token>`
3. 使用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间
4. 从 JWT `sub` claim 提取 userId,调用 `findUserById()` 补齐用户信息
5. 构造统一的 `{ user, session }`
JWT access token 由 oauthProvider 签发,条件是前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`)。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。
这样业务中间件和路由层不需要关心 token 来自 better-auth session 还是 OIDC JWT access token。
### 自动 Token 刷新
客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token``grant_type=refresh_token`),刷新后直接覆盖本地 access token。页面重载后从 localStorage 恢复刷新调度:
- `auth/v1/oidc-client-id` — 客户端 ID
- `auth/v1/oidc-client-secret` — 客户端 Secret
- `auth/v1/oidc-token-expiry` — Token 过期时间戳
### provider 参数直通
客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。
## 路由注册顺序
Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要:
1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider
2. `USE /api/auth/*` — rate limiterIP 限流)
3. `.route('/api/auth', createOIDCTokenAuthRoute(deps))` — token auth 辅助路由(`/get-session``/sign-out``/list-sessions`
4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继
5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata
6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery
7. `['POST', 'GET'] /api/auth/*`**catch-all**,将所有其他请求转发给 `auth.handler()`
自定义 auth 路由注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize``/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。
## 踩坑记录
### better-auth redirect_uri 精确匹配
better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**`authorize.mjs`):
```javascript
client.redirectUrls.find(url => url === ctx.query.redirect_uri)
```
RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。
### better-auth cookie 与 Bearer 共存
better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。
### skipStateCookieCheck
Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。
### better-auth internalAdapter
`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`
### OIDC 流程中断:callbackURL 必须指回 authorize
社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`
## 修改指南
- 新增 OIDC client → `src/libs/auth.ts``buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts`
- 改登录页 → `src/utils/sign-in-page.ts`HTML),或 `src/routes/auth/index.ts``/sign-in` 路由
- 改认证中间件 → `src/app.ts` 的 session middleware
- 改 trusted origins → `src/utils/origin.ts`
- 改 Bearer 鉴权解析 → `src/libs/request-auth.ts`(JWT 本地验签,依赖 jose + JWKS
- 改 token auth 辅助路由 → `src/routes/oidc/token-auth.ts`
- 改回调中继 → `src/routes/oidc/electron-callback.ts`
- 改 Auth 路由结构 → `src/routes/auth/index.ts`
- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie
- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts``packages/stage-ui/src/libs/auth-oidc.ts`
- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts`
@@ -1,115 +0,0 @@
# Billing Architecture
## 架构概述
`apps/server` 的计费链:**Postgres 是唯一账本真相源,所有余额写操作(debit / credit)和 ledger 行写入都在同一个 DB 事务里完成**。Redis 只承担余额读缓存。不再使用 Redis Stream / 后台 consumer 处理计费副作用。
### 数据模型
- **`user_flux`** — 用户余额快照(单行/用户)
- **`flux_transaction`** — append-only 账务流水(type: credit / debit / initial / promo, amount, balanceBefore, balanceAfter, requestId, metadata
- partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`DB 层幂等防重
- **`llm_request_log`** — 每个 LLM/TTS 请求的可观测记录(model / status / duration / fluxConsumed / token 用量)
### debitFlux 链路
`BillingService.consumeFluxForLLM()` 调用 `debitFlux()`,单个事务内:
1. 若有 `requestId`,先查 `flux_transaction` 是否已存在同 `(userId, requestId)` 行 → 命中则直接返回历史结果,不再扣费、不写新行(幂等回放)
2. `SELECT user_flux FOR UPDATE` 锁行
3. 检查余额(不足返回 402
4. 更新 `user_flux.flux`
5. `INSERT INTO flux_transaction (...)`,把扣费金额、token 用量、source 写进 metadata
6. 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存(失败仅 warn 日志)
### credit 链路
`creditFlux()` / `creditFluxFromStripeCheckout()` / `creditFluxFromInvoice()` 全部在事务内同步:
- claim 行(Stripe 路径)/ 幂等查 `flux_transaction`admin 路径)
-`user_flux` 行 → 加额 → 更新
-`flux_transaction`
- 事务提交后 `redis.set` 更新缓存
Stripe 路径靠 `stripe_checkout_session.fluxCredited` / `stripe_invoice.fluxCredited` 标志做对象级幂等;admin 路径靠 `(userId, requestId)` 唯一索引做幂等。
### LLM 请求日志
OpenAI route (`routes/openai/v1/index.ts`) 在 `consumeFluxForLLM` 完成后调用 `requestLogService.logRequest(...)` 同步写 `llm_request_log`。失败被记为 warn 日志,不阻断已经返回给用户的响应(流式响应已发出,错误兜不回来;非流式情况下 debit 已扣,request log 丢失也只是观测层面的损失)。
`llm_request_log` 没有 FK,没有二级索引,单纯追加;写入成本可以忽略。
### 进程角色
只有 `api` 一个 role`src/bin/run.ts`),且没有任何"常驻后台 loop"或"fire-and-forget 异步任务"。所有写路径(包括 admin flux grant)都在请求线程内完成;多实例安全靠 `(userId, requestId)` 幂等索引。详见 [`workers-and-runtime.md`](workers-and-runtime.md)。
### Stripe 定价
Flux 充值定价完全由 Stripe Product/Price 管理,详见 [stripe-pricing.md](stripe-pricing.md)。
### Sub-Flux 计量服务(债务账本)
TTS 字符、STT 秒等单价 < 1 Flux 的服务通过 `FluxMeter` 累计零头,跨阈值才下扣,避免短请求被向上取整为 1 Flux。详见 [flux-meter.md](flux-meter.md)。
## 关键服务
### BillingService (`services/domain/billing/billing-service.ts`)
所有余额写操作的唯一入口:
- **`consumeFluxForLLM()`** — LLM 请求扣费包装;事务内 `lock → check → update → insert ledger`,提交后刷 Redis 缓存;带 `requestId` 时支持幂等回放
- **`creditFlux()`** — 通用充值(admin promo / 普通 credit);幂等
- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,按 session 幂等
- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,按 invoice 幂等
### FluxService (`services/domain/flux.ts`)
只负责读操作:
- **`getFlux()`** — Redis cache-aside 读(miss → DB → 填充 Redis),新用户自动初始化
- **`updateStripeCustomerId()`**
### Redis 职责边界
Redis **不是**余额真相源,仅用于:
- `getFlux()` 读缓存(丢失无影响)
- 配置 KV
- WebSocket 广播
不再使用 Redis Streams 做计费链路。
## 实现状态
| Phase | 状态 | 关键点 |
|-------|------|--------|
| 1. DB-first 账本 | ✅ | `flux_transaction` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 |
| 2. 同步事务 ledger 写入 | ✅ | debit / credit 在单一事务内同时改余额和写 ledger,不再有 stream consumer |
| 3. Stripe 幂等 | ✅ | checkout + invoice 事务内幂等检查 |
| 4. LLM 计费优化 | ⚠️ | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback |
| 5. 单进程部署 | ✅ | 只剩 `api` roleadmin flux grant 在 POST 请求线程内同步执行,没有后台 loop |
| 6. 幂等防重 | ✅ | `flux_transaction` partial unique index on `(userId, requestId)` + 事务内回放命中检查 |
### 已删除
- `flux-write-back.ts` — 定时回写补偿机制
- `FluxService.consumeFlux()` / `addFlux()` — 写操作集中到 BillingService
- `llm_request_log.settled` — 无消费者
- `outbox_events` 表及 outbox-dispatcher 进程
- `cache-sync-consumer` 进程角色
- **Redis Stream `billing-events` + `worker` role + `billing-consumer-handler`** — 异步副作用全部回收到事务内同步执行;不再有“事务提交了但 XADD 失败 → ledger 丢行”的窗口
- 相关 env`BILLING_EVENTS_STREAM` / `BILLING_EVENTS_CONSUMER_NAME` / `BILLING_EVENTS_BATCH_SIZE` / `BILLING_EVENTS_BLOCK_MS` / `BILLING_EVENTS_MIN_IDLE_MS`
## 剩余 TODO
### LLM 计费精度
- [ ] **tiktoken fallback** — gateway 未返回 usage 时用 tiktoken 从 request messages + response body 自算 token 数
- [x] **消除静默失败** — non-streaming: debit 失败直接抛错阻断响应;streaming: 已发送无法撤回,改为 error 级别日志 + 记录 requestId 便于追查
## 明确不做
- 不引入 Kafka / RabbitMQ
- 不拆成多个独立 repo
- 不做预扣模式(无法准确估算 LLM 响应 token 数)
- 不再为“异步副作用”单独拉一个 worker 进程;事务内同步搞定就够了。如果以后真有阻塞型耗时副作用,单独评估时再说
@@ -1,166 +0,0 @@
# Config And Naming Conventions
## 目标
这篇文档收口三类容易逐步漂移的约定:
- `configKV` 的默认值和读取语义
- Redis key / channel 的命名规则
- HTTP route 的资源命名规则
这些约定不是“代码风格建议”,而是为了减少:
- 默认值写两份导致的配置漂移
- Redis key 命名混用导致的排障成本
- HTTP route 语义不稳定导致的版本化困难
## `configKV` 约定
### 单一真相源
`src/services/adapters/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源:
- 配置值的运行时校验
- 配置值的默认值
- Redis 中的序列化 / 反序列化 shape
这意味着:
- 默认值必须定义在 `ConfigEntrySchemas`
- 业务代码不要再写第二份 `?? defaultValue`
- `configKV.get()` / `configKV.getOrThrow()` 应直接依赖 schema 默认值
### 读取语义
- `getOptional(key)`
- 用于“这个 key 合法地可以不存在”的场景
- 对 required key,未配置时返回 `null`
- 对带 schema 默认值的 key,返回默认值
- `getOrThrow(key)`
- 用于“缺失就是配置错误”的场景
- required key 未配置时抛 `CONFIG_NOT_SET`
- `get(key)`
-`getOrThrow(key)` 的别名
- 默认用于业务代码
### 禁止事项
- 不要给 `getOptional` 增加调用点默认值参数,例如 `getOptional(key, fallback)`
- 不要同时在 schema 和调用侧维护两份默认值
- 不要绕过 `configKV` 直接从 Redis 读配置
### 当前例子
推荐:
```ts
const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS')
const maxCheckoutAmount = await configKV.get('MAX_CHECKOUT_AMOUNT_CENTS')
```
不推荐:
```ts
const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1
const maxCheckoutAmount = (await configKV.getOptional('MAX_CHECKOUT_AMOUNT_CENTS')) ?? 1_000_000
```
## Redis key / channel 命名
### 命名规则
Redis key 和 channel 统一采用“分段命名”,推荐使用冒号 `:` 作为分隔符:
```txt
{scope}:{id}:{resource}
{scope}:{id}:{subscope}:{subid}:{resource}
lock:{domain}:{id}
config:{key}
```
推荐例子:
```txt
user:{userId}:flux
config:{key}
chat:{userId}:broadcast
lock:user:{userId}:flux
```
不推荐例子:
```txt
flux:{userId}
chat:broadcast:{userId}
userFlux:{userId}
userUidFlux1
```
### 设计原则
- 前缀表达 namespace,而不是随手缩写
- 真实 Redis key 不要出现 `1``2` 这种占位编号
- 参数占位编号只用于文档里的模板名,不用于运行时 key
- key / channel 必须通过 helper 收口,不要在业务代码里散落模板字符串
### 文档里的模板命名
如果要在文档中表示“这个 key 有几个参数位”,可以用编号描述模板:
- `userUserId1Flux`
- `configKey1`
- `lockDomain1Id1`
但最终真实 key 仍然必须是:
```txt
user:{userId}:flux
config:{key}
lock:{domain}:{id}
```
## HTTP route 命名
### 资源命名原则
- 优先使用复数资源名
- 从属资源优先挂在父资源下
- 当前登录用户资源优先使用 `me`
推荐:
```txt
/api/v1/flux
/api/v1/flux/history
```
不推荐:
```txt
/api/user/flux
/api/flux
```
### 版本化约束
如果某类 HTTP API 需要长期稳定对外契约,优先在一个明确子树下版本化,例如:
```txt
/api/v1/...
/api/v1/openai/...
```
不要让“部分资源版本化、部分资源裸挂”长期并存而没有说明。
## TODO
- Replace DB-derived HTTP request schemas for characters/providers/chats with explicit DTO schemas.
- Move ownership and membership authorization rules behind actor-aware service APIs instead of splitting them across routes and services.
- Stabilize HTTP response shapes so services no longer leak raw Drizzle returning arrays to routes.
- Encode chat member invariants in schema validation and map those failures to 4xx API errors.
- Split the OpenAI compat route and chat WebSocket handler into smaller modules so transport code stops owning orchestration complexity.
-`apps/server` 中现有 Redis key / channel 继续向 helper 收口,避免业务代码里散落模板字符串。
- 统一把旧式 key 命名迁移到分段命名风格,优先处理 Flux cache、chat broadcast、lock key。
-`configKV` 增补一份“哪些配置属于 infra、哪些属于运营策略”的清单,避免继续模糊放置位置。
- 把所有 `configKV.getOptional(...) ?? defaultValue` 模式清理掉,默认值统一回到 `ConfigEntrySchemas`
- 评估是否继续沿用统一 `/api/v1/*` 版本树,还是为兼容 API 与业务 API 引入更明确的子域分隔。
@@ -1,237 +0,0 @@
# Data Model And State
## 真相源原则
这套服务端最关键的状态归属如下:
- `Postgres`
- 用户认证数据
- 角色、聊天、Provider 配置
- Flux 余额与账本
- Stripe 业务镜像
- LLM 请求日志
- `Redis`
- Flux 余额缓存
- 服务配置 KV
- 聊天跨实例广播 (Pub/Sub)
- Sub-Flux 计量债务账本(TTS 字符等,详见 `flux-meter.md`
- TTS voices 上游响应缓存
如果要判断”改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。Redis Streams 已全部移除,没有”计费事件队列”这层抽象。
## 主要表分组
### 认证
- `user`
- `session`
- `account`
- `verification`
来源文件:
- `src/schemas/accounts.ts`
说明:
- `better-auth` 直接用这组表
-`pnpm -F @proj-airi/server auth:generate` 自动产物,手改会被覆盖
### 角色与用户交互
- `characters`
- `character_covers`
- `avatar_model`
- `character_capabilities`
- `character_i18n`
- `character_prompts`
- `user_character_likes`
- `user_character_bookmarks`
来源文件:
- `src/schemas/characters.ts`
- `src/schemas/user-character.ts`
说明:
- 角色实体采用软删除
- 点赞与收藏通过中间表建模
- 计数值冗余保存在 `characters` 表上
### 聊天
- `chats`
- `chat_members`
- `messages`
- `media`
- `stickers`
- `sticker_packs`
来源文件:
- `src/schemas/chats.ts`
说明:
- `messages.seq` 是会话内顺序字段
- 写消息时通过 `SELECT ... FOR UPDATE` 锁 chat 以串行生成 seq
- `senderId` 是宽松字段,不强制外键
### Provider 配置
- `user_provider_configs`
- `system_provider_configs`
来源文件:
- `src/schemas/providers.ts`
说明:
- 运行时查询时会把系统配置和用户配置拼接成一个结果集
- `config``jsonb`
### Flux / 账本
- `user_flux`
- `flux_transaction`
来源文件:
- `src/schemas/flux.ts`
- `src/schemas/flux-transaction.ts`
职责边界:
- `user_flux`
- 当前余额快照(单行/用户)
- `flux_transaction`
- append-only 账本流水(type: credit / debit / initial / promo
- 同时承担系统真相源和用户可见历史,`/api/v1/flux/history` 直接读这张表
关键约束:
- `flux_transaction``(userId, requestId) WHERE requestId IS NOT NULL` 有部分唯一索引
- 用来做扣费 / 充值幂等(含 admin promo grant 的 `idempotencyKey`
### Stripe 业务镜像
- `stripe_customer`
- `stripe_checkout_session`
- `stripe_subscription`
- `stripe_invoice`
来源文件:
- `src/schemas/stripe.ts`
说明:
- 这些表是 Stripe 状态的本地镜像
- 真正的余额变化仍由 `billingService` 写入 `user_flux + flux_transaction`
- `fluxCredited` 字段用于避免重复入账
### LLM 请求日志
- `llm_request_log`
来源文件:
- `src/schemas/llm-request-log.ts`
说明:
- 只做追加写入
- 明确不加 user 外键,以避免高并发写入的额外约束成本
## 服务与状态写入边界
### `createFluxService()`
负责:
- 余额读取
- 新用户首次读取时初始化 `user_flux`
- Redis cache-aside
不负责:
- 扣费
- 充值
- transaction 写入
### `createBillingService()`
负责:
- 所有余额写操作
- DB 事务
- debitFlux / credit 方法:事务内 lock → check → update `user_flux` → insert `flux_transaction` ledger
- 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存
这是所有 Flux 写路径应收敛到的中心。
### `createStripeService()`
负责:
- Stripe 实体 upsert
不负责:
- 最终 Flux 入账
真正入账通过 `billingService.creditFluxFromStripeCheckout()` 或相关 credit 方法完成。
## Redis 中的数据类型
### Flux 缓存
- key: `flux:<userId>`
- value: 字符串化整数
写入来源:
- `fluxService.getFlux()` cache miss 后回填
- `billingService` 余额事务提交后 best-effort `redis.set` 直接更新(API 进程内同步)
### 配置 KV
- key: `config:<CONFIG_NAME>`
`config-kv.ts` 管理,支持:
- 数值
- 字符串
- `FLUX_PACKAGES` JSON
### 聊天跨实例广播
- channel: `chat:broadcast:<userId>`
## 幂等与并发控制
### 余额并发
`billingService` 在事务中:
1. (可选)按 `(userId, requestId)` 命中 ledger → 命中即返回,跳过余下步骤
2. `SELECT user_flux FOR UPDATE`
3. 计算新余额
4.`user_flux` + 写 `flux_transaction` ledger
5. 事务提交后 best-effort `redis.set`
这保证同一用户余额更新是串行化的,并且 ledger 行与余额变更在同一原子提交里。
### Stripe 幂等
主要依赖:
- `stripe_checkout_session.fluxCredited`
- `stripe_invoice.fluxCredited`
- `flux_transaction(userId, requestId)` 唯一约束
## 现有代码中的结构信号
- `src/schemas/flux-grant-batch.ts` 是已废弃的旧 admin batch 设计 schema,没有被 `app.ts` 装配也没有 migration 在用,是 dead code,改这块前直接删除。当前 admin 发 FLUX 走 `/api/admin/flux-grants` 同步路径,不写新表。
@@ -1,78 +0,0 @@
# Email auth via Resend (apps/server + apps/ui-server-auth)
Status: in progress
Last updated: 2026-04-27
## Goal
1. 接入 **Resend** 作为 `apps/server` 的统一邮件发送 service。
2. 把 Better Auth 的四个邮件回调接好:
- `emailVerification.sendVerificationEmail`(注册后验证邮箱)
- `emailAndPassword.sendResetPassword`(忘记密码)
- `user.changeEmail.sendChangeEmailVerification`(改邮箱)
- `magicLink.sendMagicLink`passwordless 登录,启用 plugin
3.`apps/ui-server-auth` 加上邮箱注册 / 邮箱密码登录 / 忘记密码 / 重置密码 等界面。
## 用户路径(必须端到端跑通)
只有这两条本期要 ship
1. **注册路径**:用户敲 `/sign-up` → 填邮箱 + 密码 → 提交 → 进 `verify-email` 提示页 → 收邮件点链接 → `verify-email?token=...` 落地页提示成功 → 跳 `/sign-in`
2. **忘记密码路径**:用户在 `/sign-in` 点 "Forgot password" → 进 `/forgot-password` 输邮箱 → 提交 → 提示已发送 → 用户点邮件链接 → `/reset-password?token=...` 输新密码 → 跳 `/sign-in`
3. **常规邮箱登录**`/sign-in` 输邮箱 + 密码 → 走 OIDC `loginPage` 流程把用户登入,返回上游 `/oauth/authorize`
服务端为 magic link / change email 接好回调(避免功能闭包不齐一半),但前端 UI 留待后续。Service 拒绝静默吞错——发送失败要走错误响应让 Better Auth 把错抛回前端。
## 范围明确
In:
- `apps/server/src/services/adapters/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。
- `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。
- `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。
- `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。
- `apps/ui-server-auth/src/pages`:扩 `sign-in.vue`;新增 `sign-up.vue``verify-email.vue``forgot-password.vue``reset-password.vue`
- `apps/ui-server-auth/src/modules/sign-in.ts` 同级补 `email-password.ts` 处理 emailPassword sign-in/up + forgot/reset 的真实调用。
- `packages/i18n`:新增 auth.signUp / verifyEmail / forgotPassword / resetPassword 字段。
Out:
- Magic link 前端 UI`magic-link-sent.vue` / sign-in 上的 "Email me a link" 入口)。
- Change email 前端流程(账号设置页里发起、点击新邮箱链接验证)。
- 自定义 SMTP fallback / 多 provider 抽象。本期只接 Resend,但 service 接口签名留 provider 替换余地。
- 邮箱 / 邮件模板的 i18n(先英文一个版本,后续补)。
## 关键决策
- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom``@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。
- **触发邮件的位置**Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。
- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放独立部署的 `apps/ui-server-auth``API_SERVER_URL` 是 server 自身(如 `https://api.airi.build`),ui-server-auth 是同站点的另一域(如 `https://accounts.airi.build/ui`);两者通过 trustedOrigins 互信。链接组装规则:
- Verify email`<UI_BASE>/verify-email?token=<token>`
- Reset password`<UI_BASE>/reset-password?token=<token>`
-`getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 `<UI_BASE>`,避免硬编码。
- **`requireEmailVerification: true` 开启的副作用**:现存历史用户(尚未验证)将在下次登录被拦截。**社交登录(Google/GitHub)默认 `emailVerified=true`**,不受影响。需要在 sign-up 后端响应中带 `requiresEmailVerification` 标志,前端据此跳到 `verify-email` 提示页。
- **OIDC `loginPage: '/sign-in'` 不变**:sign-in 加表单后仍然走 `oauth/authorize → /sign-in?... → 登录成功 → callbackURL 回 oauth/authorize`,不破坏现有流程。
## 假设 / 待验证
- `resend` SDK ESM-only?需在加包后 `pnpm typecheck` 验证(unverified)。
- `better-auth/plugins/magic-link` 可与 `oauthProvider` 共存(unverified,但插件是独立 endpoint,不冲突)。
- ui-server-auth 在 dev 下走 `http://localhost:5173`,与 `apps/server` 不同源。`server` 已在 `getAuthTrustedOrigins` 把 dev origin 加进来。
## 验证计划
每条用户路径要落一份验证记录到 `docs/ai/context/verifications/email-auth-<path>.md`
1. `email-auth-signup.md`:dev 环境注册一次,列出真实 curl / 浏览器步骤、Resend dashboard 命中、点链接落地页结果。
2. `email-auth-forgot.md`:忘记密码同上。
3. `email-auth-signin-email-password.md`emailPassword sign-in 完整 OIDC 闭环。
未跑过这三条 = 默认 unverified,不能声明完成。
## 不做(明确说"以后"
- 邮件 i18n(仅英文)
- 邮件模板真实视觉设计(先用最小可读模板)
- Resend webhookbounce / complaint 回调)接入
- 邮件审计日志写入 `request_log`
- Magic link 前端 UI 与 change-email 前端 UI
-114
View File
@@ -1,114 +0,0 @@
# Flux Meter(债务账本计费)
## 背景
Flux 是整数计费单位。但部分服务(TTS 字符、STT 秒、embedding token 等)的单价远小于 1 Flux:例如 TTS 当前定价 `FLUX_PER_1K_CHARS_TTS = 2`,意味着 1 Flux ≈ 500 字符。
最初的实现采用 `max(MIN_CHARGE_TTS, ceil(chars/1000 * rate))`,每个 TTS 请求都被向上取整为至少 1 Flux。这在前端把一整轮 Agent 回复切成 N 个短句分发的场景下极不公平:100 字的回复被切 10 段 = 10 Flux,而单次发完只要 1 Flux。
## 决策
实现一层通用的"债务账本",把不到 1 Flux 的零头存在 Redis,跨请求累计,攒够整数 Flux 才下扣。
### 为什么不是 sessionID / turnId 聚合
考虑过让前端给每轮对话发一个 turnId,服务端按 turn 聚合后结算。否决理由:
1. **前端改造成本**:要生成 turnId、改 OpenAI 兼容请求 header、加 `finalize` 信号、处理崩溃路径
2. **预扣边界混乱**:一轮总字符数事先未知,余额检查要按"最坏情况"或"已累计 + 当前"估算,余额刚好够时容易在中途 402,把一轮对话切两半(前半段有声音、后半段没)
3. **结算依赖客户端信号**finalize 不发就要 keyspace notification + worker 兜底,多实例下要选主或幂等
4. **欠账上限不可控**:一轮可以有几千字,欠账可能多 Flux
债务账本不依赖任何业务边界:每次精确扣,欠账恒定 < 1 Flux< `unitsPerFlux` 个单位),TTL 到期抹零,对账简单。
### 为什么不是时间窗口
5 分钟聚合也能解决"短请求被高估"问题,但需要 cron / 懒扣双机制处理窗口边界,且窗口跨越用户会话时语义诡异。债务账本没有"窗口"概念,纯量化累计。
## 数据流
```
┌──────────┐ units ┌─────────────┐
│ route │──────────▶│ FluxMeter │
│(handleX) │ │ accumulate()│
└──────────┘ └─────┬───────┘
│ Lua: INCRBY + 阈值判断 + DECRBY
┌──────────┐
│ Redis │ flux-meter:{name}:{userId}:debt
└─────┬────┘
│ 跨阈值 → fluxToDebit > 0
┌──────────────────────┐
│ BillingService │
│ consumeFluxForLLM() │ ← 走原有 debitFlux + Stream
└──────────────────────┘
```
### Lua 脚本(原子)
```lua
local debt = redis.call('INCRBY', key, units)
redis.call('EXPIRE', key, ttl)
if debt >= unitsPerFlux then
local flux = math.floor(debt / unitsPerFlux)
redis.call('DECRBY', key, flux * unitsPerFlux)
return {flux, debt - flux*unitsPerFlux}
end
return {0, debt}
```
INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实例并发请求同一用户安全。
## API
`packages/server/src/services/domain/billing/flux-meter.ts`
- `createFluxMeter(redis, billingService, { name, resolveRuntime })` → meter 实例
- `resolveRuntime: () => Promise<{ unitsPerFlux, debtTtlSeconds }>` **每次调用都执行**,不做进程内缓存。多实例部署下任一实例改配置,其它实例下一次请求立即生效。
- 配置缺失不会让 `createApp` 启动阶段挂,只会在首个 TTS 请求时抛错,配合 route-level `configGuard` 产生 per-request 503,不会连带 chat/auth/stripe 一起挂。
- `meter.assertCanAfford(userId, newUnits, currentBalance)` — 请求前余额校验,不足直接 throw 402
- `meter.accumulate({ userId, units, currentBalance, requestId, metadata })` — 累加并按需结算,返回 `{ fluxDebited, debtAfter, balanceAfter }`。billing debit 抛错时,已结算的 units 会被 INCRBY 回滚到债务账本,保证不漏账。
- `meter.peekDebt(userId)` — 读当前未结算字符数(运维/调试用)
## 复用指南
任何"消耗单位 < 1 Flux"的服务都应该走债务账本,不要重复实现"单请求最低消费"。
### 已接入
| 服务 | name | unitsPerFlux 来源 |
|---|---|---|
| TTS | `tts` | `1000 / FLUX_PER_1K_CHARS_TTS`(在 `app.ts` 装配时计算) |
### 推荐接入
| 服务 | name | unitsPerFlux 示意 |
|---|---|---|
| STT 转录 | `stt` | 60 秒 = 1 Flux |
| Embedding | `embedding` | 10000 token = 1 Flux |
| 自营小模型 chat | `llm-mini` | 视定价而定 |
**不适用**:每次调用本身就 ≥ 1 Flux 的服务(如图像生成)。直接 `consumeFluxForLLM` 即可,套一层 meter 反而降低可读性。
### 接入步骤
1.`services/adapters/config-kv.ts` 加费率/TTL 配置项
2.`app.ts``injeca.provide` 注册新 meter,注入对应路由 / 服务
3. 在路由中:先 `assertCanAfford`,调上游成功后 `accumulate`
4. 加单测覆盖:累计跨阈值、empty input、余额不足
## Tradeoff & 已知局限
- **欠账上限**:每用户每 meter 最多欠 `unitsPerFlux - 1` 个单位(< 1 Flux),TTL 到期抹零。这部分给用户。
- 想严格不欠账:加 settler worker 监听 `__keyevent@0__:expired` 在过期时强制结算到下一个整 Flux。当前不做,量化损失太小。
- **审计粒度变粗**`flux_transaction` 一条记录可能对应多次请求,description 为 `<name>_request`(如 `tts_request`,和 `llm_request` 保持同一命名风格)。具体哪几个 requestId 贡献了这次扣费,靠 OTel span / `request_log` 反查。
- **预扣不精准**`assertCanAfford` 按"当前累计 + 这次 units"算,无法预知后续请求。极端情况下用户余额从够到不够之间会有几次请求成功(最多欠 < 1 Flux),可接受。
- **TTL 重置**:每次 accumulate 都 `EXPIRE`,一个长期活跃用户的债务永远不会过期,会一直滚到下次跨阈值。这是期望行为。
## 不做
- 不做会话 / turn 级聚合(理由见上)
- 不做 keyspace notification 兜底结算(量化损失可忽略)
- 不在第一版支持 meter 间组合扣费(一次请求消耗多种资源)
- 不为 meter 单独建 transaction 表(`flux_transaction` 已够用)
@@ -1,111 +0,0 @@
# Langfuse LLM-native 观测接入
逐条 prompt 级 trace + 评测 + 成本归因到用户/会话。阶段 1(chat completion + TTS speech)已实现并验证,见下「已实现」与 `verifications/langfuse-tracing.md`
## 目标
1. 逐条 prompt trace:每次 `/api/v1/openai/chat/completions` 的 input messages、output、model 完整可读。
2. 评测 evaldataset、人工标注、LLM-as-judge。
3. 成本归因:按 user 和按 conversation/session 切分 token 与成本。
## 为什么直接 Langfuse,不先用 Grafana 顶
Grafana 栈(Prometheus/Tempo/Loki)已经管好 ops 聚合层,继续不动:rate、latency、聚合 token 吞吐/消耗、按模型 flux、错误、fallback、上游健康。这层完整。
上面三个目标里 Grafana 栈的实际能力:
- 逐条 prompt traceTempo 能塞 span,但 trace 是 head-sampled,采样比 < 1 直接丢 promptspan 属性有体积上限,长 prompt 被截;没有把 prompt 当一等对象读/对比/标注的界面。
- eval:零能力,dataset / 标注队列 / LLM-as-judge 全得自己造。
- 按 user/session 成本:Prometheus 按 userId/sessionId 做 label 会高基数爆炸,做不了。按 user 的成本原料其实在 `llm_request_log` 表里,用 SQL 能查,跟 Grafana 无关。
这三件事里正文采集是最重的活,两边都得从零写,先做 Grafana 一点不省。真正能省的只有 eval 和 per-user/session 成本,而这俩在 Grafana 上等于手搓一个更差的 Langfuse,迁移时全扔。所以不走「先 Grafana 再 Langfuse」,直接 Langfuse 补 LLM-native 这一层。
边界划分:
- Grafana 栈:ops 指标,不动。
- Langfuse:逐条 prompt trace + 正文 + eval + user/session 成本归因。
## 选型:Langfuse v5 = OpenTelemetry SpanProcessor
Langfuse v5 JS SDK 基于 OpenTelemetry`@langfuse/otel``LangfuseSpanProcessor` 就是一个 OTel SpanProcessor。AIRI 这里没有把它挂到现有 NodeSDK 上,而是起一个独立 `NodeTracerProvider` 并通过 `setLangfuseTracerProvider()` 只给 `@langfuse/tracing` 使用。
- Grafana 出口:`OTLPTraceExporter` → Grafana Cloud Tempo。
- Langfuse 出口:独立 provider 上的 `LangfuseSpanProcessor` → Langfuse Cloud。
- 两者共享 OTel context/trace id,但不共享 SpanProcessor,避免 prompt/completion 正文进 Grafana Tempo。
不走「复用 OTLP exporter 指向 Langfuse」的原因:① 目标里有 evaleval 只能走 Langfuse SDK/APIOTLP 解决不了;② 现有 span 用 `airi.gen_ai.*` 自定义 attribute keyLangfuse 不认,得改成它认的 generation 字段。一套 SDK 把 trace + 正文 + 成本 + eval 全包,不维护两条上报路。
需要的包:`@langfuse/tracing``@langfuse/otel`
## 部署形态与脱敏决策
- 形态:Langfuse Cloud。
- 脱敏:不脱敏。prompt/completion 正文全量出境到 Langfuse 托管,已确认可接受。
- 凭据:`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` / `LANGFUSE_BASE_URL`(SDK 默认变量名,带下划线),走 Railway env secret,本地走 `.env.local`。instrumentation.ts 和网关 route 都直读 `process.env`,**不进 `env.ts` 的 valibot schema**。原因:instrumentation 是 preload(在 env 解析之前跑),且这是部署开关(类比 NODE_ENV)不是服务依赖,不需要进 DI。
## Session 来源决策
网关 `openai/v1/index.ts` 是无状态 OpenAI 兼容代理,本身没有 conversation/session 概念,每请求只有 `c.get('user')` 的 userId 和一个临时 `nanoid()` requestId。`chats` 表存在但跟这条代理路径零关联(那是另一套 `/chats` API)。
所以 session id 必须客户端传。决策:客户端传对话 id,网关读出来当 Langfuse `sessionId`
落地点几乎免费:stage-ui 的 `packages/stage-ui/src/libs/providers/providers/official/shared.ts` 已有 `withCredentials()` fetch 包装器在每请求注入 `Authorization` header。在同一处加一个 `x-airi-session-id` header 即可,不用碰 `@xsai` 的 body 透传。stage-web 和 stage-tamagotchi 都复用这个 provider,改一处两端生效。
改端范围仅此一处。telegram-bot 自带 provider,不经 server 网关,不在范围内。
## 成本归因方案
- USD 真金成本:交给 Langfuse 按 model 定价表自动算(传 `usageDetails` 的 input/output tokens 即可)。风险见下。
- flux 业务成本:作为自定义字段放 generation 的 `metadata`flux 不是货币,不塞 `costDetails`)。
- 这样业务成本(flux)和真金成本(USD)都在,按 user / session 都能切。
## 已实现(阶段 1:chat completion)
### `apps/server/instrumentation.ts`
- `langfuseEnabled = !!LANGFUSE_PUBLIC_KEY && !!LANGFUSE_SECRET_KEY`,与 `otlpEndpoint` **独立门控**。两者任一开启就启动 NodeSDK;只配一个不会让另一个静默 no-op。
- OTLP 的 trace exporter / metric reader / log processor 仅在 `otlpEndpoint` 存在时挂(条件 spread),NodeSDK 的 `spanProcessors` 数组只装 OTLP 的 `BatchSpanProcessor`
- Langfuse 起独立 `NodeTracerProvider`(`langfuseProvider`),其上挂 `LangfuseSpanProcessor`(`exportMode: 'batched'`),再 `setLangfuseTracerProvider(langfuseProvider)``startObservation` 路由到它。
- `shouldExportSpan`:只放行带 `langfuse.*` 属性的 span(SDK 创建的 generation 都带 `langfuse.observation.type` 等)。这是防御性兜底,独立 provider 本来就只见自己的 span。
- 独立 provider 显式 `AlwaysOnSampler`:调低 `OTEL_TRACES_SAMPLING_RATIO` 给 Grafana 降量,不会丢 Langfuse generation,逐条 prompt 捕获保持完整。
- SIGTERM:`Promise.all([sdk.shutdown(), langfuseProvider?.shutdown()])``sdk.shutdown()` 不会 drain 独立 provider,必须显式 shutdown,否则最后一批 generation 在部署重启时丢失。
### `apps/server/src/routes/openai/v1/index.ts`(`handleCompletion`)
`langfuseEnabled` 门控(只读一次,非每请求):读 `process.env.LANGFUSE_TRACING_ACTIVE`,这个 sentinel 由 instrumentation.ts 在 `setLangfuseTracerProvider()` 成功**之后**才置 `'1'`。**禁用时不创建 generation** —— 这很关键:Langfuse 关闭时没调 `setLangfuseTracerProvider`,`startObservation` 会 fallback 到全局 provider,正文就会漏进 OTLP/Grafana。gate 绑定真实 provider 状态(单一真相在 instrumentation.ts),而不是在 route 里独立再判一次 key —— 避免将来改 instrumentation 的开关条件时两处 desync 导致正文漏到错误后端。
output(给 eval 用,要可读):非流式 = `responseBody`;流式 = 从 SSE delta 解析出的 assistant 正文(`extractSseDeltaText` 逐行解析 `choices[0].delta.content`,**不是**原始 `data: {...}` 框架),硬上界 1M 字符防止长输出 × 高并发占内存。
generation 形态:
- `startObservation('chat.completion', { input: body.messages, model: requestModel, metadata: { requestId, stream } }, { asType: 'generation' })`
- 身份:`generation.otelSpan.setAttribute('langfuse.user.id', user.id)`;有 `x-airi-session-id` header 时再 set `langfuse.session.id`。这两个 compat 属性被平台提升为 trace 级,支撑按 user/session 归因。
- output:非流式 = `responseBody`;流式 = 后台累积的 assistant 正文。
- usageDetails:`{ input: promptTokens, output: completionTokens }`,复用计费已提取的 usage。
- metadata:保留 `{ requestId, stream }`,完成时补 `{ fluxConsumed }`
- 生命周期:5 个退出分支各 `generation?.update(...)` + `generation?.end()` 恰好一次 —— router throw catch、`!response.ok`、流式 interrupted(finally)、流式 completed(finally)、非流式。错误分支标 `level: 'ERROR'` + `statusMessage`。流式 generation 在后台 async IIFE 的 finally 里结束,跟 `span.end()` 对齐,不在 response 返回时提前结束。
### `apps/server/src/routes/openai/v1/index.ts`(`handleTTS`)
- `startTtsGeneration({ input: { text, voice, speed, responseFormat }, model, requestId, userId, sessionId })` 创建 `tts.speech` generation。
- 不缓冲二进制 audio 到 Langfuse;成功 output 只记录 `{ contentType }`
- usageDetails 使用 `{ input: inputChars }`flux 作为 metadata 记录。
- router throw、上游非 2xx、billing/Redis failure 都会 `fail(...)` 并 end;成功在 `ttsMeter.accumulate()``succeed(...)`
### `packages/stage-ui/src/libs/providers/providers/official/shared.ts`
- `withCredentials()``Authorization` 外,会在 Pinia 已初始化且有 active chat session 时注入 `x-airi-session-id`
- stage-web / stage-tamagotchi / stage-pocket 复用 official provider 的请求都会带同一个会话 id;匿名或非 chat 上下文没有 active session 时自动退化为 user-only trace。
### `apps/server/src/libs/env.ts`
未改 —— LANGFUSE_* 故意不进 valibot schema(见「部署形态与脱敏决策」)。
## 验证
见 [`verifications/langfuse-tracing.md`](./verifications/langfuse-tracing.md)。Langfuse Cloud(project airi)回读到 `chat.completion` GENERATION,完整带 input(messages)/ output / model / usageDetails / userId / sessionId / metadata,trace 与 generation 共享 traceId。当前代码路径的 typecheck、targeted Vitest、eslint 通过。
## 待办(后续阶段)
- **staging 真实端到端**:`.env.local` 的 DB/Redis/OTLP 全指生产,本地起真实 server 会连生产。在 staging(指向非生产)起 server 发一次真实 chat 请求补全 HTTP 路径端到端。
- **model 定价匹配**:网关 model 是解析后的路由名,能否命中 Langfuse 定价表算 USD 待实测;命不中就配自定义定价或传 `costDetails`。flux 已放 metadata。
@@ -1,111 +0,0 @@
# LLM/TTS router codex review — deferred follow-ups
Codex independent review (2026-05-15) on commit `1bb0aab2f` returned 12
findings. Applied 3 HIGH inline (see commits after `1bb0aab2f`). The 9
remaining findings are deferred — each evaluated through the AGENTS.md
"外部建议 3 问过滤" and tracked here per "拒绝时留痕" rule.
## Applied inline (HIGH severity)
1. **Client abort signal not threaded into `llmRouter.route()`** — fixed in
`apps/server/src/routes/openai/v1/index.ts`. `c.req.raw.signal` now flows
into both the router and the legacy `fetch()` fallback. Prevents the
"client disconnects but upstream keeps generating + burning paid quota"
leak.
2. **Failed upstream response bodies not drained** — fixed in
`apps/server/src/services/domain/llm-router/router.ts`. Every non-2xx fallback
path now calls `response.body?.cancel()` before continuing. Prevents
socket-pool exhaustion under fallback storms.
3. **SSML voice attribute injection** — fixed in
`apps/server/src/services/adapters/tts/azure.ts`. Voice id is
regex-validated (`^[a-z0-9-]+$/i`) before SSML interpolation; invalid
values throw `BAD_REQUEST`. Prevents attribute-context breakout under
the server's Azure credential.
## Deferred — rationale per finding
### #4: in-flight `loadFresh()` can repopulate cache after `invalidate()`
**Severity**: MEDIUM. **Decision**: defer to v1.x.
**Rationale**: race window is bounded by the Pub/Sub-driven invalidate
firing during a concurrent TTL-driven reload. In practice the operator
revokes a key, the active in-flight load was already reading the
*pre-revocation* config and so writes back the old key state. Worst case
the stale config lives until next TTL expiry (5s). Acceptable for v1
given the 5s SLO target — not a security hole, just a propagation hiccup.
Fix shape (generation counter on `loadFresh`) is well-known and cheap; do
it the next time someone touches `config-loader.ts`.
### #5: no single-flight on concurrent cache miss
**Severity**: MEDIUM. **Decision**: defer.
**Rationale**: configKV's underlying Redis read is single-digit-ms in
practice; even a 10-request stampede is 10 cheap reads. The plan's
adversarial reviewer flagged this; we accepted because the cure
(promise-dedup) adds state with its own race window and rarely matters
at airi's current QPS. Revisit if `airi.gen_ai.gateway.config.reload`
spikes after a Pub/Sub burst.
### #6: `fullChainTimeoutMs` not enforced as a hard cap
**Severity**: MEDIUM. **Decision**: apply in next iteration.
**Rationale**: legit bug — per-attempt × N keys can exceed the configured
60s cap. Worth fixing but requires a route-level abort controller that
composes with the per-attempt one; not a 5-minute change. Track here so
it doesn't get lost.
### #7: 200-with-error-envelope not detected
**Severity**: MEDIUM. **Decision**: reject for v1.
**Rationale**: codex flagged this without citing real evidence of any
v1 provider doing it. The four configured providers (OpenRouter, Azure,
DashScope, Volcengine) return proper HTTP status codes for errors. Adding
body-shape detection across LLM + 3 TTS adapters adds complexity that
guards against a hypothetical. "外部建议 3 问过滤" (c): would create
duplicated parsing logic in every adapter. Revisit if a provider is added
that does return 200-with-error.
### #8: incomplete plaintext zeroization in `encryptKey()`
**Severity**: MEDIUM. **Decision**: partial accept — fix in next iteration.
**Rationale**: codex is right that `encryptKey` leaves `plaintextBytes`
uncleared. Fix is `try/finally { plaintextBytes.fill(0) }` — apply in
v1.x. Note: rendered `Authorization: Bearer <key>` strings cannot be
zeroized once V8 has interned them as JS strings; that limitation is
fundamental and worth documenting in `envelope-crypto.ts` JSDoc.
### #9: provider API keys via argv in `seed-router-config.ts`
**Severity**: MEDIUM. **Decision**: apply.
**Rationale**: legit security issue — keys appear in `ps` output and
shell history. Switch to env var input (`OPENROUTER_KEY` etc.) and
remove the `--openrouter-key` flag. Will apply in next iteration to the
seed script; the script is operator-only and runs locally so impact is
bounded but the principle is right.
### #10: `e2e-llm-router.ts` debug fetch logs auth header prefix
**Severity**: MEDIUM. **Decision**: apply now (trivial).
**Rationale**: legit. The 30-char prefix can identify accounts. Fix shape:
replace `${auth.slice(0, 30)}...` with `<set>`. Apply in next commit.
### #11: identical `current` / `previous` master key not rejected
**Severity**: LOW. **Decision**: apply.
**Rationale**: defensive guard, ~3 lines. Will apply in next iteration.
### #12: Pub/Sub handler unbounded JSON parse
**Severity**: LOW. **Decision**: defer.
**Rationale**: Redis is on the trusted private network in our deployment
model; HMAC was already deferred from the plan (P2 finding from
ce-doc-review). Size guard is small but its security value is contingent
on the same untrusted-Redis threat we explicitly accepted. Mark as
follow-up when HMAC is added in the same iteration.
## Tracked
- v1.x cleanup pass (issues #6, #8, #9, #10, #11): bundled commit before
next ship.
- v1.x or v2 (issues #4, #5, #7, #12): revisit when ops data shows the
underlying assumption broke.
@@ -1,289 +0,0 @@
# Metrics Ownership
这份文档定义 AIRI 团队的指标分层规则:什么指标该走 Grafana / PrometheusOTel server-side),什么该走 PostHogfrontend/external product analytics),同名指标怎么处理。落地这份是为了避免后期"同一个 KPI 三处不同数"的漂移。
## 总原则
工具职责正交,**互补不互替**
| 层 | 工具 | 关键属性 |
|---|---|---|
| **System / API observability** | Grafana Cloud + Prometheus + OTel | 系统健康、延迟、错误率、SRE on-call 告警 |
| **Product analytics** | PostHog Cloud | 前端用户行为、漏斗、retention、cohort、A/B、feature adoption |
| **Financial truth source** | Postgres (`flux_transaction` / Stripe webhook 持久化) | 收入与扣费 ledger,任何展示都视作近似 |
| **LLM-native observability** | Langfuse Cloud(已接入:chat completion + TTS speech | 逐条 prompt/completion trace、TTS text trace、token/字符用量、按 user/session 成本归因、eval。真实 staging HTTP E2E 与 model 定价匹配待做 |
**业界没有权威的判定 framework**(参见下方"参考来源"),这份文档落实成项目内的可执行规则。
## 7 题判定 Checklist
每条新增指标依次问这 7 个问题:
| # | 问题 | 偏 Grafana | 偏 PostHog |
|---|------|-----------|------------|
| 1 | 超阈值需要**分钟级 on-call 告警** | ✓ | |
| 2 | 主要读者是 **SRE / 后端工程师**,不是 PM | ✓ | |
| 3 | 需要跟 **trace / log join**(分布式 debug)? | ✓ | |
| 4 | 含义依赖**用户身份 / session**"哪个用户做了什么")? | | ✓ |
| 5 | 消费场景是**漏斗 / retention cohort / A/B test** | | ✓ |
| 6 | 会被 **CEO / PM 在周会 OKR review** 看? | | ✓ |
| 7 | 采集点在**前端页面**pricing page、onboarding)? | (拿不到) | ✓ |
**裁决规则**
- ≥4 个偏一侧 → 那一侧
- 平局 → 两边都放,但**指定唯一 truth side**(见下文)
- 如果一个指标 7 题答下来很纠结,多半是**指标定义本身没拆干净**——应该拆成两个不同的指标,分别归到两边,而不是混合归属
## Truth Side(重复指标处理)
业界没有银弹(PostHog 官方在 [issue #43633](https://github.com/posthog/posthog/issues/43633) 也承认 dual-emit 没有统一 pattern)。我们的做法:**接受两边数字差异,dashboard 上标注语义不同**。
### Truth side 指定原则
| 指标类型 | Truth side | 理由 |
|---|---|---|
| 计费 ledger(每一分钱可审计) | **Postgres** | Grafana / PostHog 都视作近似展示,争议查 SQL |
| HTTP / WS / DB / Stripe webhook **计数** | **Grafana**OTel counter | 系统事件,PostHog 看不到 |
| 用户去重 DAU / WAU / retention | **Postgres → Grafana** for server truth; **PostHog** for frontend journey | Server 不直接发 PostHog;后端事实查 `product_events` / `user.last_seen_at` |
| 收入展示(MRR / ARR / churn revenue | **Postgres → 两边展示** | 真相在 PostgresGrafana 取系统侧切片(panel-30),PostHog 取用户维度切片 |
| LLM token / cost(聚合速率、按模型) | **Grafana**OTel counter | 系统侧聚合,SRE 视角 |
| LLM 逐条 prompt / completion / TTS text / eval / 按 user-session 成本 | **Langfuse** | 已接入 chat completion + TTS speech,正文级 trace + eval |
| 用户行为漏斗各步骤 | **PostHog** for frontend steps; **Postgres/Grafana** for server steps | Server-side facts 不在请求路径发 PostHog |
### Better Auth session table 与活跃用户
`user_active_sessions`COUNT(\*))和 `user_distinct_active`COUNT(DISTINCT user_id))共享同一张 `session` 表:
- **Better Auth 每次 sign-in / 每次 OIDC access-token 颁发都新建一条 session row,从不主动 GC 过期 row**——因为 `oauth_access_token.session_id` FK 指向 session`apps/server/src/libs/auth.ts:513` 注释)
- 实战观察:~80K `user_active_sessions` 对应实际只有几百 distinct user。比例 5+ 就该考虑加 session GC cron 或缩短 Better Auth `expiresIn`
- 永远展示 `user_distinct_active` 给非工程师看(PM、运营);`user_active_sessions` 留给工程师 debug
### Dashboard 标注规则
两边都展示的指标,**必须**在 Grafana panel description 和 PostHog insight description 里:
1. 注明 truth side"Truth: Postgres `flux_transaction` 表" / "Truth: PostHog 事件去重"
2. 注明本侧统计的语义差异(如 "Grafana 这里是 session 计数,不去重;PostHog 那边是 user 去重 DAU"
3. 如果两边数字差异预期 > 10%,写明合理范围
## PostHog 事件命名约定
格式:`<noun>_<verb_past_tense>`,全部 `snake_case`
| 约定 | 示例 |
|---|---|
| 名词在前,动词过去式在后 | `pricing_page_viewed``plan_selected``payment_completed` |
| 一律 past tense | `signup_completed` 不是 `complete_signup` |
| 不带产品 / 模块前缀 | `chat_session_started` 不是 `airi_chat_session_started` |
| 不带技术细节前缀 | `model_switched` 不是 `frontend_model_switched` |
| properties 用 `snake_case` | `{ plan_id, price_usd, checkout_session_id }` |
| 跟外部系统串联的 ID 用原平台命名 | `stripe_customer_id``stripe_subscription_id``checkout_session_id` |
`distinctId` 在登录后必须调 `posthog.identify(userId)`userId 用 Better Auth 的 user id(跟 server 里的 `c.get('user').id` 一致)。AIRI server 不直接接入 `posthog-node`,后端事实事件写入 Postgres `product_events` 并通过 `airi_product_events_total` 暴露到 Grafana。前端 wiring 由 `useSharedAnalyticsStore.initialize()` 自动处理,不需要每个 caller 手动 identify。
参考来源:[PostHog: 5 events all teams should track](https://posthog.com/blog/events-you-should-track-with-posthog)。
## Grafana 指标命名约定
沿用现有 [`observability-conventions.md`](./observability-conventions.md) 不再重复,关键约束:
- OTel semconv 优先(`http_*` / `db_*` / `gen_ai_*`),匹配不上才放 `airi.*` 命名空间
- counter 一律 `_total` 后缀,histogram 一律 `_seconds_bucket` / `_bytes_bucket`
- label 基数受控(route pattern 而非 URLmodel name 而非 prompt
## 当前指标归属总表
### Grafana / Prometheus(系统侧)
来源:`apps/server/src/otel/index.ts` 全量列表见 [`observability-metrics.md`](./observability-metrics.md)。Dashboard 配置在 [`apps/server/otel/grafana/dashboards/build.ts`](../../otel/grafana/dashboards/build.ts)。
| 域 | 代表性指标 | Truth | 备注 |
|---|---|---|---|
| HTTP | `http_server_request_duration_seconds_*` | Grafana | OTel 标准 |
| WS | `ws_users_online` / `ws_connections_active` / `ws_messages_*_total` | Grafana | `ws_users_online` 是 Redis Pub/Sub channel 去重后的集群在线用户数;连接数仍用于排查多标签页和连接泄漏 |
| LLM | `gen_ai_client_operation_count_total` / `gen_ai_client_first_token_duration_seconds` | Grafana | |
| Billing | `airi_billing_flux_unbilled_total` | Grafana | **告警必须**`increase(airi_billing_flux_unbilled_total[5m]) > 0` |
| Auth | `user_active_sessions` / `user_distinct_active` | Postgres → Grafana 派生 | 集群级 gauge,用 `avg()` 不要 `sum()`。两个一起看:`user_active_sessions` = `COUNT(*)`session row 数,会膨胀), `user_distinct_active` = `COUNT(DISTINCT user_id)`(真实活跃用户数)|
| Stripe | `airi_stripe_revenue_minor_unit_total` / `stripe_events_total` | Postgres → 两边展示 | Grafana 是系统侧 webhook 计数 |
| Runtime | `v8js_memory_*` / `nodejs_eventloop_delay_*` | Grafana | per `service_instance_id` |
| Rate-limit | `airi_rate_limit_blocked_total` | Grafana | in-memory per replica |
### PostHog(前端 / 外部数据源,产品侧)
已接入:
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。
- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHogLLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。
- 前端 identity`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
- 平台统一写入 `app_surface``entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。
已埋点:
| 域 | 事件 | 来源 | 落点 | Truth |
|---|---|---|---|---|
| 付费漏斗 | `pricing_page_viewed` / `plan_selected` / `checkout_started` | 前端 | `packages/stage-pages/src/pages/settings/flux.vue` | PostHog |
| 付费漏斗终点 | `payment_completed` | 后端 webhook | `product_events` + Grafana,并 best-effort 转发 PostHog | Postgres |
| Activation / Retention | `first_model_selected` / `model_switched` | 前端(consciousness store watcher | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog |
| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog |
| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
| Conversation controls | `chat_session_selected` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog |
| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog |
| Churn | `subscription_cancelled`(带 cancellation_reason | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres |
| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 兼容指标 | `first_message_sent` | 前端 | 仍有生产者,仅供历史 dashboard;新激活口径使用 `chat_activation_succeeded` | PostHog |
| 聊天轮次 | `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 前端 core runtime | 以 `conversation_id` / `round_id` / `turn_index` 关联;成功和失败各有唯一终点事件 | PostHog |
| 注册 UI | `signup_form_completed` | auth SPA | 匿名表单完成信号,不计作注册事实 | PostHog |
| 注册事实 | `signup_completed` | Better Auth user create hook | 仅服务端生产,以 Better Auth user id 识别 | Postgres + PostHog |
待埋点(API 已在 `use-analytics.ts` 暴露但调用点未接入):
| 域 | 事件 | 状态 |
|---|---|---|
| Retention | `voice_mode_activated` | 需要先在 hearing store 加显式 `enableVoiceMode` action — 当前 hearing 没有单一"用户主动启用"那一刻的 trigger,被动监听 + 录音 action 不构成 user intent 信号 |
| Feature adoption | `flux_image_generated` | 等图片生成 feature 上线 |
### 双展示指标(同名两边都有)
| 指标 | Grafana | PostHog | Truth | 语义差异 |
|---|---|---|---|---|
| 活跃用户数 | `user_active_rolling` / `user_distinct_active` | DAU = 前端 journey 去重 distinctId | **Postgres/Grafana** for server truth | Grafana 是服务端可验证活跃,PostHog 是前端产品旅程 |
| Checkout 完成数 | `stripe_checkout_completed_total` + `product_events.payment_completed` | Stripe source connector / offline import | **Postgres** | Grafana 是 webhook 计数,PostHog 是产品漏斗展示 |
| LLM 请求 | `gen_ai_client_operation_count_total` | `chat_session_started` 等 | **Grafana**(系统计数) | PostHog 是用户维度切片,会少于 GrafanaPostHog 只覆盖 logged-in user |
## PostHog 接入路线图
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实(注册、支付、订阅),per-request 路径仍只写 Postgres/Grafana。
### 阶段 1P0 — 付费漏斗 + activation
所有运行端共用根目录 `posthog.config.ts`(单一 project key`app_surface` super property 区分端)。初始化实况:
```ts
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config'
// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24'
// SPA 路由切换自动发 $pageview / $pageleave,页面浏览不再手动埋。
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// 登录后(stage 端在 analytics storeauth 端在 profile.vue
posthog.identify(user.id)
// 在 flux.vue
posthog.capture('pricing_page_viewed', { plan_period, source })
```
`apps/stage-tamagotchi`Electron renderer):
```ts
// NOTICE: Electron CSP 下普通 import 会静默失效,必须用 full bundle。
// 参考:https://posthog.com/tutorials/electron-analytics
import posthog from 'posthog-js/dist/module.full.no-external.js'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://t.airi.build',
autocapture: false, // 桌面应用没有传统 URL 路由,手动控制
})
```
埋点事件清单(P0):
- 前端:`pricing_page_viewed``plan_selected``checkout_started``signup_form_completed`ui-server-auth 匿名邮箱表单里程碑)、`first_model_selected`
- 后端:`product_events` 是事实账本;其中业务事实白名单(`signup_completed``payment_completed``subscription_started/renewed/cancelled`)由 product-events 服务经 posthog-node 转发一份到 PostHog`apps/server/src/services/domain/product-events.ts`distinctId = Better Auth user id)。LLM / TTS 等 per-request 事件不转发
PostHog UI 配两个 funnel
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发)
- **激活漏斗** (14d 窗口)`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed`
### 阶段 2P1 — retention / feature adoption / churn
埋点事件清单:`character_created``voice_mode_activated``chat_session_started``model_switched``flux_image_generated``subscription_cancelled`
PostHog UI 配 cohort
- **D7 Retention by voice mode**:第一次 session 用了 `voice_mode_activated` 的用户 vs 没用的,看 D7/D30 retention 差异
- **Churn 14d**:过去 14d 没有 `chat_session_started` 的付费用户,作为召回 cohort
### Stripe → PostHog 集成路径
**不在 server webhook 里手动 capture**
| 路径 | 用途 |
|---|---|
| PostHog Stripe **source connector** | MRR / ARR / churn revenue dashboardPostHog 原生 Revenue analytics |
| 离线导入 `product_events.payment_completed`(可选) | 漏斗终点 event,跟前端 `checkout_started` 串联 |
不要在 API 请求路径同步发 PostHogPostHog 网络尾延迟会污染 auth / billing / chat route latency。需要 person-level funnel endpoint 时,用后台导入或 connector,不阻塞用户请求。
## 5xx Triage 路径
Dashboard 上 follow 这条 panel 链可以从"出事了"一路 drill 到"哪个 trace 是真凶"
1. **panel-4 `5xx Rate %`**Row 1)— 数字 / gauge 颜色变红,说明出事
2. **panel-94 `Errors by Route`**HTTP row)— "什么时候开始的、哪些 route / status 在失败"
3. **panel-91 `Warn / Error Logs`**Logs row)— 实际 warn/error 消息,里面有 `trace_id` field 可点 → Tempo 看完整 trace 回放
### Tempo / Loki derived fields 配置(一次性)
panel-91 的 `trace_id` 字段必须配 Grafana Cloud Loki datasource 的 **Derived fields** 才能跳 Tempo。这不在 dashboard JSON 范围内,是 datasource 级配置:
- **Grafana Cloud** → Connections → Data sources → 选 `grafanacloud-projairi-logs`Loki)→ Derived fields
- 添加:
- **Name**: `trace_id`
- **Type**: Regex in label or value
- **Regex**: `"trace_id":"([a-f0-9]+)"`(匹配我们 logger 的 JSON 输出)
- **URL**: 留空
- **Internal link**: ✓,datasource 选 `grafanacloud-projairi-traces`Tempo
- 同样手法可加 `req` (request id) → 配 internal link 回 Loki 自身,按 requestId filter
配置完之后日志面板里 `trace_id` 会变成蓝色可点,直接跳 Tempo waterfall。这一步配置只做一次,新加 panel 自动享有。
## Grafana Alert SOP
Alert rules **不放在** `apps/server/otel/grafana/dashboards/build.ts` 里——Grafana Cloud 用 Unified Alertingrule 在 Grafana UI 或 alerting API 管理,跟 dashboard JSON 解耦。这一节维护我们应该配的 alert rule,新加 rule 时同步更新这里。
### P0 — page on-callPagerDuty / Slack on-call channel
| Alert | Query | Threshold | Notes |
|---|---|---|---|
| **Flux Unbilled leak** | `increase(airi_billing_flux_unbilled_total[5m])` | `> 0` for 5m | 收入直接漏;分 `reason` label 看是 `partial_debit_drained`(用户余额耗尽,预期)还是 `debit_failed`DB / 真异常)。后者更急 |
| **5xx Rate spike** | `100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count[5m]))` | `> 5%` for 10m | 跟 panel-4 阈值对齐 |
| **Email Failure spike** | `100 * sum(rate(airi_email_failures_total[5m])) / clamp_min(sum(rate(airi_email_send_total[5m])) + sum(rate(airi_email_failures_total[5m])), 1)` | `> 5%` for 10m | Resend / DNS / 黑名单挂了会阻塞注册流程 |
### P1 — notify onlySlack ops channel,不分页)
| Alert | Query | Threshold | Notes |
|---|---|---|---|
| **WS Connections cliff** | `sum(ws_connections_active)` | drop to 0 for 5m | 全断说明部署 / LB 异常 |
| **DB Pool exhaustion** | `max by (service_instance_id) (db_client_connection_count)` | `>= DB_POOL_MAX - 1` for 5m | 哪个 instance 满了 |
| **Heap > 85%** | `100 * sum by (service_instance_id) (v8js_memory_heap_used_bytes) / sum by (service_instance_id) (v8js_memory_heap_limit_bytes)` | `> 85%` for 15m | 内存泄漏前兆 |
| **Stripe webhook fail** | `increase(stripe_events_total{event_type="payment_intent.payment_failed"}[1h])` | `> 10` per hour | 支付链路问题 |
### 配置入口
Grafana Cloud → Alerts & IRM → Alert rules → New alert rule。把上面 query 粘进 PromQL editorthreshold 按表设置,labels 加 `severity=p0|p1`notification policy 按 severity 路由到 PagerDuty 或 Slack。
每加一条 alert,**更新这张表**——alert 没在文档里登记 = 不知道为什么 page、不知道 owner、不知道历史阈值改动。
## 何时打破规则
这份文档定的是**默认值**,不是法律。下列情况可以打破:
- **系统指标也需要给 PM 看**(如 LLM provider 可用性影响产品决策)→ Grafana truth + 周期性 export 给 PostHog dashboard 展示
- **产品指标需要分钟级告警**(如付费转化突然归零)→ Grafana alert 监 Stripe webhook 计数,PostHog truth 不变
- **A/B test 影响系统指标**(如新 LLM router 影响延迟)→ feature flag 同时打到两边,Grafana panel 按 flag value 分线展示
打破规则的指标必须在 dashboard description 里说明,**不要静默打破**。
## 参考来源
业界没有权威 framework,下列来源是这份文档的依据:
- [PostHog Product Metrics Handbook](https://posthog.com/handbook/product/metrics) — PostHog 自己的内部分层
- [PostHog issue #43633](https://github.com/posthog/posthog/issues/43633) — dual-emit 问题的工程承认
- [Honeycomb Observability 2.0](https://www.honeycomb.io/blog/time-to-version-observability-signs-point-to-yes) — "消除工具边界"的少数派立场
- [Reforge: North Star Metrics](https://www.reforge.com/blog/north-star-metrics) — leading vs lagging 区分
- [DEV: Metrics for 500 Engineers with Linear + Grafana + PostHog](https://dev.to/johalputt/how-to-set-up-developer-metrics-for-500-engineers-using-linear-20-grafana-110-and-posthog-30-3l73) — 与我们结构最接近的公开案例
- [PostHog: Stripe payment platform](https://posthog.com/docs/revenue-analytics/payment-platforms/stripe) — Stripe 集成路径官方文档
- [PostHog: Electron analytics](https://posthog.com/tutorials/electron-analytics) — Electron renderer 接入要点
- [Google SRE Book: Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) — Four Golden Signals
- [Stripe: Essential SaaS Metrics](https://stripe.com/resources/more/essential-saas-metrics) — 收入侧指标定义
@@ -1,293 +0,0 @@
# Observability Conventions
这份约定定义 AIRI 服务端新增 trace / metric attributes 时应该遵守的命名规则,目标是减少自定义前缀扩散,并让 Grafana / Tempo / Loki 查询尽量对齐 OpenTelemetry 语义约定。
## 总原则
- 能直接映射到 OpenTelemetry semantic conventions 的字段,优先使用标准字段。
- 不能映射到标准字段、但确实属于 AIRI 业务语义的字段,统一放到 `airi.*` 命名空间下。
- 不要新增新的顶级前缀,例如 `llm.*``gateway.*``telegram.*` 之类的 attribute key。
- span name、event name、metric name 不等于 attribute key;是否迁移它们要单独评估兼容性。
- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) 引用。
## 标准字段优先级
### GenAI
优先使用:
- `GEN_AI_ATTR_OPERATION_NAME`
- `GEN_AI_ATTR_REQUEST_MODEL`
- `GEN_AI_ATTR_USAGE_INPUT_TOKENS`
- `GEN_AI_ATTR_USAGE_OUTPUT_TOKENS`
- `SERVER_ATTR_ADDRESS`
- `SERVER_ATTR_PORT`
适用场景:
- chat completion
- embeddings
- 其他能明确归类到 GenAI 上游调用的请求
注意:
- 当前 OpenTelemetry GenAI semantic conventions 仍处于 `Development` 状态,因此只在“语义明确匹配”时采用。
- 没有明确标准归属的字段不要硬塞进 `gen_ai.*`
### Database / Redis
优先使用:
- `db.system.name`
- `db.operation.name`
- `db.namespace`
- `db.query.text`
- `db.response.status_code`
- `server.address`
- `server.port`
Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重复造一套并行命名。
## AIRI 自定义字段
以下场景使用 `airi.*`
- 计费或余额语义
- 仅 AIRI 内部存在的流式控制字段
- 临时调试但仍需要进入可观测系统的业务字段
当前 attribute 示例:
- `AIRI_ATTR_BILLING_FLUX_CONSUMED`
- `AIRI_ATTR_GEN_AI_STREAM`
- `AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED`
- `AIRI_ATTR_GEN_AI_OPERATION_KIND`
- `AIRI_ATTR_GEN_AI_INPUT_MESSAGES`
- `AIRI_ATTR_GEN_AI_INPUT_TEXT`
- `AIRI_ATTR_GEN_AI_OUTPUT_TEXT`
当前 `airi.*` metric 命名空间(Prom 系列名见 [`observability-metrics.md`](./observability-metrics.md)):
- 计费:`airi.billing.flux.consumed` / `.credited` / `.unbilled` / `.tts.chars` / `.tts.preflight_rejections`
- 收入:`airi.stripe.revenue`
- 邮件:`airi.email.send` / `.failures` / `.duration`
- 限流:`airi.rate_limit.blocked`
- GenAI`airi.gen_ai.stream.interrupted`
## Metric Name 策略
`apps/server` 的 LLM gateway metric 现在全部用标准 `gen_ai.client.*` semconv 名 + AIRI `airi.billing.*` 计费名。旧的 `llm.request.*` / `llm.tokens.*` / `flux.consumed` 字面名都已经迁移完,请**不要在新代码或 reviewer 建议里复活**它们 —— 代码里 const 命名(如 `METRIC_FLUX_CONSUMED`)保留是历史 identifier,对应的字面值已经是 `airi.billing.flux.consumed`,以字面值为准。
新增或重命名 metric 时遵守:
- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。**先确认 dashboard / alerts 是否在跑这条 series**,再决定是否重命名。
- 重命名一定要走兼容迁移:先双发新旧两条 series,留出窗口给消费方切换,再删旧的;不要在普通功能改动里直接重命名。
- 完整 metric 清单(含 Prometheus 系列名)维护在 [`observability-metrics.md`](./observability-metrics.md)。新增任何 metric 都要同步更新那份文档。
## Grafana / Prometheus 查询策略
面板和告警查询优先依赖 metric labels,对齐我们已经统一的 attributes。
### GenAI 面板应该查什么
优先使用这些 Prometheus label
- `gen_ai_request_model`
- `gen_ai_operation_name`
- `airi_gen_ai_operation_kind`
- `http_response_status_code`
说明:
- Prometheus 暴露时会把 attribute key 里的 `.` 转成 `_`,所以 `gen_ai.request.model` 会变成 `gen_ai_request_model`
- `gen_ai_operation_name` 适合 chat、embeddings 这类有明确 semconv 的操作。
- `airi_gen_ai_operation_kind` 适合当前没有明确 semconv 的 AIRI 自定义操作类型,例如 `tts``asr`
### 不再新增使用的旧查询维度
新增 dashboard、录制规则、告警时,不要再新增依赖这些旧 label:
- `model`
- `type`
旧面板可以渐进迁移,不要求一次性全部替换,但新改动必须直接使用新标签。
### 当前已落地的 dashboard 例子
[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询:
- Request rate by model: `gen_ai_request_model`
- Request rate by operation: `gen_ai_operation_name` + `airi_gen_ai_operation_kind`
- Latency by model: `gen_ai_request_model`
- Flux consumed by model: `gen_ai_request_model`
- Token throughput by model: `gen_ai_request_model`
如果未来新增本地 dashboard 或新的 cloud dashboard,默认按这一套 label 维度来。
## Span Name 策略
span name 目前允许保留业务可读格式,例如:
- `llm.gateway.chat`
- `llm.gateway.tts`
- `llm.gateway.asr`
原因:
- span name 主要服务于人工浏览和局部检索。
- 语义筛选应优先依赖 attributes,而不是依赖 span name 文本。
如果未来统一 span name,也应保证查询主要依赖 `gen_ai.*` / `db.*` / `airi.*` attributes。
## 修改前检查
新增 observability 字段前,先问自己:
1. 这个字段能否映射到已有 OTel semconv
2. 如果不能,它是否明确属于 AIRI 业务语义?
3. 如果属于 AIRI,是否应该挂到 `airi.*`,而不是新造顶级前缀?
4. 我改的是 attribute key 还是 metric name / span name
5. 如果是 metric name,是否已经评估 Prometheus / Grafana / alerting 兼容性?
6. 如果要改 dashboard,我是否优先用了 `gen_ai_request_model``gen_ai_operation_name``airi_gen_ai_operation_kind`,而不是旧的 `model` / `type`
## 当前参考实现
- [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts)
- [apps/server/src/routes/v1completions.ts](/apps/server/src/routes/v1completions.ts)
- [apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts)
- [services/telegram-bot/src/llm/actions.ts](/services/telegram-bot/src/llm/actions.ts)
- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts)
## SemconvStability 迁移说明
`@opentelemetry/instrumentation-http` 0.215+ 默认 OLD semconv`http.server.duration` in ms),不是 STABLE 名。AIRI 在 [apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 顶部强制 `OTEL_SEMCONV_STABILITY_OPT_IN=http`(仅 STABLE)。
| Semconv 模式 | 发哪些 series | 我们用 |
|---|---|---|
| OLD(默认)| `http.server.duration` (ms)、`http.client.duration` (ms)、attr 用 `http.method` / `http.status_code` | ❌ |
| STABLE`=http`| `http.server.request.duration` (s)、`http.client.request.duration` (s)、attr 用 `http.request.method` / `http.response.status_code` | ✅ |
| 双发(`=http/dup`)| 上面两套都发 | 仅在有外部 OLD-name 消费者待迁移时启用 |
**为什么直接 STABLE-only**
- grep 整仓库零 OLD-name 引用
- Dashboard 与服务代码 checked in 在一起,无外部 dashboard
- 迁移没有自然终点,OLD 系列不显式清理就一直占 storage
- 双发会让每条 HTTP 请求 cardinality 翻倍
**何时切回 `dup`**:将来如果有别的 service 主动 scrape 本 server 的 OLD-name 系列,临时切几周完成迁移即可。
## Multi-Replica 注意事项
服务跑在 Railway 上有 ≥2 个副本(见 [workers-and-runtime.md](./workers-and-runtime.md)),所有 metric 设计必须显式考虑跨副本聚合。
### `service.instance.id` 必须设
[apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 在 resource 上注入 `service.instance.id`,按优先级取 `RAILWAY_REPLICA_ID``SERVER_INSTANCE_ID``randomUUID()`(带 warn 日志,提示 ops 系列会随重启 churn)。`HOSTNAME` 曾经在 fallback 链里但 Railway 没文档化它是否 per-replica 唯一,所以踢出去了;需要跨重启稳定时显式设 `SERVER_INSTANCE_ID`
**没设的后果**:两个副本的所有 metric series label tuple 完全一致(`service_name` + `deployment_environment` 一样),Prometheus 收到时按规则丢一条 / collapse 系列,结果一个副本完全消失。
加新 metric 时不用做任何事——只要从 `meter` 创建出来,instance id 自动随 resource 一起带上。
### 按 instrument 类型的副本安全表
| 类型 | 副本安全? | 聚合方式 | 备注 |
|---|---|---|---|
| `Counter` | ✅ | `sum(rate(x[5m]))` | 每副本本地累加,`rate()` 自动处理重启 |
| `Histogram` | ✅ | `histogram_quantile(0.95, sum by (le, ...) (rate(x_bucket[5m])))` | 每副本本地 bucket`sum by (le)` 合并 |
| `ObservableGauge`**per-replica 状态**,如 `ws.connections.active` | ✅ | `sum(x)` | callback 读本地 registry,所有副本求和 = 集群总量 |
| `ObservableGauge`**cluster-wide 状态**,如 `user.active_sessions` / `ws.users.online` | ⚠️ | `max(x)``avg(x)` | 所有副本读同一份外部状态(DB / Redis),sum 会乘以副本数 |
| `UpDownCounter` | ⚠️ | 看场景 | 必须保证 `+1``-1` 在**同一副本**触发;否则单副本永久 +N 另一副本永久 -N |
### `UpDownCounter` 红线
只在以下情况用:
- `+1` 和对应的 `-1` 都在**同一请求生命周期**或**同一进程的局部状态机**里发生(典型:`http.server.active_requests` —— 请求开始 +1,结束 -1,必在同一副本)
- 不依赖任何外部 TTL / GC / 异步过期
如果存在「TTL 自然过期」「跨实例资源转移」「依赖 webhook 异步触发 -1」之类的情况,**不要用 UpDownCounter**。改用:
- `ObservableGauge` 从权威存储(DB / Redis)按 callback 读真实值,dashboard 用 `max()` / `avg()` 聚合
- 或者只保留对应的 `Counter`"created" + "deleted"),让 dashboard 自己算差值
历史教训:`user.active_sessions` 最早是 UpDownCounter,登录 +1 / 登出 -1。但 Better Auth 的 session TTL 过期不会调 delete hookcounter 单实例就漂;多副本登录在 A、登出在 B 直接撕裂。改成 `ObservableGauge` 后由 [apps/server/src/app.ts](/apps/server/src/app.ts) 的 `registerActiveSessionsGauge` 通过 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()` 在 scrape 时按需查 DB,带 10s 内存缓存避免 hammer。
三个 DB-backed user gauge 的语义区分(都是 cluster-widedashboard 用 `max()` / `avg()`):
| Metric | 含义 | 来源 | 注册位置 |
|---|---|---|---|
| `user.active_sessions` | 当前未过期的 session **行数**(含 OIDC token 刷新产生的行,会膨胀) | `COUNT(*) FROM session WHERE expires_at > now()` | `registerActiveSessionsGauge` |
| `user.distinct_active` | 当前持有 ≥1 个未过期 session 的**去重用户数**"此刻在线" | `COUNT(DISTINCT user_id) FROM session WHERE expires_at > now()` | `registerDistinctActiveUsersGauge` |
| `user.active_rolling` | 滚动窗口去重活跃用户 DAU/WAU/MAU"近 N 天回来过"),按 `window="24h"\|"7d"\|"30d"` 打 label | `COUNT(*) FILTER (WHERE last_seen_at > now()-window) FROM user` | `registerRollingActiveUsersGauge` |
`user.active_rolling``user.last_seen_at`(登录 + 每次 OIDC token 刷新约每小时 touch 一次,是 per-user 的「最后活跃」时间戳),不依赖 session 是否过期,所以能回答「本周回来过多少人」。一次 query 用三个 `FILTER` 把三个窗口算完,缓存 60s(窗口变化慢且要全表扫 `user`TTL 比 session gauge 长)。
### Dashboard 查询模板
加新 panel 时按这个清单核对:
| 数据语义 | PromQL 模板 |
|---|---|
| 业务事件速率(Counter | `sum(rate(x_total{...}[$__rate_interval]))` |
| 按 label 切分速率 | `sum by (<label>) (rate(x_total{...}[$__rate_interval]))` |
| 时延分位(Histogram | `histogram_quantile(0.95, sum by (le, <label>) (rate(x_bucket{...}[$__rate_interval])))` |
| 集群总量(per-replica gauge | `sum(x{...})` |
| 集群唯一值(cluster-wide gauge | `max(x{...})``avg(x{...})` |
| 按副本拆分调试 | `<agg> by (service_instance_id) (x{...})` |
| 错误率 | `100 * sum(rate(x_total{...,status_code=~"5.."}[5m])) / sum(rate(x_total{...}[5m]))` |
红线:**任何 cumulative counter 都不能直接 `sum()` 不 wrap rate/increase**。Counter 在副本重启时归零,没有 rate() 包裹 Prometheus 会跳变;用 `increase($__range)` 看「时间窗口内总量」,用 `rate([interval])` 看「当前速率」。
比例的分母也不能用 `clamp_min(rate(...), 1)` 防零:这会在流量低于 1 req/s 时系统性低估错误率。零流量应由 Grafana 的 no-value 展示策略处理,不应修改真实分母。
### 「按副本拆分」何时加
默认 panel 都聚合到集群层面。但以下场景应该加 `by (service_instance_id)` 拆分图:
- 进程级资源(heap、event loop、DB pool)——一个副本泄漏 / pin CPU 别的副本掩盖不掉
- WS 连接 ——可以看出来是不是单个副本不均衡
- 自定义的 ObservableGauge 排查
Dashboard 当前 Infrastructure 行已经是 by instance 的(Heap、Event Loop、DB Pool)。
## Counter priming 注意事项
OTel SDK 的 Counter / UpDownCounter 在第一次 `.add()` 之前**完全不出现在 Prometheus 抓取里**。Histogram 同理(要等第一次 `.record()`)。
后果:低流量 metric 在 dashboard 上看起来像「埋点丢了」,告警里 `absent()` 也无法工作。
[apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts) 的 `primeCounter` 在 SDK 启动后给每个 Counter 调一次 `.add(0)`,把 series 注册出来;`0` 不影响 rate / sum 计算。
加新 Counter 时**记得加进 prime 列表**,否则未触发的指标在 Grafana 里就是空的。
验证脚本:[apps/server/src/scripts/otel/smoke.ts](/apps/server/src/scripts/otel/smoke.ts)
```sh
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
```
打印 SDK 启动后立即可见的所有 instrument 名字。
## Dashboard 变量陷阱
**变量定义里不要引用业务 metric**。早期 [airi-server-overview-cloud.json](/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 的 `$env` / `$service` 都从 `http_server_request_duration_seconds_count` 取 label values —— 升级 instrumentation-http 后这个系列没了,导致:
1. 两个变量解析为空字符串
2. 所有 panel 的 `{service_name=~"$service", deployment_environment=~"$env"}` 匹配零 series
3. 整个 dashboard 全 No Data**包括那些 metric 还活着的 panel**
修法:变量改用 `target_info`。这是 OTel SDK 启动就发的 resource-only series,永远存在,且天然自带 `service_name` / `deployment_environment` / `service_version` 这套 resource attributes。
```promql
# Good
label_values(target_info, deployment_environment)
label_values(target_info{deployment_environment=~"$env"}, service_name)
# Bad — 任何业务 metric 改名/迁移就全盘崩
label_values(http_server_request_duration_seconds_count, deployment_environment)
```
后续新增 dashboard 默认沿用 `target_info` 这条惯例。
## 完整 metric 目录
按域分组的全量 metric 清单(名字、类型、单位、labels、落点)见 [`observability-metrics.md`](./observability-metrics.md)。每加一个新 metric 时同步更新该文档。
@@ -1,180 +0,0 @@
# Metrics Catalog
服务端当前所有 metric 的完整目录。按业务领域分组。
> 命名规则、`airi.*` 边界、attribute 选择请看 [`observability-conventions.md`](./observability-conventions.md)。本文档只做"哪些 metric 存在、怎么查"。
## 名字到 Prometheus 系列的换算
OTel SDK 在导出到 Prometheus 时做两件事:
1. `.``_``airi.billing.flux.consumed``airi_billing_flux_consumed`
2. Counter 加 `_total` 后缀:`auth.attempts``auth_attempts_total`
3. Histogram 拆三件套:`http.server.request.duration`
- `http_server_request_duration_seconds_bucket`(含 `le` label
- `http_server_request_duration_seconds_count`
- `http_server_request_duration_seconds_sum`
4. UpDownCounter / ObservableGauge 不加 `_total``ws.connections.active``ws_connections_active``user.active_sessions``user_active_sessions`
5. 带单位的 instrument 在 SDK 导出时把单位插进名字:`airi.stripe.revenue`unit `minor_unit`)→ `airi_stripe_revenue_minor_unit_total`
> 查询面板若拼名字时不确定后缀,先用 `{__name__=~"airi_billing_flux.*"}` 之类正则探一下。
## HTTP(来自 instrumentation-http
| Metric | 类型 | Unit | 来源 | 关键 attributes |
|---|---|---|---|---|
| `http.server.request.duration` | Histogram | s | [`@hono/otel`](https://www.npmjs.com/package/@hono/otel) `httpInstrumentationMiddleware` in [app.ts](../../src/app.ts) | `http.request.method``http.route``http.response.status_code` |
| `http.server.active_requests` | UpDownCounter | — | 同上 | `http.request.method` |
> **入站走 @hono/otel,出站走 auto HttpInstrumentation**auto instrumentation 在 Node http 层抓数据时 Hono 还没匹配路由,`http.route` label 永远为空。`@hono/otel` 在 Hono middleware 链里跑,能拿到匹配后的路由 pattern`/api/v1/users/:id` 而非具体 URL),所以入站 metric 由它产生。auto HttpInstrumentation 在 [instrumentation.ts](../../instrumentation.ts) 里通过 `ignoreIncomingRequestHook: () => true` 仅保留**出站**LLM gateway、Stripe、Resend),那部分还是要它来跟踪。
>
> **STABLE-only**[instrumentation.ts](../../instrumentation.ts) 把 `OTEL_SEMCONV_STABILITY_OPT_IN=http` 提前注入。OLD 系列(`http.server.duration` in ms)不再发射。详见 [`observability-conventions.md` 的 SemconvStability 章节](./observability-conventions.md#semconvstability-迁移说明)。
>
> `/livez` 和 `/readyz` 在 [app.ts](../../src/app.ts) 的 @hono/otel 包装层被显式 skipK8s 风格探针不进 metric。
## Auth & Users
全部由 [libs/auth.ts](../../src/libs/auth.ts) Better Auth hooks 触发。
| Metric | 类型 | 落点(hook | Labels |
|---|---|---|---|
| `auth.attempts` | Counter | `before` hookpath 含 `/sign-in``/sign-up` | `auth.method`path 末段) |
| `auth.failures` | Counter | `after` hook`ctx.context.returned``error` | `auth.method` |
| `user.registered` | Counter | `databaseHooks.user.create.after` | — |
| `user.login` | Counter | `databaseHooks.session.create.after` | — |
| `user.active_sessions` | ObservableGauge | [app.ts](../../src/app.ts) `registerActiveSessionsGauge`scrape 时查 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()`10s 内存缓存) | — |
> **`user.active_sessions` 是 cluster-wide gaugedashboard 必须用 `max()` / `avg()`,不能用 `sum()`**。所有副本读同一份 DB 报同一个值,sum 会乘以副本数。详见 [observability-conventions.md 的 Multi-Replica 章节](./observability-conventions.md#multi-replica-注意事项)。
>
> 历史:之前是 UpDownCounter+1 on login, -1 on logout),但 Better Auth session TTL 过期不会调 delete hookcounter 单实例就漂;多副本下登录在 A、登出在 B 会直接撕裂正负数。所以改成 DB-backed gauge。
## Engagement
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
| `chat.messages` | Counter | [services/domain/chats.ts](../../src/services/domain/chats.ts) `pushMessages` | — |
| `character.created` | Counter | [services/domain/characters.ts](../../src/services/domain/characters.ts) | — |
| `character.deleted` | Counter | 同上 | — |
| `character.engagement` | Counter | 同上(like/bookmark | `action``like` / `unlike` / `bookmark` / `unbookmark` |
| `ws.connections.active` | ObservableGauge | [routes/chat-ws/index.ts](../../src/routes/chat-ws/index.ts) `addCallback` walks `userConnections` Map | — |
| `ws.users.online` | ObservableGauge | [otel/gauges/ws-online-users.ts](../../src/otel/gauges/ws-online-users.ts) counts unique active `user:*:chat:broadcast` Redis Pub/Sub channels | — |
| `ws.messages.sent` | Counter | 同上 | — |
| `ws.messages.received` | Counter | [services/domain/chats.ts](../../src/services/domain/chats.ts) | — |
> `ws.users.online` 是 cluster-wide gauge:同一用户无论打开多少标签页、连接到多少个 Server 副本,Redis 都只返回一个活跃 channel。每个副本读取并上报同一个全局值,因此 Dashboard 必须使用 `max()` / `avg()`,不能使用 `sum()`。
## Product Analytics
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
| `airi.product.events` | Counter | [services/domain/product-events.ts](../../src/services/domain/product-events.ts) `track` | `feature``action``status``source`(可选) |
> **Prometheus 只看事件量,不看独立用户**`airi.product.events` 的 labels 必须保持低基数,不能加 `user_id`、`session_id`、request id、raw error message 或 prompt。要回答"每个功能有多少独立用户",查 Postgres `product_events``count(distinct user_id)`。
## Revenue & Billing
### Stripe lifecycle
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
| `stripe.checkout.created` | Counter | [routes/stripe/index.ts](../../src/routes/stripe/index.ts) `/checkout` POST | — |
| `stripe.checkout.completed` | Counter | webhook `checkout.session.completed` | — |
| `stripe.payment.failed` | Counter | webhook `invoice.payment_failed` | — |
| `stripe.subscription.event` | Counter | webhook `customer.subscription.*` | `event_type``created`/`updated`/`deleted` |
| `stripe.events` | Counter | 任何 webhook | `event_type`(完整 event.typee.g. `invoice.paid` |
| `airi.stripe.revenue` | Counter`minor_unit` | webhook `checkout.session.completed` + `invoice.paid` | `currency``source``checkout`/`invoice` |
> **金额单位**`airi.stripe.revenue` 用最小币种单位(cents 等),跨币种 sum 没有意义,**永远 `sum by (currency)`**。要换主单位(dollars 等)做 `/ 100` 即可,前提是该币种没有不同 minor unit 比例。
### Flux ledger
| Metric | 类型 | 落点 | Labels |
|---|---|---|---|
| `airi.billing.flux.consumed` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) `recordMetrics`chat / tts | `gen_ai.request.model``gen_ai.operation.name`/`airi.gen_ai.operation.kind``http.response.status_code` |
| `airi.billing.flux.credited` | Counter | [services/domain/billing/billing-service.ts](../../src/services/domain/billing/billing-service.ts) 三条入账路径 | `source``stripe.checkout`/`stripe.invoice`/`promo`/`admin_grant`/...)、`type``credit`/`promo` |
| `airi.billing.flux.unbilled` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) streaming 路径里 `consumeFluxForLLM` 失败的 catch | `gen_ai.request.model``reason``debit_failed`)、`stage``streaming` |
| `flux.insufficient_balance` | Counter | [services/domain/billing/billing-service.ts](../../src/services/domain/billing/billing-service.ts) `debitFlux` | — |
| `airi.billing.tts.chars` | Counter | [services/domain/billing/flux-meter.ts](../../src/services/domain/billing/flux-meter.ts) `accumulate` | `meter``tts`)、`model` |
| `airi.billing.tts.preflight_rejections` | Counter | `flux-meter.ts` `assertCanAfford` | `meter``reason``insufficient_balance` |
> **`airi.billing.flux.unbilled` 是 P0 告警金线**:流式响应已经发给用户(HTTP 200,token 已经流出),但 post-stream debit 抛错——response 路径不会因此 5xxDB latency 也只在 catch 那一瞬间显著。HTTP / DB 告警**覆盖不到**这条静默 revenue leak。推荐 alert`increase(airi_billing_flux_unbilled_total[5m]) > 0` 持续 > 0 立刻 page。
## GenAI
| Metric | 类型 | Unit | 落点 | Labels |
|---|---|---|---|---|
| `gen_ai.client.operation.duration` | Histogram | s | `routes/openai/v1/index.ts` `recordMetrics` | `gen_ai.request.model``gen_ai.operation.name`/`airi.gen_ai.operation.kind``http.response.status_code` |
| `gen_ai.client.operation.count` | Counter | — | 同上 | 同上 |
| `gen_ai.client.token.usage.input` | Counter | — | 同上 | 同上 |
| `gen_ai.client.token.usage.output` | Counter | — | 同上 | 同上 |
| `gen_ai.client.first_token.duration` | Histogram | s | 流式 reader 第一个非空 chunk 抵达时 | `gen_ai.request.model``gen_ai.operation.name` |
| `airi.gen_ai.stream.interrupted` | Counter | — | 流式 reader catch | `gen_ai.request.model``stage``before_first_chunk`/`mid_stream` |
## EmailResend
来源 [services/adapters/email.ts](../../src/services/adapters/email.ts) 的 `send()` 内部 try/catch。
| Metric | 类型 | Labels |
|---|---|---|
| `airi.email.send` | Counter | `template``verification`/`password_reset`/`magic_link`/`change_email`/`delete_account`/`unknown` |
| `airi.email.failures` | Counter | `template``error_name`Resend `error.name``unhandled` |
| `airi.email.duration` | Histograms | `template``outcome``ok`/`error` |
## Rate limiting
来源 [middlewares/rate-limit.ts](../../src/middlewares/rate-limit.ts) 的 `handler`
| Metric | 类型 | Labels |
|---|---|---|
| `airi.rate_limit.blocked` | Counter | `route`callsite 提供,e.g. `auth.api` / `openai.completions` / `stripe.checkout`)、`key_type``user`/`ip`)、`limit`(窗口内最大次数) |
> **注意**`route` 是 callsite 显式提供的稳定 label,不是 raw URL path —— URL path 是高 cardinality,会爆炸。新加 rate limiter 时记得传 `routeLabel`。
## Node.js Runtime
来自 `@opentelemetry/instrumentation-runtime-node`,下面这些是 dashboard 上用到的子集(不全列):
- `v8js.memory.heap.{used,limit,space.physical_size,space.available_size}` Gauge / bytes
- `nodejs.eventloop.delay.{p50,p99,mean,...}` Gauge / s
- `nodejs.eventloop.utilization` Gauge / ratio
- `v8js.gc.duration` Histogram / s
## 已落地的 dashboard 行映射
[airi-server-overview-cloud.json](../../otel/grafana/dashboards/airi-server-overview-cloud.json) 由 [`build.ts`](../../otel/grafana/dashboards/build.ts) 生成(**直接改 JSON 会在下次 regenerate 时被覆盖;改 build.ts**),跑 `pnpm -F @proj-airi/server otel:dashboards` 重新生成。从上到下:
| Row | viz | 关键 metric |
|---|---|---|
| Service Health | stat / gauge / timeseries | `user.total``max()`)、`user.active_sessions``avg()`)、`ws.users.online``max()`)、`ws.connections.active``sum()` trend)、`http.server.request.duration_count`req/s + 5xx%)、`gen_ai.client.operation.count` |
| User Engagement | stat | `user.active_rolling`DAU / WAU / MAU`max()` |
| Product Analytics | stat / gauge / bargauge / timeseries | `airi.product.events`Prom-safe event volume by `feature` / `action` / `status`distinct users 仍查 Postgres `product_events` |
| HTTP | heatmap / bargauge / timeseries | `http.server.request.duration_count`status mix、top routes、route errors)、`http.server.request.duration_bucket`P95 by route |
| LLM Gateway | timeseries | `gen_ai.client.operation.count`by model)、`gen_ai.client.first_token.duration_bucket` / `gen_ai.client.operation.duration_bucket`TTFB + end-to-end P95 |
| Provider Upstreams | timeseries | `gen_ai.client.operation.count` / `gen_ai.client.operation.duration_bucket` by `provider``airi.billing.tts.chars` |
| LLM Tokens & Quality | stat / timeseries | `gen_ai.client.token.usage.{input,output}``airi.billing.flux.unbilled``airi.gen_ai.stream.interrupted` |
| LLM Router Health | stat / gauge / timeseries | `airi.gen_ai.gateway.{key.exhausted,decrypt.failures,fallback.count,upstream.errors}` |
| Business | stat / gauge | `airi.stripe.revenue`by currency)、checkout conversion %、`stripe.events` 分布 |
| Infrastructure (collapsed, **by `service_instance_id`**) | timeseries | `db_client_operation_duration` P95cluster)、`db_client_connection_count``v8js_memory_heap_used_bytes` %、`nodejs_eventloop_delay_p99_seconds` |
| Logs | logs | Loki,不是 Prometheus |
> **Multi-replica 聚合方式**:所有 panel 在 `build.ts` 里都已经按 `observability-conventions.md` 的副本安全表选择了正确的 aggregatorCounter 用 `sum(rate)`、cluster-wide gauge 用 `max()`、per-process gauge 用 `sum()`、infra 排查面板用 `by (service_instance_id)`)。加新 panel 时按那张表对照一遍。
## 验证 metric 是否已注册
[`src/scripts/otel/smoke.ts`](../../src/scripts/otel/smoke.ts) 跑一遍:
```sh
pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts
```
会打印 SDK 启动时立即 export 的所有 instrument 名字。**Counter 通过 `.add(0)` priming**[otel/index.ts](../../src/otel/index.ts) `primeCounter`)后会出现在这里 —— Histogram 不会,要等真实 `.record()` 才出现。
## 加新 metric 时的 checklist
1. 决定命名空间:能映射到 OTel semconv 就用标准名,否则放 `airi.*`(不要造新顶级前缀)
2. 在 [utils/observability.ts](../../src/utils/observability.ts) 加常量
3. 在 [otel/index.ts](../../src/otel/index.ts) 的对应 metric group 接口(`HttpMetrics`/`AuthMetrics`/...)加字段,并在 `initOtel``meter.create*` 创建
4. **如果是 Counter,在 `primeCounter` 调用列表里加一行** —— 否则低流量时 panel 看起来"没数据"
5. 在 callsite 通过 DI 拿到 metrics 对象后调 `.add()` / `.record()`
6.`pnpm -F @proj-airi/server exec node --import tsx ./src/scripts/otel/smoke.ts` 确认注册
7. 更新本文档对应章节
@@ -1,366 +0,0 @@
# Product Analytics Dashboard Setup
This document turns `product-analytics-instrumentation.md` into dashboard setup steps. It intentionally excludes Discord / QQ ingestion and daily / weekly report automation.
After setting up the dashboards, run `verifications/product-analytics-smoke.md` against the deployed environment.
## Scope
In scope:
- PostHog insights for frontend product journeys.
- Grafana panels for server-side product event health.
- Alert rules that can be configured directly in PostHog / Grafana.
Out of scope for this pass:
- Discord / QQ bot or spreadsheet synchronization.
- Daily / weekly report generation scripts.
## Destination Rules
| Question | Destination | Reason |
|---|---|---|
| Can a user start chatting? | PostHog | Frontend journey and distinct user funnels |
| Where does provider setup fail? | PostHog | Provider config events are frontend PostHog events |
| Which voice is selected or previewed? | PostHog / Postgres metadata | `voice_id` is high-cardinality and must not be a Prometheus label |
| Is server TTS healthy right now? | Grafana | Server product events and OTel metrics are Prometheus-safe |
| Are users submitting feedback? | PostHog | App feedback is frontend product analytics |
## PostHog Dashboard
Create a dashboard named `AIRI Activation And Feedback`.
Live dashboard created on 2026-06-30:
- Project: `Project AIRI (Web)` (`90721`)
- URL: `https://us.posthog.com/project/90721/dashboard/1779029`
- Current cards:
- Text card: `AIRI product analytics runbook`
- Funnel: `Chat activation funnel`
- Trend: `Official provider usage`
- Funnel: `Official TTS activation`
- Trend: `Paywall exposure`
- Trend: `Provider config failures`
- Trend: `TTS voice selection and preview`
- Trend: `Top selected TTS voices`
- Trend: `Voice input friction`
- Trend: `Feedback and bug reports`
### Insight 1: Chat Activation Funnel
Type: Funnel
Steps:
1. `chat_activation_started`
2. `chat_activation_succeeded`
3. `second_turn_started`
Breakdowns:
- `provider_mode`
- `app_surface`
Filters:
- Date range: last 7 days
- Exclude internal users if the project has an internal user cohort.
Watch for:
- Official provider conversion lower than custom provider conversion.
- Large drop after `chat_activation_started`.
- First-turn success but weak `second_turn_started` conversion.
### Insight 1b: Official Provider Selection
Type: Trends
Events:
- `official_provider_selected`
Breakdowns:
- `provider_id`
- `source`
- `auto_selected`
Watch for:
- Official provider auto-selection is present but users do not reach `second_turn_started`.
- A single provider id dominates errors or activation drop-off.
### Insight 2: Chat Activation Failures
Type: Trends
Events:
- `chat_activation_failed`
Breakdowns:
- `failure_stage`
- `error_code`
- `provider_mode`
Display:
- Stacked bar or line chart.
Watch for:
- `failure_stage = provider_config`
- `failure_stage = model_list`
- `failure_stage = llm_response`
### Insight 2a: All Message Round Failures
Type: Trends
Event:
- `message_round_failed`
Breakdowns:
- `failure_stage`
- `error_code`
- `provider_id`
- `app_surface`
Watch for:
- Failures where `turn_index > 1`, which are intentionally outside the activation-failure series.
- Repeated failures for the same `conversation_id` with different `round_id` values.
### Insight 3: Provider Configuration Health
Type: Funnel
Steps:
1. `provider_config_started`
2. `provider_config_succeeded`
Breakdowns:
- `provider_mode`
- `step`
Companion trend:
- Event: `provider_config_failed`
- Breakdown: `error_code`
Watch for:
- Official provider failures greater than zero for more than 15 minutes.
- `step = manual_chat_ping` failures after auto validation succeeds.
### Insight 4: Model List Health
Type: Trends
Events:
- `model_list_loaded`
- `model_list_failed`
Breakdowns:
- `provider_id`
- `provider_mode`
Watch for:
- `model_list_failed` spikes for one provider.
- High failure rate after a release.
### Insight 5: TTS Voice Selection
Type: Trends
Events:
- `voice_selected`
- `voice_preview_played`
- `voice_pack_bound`
- `official_tts_exposed`
- `official_tts_preview_started`
- `official_tts_preview_succeeded`
- `official_tts_auto_enabled`
Breakdowns:
- `voice_type`
- `tts_provider_id`
- `source`
Do not use:
- Prometheus labels for `voice_id` or `voice_pack_id`.
Use PostHog or SQL when grouping by:
- `voice_id`
- `voice_pack_id`
Watch for:
- Users see official TTS but do not preview it.
- Official TTS preview succeeds but chat auto TTS is not triggered later.
### Insight 6: Voice Input Friction
Type: Funnel
Steps:
1. `voice_input_started`
2. `stt_succeeded`
Companion trends:
- `microphone_permission_denied`
- `audio_device_unavailable`
- `voice_input_cancelled`
- `stt_failed`
Breakdowns:
- `stt_provider_id`
- `error_code`
- `app_surface`
### Insight 7: Feedback And Bug Reports
Type: Trends
Events:
- `feedback_submitted`
- `bug_report_submitted`
Breakdowns:
- `category`
- `severity`
- `entrypoint`
- `app_surface`
Watch for:
- `severity = blocker` spikes.
- `entrypoint = about_update_error` after desktop releases.
## Grafana Dashboard
Source of truth:
- `apps/server/otel/grafana/dashboards/build.ts`
- Generated JSON: `apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json`
The `Product Analytics` row includes:
- `Product Events (range)`
- `Product Failure %`
- `TTS Success %`
- `TTS Failed / Blocked (range)`
- `TTS Blocked by Reason`
- `TTS Blocked by Flux Bucket`
- `Top Product Actions (range)`
- `Product Event Rate`
- `TTS Event Rate by Source`
Live import status:
- Imported on 2026-06-30.
- Live URL: `https://projairi.grafana.net/d/ad8qbp5/airi-server-overview`
- Dashboard: `AIRI Server Overview - Product Analytics` (`ad8qbp5`)
- The live dashboard now shows the full `Product Analytics` row:
- `Product Events (range)`
- `Product Failure %`
- `TTS Success %`
- `TTS Failed / Blocked (range)`
- `TTS Blocked by Reason`
- `TTS Blocked by Flux Bucket`
- `Top Product Actions (range)`
- `Product Event Rate`
- `TTS Event Rate by Source`
- The generated JSON remains the source of truth for the Product Analytics / TTS panel set.
Permission notes from the import retry:
- The first import attempt with title `AIRI Server Overview` and UID `rbr55dn` showed duplicate title / UID warnings because it targets the existing dashboard.
- A second import attempt with a new title / UID (`AIRI Server Overview - Product Analytics Test`, `airi-product-analytics-test`) removed the duplicate warnings, but still did not import.
- API confirmation returned `403 Access denied`: `You'll need additional permissions to perform this action. Permissions needed: any of dashboards:create, dashboards:write`.
- The logged-in Grafana user `1260907335@qq.com` has org role `Viewer`; API metadata for `/d/rbr55dn/airi-server-overview` reports `canSave=false`, `canEdit=false`, `canAdmin=false`.
- After permissions were updated, the generated dashboard was imported from Microsoft Edge. Grafana assigned the imported dashboard UID `ad8qbp5` instead of overwriting the earlier `rbr55dn` dashboard, so the imported dashboard was renamed to `AIRI Server Overview - Product Analytics` to avoid ambiguity.
- On 2026-07-01, the live `ad8qbp5` dashboard was updated to include `TTS Blocked by Reason` and `TTS Blocked by Flux Bucket`. The live dashboard uses panel id `105` for the Flux bucket panel because id `103` was already occupied by the imported `User Engagement` row.
Regenerate after dashboard changes:
```bash
node node_modules/tsx/dist/cli.mjs apps/server/otel/grafana/dashboards/build.ts
```
## Alert Setup
### PostHog Alerts
Configure these as insight subscriptions or monitor-style alerts.
| Alert | Insight | Trigger |
|---|---|---|
| Activation drop | Chat Activation Funnel | `chat_activation_succeeded / chat_activation_started` drops by 15% vs previous 24h |
| Provider config regression | Provider Configuration Health | Official provider `provider_config_failed` is greater than 0 for 15 minutes |
| Voice input spike | Voice Input Friction | `stt_failed / voice_input_started` exceeds 20% over 1h |
| Feedback spike | Feedback And Bug Reports | `bug_report_submitted` doubles vs previous 24h |
### Grafana Alerts
Use these PromQL expressions from the server dashboard context.
TTS success below 95% over 15 minutes:
```promql
100 * sum(increase(airi_product_events_total{feature="tts", action="speech_succeeded", status="succeeded"}[15m]))
/
clamp_min(sum(increase(airi_product_events_total{feature="tts", action="speech_requested", status="started"}[15m])), 1)
< 95
```
TTS blocked spike over 15 minutes:
```promql
sum(increase(airi_product_events_total{feature="tts", action="speech_blocked", status="blocked"}[15m])) > 10
```
TTS failed spike over 15 minutes:
```promql
sum(increase(airi_product_events_total{feature="tts", action="speech_failed", status="failed"}[15m])) > 5
```
Product failure ratio above 10% over 15 minutes:
```promql
100 * sum(increase(airi_product_events_total{feature!="", action!="", status="failed"}[15m]))
/
clamp_min(sum(increase(airi_product_events_total{feature!="", action!=""}[15m])), 1)
> 10
```
## Verification Checklist
- PostHog can show `chat_activation_started -> chat_activation_succeeded -> second_turn_started` by `provider_mode`.
- PostHog can show `official_provider_selected` by `provider_id`, `source`, and `auto_selected`.
- PostHog can show `voice_selected` by `voice_type` and `tts_provider_id`.
- PostHog can show official TTS exposure / preview / auto-enabled events.
- PostHog can show `paywall_seen` by `flux_balance_bucket`.
- PostHog can show `feedback_submitted` and `bug_report_submitted`.
- Grafana dashboard JSON contains `TTS Success %`, `TTS Failed / Blocked (range)`, `TTS Blocked by Reason`, `TTS Blocked by Flux Bucket`, and `TTS Event Rate by Source`.
- Grafana product analytics panels use only bounded labels: `feature`, `action`, `status`, `source`, `reason`, `flux_balance_bucket`.
@@ -1,939 +0,0 @@
# Product Analytics Instrumentation Plan(产品埋点与数据播报方案)
这份文档把 AIRI 当前埋点现状、社区侧最关心的问题、缺口、事件 schema、看板和异常播报整理到一处。它补充 [`metrics-ownership.md`](./metrics-ownership.md):后者定义指标归属和命名规则,本文定义“为了回答产品/社区问题,接下来要补什么”。
## TL;DR
- 现在已经能看 activation、Provider 配置次数、国家来源、付费漏斗、LLM 请求和服务端 TTS 请求健康。
- 现在还不能可靠回答“用户最常用哪个 TTS 音色 / Voice Pack”,因为前端和服务端事件都缺稳定的 `voice_id` / `voice_type` / `voice_pack_id`
- 最优先补的不是更多点击,而是“能不能开始聊天”“卡在哪个配置步骤”“语音 / TTS 为什么失败”。
- 官方 Provider 要和自配置 Provider 分开看,核心判断是官方路径是否提高 activation、降低配置失败和缩短首次聊天时间。
- 自配置用户可能更高价值,不能只看失败率;需要同时看 retention、paid conversion 和 feedback rate。
- PostHog 负责用户路径、漏斗、留存和分群;Grafana 负责服务端健康和异常;Postgres / SQL 负责高基数字段聚合,例如 voice / voice pack。
- Prometheus 不要加 `voice_id``voice_pack_id`、用户自定义模型名等高基数字段。
- 日报先做“指标 + 异常提醒”,周报再加入社区侧解释和下周行动建议。
- Discord / QQ 反馈要有轻量标签,不能完全靠埋点替代社区观察。
## 背景
社区反馈里最常见的劝退点集中在:
- 性能问题
- 模型 / Provider 配置复杂
- 配置失败或模型列表加载失败
- Bug 多,用户不知道卡在哪
- 语音输入体验不稳定
产品方向是降低上手门槛,让 AIRI 更开箱即用;官方 Provider 已经上线,但仍有一部分用户喜欢自配置模型和音色。社区侧同时关心海外用户、付费用户、二次开发用户、角色聊天用户,以及愿意反馈问题的用户。
因此埋点目标不是单纯统计点击,而是回答:
1. 新用户有没有正常开始聊天?
2. 用户卡在 Provider / 模型配置的哪一步?
3. 官方 Provider 是否真的降低了上手门槛?
4. 自配置用户和官方 Provider 用户在留存、付费、反馈上有什么差异?
5. TTS 音色和提供商到底怎么被选择、试听和实际使用?
6. Bug、性能、语音输入失败分别造成了多少流失?
## 现状审计
### PostHog
线上 `Project AIRI (Web)` 已有核心 dashboard`My App Dashboard`
已覆盖:
- first message 趋势
- Provider 配置次数
- 国家来源
- Signup activation funnel
- 7-day activation retention
- Paywall conversion
- LLM request volume / error rate
- rage-click friction
近 7 天仍活跃的主要产品事件包括:
- `app_loaded`
- `first_message_sent`
- `first_model_selected`
- `model_switched`
- `message_send_started`
- `llm_request_started`
- `llm_first_token`
- `message_round`
- `chat_session_started`
- `chat_session_selected`
- `chat_message_deleted`
- `chat_messages_cleared`
- `pricing_page_viewed`
- `plan_selected`
- `checkout_started`
- `provider_card_clicked`
- `stt_started`
- `stt_succeeded`
- `stt_failed`
- `tts_stop_clicked`
- `character_switched`
- `character_deleted`
PostHog 当前缺口:
- 没有 `voice_id``voice_pack_id``voice_type` 等 TTS 音色维度。
- 近 30 天匹配 `tts` / `speech` / `voice` 的客户端事件里,`voice``voice_id``voiceId``voice_pack_id``voice_type` 均未出现有效值。
- `model` / `model_id` 仍有自由文本风险,需要收敛白名单和脱敏策略。
- PostHog 漏斗主要覆盖前端旅程,服务端 TTS 真实请求没有同步进入 PostHog。
### Grafana / Prometheus
线上 `AIRI Server Overview` 已覆盖:
- DAU / WAU / MAU
- Active sessions
- HTTP / WS health
- Product Analyticsevent volume、failure rate、top product actions、event rate
- LLM Gatewayrequest rate by model、latency、provider failure
- TTS Characters/s by Model
- Stripe / revenue
Grafana 当前能回答:
- TTS 请求量、成功量、失败量、余额挡住量。
- TTS 字符消耗按 model 聚合,例如 `stepfun/stepaudio-2.5-tts``volcengine/seed-tts-2.0``alibaba/cosyvoice-v1`
- 服务端 `product_events` 的 Prometheus label 当前包括 `feature``action``status``source`,再加部分 metric 的 `model` / `provider`
Grafana 当前不能回答:
- 哪个 TTS 音色使用最多。
- 哪个 Voice Pack 使用最多。
- 官方默认音色与用户自选音色的转化差异。
- 试听音色后是否真的绑定 / 使用。
## 用户分群
第一版不要求用户手动选 persona,先从行为推断。
| Segment | 初始推断规则 | 用途 |
|---|---|---|
| `new_user` | 账号创建后 7 天内或首次 `app_loaded` 后 7 天内 | 新手引导、激活漏斗 |
| `official_provider_user` | 关键事件里 `provider_mode = official` | 衡量开箱即用效果 |
| `custom_provider_user` | 关键事件里 `provider_mode = custom` | 衡量自配置门槛和失败率 |
| `role_chat_user` | 导入 / 切换角色、绑定音色、频繁角色聊天 | 角色陪伴体验 |
| `developer_user` | 使用插件、API、Devtools、二开相关入口 | 二次开发群体 |
| `paid_user` | Stripe / Postgres 付费状态为真 | 付费转化与保留 |
| `feedback_user` | 提交 bug report / feedback | 高价值社区用户 |
公共字段建议:
| Field | Values | Notes |
|---|---|---|
| `app_surface` | `web` / `electron` / `mobile` / `auth` / `docs` / `server` | 所有关键事件的平台 / 运行端 |
| `entry_surface` | `settings_flux` / `onboarding` / `chat_toolbar` 等受控枚举 | 业务入口;不得用于表示运行端 |
| `provider_mode` | `official` / `custom` / `unknown` | 官方开箱即用 vs 用户自配置 |
| `provider_id` | 白名单 ID | 不传 raw URL / raw key / 用户输入 |
| `model_id` | 白名单或归一化后的 ID | 自定义模型用 `is_custom_model = true` |
| `is_custom_model` | boolean | 避免把任意文本当 group-by |
| `is_paid_user` | boolean | 从服务端或 PostHog person profile 派生 |
| `setup_completed` | boolean | 是否完成基础配置 |
| `region` | PostHog geo | 不由客户端手动传 |
## P0 埋点
### Chat Activation
目标:社区侧判断“能正常开始聊天”。
| Event | Owner | Truth | When |
|---|---|---|---|
| `chat_activation_started` | frontend | PostHog | 用户进入首次聊天路径或点击发送第一条消息前 |
| `chat_activation_succeeded` | frontend | PostHog | 首次消息完成并看到 assistant response |
| `chat_activation_failed` | frontend | PostHog | 首次消息未完成,包含配置、网络、鉴权、余额、模型等失败 |
| `message_round_failed` | frontend | PostHog | 任意用户轮次在 assistant response 完成前失败;成功轮次的唯一终点仍为 `message_round` |
| `official_provider_selected` | frontend | PostHog | 官方 Provider 被默认落地或在设置页被手动选择,记录 provider id 与是否自动选择 |
| `second_turn_started` | frontend | PostHog | 同一会话开始第二轮对话 |
字段:
| Field | Required | Notes |
|---|---|---|
| `provider_mode` | yes | `official` / `custom` |
| `provider_id` | yes | 归一化 ID |
| `model_id` | yes | 归一化 ID |
| `app_surface` | yes | web / electron / mobile |
| `conversation_id` | yes | 应用会话 ID;同一 conversation 的聊天事件保持一致 |
| `round_id` | yes | 单轮关联 ID,复用该轮 user message id;同一轮所有聊天主链路事件保持一致 |
| `turn_index` | yes | conversation 内从 `1` 开始的用户轮次;`second_turn_started` 固定为 `2` |
| `time_to_first_message_ms` | success only | 从 app start 或 onboarding complete 到首次成功 |
| `error_code` | failed only | 稳定错误码 |
| `failure_stage` | failed only | `provider_config` / `model_list` / `message_send` / `llm_response` / `tts` |
| `auto_selected` | official provider only | 官方默认 Provider 自动落地时为 `true` |
推荐看板:
- 新用户 `chat_activation_started -> chat_activation_succeeded` 漏斗。
-`provider_mode` 拆分 activation conversion。
- `chat_activation_failed``failure_stage` / `provider_id` 排名。
- `message_round_failed``turn_index` / `failure_stage` / `provider_id` 排名,用于分析激活后的聊天失败。
异常提醒:
- 新用户 activation conversion 24h 环比下降超过 15%。
- `provider_mode = official` 的 activation failure 上升,优先排查官方 Provider。
### AI generation 用量事实
`$ai_generation` 是 token / usage / 成本覆盖率事实来源;不要把 Prompt、回复正文、API Key、raw endpoint 发到 PostHog。
| Field | Required | Notes |
|---|---|---|
| `$ai_trace_id` | yes | 优先使用真实 `conversation_id`;无客户端会话 header 时用 server request id 兜底 |
| `$ai_session_id` | yes | 与 `conversation_id` 保持一致 |
| `$ai_span_id` | yes | 与 `round_id` / generation id 保持一致 |
| `$ai_model` | yes | 原始生成模型;不要跨 Provider 合并 |
| `$ai_provider` | yes | 实际生成 Provider |
| `airi_user_id` | server yes | Better Auth user id,便于和 Pro / 付费事件关联 |
| `conversation_id` | yes | 始终存在;结合 `conversation_id_source` 判断是否真实应用会话 |
| `conversation_id_source` | yes | `client_header` / `client_runtime` / `server_request` |
| `round_id` | yes | 单轮或 request-level generation id |
| `app_surface` | when known | 只表示用户产品端:`web` / `electron` / `mobile`;不要用 `server` 兜底 |
| `capture_surface` | yes | 事件采集端:`client` / `server` |
| `usage_source` | yes | `reported` / `estimated` / `unavailable` |
| `token_usage_available` | yes | token 是否可用于聚合;日报 token 分析先过滤 `true` |
| `cost_usd_source` | yes | `reported` / `estimated` / `unavailable` |
| `cost_usd_known` | yes | `false` 不能按零成本处理,只能计入未知成本覆盖率 |
日报里的 Pro token / cost
- Token 总量、P50、P90:只统计 `token_usage_available = true`
- AIRI USD 成本:只统计 `cost_usd_known = true``cost_usd_known = false` 单独报 unknown generation count / coverage。
- 服务端 request fallback`conversation_id_source = server_request` 只能做 request 级 usage,不能和 `message_round` 当成同一应用会话 join。
- 模型分析按 `$ai_provider + $ai_model` 看;不要新增 `canonical_model` / `model_family` 把不同供应链揉在一起。
### Provider And Model Configuration
目标:定位配置复杂和失败劝退。
| Event | Owner | Truth | When |
|---|---|---|---|
| `provider_config_started` | frontend | PostHog | 打开 Provider 配置表单 |
| `provider_config_succeeded` | frontend | PostHog | 配置保存并通过最小校验 |
| `provider_config_failed` | frontend | PostHog | 保存、校验、鉴权或连接测试失败 |
| `model_list_loaded` | frontend | PostHog | 模型列表加载成功 |
| `model_list_failed` | frontend | PostHog | 模型列表加载失败 |
字段:
| Field | Required | Notes |
|---|---|---|
| `provider_id` | yes | 归一化 ID |
| `provider_mode` | yes | 官方 Provider 也要记录 |
| `step` | yes | `open_form` / `save` / `validate` / `load_models` / `select_model` |
| `duration_ms` | no | 保存或加载耗时 |
| `error_code` | failed only | 不能写 raw error message |
| `http_status` | failed only | 有 HTTP 边界时记录 |
推荐看板:
- Provider 配置成功率。
- 模型列表加载成功率。
- Top failing providers。
- 官方 Provider vs 自配置 Provider 配置耗时和失败率。
异常提醒:
- `model_list_failed` 任一 Provider 1h 内增长超过 2 倍。
- 官方 Provider `provider_config_failed` 非零连续 15 分钟。
### TTS Voice And Provider
目标:回答“用户选择了哪种音色和提供商”。
| Event | Owner | Truth | When |
|---|---|---|---|
| `tts_provider_selected` | frontend | PostHog | 用户选择或切换 TTS Provider |
| `voice_selected` | frontend | PostHog | 用户选择音色,包括官方默认落地时的 baseline |
| `voice_preview_played` | frontend | PostHog | 用户试听音色 |
| `voice_pack_bound` | frontend | PostHog | 用户把 Voice Pack 绑定到角色 |
| `official_tts_exposed` | frontend | PostHog | 官方 TTS 设置入口或激活入口被展示 |
| `official_tts_preview_started` / `official_tts_preview_succeeded` | frontend | PostHog | 官方 TTS 试听开始 / 成功 |
| `official_tts_auto_enabled` | frontend | PostHog | 聊天自动语音实际触发官方 TTS |
| `speech_requested` | server | Postgres/Grafana | REST / WS TTS 请求开始;沿用现有 `feature = tts` action |
| `speech_succeeded` | server | Postgres/Grafana | REST / WS TTS 交付成功;沿用现有 `feature = tts` action |
| `speech_failed` | server | Postgres/Grafana | TTS 上游、配置、路由失败;沿用现有 `feature = tts` action |
| `speech_blocked` | server | Postgres/Grafana | Flux 不足等业务阻断;沿用现有 `feature = tts` action |
字段:
| Field | Required | Notes |
|---|---|---|
| `tts_provider_id` | yes | 归一化 ID |
| `tts_model_id` | yes | 归一化 ID |
| `voice_id` | yes | 官方 catalog ID;自定义音色见数据卫生 |
| `voice_type` | yes | `official_default` / `official_selected` / `custom_configured` / `voice_pack` |
| `voice_pack_id` | no | Voice Pack 绑定和服务端使用时记录 |
| `source` | yes | `settings` / `onboarding` / `chat_auto_tts` / `manual_preview` |
| `trigger` | server TTS | `auto` / `manual` |
| `input_chars` | server TTS | 字数 |
| `duration_ms` | server TTS | 端到端耗时 |
| `error_code` | failed only | 稳定错误码 |
推荐看板:
- Top voices by `voice_selected`
- Top voices by server `feature = tts` + `action = speech_succeeded`
- Voice preview to selection conversion。
- Official default vs official selected vs custom configured。
- TTS failure / blocked rate by provider, model, voice type。
异常提醒:
- 某个官方音色的 server `speech_failed` 持续上升。
- `voice_preview_played -> voice_selected` 转化下降。
- server `speech_blocked` 突增,提示 Flux 或默认策略可能影响体验。
### Voice Input
目标:语音输入是明确社区痛点,需要把权限、设备和 Provider 失败拆开。
当前已有:
- `stt_started`
- `stt_succeeded`
- `stt_failed`
补充:
| Event | Owner | Truth | When |
|---|---|---|---|
| `voice_input_started` | frontend | PostHog | 用户开始语音输入 |
| `microphone_permission_requested` | frontend | PostHog | 首次或重新请求麦克风权限 |
| `microphone_permission_denied` | frontend | PostHog | 权限被拒绝 |
| `audio_device_unavailable` | frontend | PostHog | 无设备、设备被占用、采样失败 |
| `voice_input_cancelled` | frontend | PostHog | 用户主动取消或超时 |
字段:
| Field | Required | Notes |
|---|---|---|
| `stt_provider_id` | yes | 归一化 ID |
| `app_surface` | yes | web / electron / mobile |
| `duration_ms` | no | 用户按住或录音时长 |
| `error_code` | failed only | `permission_denied` / `device_unavailable` / `provider_error` / `timeout` |
推荐看板:
- Voice input start -> STT success funnel。
- Permission denied rate by browser / app surface。
- STT failure rate by Provider。
### Feedback And Bug Reports
目标:社区侧认为“愿意反馈问题”是高价值行为,应进入核心指标。
| Event | Owner | Truth | When |
|---|---|---|---|
| `bug_report_opened` | frontend | PostHog | 打开 bug report dialog |
| `bug_report_submitted` | frontend/server | PostHog + Postgres optional | 成功提交 |
| `feedback_submitted` | frontend/server | PostHog + Postgres optional | 非 bug 的反馈 |
| `community_feedback_tagged` | manual/job | Postgres optional | Discord / QQ 反馈被人工归类 |
字段:
| Field | Required | Notes |
|---|---|---|
| `source` | yes | `app` / `discord` / `qq` / `github` / `email` / `other` |
| `category` | no | 见下方社区反馈标签 |
| `severity` | yes | `blocker` / `major` / `minor` / `suggestion` |
| `user_type` | yes | `new_user` / `paid_user` / `overseas_user` / `developer_user` / `role_chat_user` / `unknown` |
| `entrypoint` | yes | `about_update_error` / `community_manual_tag` 等低基数入口 |
| `app_surface` | in-app | web / electron / mobile |
| `provider_mode` | no | 可从最近一次配置状态补齐 |
| `description_length_bucket` | bug report | `empty` / `short` / `medium` / `long`,不要上传正文 |
| `include_triage_context` | bug report | 是否附带页面上下文 |
| `screenshot_attached` | bug report | 是否附带截图或录屏 |
推荐看板:
- Feedback users count。
- Bug report trend by category。
- 配置失败后 24h 内是否反馈。
社区反馈标签建议:
| Category | 适用反馈 |
|---|---|
| `performance` | 卡顿、慢、首 token 慢、内存 / CPU 异常 |
| `provider_config` | Provider 配置复杂、保存失败、Key / Endpoint 不知道怎么填 |
| `model_list` | 模型列表加载失败、模型不可选、模型名不符合预期 |
| `crash` | 崩溃、白屏、不可恢复异常 |
| `ui_ux` | UI 操作不顺、按钮找不到、信息表达不清 |
| `voice_input` | 麦克风权限、录音、STT、语音输入失败 |
| `tts` | TTS 音色、试听、Voice Pack、音色绑定问题 |
| `payment` | Flux、余额不足、付费、checkout、扣费疑问 |
| `chat_activation` | 新手无法开始聊天、首次发送失败、开箱即用失败 |
| `live2d` | Live2D / 模型显示 / 角色舞台问题 |
| `desktop_window` | 桌宠窗口、置顶、显示器、多屏问题 |
| `mobile` | iOS / Android / 移动端体验 |
| `unknown` | 信息不足,待社区负责人二次归类 |
社区侧现在可以这样做:
1. Discord / QQ 反馈先人工记录,不等 App 内入口完善。
2. 每条反馈只需要填:日期、来源、用户类型、分类、严重程度、原文链接、简短摘要、是否已转 issue。
3. 原文和截图留在 Discord / QQ / issue,不进 PostHogPostHog 只放标签和计数。
4. 每周把 `category` + `severity` 聚合进周报,用来解释为什么某个指标变差。
## P1 埋点
### Onboarding
当前 `onboarding_step_completed` 不足以定位用户卡点。
补充:
- `onboarding_started`
- `onboarding_step_viewed`
- `onboarding_step_completed`
- `onboarding_skipped`
- `onboarding_failed`
字段:
- `step`
- `time_spent_ms`
- `provider_mode`
- `provider_id`
- `error_code`
看板:
- Onboarding step drop-off。
- 官方 Provider onboarding conversion。
- Skip 后是否仍完成 `chat_activation_succeeded`
### Chat Reliability
补充:
- `message_send_failed`
- `assistant_response_failed`
- `generation_cancelled`
- `generation_retried`
- `response_regenerated`
字段:
- `model_id`
- `provider_id`
- `provider_mode`
- `has_voice`
- `latency_ms`
- `error_code`
- `app_surface`
看板:
- Message send success rate。
- Retry / cancel rate。
- Failure by provider and model。
### Character And Role Usage
补充:
- `character_created`
- `character_imported`
- `character_edited`
- `character_switched`
- `character_deleted`
- `display_model_changed`
- `voice_pack_bound`
字段:
- `character_type`: `built_in` / `imported` / `custom`
- `has_voice`
- `voice_type`
- `app_surface`
看板:
- Character adoption。
- 角色用户与普通聊天用户 retention / payment 差异。
### Payment And Flux Friction
当前已有 `pricing_page_viewed``plan_selected``checkout_started`,服务端有 payment truth。补充:
- `flux_low_warning_shown`
- `flux_topup_clicked`
- `checkout_failed`
- `payment_completed_imported`
字段:
- `entry_surface`(付费入口,例如 `settings_flux`;运行端使用 `app_surface`
- `balance_state`
- `plan_id`
- `currency`
- `error_code`
注意:
- `payment_completed` 真相仍在 Postgres / Stripe webhook。
- PostHog 只用于漏斗展示,优先用 Stripe connector 或离线导入。
## 事件复用关系
新增埋点时先复用现有事件,不要把相同事实拆成多个名字。
| Existing / New | Relationship | Notes |
|---|---|---|
| `first_message_sent` | 保留历史指标 | 继续用于老 dashboard;新激活口径用 `chat_activation_succeeded` |
| `chat_activation_started` / `chat_activation_succeeded` / `chat_activation_failed` | 新核心 activation 口径 | 用来回答“用户能不能正常开始聊天” |
| `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 聊天主链路 | 共享 `conversation_id` / `round_id` / `turn_index``message_round``message_round_failed` 分别是单轮成功 / 失败的唯一终点;不再重复发送 `chat_started``assistant_response_completed``chat_failed` 或 chat 的通用 `feature_used` 别名 |
| `signup_form_completed` / `signup_completed` | UI 里程碑 / 注册事实 | 前者可匿名;后者只由服务端按 Better Auth user id 发送,禁止复用同名客户端事件 |
| `provider_card_clicked` | 保留入口点击 | 不等于配置成功;成功 / 失败看 `provider_config_succeeded` / `provider_config_failed` |
| `first_model_selected` / `model_switched` | 保留模型选择行为 | 配置链路和模型列表健康看 `model_list_loaded` / `model_list_failed` |
| `stt_started` / `stt_succeeded` / `stt_failed` | 保留 STT Provider 结果 | 权限和设备问题用新增 `microphone_*` / `audio_device_unavailable` 拆开 |
| `tts_stop_clicked` | 保留用户停止行为 | 不代表音色选择;音色选择用 `voice_selected` |
| `speech_requested` / `speech_succeeded` / `speech_failed` / `speech_blocked` | 服务端 TTS truth | 继续沿用 `feature = tts`,只补 metadata 字段 |
| `pricing_page_viewed` / `plan_selected` / `checkout_started` | 保留付费漏斗前段 | 真正 payment completed 仍以 Stripe / Postgres 为准 |
| `paywall_seen` | 付费漏斗入口 | 用 `flux_balance_bucket` 分层,不上报精确余额 |
## 数据卫生
### 不要把自由文本直接作为分析维度
PostHog 线上已经能看到 `model` / `model_id` 存在自由文本风险。后续新增字段必须遵循:
- Provider、model、voice 使用稳定 ID。
- 自定义值不要直接 group-by。
- 不跨 Provider 合并模型名:`deepseek-chat``deepseek/deepseek-chat` 可能代表不同供应链 / 成本口径,日报按原始 Provider + model 组合看。
- 自定义模型传:
- `provider_id = custom`
- `is_custom_model = true`
- `custom_model_hash` 可选,必须单向 hash,不能还原原文。
- 自定义 voice 传:
- `voice_type = custom_configured`
- `voice_id = custom`
- `custom_voice_hash` 可选,必须单向 hash。
- 错误字段传稳定 `error_code`,不要传 raw error message。
### 字段基数约束
| Field | Cardinality | Rule |
|---|---|---|
| `provider_id` | low | 白名单 |
| `provider_mode` | low | enum |
| `model_id` | medium | 官方 catalog 或归一化 ID |
| `voice_id` | medium | 官方 catalog 或 `custom` |
| `voice_pack_id` | medium | 只进 PostHog / Postgres,不进 Prometheus label |
| `error_code` | low | enum |
| `source` | low | enum |
| `app_surface` | low | runtime enum |
| `entry_surface` | low | 低基数业务入口 enum;不得复用为 runtime |
Prometheus label 不放 `user_id``session_id``voice_pack_id`、自定义模型名、自定义音色名。
### 不要做什么
- 不要把用户输入、聊天正文、角色 prompt、raw API key、raw Endpoint、raw model name、raw voice name 发到 PostHog / Grafana。
- 不要把 raw error message 作为分析字段;统一映射成稳定 `error_code`
- 不要在 Prometheus label 里加入 `voice_id``voice_pack_id``session_id``user_id`、自定义模型名、自定义音色名。
- 不要为了看一个 funnel 同时新增两个语义相同的事件;优先查上面的事件复用关系。
- 不要在请求主链路里同步等待 PostHog 发送完成;失败不能影响用户聊天 / TTS。
- 不要只看点击量判断用户意图;至少结合 success / failed / blocked 和社区反馈标签。
## 看板建议
### PostHog
新建或扩展 dashboard`Onboarding and Activation`
- 新用户 activation funnel
- `app_loaded`
- `chat_activation_started`
- `provider_config_succeeded`
- `model_list_loaded`
- `chat_activation_succeeded`
- Official vs custom Provider activation split。
- Provider configuration failure ranking。
- Onboarding step drop-off。
新建 dashboard`Voice and TTS Adoption`
- Top voices by selected users。
- Top voices by successful TTS requests。
- Voice preview -> selection conversion。
- Official default vs selected vs custom configured。
- Voice input start -> STT success funnel。
扩展 `My App Dashboard`
- 保留现有 activation、paywall、LLM、rage-click。
- 增加 `chat_activation_succeeded``provider_config_failed``stt_failed``voice_selected` 摘要卡。
### Grafana
扩展 `AIRI Server Overview`
- TTS request / success / failed / blocked by source。
- TTS character total by model over range。
- Product action failure rate by feature/action。
不要在 Prometheus 增加 `voice_id` label。若要看音色排行:
- Postgres `product_events.metadata.voice_id` 做 SQL / admin API 聚合。
- 或离线导入 PostHog,用 PostHog group-by 展示。
## 播报、分析和可视化方案
### 分层原则
播报要分三层,不要把所有指标塞进一个 dashboard。
| Layer | Frequency | Audience | Goal | Tool |
|---|---|---|---|---|
| 实时异常 | 5m - 1h | 工程 / on-call / 社区负责人 | 发现“今天是不是坏了” | Grafana alert + PostHog insight alert |
| 日报 | daily | 社区 / 产品 / 工程 | 看上手、聊天、语音、付费是否正常 | PostHog + Grafana + 少量 SQL |
| 周报 | weekly | 产品 / 战略讨论 | 看趋势、用户意图、投入方向 | PostHog cohort/funnel + SQL 聚合 |
工具分工:
| Tool | 用途 | 不适合做什么 |
|---|---|---|
| PostHog | 用户路径、漏斗、留存、分群、前端行为 | 服务端真实扣费、低延迟 on-call |
| Grafana | 服务端健康、TTS/LLM 请求、错误、余额阻断、告警 | 高基数用户行为和音色排行 |
| Postgres / SQL | 付费事实、`product_events.metadata`、voice / voice pack 聚合 | 实时看板和复杂前端路径 |
| Community tags | Discord / QQ 反馈归类 | 自动替代埋点 |
### 日报模板
日报回答“今天是否健康,有没有需要马上处理的问题”。
```md
# AIRI 数据日报 YYYY-MM-DD
## 核心状态
- DAU: <value> (<day-over-day>)
- New users: <value>
- Chat activation: <chat_activation_succeeded / chat_activation_started>
- Official provider activation: <value>
- Custom provider activation: <value>
- Paid conversion proxy: pricing -> plan -> checkout = <value>
## 上手和配置
- Top provider config failures:
1. <provider_id> / <error_code> / <count>
2. <provider_id> / <error_code> / <count>
- Model list failure rate: <value>
- New-user first-message median time: <value>
## 聊天与性能
- Message round success: <value>
- LLM first-token p95: <value>
- LLM / chat failures by provider:
1. <provider_id> / <error_code> / <count>
## 语音与 TTS
- STT success rate: <value>
- Microphone permission denied: <value>
- TTS success / failed / blocked: <succeeded>/<failed>/<blocked>
- Top voices selected:
1. <voice_id> / <users>
2. <voice_id> / <users>
- Top voices used:
1. <voice_id> / <successful_requests>
2. <voice_id> / <successful_requests>
## 反馈与异常
- Bug reports: <value>
- Feedback submitted: <value>
- Discord / QQ 高关键词:
- <category>: <count>
- 异常提醒:
- <alert_name>: <current> vs <baseline>, 建议动作 <action>
```
第一版日报可以先不追求自动生成完整解释,只要自动填指标,并把异常规则命中的项放到“异常提醒”即可。
### 周报模板
周报回答“用户意图有什么变化,战略上要改什么”。
```md
# AIRI 数据周报 YYYY-WW
## 本周结论
1. <最重要趋势,例如 official provider 激活率上升>
2. <最大风险,例如 custom provider 配置失败仍高>
3. <建议动作,例如下周优先修 model list failed>
## 新手上手
- New users: <value>
- Activation funnel:
- app_loaded -> chat_activation_started: <value>
- chat_activation_started -> provider_config_succeeded: <value>
- provider_config_succeeded -> chat_activation_succeeded: <value>
- Official vs custom:
- official activation: <value>
- custom activation: <value>
- conclusion: <which path is healthier>
## 用户意图
- Role chat users: <value>
- Developer users: <value>
- Voice users: <value>
- Paid users: <value>
- Feedback users: <value>
- 海外用户占比: <value>
## 语音和音色
- Top selected voices: <voice_id list>
- Top used voices: <voice_id list>
- Default voice adoption: <value>
- Custom configured voice adoption: <value>
- Voice preview -> selected conversion: <value>
- TTS blocked / failed trend: <value>
## 配置和 Bug
- Top failing providers: <list>
- Top failing error codes: <list>
- Rage-click pages / app surfaces: <list>
- Discord / QQ feedback categories:
- performance: <count>
- config: <count>
- bug: <count>
- voice_input: <count>
## 下周建议
- Product: <one action>
- Engineering: <one action>
- Community: <one action>
```
周报需要人工写“结论”和“建议动作”。指标只负责提示方向,社区反馈负责解释为什么。
### 可视化布局
#### Executive Overview
给产品 / 社区快速看:
- Activation conversion
- Official vs custom activation
- Provider config failures
- STT success rate
- TTS success / blocked
- Top voices selected / used
- Bug reports / feedback
- Paywall funnel
#### Onboarding And Activation
给负责上手体验的人看:
- Funnel`app_loaded -> chat_activation_started -> provider_config_succeeded -> model_list_loaded -> chat_activation_succeeded`
- Breakdown`provider_mode``app_surface``region`
- TableTop `provider_config_failed` by `provider_id` / `error_code`
- Timeseries`time_to_first_message_ms` p50 / p95
#### Voice And TTS Adoption
给语音和角色体验看:
- BarTop voices by selected users
- BarTop voices by successful TTS requests
- Funnel`voice_preview_played -> voice_selected -> speech_succeeded`
- Timeseries`speech_succeeded` / `speech_failed` / `speech_blocked`
- Breakdown`voice_type` = official default / official selected / custom configured / voice pack
#### Reliability And Friction
给工程和社区排障看:
- Grafana5xx、LLM latency、provider failure、TTS blocked
- PostHograge-click trend、failed frontend events
- TableTop error_code by app surface / provider
- Community tagsDiscord / QQ 反馈分类趋势
### 分析方法
#### Official provider 是否降低门槛
看:
- `chat_activation_succeeded / chat_activation_started` by `provider_mode`
- `time_to_first_message_ms` by `provider_mode`
- `provider_config_failed` by `provider_mode`
- D7 retention by `provider_mode`
如果 official 激活率高、耗时短、失败率低,说明开箱即用策略有效。若 official 使用率高但失败率也高,优先修官方 Provider 稳定性。
#### 自配置用户是不是更高价值
看:
- `custom_provider_user` 的 D7 / D30 retention
- `custom_provider_user` 的 feedback rate
- `custom_provider_user` 的 paid conversion
- `custom_provider_user` 的 config failure rate
如果自配置用户付费和反馈更高,但失败率也高,可以把高级配置保留,但需要更好的错误提示和导入模板。
#### 哪些 Bug 最劝退
看:
- `chat_activation_failed` by `failure_stage`
- `provider_config_failed` by `error_code`
- `model_list_failed` by `provider_id`
- `$rageclick` by page / `app_surface`
- Discord / QQ `category = bug` 的高频词
日报只报异常;周报把异常和社区反馈合并成“优先修复建议”。
#### TTS 音色策略
看:
- `voice_selected` users by `voice_id`
- `speech_succeeded` count by `voice_id`
- `voice_preview_played -> voice_selected` conversion by `voice_id`
- `speech_failed / speech_requested` by `voice_id`
- retention / paid conversion by `voice_type`
用法:
- 选择多但使用少:可能试听不错,实际聊天不合适。
- 使用多但失败高:优先修该音色或 Provider。
- 默认音色使用高但切换少:默认可能足够好,也可能用户没发现入口,需要结合 `voice_preview_played` 看。
- 自定义音色用户留存高:说明高阶用户重视声音个性化。
### 自动化路线
第一阶段:半自动日报。
- Grafana 提供服务端健康和 TTS/LLM 指标。
- PostHog 提供 activation、provider、voice、STT、feedback 指标。
- SQL 提供 voice / voice pack 聚合。
- 由脚本生成 Markdown,发到 Discord / QQ / 飞书其中一个固定频道。
第二阶段:异常驱动播报。
- 每小时检查 activation、provider config、model list、STT、TTS blocked、bug report。
- 只有超过阈值才发提醒。
- 提醒里必须带“建议查看哪个 dashboard / query”。
第三阶段:周报带人工结论。
- 自动填指标和 Top lists。
- 社区负责人补充 Discord / QQ 反馈解释。
- 产品 / 工程共同确认下周行动。
## 异常播报
第一版日报 / 周报走“指标 + 异常提醒”。日报不要做复杂归因;周报允许加入人工结论。
### 每日必看指标
建议内容:
- DAU / WAU / MAU。
- New users。
- `chat_activation_succeeded` 转化率。
- Official vs custom Provider activation conversion。
- Top Provider config failures。
- STT success rate / failure rate。
- TTS success / failed / blocked。
- Top voices selected / used。
- Bug reports / feedback count。
- Paywall funnelpricing -> plan -> checkout。
### 异常规则
| Alert | Trigger | Action |
|---|---|---|
| Activation drop | 24h `chat_activation_succeeded / chat_activation_started` 环比下降 15% | 查 Provider config / model_list / LLM failures |
| Official Provider regression | 官方 Provider `provider_config_failed` 连续 15 分钟非零或 24h 明显上升 | 优先排官方配置 |
| Model list failure spike | 任一 Provider `model_list_failed` 1h 翻倍 | 查 Provider API / auth / CORS |
| STT failure spike | `stt_failed / stt_started` 超过阈值 | 查权限、设备、Provider |
| TTS blocked spike | `feature = tts` + `action = speech_blocked` 1h 翻倍 | 查 Flux、默认音色成本、余额提示 |
| Bug report spike | `bug_report_submitted` 24h 翻倍 | 社区同步归类 |
| Rage-click spike | `$rageclick` 7d trend 异常 | 结合页面和 session replay |
## 实施顺序
### 分阶段落地
| Phase | Scope | Owner | 产出 |
|---|---|---|---|
| Phase 0 | 字段归一化、事件复用确认、敏感字段拦截 | frontend / server | 公共 helper、事件字典、测试用例 |
| Phase 1 | Chat activation、Provider / model 配置、TTS voice 字段 | frontend / server | 能回答“能不能开始聊天”和“哪个音色常用” |
| Phase 2 | Voice input、feedback、community tags | frontend / community | 能定位语音输入和社区反馈高频问题 |
| Phase 3 | PostHog / Grafana dashboard、半自动日报、异常提醒 | data / server / community | 每日播报和异常告警可用 |
| Phase 4 | 周报、cohort、retention、付费 / 反馈关联分析 | product / community / data | 支持战略复盘和下周优先级 |
### 任务顺序
1. 先补字段归一化 helpers,尤其是 Provider / model / voice。
2. 补 Chat Activation 三个事件。
3. 补 Provider / model 配置成功失败事件。
4. 补 TTS voice 选择、试听、Voice Pack 绑定事件。
5. 服务端 `product_events.metadata` 补 TTS `voice_id``voice_type``voice_pack_id`
6. 补语音输入权限 / 设备事件。
7. 扩 PostHog dashboard。
8. 扩 Grafana dashboard,但不把 voice 放进 Prometheus label。
9. 建半自动日报 MarkdownPostHog + Grafana + SQL 聚合,先发固定频道。
10. 建异常检查:activation、provider config、model list、STT、TTS blocked、bug report。
11. 建周报模板:自动填指标,社区负责人补充 Discord / QQ 反馈解释和下周建议。
### 当前接入状态(2026-07-10
已接入代码:
- Chat activation`chat_activation_started``chat_activation_succeeded``chat_activation_failed` 只覆盖每个 conversation 首次 assistant response 之前的尝试;后续轮次继续发 message / latency events,第二轮单独发 `second_turn_started`
- Chat correlation:每次发送以 user message id 作为 `round_id`activation、message、LLM latency、render、`message_round``message_round_failed` 事件共享 `conversation_id``round_id``turn_index`
- Identity:匿名 auth SPA 发 `signup_form_completed`;只有服务端 Better Auth user create hook 发 identified `signup_completed`。平台统一使用 `app_surface`,业务入口统一使用 `entry_surface`
- Chat event reuse:主链路使用 `message_send_started``message_sent``llm_*``message_round``message_round_failed`;每轮成功 / 失败各自只有一个终点事件,不再发送 `chat_started``assistant_response_completed``chat_failed` 和 chat 的通用 `feature_used` 别名。
- Model list`model_list_loaded``model_list_failed`
- Provider config`provider_config_started``provider_config_succeeded``provider_config_failed`
- TTS voice`tts_provider_selected``voice_selected``voice_preview_played``voice_pack_bound``official_tts_exposed``official_tts_preview_started``official_tts_preview_succeeded``official_tts_auto_enabled`
- TTS 服务端 metadataREST / WS TTS `product_events.metadata` 已补 `voice_id``voice_type``voice_pack_id``block_reason``failure_reason``flux_balance_bucket`
- Voice input`voice_input_started``microphone_permission_requested``microphone_permission_denied``audio_device_unavailable``voice_input_cancelled`
- STT:保留 `stt_started``stt_succeeded``stt_failed`,并将失败码收敛到稳定枚举,避免上报 raw error。
- Feedback`feedback_submitted` / `bug_report_submitted` 的低基数字段与 analytics API 已定义;产品内反馈提交入口与服务端收件流程拆到单独 PR。
- Grafana Dashboard`Product Analytics` 行已补 TTS success、TTS failed / blocked、TTS event rate by source、TTS blocked by reason、TTS blocked by Flux bucket 面板,并保留 voice drilldown 在 Postgres metadata / PostHog,不进入 Prometheus labels。
- Dashboard setup 文档:`product-analytics-dashboard-setup.md` 已补 PostHog insights、Grafana panels、PostHog / Grafana alert 配置建议。
- 上线冒烟文档:`verifications/product-analytics-smoke.md` 已补 PostHog、Postgres、Grafana 三层验证步骤。
待接入或待产品确认:
- Discord / QQ 社区标签的数据入口,例如人工表格、bot 或 issue 同步。
- PostHog Dashboard 需要在 PostHog 账号里按 setup 文档创建 insights / alerts。
- Grafana Dashboard JSON 已更新,仍需部署 / import 到线上 Grafana。
- 日报 / 周报自动拉取脚本与提醒频道。
## 验证清单
- 新用户完成一次官方 Provider 聊天后,PostHog 能看到 `chat_activation_succeeded`
- 自配置 Provider 失败时,PostHog 能按 `provider_id` + `error_code` 聚合。
- 选择官方默认音色后,PostHog 能看到 `voice_selected``voice_type = official_default`
- 试听音色后,PostHog 能看到 `voice_preview_played`
- REST 和 WS TTS 成功后,Postgres `product_events.metadata` 能看到 `voice_id`
- Grafana 继续只按低基数字段聚合,不新增高基数 voice label。
- 日报能输出:activation conversion、Top failing providers、STT success rate、TTS success/blocked、Top voices、feedback count。
- 异常提醒命中时能带上:当前值、基线值、影响范围、建议查看的 dashboard / query。
- 周报能输出:本周结论、用户意图变化、Discord / QQ 反馈分类、下周 Product / Engineering / Community 行动建议。
@@ -1,180 +0,0 @@
# Redis Boundaries And Pub/Sub
## 目标
这篇文档约束服务端使用 Redis 时的几个高风险边界:
- key / channel 拼接
- Pub/Sub payload 序列化与反序列化
- Redis 返回值的运行时校验
- Redis 与 Postgres 的职责边界
这不是“推荐写法”集合,而是后续改代码时应该默认遵守的约束。
## 一句话规则
- 不要在业务代码里到处手写 Redis key / channel 模板字符串。
- 不要把 TypeScript 类型注解当成 Redis 边界的运行时校验。
- 不要把 Pub/Sub 当持久化通道。
- 不要让 Redis 承担余额、账本、订单这类真相源职责。
## Redis 职责边界
当前 `apps/server` 中 Redis 主要承担四类职责:
- cache
- 用户 Flux 余额读缓存 `user:{userId}:flux`
- TTS voices 上游响应(按 model 分片)`tts:voices:upstream:{model}` —— TTL 600s,仅 200 响应入缓存,见 `src/routes/openai/v1/index.ts::handleListVoices`
- config KV
- 例如 `config:{key}`
- Pub/Sub
- 例如聊天跨实例广播 `chat:{userId}:broadcast`
- 计量债务账本(atomic counter + TTL
- 例如 TTS 累计字符 `user:{userId}:flux-meter:tts:debt`
- 见 [flux-meter.md](flux-meter.md)
其中:
- Postgres 是余额、账本、订单、聊天消息等持久状态的唯一真相源
- Redis Pub/Sub 只负责降低跨实例通知延迟,不提供持久化、回放、补偿
> NOTICE: Redis Streams(曾经的 `billing-events`)已被移除。现在没有任何业务依赖 Stream 抽象,未来如果要再上 Stream,请先重新评估是否真的需要异步副作用,而不是把它作为默认选项。
## Key / Channel 收口规则
### 必须收口
Redis key 和 Pub/Sub channel 必须通过单独 helper 构造,不要在多个调用点重复写模板字符串。
推荐模式:
```ts
function fluxRedisKey(userId: string): string {
return `user:${userId}:flux`
}
function userBroadcastChannel(userId: string): string {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('user broadcast channel requires a non-empty string userId')
}
return `chat:${userId}:broadcast`
}
```
这样做的原因不是“风格统一”,而是为了避免:
- key 前缀分散在多个文件
- 某个调用点把对象、空串、错误 id 拼进 channel
- 后续重构前缀或路由粒度时漏改
### 禁止依赖模板字符串兜底
不要假设 `` `${value}` `` 可以安全把任意值转成 Redis key。
原因:
- 如果 `value` 在运行时是对象,会得到 `[object Object]`
- 这类错误不会在 TypeScript 编译期暴露
- 一旦写进 Redis channel / key,排查成本很高
## Pub/Sub Payload 规则
### 发布侧
发布侧必须显式构造消息对象,不要把“业务对象刚好长得像 payload”当成协议。
推荐模式:
```ts
interface BroadcastMessage {
userId: string
payload: {
chatId: string
messages: unknown[]
fromSeq: number
toSeq: number
}
}
function createBroadcastMessage(
userId: string,
payload: BroadcastMessage['payload'],
): BroadcastMessage {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('broadcast message requires a non-empty string userId')
}
return { userId, payload }
}
```
### 消费侧
消费侧不要只写:
```ts
const data = JSON.parse(message) as BroadcastMessage
```
因为这只是类型断言,不是校验。
至少要验证:
- `userId` 是非空字符串
- `payload.chatId` 是字符串
- `payload.messages` 是数组
- `fromSeq` / `toSeq` 是数字
如果消息不合法,应该记录错误并丢弃,而不是继续广播到本地连接。
## Chat WS 当前约束
`src/routes/chat-ws/index.ts` 当前采用:
- 同实例内存连接表
- 跨实例 Redis Pub/Sub
这个设计的语义必须明确:
- 广播通知不是持久化消息
- 丢广播不会丢聊天真相数据
- 客户端补齐消息仍然依赖 `pullMessages`
因此后续如果改聊天同步:
- 需要”可重放”时,不要继续堆在 Pub/Sub 上
- 需要”跨实例即时通知”时,可以继续用 Pub/Sub
- 需要”持久事件消费”时,先回到 NOTICE 评估是否真的需要异步副作用,再考虑引入 Streams 这类抽象
## 修改 Redis 代码时的检查清单
- 这个 Redis 数据是 cache、KV、Pub/Sub 还是 Stream
- 它是不是被误当成真相源?
- key / channel 是否通过 helper 统一构造?
- 是否校验了关键标识符,例如 `userId`、`chatId`、`streamMessageId`
- Pub/Sub payload 是否有显式创建函数和解析函数?
- 解析失败时是否会安全丢弃,而不是继续传播?
- 这个需求是否其实应该用 Streams,而不是 Pub/Sub
## 当前代码可直接参考的位置
- key helper 集中点
- `src/utils/redis-keys.ts`
- 余额读 cache-aside
- `src/services/domain/flux.ts`
- Sub-Flux 计量债务账本
- `src/services/domain/billing/flux-meter.ts`
- TTS voices 上游响应缓存
- `src/routes/openai/v1/index.ts::handleListVoices`
- Pub/Sub 聊天广播
- `src/routes/chat-ws/index.ts`
- 命名规范和待迁移事项
- `config-and-naming-conventions.md`
## 对 AI / 后续修改者的直接要求
- 新增 Redis key / channel 时,先写 helper,再写调用点
- 新增 Pub/Sub payload 时,先定义消息 shape 和 parse / create 边界,再接业务逻辑
- 如果看到业务代码里散落 `` `prefix:${id}` ``,优先做小范围收口
- 如果看到 `JSON.parse(...) as SomeType` 出现在 Redis 边界,默认把它视为待修复点
@@ -1,137 +0,0 @@
# Stripe Pricing Architecture
## 设计决策
Stripe 是 Flux 充值定价的**单一真相源**。服务端不再在 Redis 维护 `FLUX_PACKAGES`,所有 package 信息直接从 Stripe API 获取。
### 为什么不用 Redis 维护 packages
之前的设计在 Redis ConfigKV 中维护 `FLUX_PACKAGES`(含 amount、label、price 等),导致:
- 价格信息在 Stripe 和 Redis 之间重复维护
- currency 硬编码为 USD,无法支持微信支付(需要 CNY/GBP)
- 新增/修改 package 需要同时改 Stripe 和 Redis
现在只需在 Stripe Dashboard 操作 Product/Price,服务端自动同步。
## 数据模型
### Stripe 侧
- **Product** — 代表 "Flux 充值" 这个商品(一个即可)
- **Price** — 代表一个具体的价格方案,每个 Price 包含:
- `unit_amount` + `currency`(如 300 USD = $3
- `currency_options`(可选)— 支持多币种展示,如 `cny: { unit_amount: 2200 }`
- `metadata.fluxAmount` — 购买此 Price 获得的 Flux 数量
- `metadata.recommended` — (可选)设为 `'true'` 时前端会高亮展示为推荐套餐
Product ID 存储在 ConfigKV `STRIPE_FLUX_PRODUCT_ID` 中(运营配置,非环境变量)。
### 多币种支持
通过 Stripe Price 的 `currency_options` 实现。一个 USD Price 可以同时支持 CNY 结算:
- 前端展示所有可用货币(从 `currency_options` 自动提取),用户通过 SelectTab 切换
- 前端 checkout 时传 `{ stripePriceId, currency }` 给服务端
- 服务端在 Checkout Session 上设 `currency` 参数,Stripe 自动用对应 `currency_options` 的金额
- Stripe Checkout 页面根据货币自动展示兼容的支付方式(如 CNY → 微信支付)
**创建带多币种的 Price**
```bash
curl https://api.stripe.com/v1/prices \
-u "$STRIPE_API_KEY:" \
-d "product=prod_xxx" \
-d "unit_amount=300" \
-d "currency=usd" \
-d "metadata[fluxAmount]=500" \
-d "currency_options[cny][unit_amount]=2200"
```
> 注意:Stripe CLI 的 `prices create` 对嵌套参数支持不好,`currency_options` 需要用 `curl` 直接调 API。
### 支付方式
`STRIPE_PAYMENT_METHODS` 在 ConfigKV 中为可选配置:
- **未设置(推荐)**:不传 `payment_method_types`Stripe 根据 Dashboard 设置和货币自动决定
- **已设置**:覆盖 Stripe 自动选择,如 `["card", "wechat_pay", "alipay"]`
如果手动指定了 `wechat_pay`,还需设 `STRIPE_PAYMENT_METHOD_OPTIONS``{"wechat_pay":{"client":"web"}}`
## 缓存
Stripe Price 列表通过 Redis 缓存(key: `cache:stripe:prices`TTL 5 分钟),所有实例共享。
- 命中:直接返回缓存的 Price 列表
- 未命中:调 Stripe API `prices.list` (含 `expand: ['data.currency_options']`),按 `unit_amount` 升序排列后写入缓存
- Checkout 时如果 priceId 不在缓存中,fallback 到 `prices.retrieve` 并 invalidate 缓存
## API 流程
### GET /api/v1/stripe/packages
1. 从 ConfigKV 读取 `STRIPE_FLUX_PRODUCT_ID`
2. 从 Redis 缓存或 Stripe API 获取 active prices
3. 返回每个 price 的所有可用货币价格:
```json
{
"stripePriceId": "price_xxx",
"label": "500 Flux",
"defaultCurrency": "usd",
"currencies": { "usd": "$3.00", "cny": "¥22.00" },
"recommended": false
}
```
### POST /api/v1/stripe/checkout
1. 前端发送 `{ stripePriceId, currency? }`
2. 服务端验证 price 归属和 `fluxAmount` metadata
3. 创建 Checkout Session
- `currency` 参数(如有)让 Stripe 用 `currency_options` 中的金额
- `payment_method_types` 根据 ConfigKV 是否配置决定传或不传
4. Webhook 收到 `checkout.session.completed` 后从 metadata 读取 fluxAmount 充值
## 运营操作
### 新增价格
用 curl 创建带多币种的 PriceStripe CLI 不支持嵌套参数):
```bash
curl https://api.stripe.com/v1/prices \
-u "$STRIPE_API_KEY:" \
-d "product=prod_xxx" \
-d "unit_amount=1200" \
-d "currency=usd" \
-d "metadata[fluxAmount]=2000" \
-d "metadata[recommended]=true" \
-d "currency_options[cny][unit_amount]=8800"
```
无需修改代码或 Redis`/packages` 端点在缓存过期后自动返回新 Price。
### 下架价格
在 Stripe Dashboard 将 Price 设为 inactive,缓存过期后 `/packages` 自动不再返回。
### 修改 Product ID
```bash
redis-cli SET "config:STRIPE_FLUX_PRODUCT_ID" '"prod_new_id"'
```
### 手动清缓存(立即生效)
```bash
redis-cli DEL "cache:stripe:prices"
```
## ConfigKV 配置清单
| Key | 类型 | 默认 | 说明 |
|-----|------|------|------|
| `STRIPE_FLUX_PRODUCT_ID` | `string?` | 无 | Stripe Product ID,未设置时 top-up 不可用 |
| `STRIPE_PAYMENT_METHODS` | `string[]?` | 无 | 不设则 Stripe 自动决定;设了则覆盖 |
| `STRIPE_PAYMENT_METHOD_OPTIONS` | `Record?` | `{}` | 支付方式选项,如 `{"wechat_pay":{"client":"web"}}` |
@@ -1,286 +0,0 @@
# Transport And Routes
## 路由总览
应用在 `src/app.ts` 中挂载以下路由:
- `GET /livez` — K8s 风格 liveness 探针,纯静态 200,不碰任何外部依赖
- `GET /readyz` — K8s 风格 readiness 探针,并发 ping Postgres + Redis;任一失败回 503。**不**检查上游 LLM key 健康(R14
- `GET /` — 服务标识 JSON,避免邮件链接拼错落到框架默认 404
- `/api/auth/*`
- `/api/v1/characters`
- `/api/v1/providers`
- `/api/v1/chats`
- `/api/v1/openai`
- `/api/v1/flux`
- `/api/v1/stripe`
- `/api/admin/flux-grants` — adminGuard 守卫,详见 `admin-flux-grants.md`
- `GET /ws/chat`
## 鉴权链路
### HTTP
- `sessionMiddleware(auth)`
- 通过 `better-auth` 解析当前 session
-`user` / `session` 注入 Hono context
- `authGuard`
- 检查 `c.get('user')`
- 未登录直接 401
### WebSocket
`GET /ws/chat` 走 query token
- 读取 `token`
-`auth.api.getSession()` 验证 Bearer token
- 校验通过后为该 `user.id` 建立 Eventa peer
这意味着聊天 WS 的鉴权方式和普通 cookie session 路径不完全相同。
## 路由到服务映射
### `/api/auth/*` 及 `/sign-in`
实现位置:
- 路由入口:`src/routes/auth/index.ts`(通过 `.route('/')` 挂载到根路径)
- token auth 辅助路由:`src/routes/oidc/token-auth.ts`
- Electron 回调中继:`src/routes/oidc/electron-callback.ts`
- better-auth 配置:`src/libs/auth.ts`
- Bearer 解析:`src/libs/request-auth.ts`
- 登录页渲染:`src/utils/sign-in-page.ts`
特点:
- 基于 `better-auth` + `oauthProvider` 插件
- 开启 email/password、Google、GitHub 社交登录
- Bearer plugin + JWT plugin 已启用
- `/api/auth/*` 有独立 IP 限流
- `GET /api/auth/get-session``POST /api/auth/sign-out``GET /api/auth/list-sessions` 由本地路由处理
- Bearer token 会先尝试 better-auth session,再回退到受信任 OIDC access token
- 详见 `auth-and-oidc.md`
### `/api/v1/characters`
实现位置:
- route: `src/routes/characters/index.ts`
- service: `src/services/domain/characters.ts`
主要能力:
- `GET /`
- 默认返回当前用户拥有的角色
- `?all=true` 返回全部未删除角色
- `GET /:id`
- `POST /`
- `PATCH /:id`
- `DELETE /:id`
- `POST /:id/like`
- `POST /:id/bookmark`
特点:
- 路由层做 `valibot` 校验
- 更新和删除会额外校验 `ownerId === user.id`
- 点赞和收藏是 toggle 语义
### `/api/v1/providers`
实现位置:
- route: `src/routes/providers/index.ts`
- service: `src/services/domain/providers.ts`
主要能力:
- 用户 Provider Config CRUD
- 查询时会合并:
- `user_provider_configs`
- `system_provider_configs`
特点:
- `findAll(ownerId)` 通过 `unionAll` 合并系统配置和用户配置
- 用户只能改自己的 user config,不能改 system config
### `/api/v1/chats`
实现位置:
- route: `src/routes/chats/index.ts`
- service: `src/services/domain/chats.ts`
主要能力:
- Chat CRUD
- 成员增删
聊天核心约束:
- 所有操作都会先校验用户是否属于 chat member
- 删除是软删除,写 `deletedAt`
- 消息序号 `seq` 在写消息时通过锁 chat 行串行分配
### `GET /ws/chat`
实现位置:
- route 注册:`src/app.ts`(在 `bodyLimit` 之前注册)
- handler factory: `src/routes/chat-ws/index.ts`
- 底层事件适配:`src/libs/eventa-hono-adapter.ts`
主要 RPC
- `sendMessages`
-`chatService.pushMessages()`
- 再调 `chatService.pullMessages()` 生成广播 payload
- `pullMessages`
-`chatService.pullMessages()`
广播策略:
- 同实例:内存 `Map<userId, Set<EventContext>>`
- 跨实例:Redis Pub/Subchannel 前缀 `chat:broadcast:`
实现约束:
- Redis Pub/Sub 只承担通知职责,不承担持久化和重放职责
- key / channel 与 payload 边界应集中收口,不要在调用点散落模板字符串和裸 `JSON.parse`
- 具体规范见 `redis-boundaries-and-pubsub.md`
### `/api/v1/openai`
实现位置:
- route: `src/routes/openai/v1/index.ts`
- 依赖服务:
- `fluxService`
- `billingService`
- `configKV`
- `requestLogService`
当前已开放:
- `POST /api/v1/openai/chat/completions`
- `POST /api/v1/openai/chat/completion`
- `POST /api/v1/openai/audio/speech`
- `GET /api/v1/openai/audio/voices`(按 model 缓存上游响应,TTL 600s,仅 200 入缓存)
`handleTranscription`STT)目前未挂载,需要时参考 `/audio/speech` 接入方式。
请求流程:
1. 校验已登录
2. 检查相关配置是否存在
3. 检查用户 Flux 是否大于 0
4. 交给 `llmRouter.route` / `llmRouter.routeTts`:读 `LLM_ROUTER_CONFIG`,按 upstream 链路 + key rotator + envelope crypto 调上游
5. 解析 usage,计算扣费
6. 记录 metrics
7.`billingService.debitFlux()`
8. 异步写 `llm_request_log`
重要取舍:
- non-streaming
- 先拿完整响应
- 再扣费
- 扣费失败会阻断响应
- streaming
- 先把流回给客户端
- 流结束后再 best-effort 扣费
- 扣费失败只打 error log,不回滚给客户端
### `/api/v1/flux`
实现位置:
- route: `src/routes/flux/index.ts`
- services:
- `fluxService`
- `fluxTransactionService`
主要能力:
- `GET /api/v1/flux`
- 读取当前用户余额
- `GET /api/v1/flux/history`
- 读取用户可见流水
### `/api/v1/stripe`
实现位置:
- route: `src/routes/stripe/index.ts`
- services:
- `fluxService`
- `stripeService`
- `billingService`
- `configKV`
主要能力:
- `GET /packages`
- `POST /checkout`
- `GET /orders`
- `GET /invoices`
- `POST /portal`
- `POST /webhook`
主要职责拆分:
- `stripeService`
- 负责把 Stripe customer / session / subscription / invoice 持久化
- `billingService`
- 负责真正改余额
### `/api/admin/flux-grants`
实现位置:
- route: `src/routes/admin/flux-grants/index.ts`
- service: `src/services/domain/admin/flux-grants/index.ts`
- guard: `src/middlewares/admin-guard.ts`
主要能力:
- `POST /api/admin/flux-grants?dryRun=true|false` — 同步给一组邮箱发 FLUX,请求线程内顺序调 `creditFlux`,返回每条 outcome
- 鉴权:`authGuard` + `adminGuard``ADMIN_EMAILS` allowlist + `email_verified=true`
- 没有 batch 表 / 状态机 / 后台 loop;详见 `admin-flux-grants.md`
## 参数校验方式
输入 schema 位于各资源路由目录下的 `schema.ts`
- `characters.schema.ts`
- `chats.schema.ts`
- `providers.schema.ts`
route 层统一使用 `safeParse`,失败时抛:
- `createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)`
## 中间件与 guard
### `configGuard`
作用:
- 检查某些 Redis 配置项是否已写入
- 缺失时返回 503
使用场景:
- LLM chat
- Stripe checkout
- 未来的 TTS / ASR
### `rateLimiter`
封装自 `hono-rate-limiter`,当前默认是单实例内存存储。
影响:
- 多实例部署下不是全局一致限流
- 适合作为基础保护,不适合作为严格额度控制
@@ -1,132 +0,0 @@
# Verification: account deletion
Status: **end-to-end verified (2026-04-28)** — UI → email → click → server
soft-delete pipeline → success page all confirmed in a live run. DB-row
inspection (`select deleted_at` on each business table) and same-email
re-registration smoke-test are recommended but not yet captured in this
record.
Last attempted: 2026-04-28
Owner: rbxin2003@gmail.com
## Live trace (2026-04-28)
Server log captured during a live deletion of `userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk`:
```
<-- POST /api/auth/delete-user
--> POST /api/auth/delete-user 200 4s
<-- GET /api/auth/delete-user/callback?token=sb7614...&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fdelete-account
[user-deletion] starting user deletion { userId=2ylsWBfP1UdjenkxBSDCyQkjarzE6ZAk reason=user-requested handlerCount=5 }
[user-deletion] handler completed { handler=stripe userId=... durationMs=1297 }
[user-deletion] Flux balance soft-deleted... { userId=... clearedFlux=500 }
[user-deletion] handler completed { handler=flux userId=... durationMs=526 }
[user-deletion] Provider configs soft-deleted { userId=... count=0 }
[user-deletion] handler completed { handler=providers userId=... durationMs=260 }
[user-deletion] Characters / likes / bookmarks soft-deleted { userId=... characters=0 likes=0 bookmarks=0 }
[user-deletion] handler completed { handler=characters userId=... durationMs=787 }
[user-deletion] Chats / messages soft-deleted { userId=... chats=0 messages=0 }
[user-deletion] handler completed { handler=chats userId=... durationMs=270 }
[user-deletion] user deletion handlers completed { userId=... reason=user-requested }
--> GET /api/auth/delete-user/callback?... 302 8s
<-- GET /auth/delete-account
--> GET /auth/delete-account 200 1ms
```
What this proves:
- 5 handlers run in registration order, ascending priority (stripe → flux → providers → characters → chats).
- Total handler time ~3.1s (mostly Stripe: 1.3s for the network round-trip).
- Verification token consumed exactly once; the callback redirected (302) to the success page.
- `clearedFlux=500` confirms the Flux handler picked up the actual balance.
- `count=0` for providers / characters / chats reflects the test user not having those records — empty soft-delete is a valid no-op.
## Known gotcha — standalone UI deploy staleness
`apps/ui-server-auth/dist/` is deployed independently from the server image.
New pages added under `apps/ui-server-auth/src/pages/` only show up after
running `pnpm -F @proj-airi/ui-server-auth build` and deploying the Cloudflare
Workers Static Assets project. Symptom of forgetting: the success page returns
`200` with the SPA HTML but renders blank because vue-router never registered
the route. Re-build and re-deploy the auth UI → fixes.
## What is verified
| Layer | Evidence | Date |
|---|---|---|
| Schema migration generated | `apps/server/drizzle/0009_perpetual_lilandra.sql`: 11 `DROP CONSTRAINT` + 7 `ADD COLUMN deleted_at` (no destructive ALTER beyond FK drop) | 2026-04-28 |
| Server typecheck | `pnpm -F @proj-airi/server typecheck` exits clean | 2026-04-28 |
| Monorepo typecheck | `pnpm typecheck` exits clean across all packages | 2026-04-28 |
| Lint | `pnpm lint` reports 0 errors in deletion-service / handler / UI files | 2026-04-28 |
| Deletion service unit tests | `pnpm exec vitest run apps/server/src/services/domain/user-deletion``2 files / 14 tests pass` (registry priority order, abort-on-error, serial execution, idempotency, per-service `deleteAllForUser` correctness) | 2026-04-28 |
| Architecture refactor | Domain knowledge moved out of `*-deletion-handler.ts` files into each business service's own `deleteAllForUser` method. Registry retained as a thin scheduler. `auth → userDeletionService → 5 business services` (auth and services no longer depend on each other). | 2026-04-28 |
| Server full test suite | `pnpm -F @proj-airi/server exec vitest run` → 244/245 pass; the single failure (`origin.test.ts`) is pre-existing on `main` and unrelated | 2026-04-28 |
| UI typecheck | `pnpm -F @proj-airi/stage-pages typecheck` and `pnpm -F @proj-airi/ui-server-auth typecheck` both clean | 2026-04-28 |
| Live server-side trace | See "Live trace" section above — full pipeline ran against real DB + Stripe sandbox + Resend | 2026-04-28 |
## What is **not** verified yet (action items)
### Path A — Migration applies cleanly to live DB
**Command (run by user):**
```sh
pnpm -F @proj-airi/server db:push
```
**Expected:** Drizzle reports `Changes applied` for 11 FK drops + 7 column additions on the local Postgres pointed to by `DATABASE_URL`.
**Risk:** if any business table currently has rows whose `userId` references a now-missing user (orphans from older bugs), DROP CONSTRAINT will succeed (unlike ADD CONSTRAINT). No data loss expected.
### Path B — Server boots with deletion service wired
**Command:**
```sh
pnpm -F @proj-airi/server dev
```
**Expected log lines:**
- `injeca` resolves `services:userDeletion` without error
- `services:auth` resolves successfully (depends on userDeletionService)
- `Server started` log line appears
**Failure mode:** if the Stripe SDK construction throws at boot, the deletion service crashes the process. Mitigation: `STRIPE_SECRET_KEY` is optional — handler tolerates `null`.
### Path C — End-to-end deletion flow (UI → email → DB)
**Setup:**
1. Server up (Path B), UI up (`pnpm -F @proj-airi/stage-web dev` with `VITE_SERVER_URL=http://localhost:3000`)
2. `RESEND_API_KEY` valid, use `rbxin2003+delete@outlook.com` (bare address suppressed — see Resend memory)
3. Pre-populate the user with non-trivial data so soft-delete has something to mark:
- Register user
- Have flux balance > 0 (initial grant covers this)
- Connect a provider via settings UI
- Create one character
- (Optional) Set up a Stripe test sub via the billing flow
**Steps + assertions:**
| # | Action | Expected | DB / API check |
|---|---|---|---|
| 1 | Settings → Account → Danger Zone → click "Delete account" | Inline confirm form appears | DOM-only |
| 2 | Type wrong email | Confirm button disabled | DOM-only |
| 3 | Type correct email + click confirm | Server log: `POST /api/auth/delete-user 200`. UI shows "Check {email} for the deletion link" | `select * from verification where identifier like 'delete-account-%'` returns one row |
| 4 | Open Resend inbox, click link in email | Browser navigates to `${API_SERVER_URL}/api/auth/delete-user/callback?token=...` then redirects to `/auth/delete-account` (success page) | server log: `[user-deletion] starting user deletion` → 5x `handler completed``user deletion handlers completed``internalAdapter.deleteUser``302` |
| 5 | Verify auth tables are gone (cascade) | `select * from "user" where email='...'` → empty | psql query |
| 6 | Verify business tables are soft-deleted (NOT cascade) | `select * from user_flux where user_id=$1` → row with `deleted_at IS NOT NULL` | psql query, $1 = old user.id |
| 7 | Same for stripe_customer, stripe_subscription, stripe_checkout_session, stripe_invoice | All `deleted_at IS NOT NULL` | psql query |
| 8 | Same for character (creator_id OR owner_id), user_provider_configs (owner_id), user_character_likes/bookmarks (user_id) | All matching rows have `deleted_at IS NOT NULL` | psql query |
| 9 | flux_transaction is **untouched** (audit) | `select count(*) from flux_transaction where user_id=$1` → unchanged from before deletion | psql query |
| 10 | Stripe API side: active sub canceled | Stripe dashboard or `GET /v1/subscriptions/$sub_id``status: "canceled"` | Stripe CLI or dashboard |
| 11 | Re-register with same email | Sign-up succeeds (unique constraint released by hard delete of `user` row) | server log: `POST /api/auth/sign-up/email 200`. New user gets a fresh user.id |
| 12 | Old soft-deleted business rows do **not** show up for the new user | `select * from user_flux where user_id=$1` (new id) is empty or has fresh row | psql query |
### Path D — Failure-mode smoke
**Setup:** kill Postgres mid-deletion (or simulate by wrapping a handler to throw)
**Expected:**
- `user-deletion` log: `handler failed; aborting deletion pipeline`
- `user` row still present (better-auth never reaches `internalAdapter.deleteUser`)
- soft-deleted rows from earlier handlers REMAIN soft-deleted (no rollback) — by design
- User can retry the deletion flow; idempotent handlers re-mark already-stamped rows as no-ops
## Known gaps
1. **No retry UI:** if Path D triggers, the user sees the API JSON error from `/delete-user/callback` instead of a friendly page. Acceptable for v1 (rare path); future hook can redirect to `/auth/delete-account?error=...`.
2. **No admin-triggered deletion:** the `reason: 'admin'` enum exists but no caller. Wire up in admin panel later.
3. **No rate limiting on `/api/auth/delete-user`:** uses the global `/api/auth/*` IP rate limit (`AUTH_RATE_LIMIT_MAX`). May want a tighter per-user cap (e.g. 1 attempt per hour) if abuse surfaces.
4. **Translations:** EN-only for new i18n keys (`server.auth.deleteAccount.*`, `settings.pages.account.danger.deleteAccount.modal.*`, `.message.emailSent`, `.error.fallback`). Other locales fall back to EN until translated.
5. **`flux_transaction` retention:** ledger now contains `user_id` strings that point to deleted users. No retention policy yet — open question for finance/legal.
## Re-verification cadence
When the deletion code path is touched (handler logic, schema, better-auth config), re-run Path C from scratch and update "Last attempted" + the table above.
@@ -1,58 +0,0 @@
# Admin Flux Grants — End-to-End Verification
## 用户路径 1admin 同步发 grant → 余额到账
- **场景**admin 通过 `POST /api/admin/flux-grants` 给一个邮箱发 100 FLUX,HTTP 同步返回 `granted` 数组 → 用户余额上升。
- **命令**(占位,需要重新实测):
```bash
TOKEN=... # admin 用户 access token
curl -s -X POST "http://localhost:3000/api/admin/flux-grants" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"local verify","amount":100,"emails":["rbxin2003@gmail.com"]}'
# 然后查余额
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/flux
# 查 ledger
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:3000/api/v1/flux/history?limit=3'
```
- **预期**
- HTTP 200body 包含 `result.granted: [{ email, userId, fluxTransactionId, balanceAfter }]``result.failed: []``result.skipped: []`
- `/api/v1/flux` 返回的 `flux` 比之前增加 100
- `/api/v1/flux/history` 顶部一条 `type='promo'`、`description='local verify'`、`metadata.issuedByUserId` = admin 的 userId
- **实际输出**:⏳ 待重新实测(架构刚从 batch 改成同步,旧 verification 已无效)。
- **环境**:本地 `pnpm -F @proj-airi/server dev`commit SHA 待补,`ADMIN_EMAILS` 含 admin 邮箱且 `email_verified=true`。
- **最后验证**:⏳ 待补
## 用户路径 2:dry-run 预览邮箱列表
- **场景**admin 在真发之前用 `?dryRun=true` 看 4 个 emailvalid + 大小写变体重复 + 找不到)的解析结果。
- **命令**
```bash
curl -s -X POST 'http://localhost:3000/api/admin/flux-grants?dryRun=true' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"smoke","amount":100,"emails":["rbxin2003@gmail.com","RBXIN2003@gmail.com","ghost@nope.example","rbxin2003@gmail.com"]}'
```
- **预期**HTTP 200`{ preview: { willGrant: 1, willSkip: { notFound: 1, userDeleted: 0, duplicateInInput: 2 }, totalFluxToIssue: 100, ... } }`。`flux_transaction` 表无新增行。
- **实际输出**:⏳ 待重新实测
- **环境**:同上
- **最后验证**:⏳ 待补
## 用户路径 3:未登录 / 非 admin / 未验证邮箱被挡住
- **场景**`adminGuard` 三种拒绝路径(401 无 session / 403 不在 allowlist / 403 邮箱未验证)。
- **命令**
```bash
curl -s -w "%{http_code}\n" -X POST http://localhost:3000/api/admin/flux-grants -d '{}'
# 期望 401
```
- **实际**:单元测试 [`admin-guard.test.ts`](apps/server/src/middlewares/tests/admin-guard.test.ts) 覆盖完整三条路径 + case-insensitive 匹配。Live 401/403 端到端验证⏳ 待补。
- **最后验证**:⏳ 待补(unit only
## 已知缺口 / 未验证
- **整套 verification 都需要重跑**:架构从 `flux_grant_batch` 异步处理改成同步 `POST /api/admin/flux-grants` 后,旧的实测输出(含 `mq-stream` 日志、`batch.status: completed` 等)全部失效。Iron Law 要求至少跑一次路径 1 + 2 替换 ⏳ 占位。
- **失败重试 + idempotencyKey**:单测覆盖了"同 key 不同 recipient → 不同 requestId"这一逻辑,但没真跑过"故意打挂 DB 触发部分 failed → 用 `idempotencyKey` 重发只补失败的"端到端。
- **`emails` 上限 200 实际响应时间**:估算 4–10s,没在生产 DB 上跑过。如果 `creditFlux` 单条 > 50ms(远端 Postgres + Redis),需要回头下调上限或加批处理优化。
@@ -1,61 +0,0 @@
# Verification: Admin 授权(role) + 封禁 + 改余额
- 环境:commit `6f63ce96e`(工作区改动未提交),本地 vitest + PGlite 内存 Postgres
- 最后验证日期:2026-05-26
- 模型:admin 授权改 better-auth `admin` 插件的 **role**(删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生 `user.banned`;改余额保留自建。设计见 `../account-ban.md`
## 已验证(fresh execution
命令:
```sh
pnpm -F @proj-airi/server typecheck # tsc --noEmit,无输出(通过)
pnpm exec vitest run apps/server # 全量 Test Files 43 passed / Tests 398 passed(含 tests/ 集成测试)
pnpm db:generate # → drizzle/0013_naive_groot.sql
pnpm -F @proj-airi/server-schema build # 重新打包迁移
```
### 用户路径 → 预期 → 实测
1. role-based adminGuardHTTP 边界)
- 预期:无 user → 401role 非 admin / 无 role → 403role='admin'(含逗号分隔 'user,admin')→ 放行
- 实测:`middlewares/tests/admin-guard.test.ts` 5 个用例通过
2. admin flux-grants 走 role 鉴权(真实 app + PGlite 集成)
- 预期:session user role='admin' → 200 grant;无 role → 403;无 session → 401
- 实测:`tests/verifications/admin-flux-grants.integration.test.ts` 通过,经 `buildApp` 全量装配 + 真实 PGliteuser 表带新 role 列),adminGuard 读 `user.role` 判定
3. 封禁立即生效(热路径,OIDC JWT)
- 预期:`resolveRequestAuth``user.banned` 命中的 principal 返回 null(即使 session 能解析);`banExpires` 已过期当未封禁
- 实测:`libs/tests/request-auth.test.ts`「rejects a banned principal even when the session resolves」「treats an expired ban as not banned」通过
4. userinfo 封禁 guardHTTP 边界)
- 预期:被封 subject 打 `/api/auth/oauth2/userinfo` → 403 不到 handler;未封禁透传;ban 过期透传
- 实测:`routes/auth/oidc-userinfo-ban.test.ts` 3 个用例通过,挂真实 `createAuthRoutes` 装配,banned 由 mock 的 getSession user 驱动
5. 改余额为 0 / 任意值(含缓存失效)
- 预期:`user_flux.flux` 覆盖,写 `admin_set` 账本(direction + before/after + issuedBy),提交后 `redis.del` 失效缓存
- 实测:`services/domain/billing/tests/billing-service.test.ts` setFlux 三个用例通过(设值 / 归零 / 初始化行 + `redis.del` 断言)
6. 余额 route 鉴权 + 校验(HTTP 边界)
- 预期:未登录 401 / 非 admin role 403 / 负余额 400 / selector 非恰好一个 400 / 正常委托并回传
- 实测:`routes/admin/users/route.test.ts` 6 个用例通过
7. 迁移
- `drizzle/0013_naive_groot.sql``ALTER user ADD role/banned/ban_reason/ban_expires` + `ALTER session ADD impersonated_by`,无 account_ban
- server-schema 重打包成功
8. 封禁撤销 OAuth 凭据(codex review 发现)
- 预期:ban 时(`updateUser` 写 banned=true)触发 `databaseHooks.user.update.after`,删该用户 oauth refresh/access token,堵住「refresh grant 换新 JWT」
- 状态:已加 hook + `// NOTICE:`,typecheck/lint 通过。hook 真正触发依赖 better-auth `updateUser` 全流程,属下方 pending(同 admin 端点真实登录流)。新 JWT 即便被 mint 也已被 isUserBannedNow 在第 3、4 条覆盖的资源路径挡住
## 待实测(pending
- better-auth `admin` 插件自身的 `/api/auth/admin/ban-user|unban-user` 端点 + `session.create.before` 登录拦截未跑真实 better-auth 登录流(需起真服务 + Postgres + OAuth)。靠源码确认语义;我们自己的热路径闸(resolveRequestAuth / userinfo guard)已被第 3、4 条真实执行覆盖
- 真实部署:第一个 admin 需手动 `UPDATE "user" SET role='admin' WHERE email='...'`;多实例 Railway + 真实 Redis 下的封禁延迟未测
## 设计取舍(不再覆盖)
-`ADMIN_EMAILS`:首次部署/新环境要手动设 role
- ban userId 单维:不覆盖「删号后用同邮箱/OAuth 重注册」(见 `../account-ban.md`
- dashboard 未上:`@better-auth/infra` dash() 是闭源 SaaS + 数据外发 + 不可扩展,业务管理将来自建 UI
@@ -1,85 +0,0 @@
# Verification: email auth via Resend
Status: **Path 1 verified**, Path 2/3 unverified.
Last attempted: 2026-04-28
Owner: rbxin2003@gmail.com
> **Note (2026-04-28):** UI base path migrated from the original
> `/_ui/server-auth/` prefix to `/auth/` (commit `d5f215134`). All Vite-built
> asset URLs and SPA routes are now served under `/auth/`. The verification
> log below has been rewritten to use the current routes; the prior path
> remains valid only for historical builds tagged before that commit.
## What's verified end-to-end
### Path 1 — Sign-up + verify email + sign-in (✅ 2026-04-27)
Tested with a live Resend API key, real Outlook inbox.
| Step | Evidence |
|---|---|
| `POST /api/auth/sign-up/email` (raw fetch) | `200` with `{ token: null, user: { ..., emailVerified: false } }` for `rbxin2003+probe@outlook.com` and `rbxin2003@outlook.com` |
| Resend dispatch | server log `<-- POST /api/auth/sign-up/email``--> POST /api/auth/sign-up/email 200 5s` (Resend API call latency, no errors logged from `services:email`) |
| Inbox delivery | User confirmed receipt at `rbxin2003@outlook.com` with subject "Verify your email", containing link `http://localhost:3000/api/auth/verify-email?token=eyJ...&callbackURL=%2F` |
| Click verify link | `GET /api/auth/verify-email?token=...&callbackURL=/``302` (redirect honored) |
| `emailVerified` flips to `true` | follow-up `POST /api/auth/sign-in/email` for the same user → `200` with `{ redirect: false, token: <session>, user: { ..., emailVerified: true, updatedAt > createdAt } }` |
| UI sign-up form submit | navigated `http://localhost:5174/auth/sign-up`, filled form via chrome-devtools, click `Create account` → server log `POST /api/auth/sign-up/email 200 2s` → browser landed on UI's verify-email page |
Two follow-up issues surfaced and were fixed in the same session:
1. **vue-i18n linked-format crash** — placeholder `you@example.com` parsed as a linked-message reference. Escaped to `you{'@'}example.com` in `packages/i18n/src/locales/en/server/auth.yaml`.
2. **Email link landed on `http://localhost:3000/` (404)** when there was no OIDC context, because Better Auth resolves bare `/` callback against `API_SERVER_URL`. Fixed in `apps/ui-server-auth/src/pages/sign-up.vue` and `sign-in.vue` by passing an absolute UI URL (`${origin}/auth/verify-email?verified=true`) when no OIDC params are present.
3. **API root + 404 friendliness** — added structured JSON for `GET /` and `notFound()` in `apps/server/src/app.ts` so stale email links / scanners hit a clear pointer instead of hono's default `404 Not Found` HTML.
- Verified with `curl http://localhost:3000/``200 {"service":"airi-api",...}` and `curl http://localhost:3000/some/random/path``404 {"error":"NOT_FOUND",...}`.
### Path 2 — Forgot + reset password (✅ 2026-04-27)
Tested with `rbxin2003+reset@outlook.com` (live Resend account). The bare `rbxin2003@outlook.com` is on Resend's suppression list and cannot be used for QA — see `~/.claude/projects/<project>/memory/reference_resend.md`.
| Step | Evidence |
|---|---|
| Sign-up `rbxin2003+reset@outlook.com` | `POST /api/auth/sign-up/email 200 2s`; UI navigated to `/verify-email?email=...` |
| Verify email | clicked link from real Outlook inbox; `GET /api/auth/verify-email?token=...&callbackURL=http://localhost:5173/auth/verify-email?verified=true` → 302 → UI shows "Email verified" |
| `POST /api/auth/request-password-reset` from UI | server log `200 3s`; UI shows "If rbxin2003+reset@outlook.com matches an account, a reset link is on the way" |
| Resend dashboard | `Reset your Project AIRI password` to `rbxin2003+reset@outlook.com``last_event: delivered` |
| Click reset link | `GET /api/auth/reset-password/<token>?callbackURL=http://localhost:5173/auth/reset-password` → 302 → UI form rendered with `?token=<token>` |
| Submit new password | `POST /api/auth/reset-password?token=...` → 200; UI shows "Password updated" |
| Sign in with new password | `POST /api/auth/sign-in/email``200` `{ token: <session>, user: { emailVerified: true, updatedAt: 2026-04-27T06:57:59.387Z } }` |
Two follow-up issues surfaced and were fixed in the same session:
1. **`apps/ui-server-auth` defaulted to production `https://api.airi.build`** because `VITE_SERVER_URL` was unset. Fixed by adding `apps/ui-server-auth/.env.development.local``VITE_SERVER_URL=http://localhost:3000`. Detected via `window.fetch` patching showing prod hostname; saved to `~/.claude/projects/<project>/memory/project_ui_server_auth_dev_env.md`.
2. **Better Auth's `originCheck` rejected `http://localhost:5173/...` callbackURLs** when the request came from a top-level GET (no Origin/Referer that matches dev origins). Fixed by adding `localhost:5173 / 5174 / 4173` to `ALWAYS_TRUSTED_AUTH_ORIGINS` in `apps/server/src/utils/origin.ts`. Prod-safe: those addresses are unreachable in prod, so the static list does not expand attack surface.
### Path 3 — Email + password sign-in via OIDC (partially verified)
`POST /api/auth/sign-in/email` was exercised directly to confirm `emailVerified` flips and a session token is issued, but the full UI-driven OIDC handoff (stage app → `/oauth2/authorize` → ui-server-auth → back to stage app with tokens) has NOT been tested in this session.
## What still needs running
### Path 2 — Forgot + reset password
1. From `/sign-in`, click "Forgot password?" → `/forgot-password`.
2. Submit the registered email. Expect `POST /api/auth/request-password-reset` returns 200, an email arrives ("Reset your Project AIRI password").
3. Click the email link. Expect server validates and 302s to `${UI}/ui/reset-password?token=<token>`.
4. Submit a new password. Expect `POST /api/auth/reset-password?token=...` returns 200; UI shows "Password updated".
5. Sign in with the new password and confirm session is issued.
### Path 3 — OIDC-bridged sign-in
1. Open a stage app (e.g. `apps/stage-web`) → triggers OIDC `/oauth2/authorize` → bounces to `ui-server-auth /ui/sign-in?...`.
2. Submit email + password against the verified user. Expect session cookie set; browser redirects to the OIDC continuation URL; stage app yields `code` → token exchange.
3. Stage app shows a signed-in state.
## Until Path 2 + 3 are ticked
Treat the email-auth feature as **partially shipped**. Sign-up + verify-email is production-quality; password reset and OIDC bridging are code-complete but not load-bearing without an end-to-end run.
## Known gaps deferred to follow-up
- Magic link UI (server-side wired, no front-end entry yet).
- Change-email front-end flow.
- Email i18n (only English).
- Resend bounce / complaint webhook ingestion.
- Email send audit log in `request_log`.
- dev/prod served-from parity (dev runs Vite at `:5174`; prod expects ui-server-auth to be deployed from `apps/ui-server-auth/dist` via Cloudflare Workers Static Assets).
@@ -1,127 +0,0 @@
# Verification: Flux Unbilled Exploit Fix
Status: **patched in commit `7267b0d6b`** (2026-05-15) for the chat-completion path. TTS flux-meter adaptation followed up in the same PR as this verification doc (see "Remaining gaps → Gap 1").
Last attempted: 2026-05-15
Owner: rbxin2003@gmail.com
## 用户路径
- **场景**:用户 `0 < balance < fallbackRate`,开 N 个并发 LLM completion 请求
- **预期(修补后)**:第一个 request 之后 pre-flight 拒绝;最多触发一次 partial debit`charged < requested`),所有剩余 request 收 402
- **实际(修补前)**N 个 request 全部走到 stream end,每个 catch `consumeFluxForLLM` 失败回滚 → balance 不变 → 用户拿到 N 次免费 LLM 响应
## Before / After 行为
### Before(修补前漏洞链)
1. Pre-flight 仅 `if (flux.flux <= 0)``apps/server/src/routes/openai/v1/index.ts`,旧版)
2. 用户余额 1 flux,但 `FLUX_PER_REQUEST` (fallback) 通常远大于 1
3. 并发 N 请求全部通过 pre-flight,上游 LLM 全部完成(响应已 stream 出去)
4. `consumeFluxForLLM``debitFlux` (`apps/server/src/services/billing/billing-service.ts:107`),旧逻辑:
```ts
if (balanceBefore < input.amount) {
throw createPaymentRequiredError('Insufficient flux')
}
```
整个 DB tx 回滚 → balance 维持 1 flux
5. catch 路径上报 `airi_billing_flux_unbilled_total{reason='debit_failed'}` += full amount
6. 用户重复同样的脚本,每次都拿免费响应
这是 Grafana panel-43 (Flux Unbilled) 累积到 **70.2K** 的根因。
### Aftercommit `7267b0d6b`
**1. Pre-flight 阈值改为 fallbackRate**`apps/server/src/routes/openai/v1/index.ts:138-142`):
```ts
const fallbackRate = await configKV.getOrThrow('FLUX_PER_REQUEST')
const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS')
const flux = await fluxService.getFlux(user.id)
if (flux.flux < fallbackRate) {
throw createPaymentRequiredError('Insufficient flux')
}
```
并发 N 请求时,pre-flight 直接 402 拒绝,不会进入上游 LLM 调用。
**2. Partial-debit 语义**`apps/server/src/services/billing/billing-service.ts:107-130`):
```ts
if (balanceBefore <= 0) {
metrics?.fluxInsufficientBalance.add(1)
throw createPaymentRequiredError('Insufficient flux')
}
const chargedAmount = Math.min(input.amount, balanceBefore)
const balanceAfter = balanceBefore - chargedAmount
const isPartial = chargedAmount < input.amount
```
- `balance > 0` 但不够 → drain 到 0ledger 记 `amount = charged`metadata 带 `requestedAmount + unbilled`
- `balance <= 0` 才 throwcatch path 上报 `reason='debit_failed'`
- Partial drain 上报 `reason='partial_debit_drained'`,跟真实 DB 错误区分
**3. Idempotency 反映原始 charged**`billing-service.ts:71-94`):
替换 request 的同 requestId 重放复用历史 `existing.amount` 作为 `charged`,避免重试时双扣 unbilled counter。
## Evidence
| Item | Reference |
|---|---|
| Fix commit | `7267b0d6b feat(server/billing): partial-debit semantics to prevent unpaid usage exploit` |
| Pre-flight gate | `apps/server/src/routes/openai/v1/index.ts:138-142` |
| Partial-debit logic | `apps/server/src/services/billing/billing-service.ts:107-148` |
| Streaming path unbilled metric | `apps/server/src/routes/openai/v1/index.ts:299-313` (label `reason='partial_debit_drained'`, `stage='streaming'`) |
| Non-streaming path unbilled metric | `apps/server/src/routes/openai/v1/index.ts:374-388` (label `stage='non_streaming'`) |
| Regression tests added | `apps/server/src/routes/openai/v1/route.test.ts` (+97 lines), `apps/server/src/services/billing/tests/billing-service.test.ts` (+89 lines) |
测试覆盖的两个核心 case
- `'rejects pre-flight when balance is below FLUX_PER_REQUEST (Issue: unpaid-usage-exploit)'` — 验证 pre-flight 在 partial-balance 用户上 rejectupstream 未被调用
- `'non-streaming completion drains partial balance and logs charged (Issue: unpaid-usage-exploit)'` — 验证 partial-debit drain 到 zero + metric 上报
## Remaining gaps
### Gap 1 — TTS flux-meter partial-debit 适配 ✅ 已修复
`apps/server/src/services/billing/flux-meter.ts:135-228` 的 `accumulate()` 现在解构 `{ charged, requested }`partial drain 时:
- 上报 `airi_billing_flux_unbilled_total{source='tts_meter', reason='partial_debit_drained', meter, gen_ai.request.model?}`
- `INCRBY` `(requested - charged) * unitsPerFlux` 回 Redis debt counter
- `AccumulateResult` 增加 `unbilledFlux` 字段供调用方观测
- 加 invariant 校验 `charged > requested` / 非整数 / 负数 → throw 而不是静默 under-restore
测试覆盖:`flux-meter.test.ts:217-260`case 名带 `Issue: unpaid-usage-exploit follow-up`。
**残余风险(已知,留 follow-up**settlement (LUA `runScript`) 和 `INCRBY` restore 之间非原子,并发请求理论上可能在窗口内读到 mid-state。窗口很小(一个 DB tx),实际命中很难触发;长期修复需要 Redis lock 或 unbilled 单独 bucket。代码里 `flux-meter.ts` 有 `// REVIEW:` 标记。
### Gap 2 — 修补前的 70.2K 历史漏账未核销
panel-43 显示的 70.2K 是 counter 累积值(`increase($__range)`),代表修补前漏出去的总量。修补**不会让 panel 自动归零**,只会让新的增量趋近 0(除真实 DB 错误)。
**建议**
- 把 dashboard 时间窗调到 commit `7267b0d6b` 部署后(2026-05-15 之后)观察增量斜率
- 加 Grafana alert`increase(airi_billing_flux_unbilled_total[5m]) > 0` → PagerDuty
- 如果业务需要核销历史 70.2K,从 `flux_transaction` ledger 反查 `metadata->>'reason' = 'debit_failed'` 的记录,配合 Loki 错误日志定位涉事 userId
## What's verified
- ✓ 代码层:pre-flight gate + partial-debit semantics 确实改了,逻辑正确
- ✓ 测试层:两个核心 regression test 覆盖 exploit 场景,case 名带 `Issue: unpaid-usage-exploit` 标识
## What's pending live verification
- ⊘ 生产 Grafana 上 panel-43 在 `2026-05-15` commit 部署后的斜率趋近 0
- ⊘ 没跑 `pnpm -F @proj-airi/server exec vitest run` 实际确认新测试 pass(建议在 push 之前跑一次)
- ⊘ TTS partial-debit 适配的 live verification(代码已修补 + 单测 14/14 pass,需要在生产观察 `airi_billing_flux_unbilled_total{source='tts_meter'}` 出现合理流量后再 close
## Recommended follow-ups
按优先级:
1. **P0 — 加 Grafana alert** `increase(airi_billing_flux_unbilled_total[5m]) > 0` → 通知
2. **P1 — 历史 70.2K 漏账处理**:查 ledger + Loki 决定核销还是补账
3. ~~**P1 — 修 TTS flux-meter 适配 partial-debit 新语义**~~ ✅ 已修复,见 Gap 1
4. **P2 — Dashboard 改造**panel-43 时间窗注解 + 区分 `reason` label 的 stack 图,把 `partial_debit_drained` 跟 `debit_failed` 分色展示)
@@ -1,195 +0,0 @@
# Verification: Flux Unbilled Historical Reconciliation
Status: **investigation framework — data gathering pending**
Owner: rbxin2003@gmail.com
Last updated: 2026-05-15
Related: [`flux-unbilled-exploit-fix.md`](./flux-unbilled-exploit-fix.md), [Grafana panel-43](../../../otel/grafana/dashboards/airi-server-overview-cloud.json)
## 用户路径
- **场景**commit `7267b0d6b` 之前累积了 ~70.2K Flux 的 unpaid usagepanel-43 `airi_billing_flux_unbilled_total` 显示值)。需要决定核销、补账、还是不处理
- **预期**:跑下面的 SQL + Loki query → 区分 partial-drain(用户已部分付款)vs debit-failedDB 错误,真零付款)→ 按 user 聚合 → 给出 reconciliation 决策
- **当前状态**:没有 prod DB 访问权限的工程师跑下面的 query。下方 SQL/queries 是**待执行的模板**,不是已采集的数据
## 两类漏账的区分
修补前 `airi_billing_flux_unbilled_total` 是单一 counter,没区分 reason。修补后(`7267b0d6b`)按 `reason` 拆成两个 label
| reason label | 触发条件 | Ledger 是否有记录 | 用户实际付款比例 |
|---|---|---|---|
| `partial_debit_drained` | `0 < balance < amount`drain 到 0 | ✓ 有(`amount = charged`metadata 带 `unbilled`) | 部分付款(drain 数额) |
| `debit_failed` | `balance <= 0` 或 DB tx 抛错 | ✗ 无(tx 回滚) | 零付款 |
**70.2K 全部发生在 5/15 之前**,那时所有失败都走 catch path → 全部记为 `reason='debit_failed'`**全部无 ledger row** → 用户实际付款为 0。
但实际不全是漏洞:少部分是真正的 DB 错误(DB outage / 唯一索引冲突)。绝大部分是 exploit。
## 取证 SQL(待执行)
> 在 Railway Postgres console 或本地 `psql $DATABASE_URL` 跑。如果 query 太重,先 `EXPLAIN ANALYZE` 看 cost`flux_transaction` 有 `flux_tx_user_id_idx` 和 `flux_tx_created_at_idx` 索引可以走
### 1. 按 type 分类的 ledger 写入分布
`reason='debit_failed'` 没 ledger row,所以这条 query **拿不到**修补前的漏洞数据——它只能 sanity check 修补后的新 row
```sql
SELECT
type,
COUNT(*) AS row_count,
SUM(amount) AS total_amount,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM flux_transaction
WHERE created_at >= '2026-04-15' -- 4 周窗口
GROUP BY type
ORDER BY total_amount DESC;
```
### 2. Partial-drain ledger rows(修补后)
`commit 7267b0d6b` 之后才会有这种 row。修补前漏出去的 70K 在这里**看不到**:
```sql
SELECT
user_id,
COUNT(*) AS partial_debit_count,
SUM(amount) AS total_charged,
SUM((metadata->>'unbilled')::bigint) AS total_unbilled,
SUM((metadata->>'requestedAmount')::bigint) AS total_requested,
MIN(created_at) AS first_partial,
MAX(created_at) AS last_partial
FROM flux_transaction
WHERE type = 'debit'
AND metadata ? 'unbilled'
AND (metadata->>'unbilled')::bigint > 0
AND created_at >= '2026-05-15' -- 修补后窗口
GROUP BY user_id
ORDER BY total_unbilled DESC
LIMIT 50;
```
### 3. 流量最高的用户(用来定位 exploit 嫌疑)
修补前的漏账主要靠这条 + Loki 日志交叉定位涉事 user
```sql
SELECT
user_id,
COUNT(*) AS debit_count,
SUM(amount) AS total_debited,
SUM(balance_after - balance_before) AS net_balance_change,
MIN(created_at) AS first_debit,
MAX(created_at) AS last_debit
FROM flux_transaction
WHERE type = 'debit'
AND created_at BETWEEN '2026-05-01' AND '2026-05-15' -- 修补前 2 周
GROUP BY user_id
HAVING COUNT(*) > 100
ORDER BY debit_count DESC
LIMIT 20;
```
异常用户特征:`debit_count` 极高 + `net_balance_change` 接近 0 即"balance 一直被推到底但没归零"。这种是 exploit 的核心 signature——攻击者维持 balance 卡在 `0 < x < fallbackRate` 区间反复触发免费请求。注意:`net_balance_change` 在 ledger 模型下应该等于 `-SUM(amount)`;如果两者接近 0 但 `SUM(amount)` 很大,说明 balance 被人工补回去过(信用 / 充值 / promo),需要进一步交叉检查。
### 4. 当前 user_flux 余额 vs ledger 一致性 sanity
```sql
WITH ledger_balance AS (
SELECT
user_id,
SUM(CASE WHEN type = 'credit' OR type = 'initial' OR type = 'promo' THEN amount
WHEN type = 'debit' THEN -amount
ELSE 0
END) AS computed_balance
FROM flux_transaction
GROUP BY user_id
)
SELECT
uf.user_id,
uf.flux AS recorded_balance,
lb.computed_balance AS ledger_sum,
uf.flux - lb.computed_balance AS drift
FROM user_flux uf
LEFT JOIN ledger_balance lb USING (user_id)
WHERE ABS(uf.flux - COALESCE(lb.computed_balance, 0)) > 0
ORDER BY ABS(uf.flux - COALESCE(lb.computed_balance, 0)) DESC
LIMIT 50;
```
正常情况下 drift 应该是 0——任何 drift 都说明 ledger 和 user_flux 表脱钩了,是 P0 事件。
## 取证 Loki(待执行)
Grafana → Explore → Loki datasource。这是**修补前漏账数据的唯一来源**(无 ledger row):
### 漏账 error log 全量
```logql
{service_name="server"} |= "Failed to debit flux after streaming — unpaid usage"
| json
| line_format "{{.userId}} | req={{.requestId}} | flux={{.fluxConsumed}} | {{.error}}"
```
### 按 userId 聚合 unbilled 数量
```logql
sum by (userId) (
count_over_time({service_name="server"} |= "Failed to debit flux after streaming" | json [30d])
)
```
### 时间分布(找爆发时段)
```logql
sum (
rate({service_name="server"} |= "Failed to debit flux after streaming" | json [5m])
)
```
修补前若有 sustained > 0 → exploit;若是窄峰 → 真实 DB outage。
## 处理决策框架
跑完上面 query + Loki 后,按下面决策树走:
```
┌──────────────────────────────────────────┐
│ 单用户漏账 fluxConsumed > 1000? │
├──────────────────────────────────────────┤
│ YES → exploit 嫌疑 │
│ ├─ 多 IP / 短时间高频 → confirmed │
│ │ │ 不补账(用户已知道是漏洞) │
│ │ │ Ban user 或 require email │
│ │ │ verification + 强制 reauth │
│ │ │ 已修补 → 单纯历史损失 │
│ │ └─ 不需要在 Postgres 写新 ledger │
│ └─ 单 IP / 时间分散 → 可能正常 power user │
│ │ 主动联系用户,问明情况 │
│ └─ 视情况决定是否赠送 flux 补偿 │
│ │
│ NO (用户总漏账 < 1000 flux) → 真异常 │
│ │ 多半是 DB outage / 单次错误 │
│ │ 不值得逐个追账 │
│ └─ 整体核销 + 跑 sanity SQL 4 验证 │
│ user_flux ≡ ledger 仍然一致 │
└──────────────────────────────────────────┘
```
**关键判断**:修补前的漏账**不在 ledger 里**debit_failed 不写 row),所以**不需要在 DB 做任何"核销"操作**——余额是干净的,损失只是"曾经免费送出去的 LLM token 成本"。
唯一需要写 DB 操作的场景:sanity SQL #4 跑出非零 drift。那是另一个 bugledger ↔ user_flux 脱钩),跟漏账无关。
## 修补后的监控建议(持续)
1. 加 Grafana alert`increase(airi_billing_flux_unbilled_total{reason!="partial_debit_drained"}[5m]) > 0` → 立即 pagepartial drain 是合理路径,不 page
2. 加每周自动 cron job 跑 sanity SQL #4drift > 0 → 报警(注意:项目里**不允许**新加后台 worker / cron,所以这个 job 应该走外部 ops 工具,比如 Railway scheduled command 或 GitHub Action
## What's verified / What's pending
| Item | Status |
|---|---|
| 漏洞已堵(commit `7267b0d6b` | ✓ 已确认(见 `flux-unbilled-exploit-fix.md` |
| 70.2K 历史漏账的 user 分布 | ⊘ 待跑 Loki query |
| user_flux ↔ ledger drift 是否存在 | ⊘ 待跑 SQL #4 |
| Exploit 涉事 user 是否已 ban / re-auth | ⊘ 等数据出来后决定 |
| `airi_billing_flux_unbilled_total{reason!="partial_debit_drained"}` Grafana alert | ⊘ 待配(见 `metrics-ownership.md` Alert SOP |
@@ -1,138 +0,0 @@
# Verification: Langfuse LLM-native tracing
## 场景
用户路径:客户端发 `POST /api/v1/openai/chat/completions` → 网关 `handleCompletion` 在处理时创建 Langfuse generation(input messages / output / model / token usage / userId / sessionId)→ 数据到达 Langfuse Cloud,可在逐条 prompt trace、eval、按用户/会话成本归因里查询。
## 为什么不用真实 server 起
`apps/server/.env.local``DATABASE_URL`(Neon)、`REDIS_URL`(Upstash)、`OTEL_EXPORTER_OTLP_ENDPOINT`(Grafana)全部指向**生产**实例。本地 `pnpm dev` 会连生产库并把 OTLP span 打到生产 Grafana,污染线上可观测性数据,也有触发真实计费的风险。因此用隔离 smoke 脚本复刻 `instrumentation.ts` + `handleCompletion` 的完全相同 wiring(独立 `NodeTracerProvider` + `LangfuseSpanProcessor` + `shouldExportSpan` langfuse.* 过滤 + `setLangfuseTracerProvider` + `startObservation(asType:'generation')` + `langfuse.user.id`/`langfuse.session.id` 属性),只验证 Langfuse 导出链路,不碰生产 DB/Redis/Grafana。生产路径的 generation 代码与 smoke 同形,typecheck 保证编译一致。
## 命令
```sh
# 1. 凭据有效性
curl -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" https://us.cloud.langfuse.com/api/public/projects
# 2. smoke 脚本复刻 wiring,发一条 chat.completion generation 后 forceFlush + shutdown
pnpm exec dotenvx run -f .env.local -- tsx <smoke> # 临时脚本,验证后已删
# 3. 回读 Langfuse Cloud
curl -u "$PK:$SK" https://us.cloud.langfuse.com/api/public/traces?limit=20
curl -u "$PK:$SK" https://us.cloud.langfuse.com/api/public/observations?limit=20
```
类型 / 静态检查:
```sh
pnpm -F @proj-airi/server typecheck # tsc --noEmit,0 错误
pnpm exec eslint apps/server/instrumentation.ts apps/server/src/routes/openai/v1/index.ts # 0 warning/error
```
## 预期
- 凭据 API 返回 200。
- smoke 无报错,forceFlush + shutdown 成功。
- 回读 traces 出现 `name=chat.completion`,带 `userId` / `sessionId`(证明 `langfuse.user.id`/`langfuse.session.id` 提升为 trace 级归因)。
- 回读 observations 出现 `type=GENERATION`,带 `model` / `input`(messages 数组)/ `output` / `usageDetails`
## 实际
- typecheck 0 错误;eslint 0 输出。
- 凭据:`status=200`,project `name=Airi` id `cmajdtoua06h2ad07yp1qf1nk`
- 直接 ingestion API POST(文档化 batch 格式)返回 `status=207`,两 event 均 `201 created` —— 写入端点 + 凭据 + 格式全部正确。
- 回读(摄取 lag 约 90s 后,新项目首批较慢):
```
TRACE name=chat.completion user=smoke-user session=smoke-session (× 多条 smoke run)
TRACE name=direct-ingest-test user=direct-user
OBS_COUNT=20
OBS name=chat.completion type=GENERATION model=smoke-test-model
in=[{"role":"user","content":"ping from airi langfuse smoke (...)"}]
out="pong"
usage={"input":5,"output":1}
```
input messages / output / model / usageDetails / userId / sessionId 全部落到 Langfuse Cloud。`shouldExportSpan` langfuse.* 过滤未拒绝 generation span(smoke 用的就是该过滤,trace 正常出现)。
## 已知限制
- 未经真实 `handleCompletion` HTTP 请求验证(需生产隔离环境 + auth token + 配置好的 LLM_ROUTER_CONFIG 模型)。smoke 复刻同一 SDK 调用形态 + typecheck 编译一致 + lint 通过,作为当前可得的最强 fresh evidence。下一次在 staging(DB/Redis/Grafana 指向非生产)起真实 server 发一次 chat 请求即可补全端到端。
## codex review 后复测
codex 独立 review 报 8 项,修了 4 项(详见下方代码改动)。修复后复测:
- typecheck `tsc --noEmit` 0 错误;eslint(instrumentation.ts + openai/v1/index.ts)0 输出。
- SSE 解析纯逻辑单测(`extractSseDeltaText` + chunk 边界组装)4 case 全 PASS:简单多 delta、内容跨 chunk 断行、usage-only/空行忽略、malformed line 降级。
-`AlwaysOnSampler`(F4 修复)的 live smoke 再次回读成功:Langfuse Cloud observation `model=verify-model`,`input=[{role:user,content:...}]`,`output="Hello"`,`usageDetails={input:5,output:2,total:6}` —— 确认 provider sampler 改动没破坏导出。
修复项:
- F4(sampler,真 bug):generation 的 parent 是 `@hono/otel` HTTP span,默认 ParentBased sampler 会让 `OTEL_TRACES_SAMPLING_RATIO<1` 时连带丢 Langfuse generation。改 langfuseProvider 显式 `AlwaysOnSampler`,Langfuse 捕获与 Grafana head-sampling 解耦。
- F6(非流式 generation 泄漏,真 bug):`response.json()` 解析失败时 span + generation 都不 end。加 try/catch 在抛出前关闭两者。
- F7(流式 output 是原始 SSE,体验):改 `extractSseDeltaText` 逐行解析出 assistant 正文,不再存 `data:` 框架。
- F9(流式 fullText 内存,真 bug):加 1M 上界;后续复审发现单个超大 delta 仍会越界,已改成按剩余容量 slice 的硬上限。
- F5(gate)codex 判 non-issue,仍主动改成 instrumentation 置的 `LANGFUSE_TRACING_ACTIVE` sentinel(单一真相,防 enable 条件 desync 漏 PII)。
- 拒绝/记录:F1(隔离)/F3(shutdown)/F5/F7-gate codex 判 non-issue,确认;F2(traceId 关联)经核对实为「继承 Hono span traceId,OTLP 开启时可关联」,已修正注释与文档(此前误述为不关联);F10(input base64 无 cap)记为已知限制,低优先。
## 抽象重构后复测(llm-tracing 深模块)
把 Langfuse 逻辑从 transport 层 `openai/v1/index.ts` 抽到 `services/domain/llm-tracing/index.ts`(gate / SDK / SSE 解析 / 生命周期全部隐藏,route 只调 `startChatGeneration``appendStreamChunk` / `succeed` / `fail`)。复测:
- `pnpm -F @proj-airi/server typecheck`:0 错误。
- `pnpm exec vitest run .../llm-tracing/index.test.ts`:**Tests 9 passed (9)** —— disabled no-op、创建参数、session 有无、非流式 output、流式跨 chunk 组装、malformed SSE 忽略、fail ERROR、幂等 end。纯逻辑单测,按 Iron Law 即该模块的 fresh evidence。
- `pnpm exec eslint`(instrumentation + route + 模块 + 测试):0 输出。
- 端到端行为不变:route 改的只是调用形态,generation 字段映射与之前 smoke 回读到的一致(input/output/model/usageDetails/userId/sessionId)。
## 收尾复测(TTS + client session + hard cap)
补齐:
- `startTtsGeneration` + `/api/v1/audio/speech` route:记录 `tts.speech` generation,不缓冲二进制 audio,只记录 input text/voice/speed/format、contentType、input char usage、flux metadata。
- `packages/stage-ui/src/libs/providers/providers/official/shared.ts`:official provider fetch 自动带 `x-airi-session-id`(Pinia active chat session 存在时)。
- 流式 output hard cap:单个超大 SSE delta 也只追加剩余容量。
复测:
- `pnpm -F @proj-airi/server typecheck`:0 错误。
- `pnpm exec vitest run apps/server/src/services/domain/llm-tracing/index.test.ts apps/server/src/services/domain/llm-router/tests/router.test.ts apps/server/src/routes/openai/v1/route.test.ts`:3 files / 77 tests passed。
- `pnpm exec eslint apps/server/instrumentation.ts apps/server/src/routes/openai/v1/index.ts apps/server/src/services/domain/llm-tracing/index.ts apps/server/src/services/domain/llm-tracing/index.test.ts apps/server/src/services/domain/llm-router/router.ts apps/server/src/services/domain/llm-router/tests/router.test.ts packages/stage-ui/src/libs/providers/providers/official/shared.ts`:0 输出。
- `pnpm -F @proj-airi/stage-ui typecheck`:0 错误。
Langfuse 隔离 live smoke(覆盖 `NODE_ENV=codex-langfuse-smoke`, `OTEL_SERVICE_NAME=server-codex-langfuse-smoke`, `SERVER_INSTANCE_ID=codex-langfuse-smoke`, 且清空 `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS`)已补跑,只复用 `.env.local` 的 Langfuse keys,不连 DB/Redis/Grafana OTLP:
- 写入命令:`pnpm exec dotenvx run -f .env.local --ignore=MISSING_ENV_FILE -- tsx --import ./instrumentation.ts scripts/langfuse-smoke.ts`
- 启动日志确认:`OpenTelemetry initialized — OTLP: off, Langfuse: https://us.cloud.langfuse.com`
- run id:`codex-langfuse-1780140569403`
- 回读 traces:
- `tts.speech`, `userId=codex-langfuse-smoke-user`, `sessionId=codex-langfuse-1780140569403-session`, `metadata.requestId=codex-langfuse-1780140569403-tts`
- `chat.completion`, `userId=codex-langfuse-smoke-user`, `sessionId=codex-langfuse-1780140569403-session`, `metadata.requestId=codex-langfuse-1780140569403-chat`
- 回读 observations:
- `tts.speech`, `type=GENERATION`, `model=codex-smoke-tts-model`, `usageDetails={input:38,total:38}`, `output.contentType=audio/mpeg`
- `chat.completion`, `type=GENERATION`, `model=codex-smoke-chat-model`, `usageDetails={input:4,output:5,total:9}`, `output="hello from chat smoke"`
- 两条回读记录的 `resourceAttributes` 均为 `service.name=server-codex-langfuse-smoke`, `service.namespace=airi`, `service.instance.id=codex-langfuse-smoke`, `deployment.environment=codex-langfuse-smoke`
仍未做真实 server HTTP E2E:本地 `.env.local` 里的 DB/Redis 仍指向生产实例。当前已验证的是同一 `instrumentation.ts` + `llm-tracing` generation SDK 写入链路;真实 HTTP 请求还需要 staging DB/Redis/router/auth token 后补跑。
## 模型归因修正(chat-auto alias → 上游模型)
Langfuse Model costs 页面曾出现 `chat-auto`。这不是 Langfuse pricing 配置问题,而是 route 在调用 `llmRouter.route(...)` 前就用 client/request model 创建 `chat.completion` generation;如果 router config 通过 `upstream.overrideModel``chat-auto` 改写成真实上游模型,Langfuse 仍记录 alias。
修正:
- `LlmRouteContext.upstreamModel`:router 成功命中上游时写入实际发给上游的 `overrideModel ?? modelName`
- `handleCompletion`:router 返回后再创建 Langfuse generation,`model` 使用 `routeCtx.upstreamModel ?? requestModel`
- route 里的 billing/request-log/本地 OTel metric 仍保持原有 `requestModel` 语义;本次只修 Langfuse model-cost 归因。
复测:
- `apps/server/src/services/domain/llm-router/tests/router.test.ts`:覆盖 `upstream.overrideModel` 同时写入 `ctx.upstreamModel`
- `apps/server/src/routes/openai/v1/route.test.ts`:覆盖请求 `model=chat-auto`、router context 返回 `openai/gpt-4o-mini` 时,`startChatGeneration({ model })` 使用 `openai/gpt-4o-mini`
- `pnpm exec vitest run apps/server/src/services/domain/llm-router/tests/router.test.ts apps/server/src/routes/openai/v1/route.test.ts apps/server/src/services/domain/llm-tracing/index.test.ts`:3 files / 78 tests passed。
- `pnpm -F @proj-airi/server typecheck`:0 错误。
- `pnpm exec eslint apps/server/src/routes/openai/v1/index.ts apps/server/src/routes/openai/v1/route.test.ts apps/server/src/services/domain/llm-router/router.ts apps/server/src/services/domain/llm-router/types.ts apps/server/src/services/domain/llm-router/tests/router.test.ts`:0 输出。
## 环境
- base commit: `dc1037f34`(本次改动未提交,工作树状态)
- Langfuse: us.cloud.langfuse.com,project Airi
- SDK: `@langfuse/tracing` + `@langfuse/otel` 5.4.0,`@opentelemetry/api` 1.9.1
- 最后验证日期:2026-05-30
@@ -1,153 +0,0 @@
# LLM/TTS router replacing knoway — verification
Verification artifacts for the in-process router. Scope tracked against
`docs/plans/2026-05-15-001-feat-llm-tts-router-replacing-knoway-plan.md`.
## Coverage status
| User path | Code wired | Has fresh evidence |
|---|---|---|
| chat completions happy (router → OpenRouter) | ✅ | ✅ commit `3a88f4225`, 2026-05-15 |
| chat completions fallback (key/upstream exhaustion) | ✅ | ❌ unit-test only, needs real-wire run |
| TTS speech (Azure) via `routeTts` | ✅ | ⏳ pending (was knoway-fetch until 2026-05-15) |
| TTS speech (dashscope-cosyvoice) via `routeTts` | ✅ | ⏳ pending |
| TTS speech (Volcengine) via `routeTts` | ✅ | ⏳ pending |
| `/audio/voices` from adapter catalog (no upstream) | ✅ | ⏳ pending (sanity curl) |
| `/livez` | ✅ | ✅ commit `cfad87757`, 2026-05-15 |
| `/readyz` | ✅ | ✅ commit `cfad87757`, 2026-05-15 |
The TTS paths and `/audio/voices` were missing from the prior revision of
this doc (which claimed `shipped across U1-U9` while the route handlers
were still hitting `GATEWAY_BASE_URL`). The router-side wiring landed
2026-05-15; the table above tracks the real evidence backlog so the doc
stops asserting completion ahead of measurement.
## E2E: chat completion through router service
- **Scenario**: operator seeds `LLM_ROUTER_CONFIG` with one OpenRouter LLM
upstream, then invokes the router directly to call OpenRouter for a chat
completion. Validates envelope decrypt → configKV load → key rotation →
upstream fetch → response parse on the real wire path.
- **Command** (admin endpoint replaced the seed script on 2026-05-18; the
2026-05-15 evidence below was captured with the now-removed
`scripts/seed-router-config.ts`):
```bash
# 1. seed via the admin endpoint — requires an account whose email is in
# ADMIN_EMAILS and is verified.
curl -sS -X POST http://localhost:3000/api/admin/config/router \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mode": "merge",
"slices": [{
"kind": "openrouter",
"modelName": "chat-default",
"overrideModel": "openai/gpt-4o-mini",
"plaintextKey": "<OPENROUTER_KEY>"
}],
"defaults": { "chatModel": "chat-default" }
}' | jq
# 2. exercise the router via the in-process e2e harness.
cd apps/server
pnpm exec dotenvx run --env-file=.env.local -- \
tsx scripts/e2e-llm-router.ts
```
- **Expected output**: `status 200`, JSON body with `choices[0].message.content`
populated, and `E2E PASS — router service successfully called OpenRouter
and returned a usable response.`
- **Actual output** (commit `3a88f4225`, 2026-05-15):
```
→ calling router.route() with model=chat-default
fetch → POST https://openrouter.ai/api/v1/chat/completions
auth = Bearer sk-or-v1-bb1a38505a7309...
body = {"messages":[{"role":"user","content":"Say \"hello world\" in exactly 3 words, no period."}],"max_tokens":20,"model":"openai/gpt-4o-mini"}
← status 200 (2958ms)
Assistant response:
model: openai/gpt-4o-mini
text: "hello world!"
tokens: prompt=21 completion=3
E2E PASS — router service successfully called OpenRouter and returned a usable response.
```
- **Environment**: commit `3a88f4225`, local dev (Node 26, pnpm 10, Postgres
+ Redis via local services), Hono 4.11.3, `.env.local` with a generated
32-byte base64 `LLM_ROUTER_MASTER_KEY`.
- **Last verified**: 2026-05-15.
## Liveness probe
- **Scenario**: `GET /livez` returns 200 with `{status: "live"}` even
when external dependencies are degraded. K8s-style flat naming; legacy
`/health` and nested `/healthz/live` removed in this revision.
- **Command**: `curl -i http://localhost:3000/livez`
- **Expected output**: HTTP 200, body `{"status":"live"}`.
- **Actual output** (commit `cfad87757` + uncommitted route rename, 2026-05-15):
```
HTTP 200
{"status":"live"}
```
Cross-check: `curl http://localhost:3000/health` → HTTP 404 (legacy
endpoint removed); `curl http://localhost:3000/healthz/live` → HTTP 404
(nested form removed).
- **Last verified**: 2026-05-15.
## Readiness probe
- **Scenario**: `GET /readyz` returns 200 when Postgres + Redis both
respond; 503 otherwise. Gateway-internal key health does NOT block
readiness (R14).
- **Command**: `curl -i http://localhost:3000/readyz`
- **Expected output**: HTTP 200, body `{"status":"ready","checks":{"db":"ok","redis":"ok"}}`.
- **Actual output** (commit `cfad87757` + uncommitted route rename, 2026-05-15):
```
HTTP 200
{"status":"ready","checks":{"db":"ok","redis":"ok"}}
```
- **Last verified**: 2026-05-15.
## Test suite
- **Scenario**: full unit-test suite for router-touched modules.
- **Command**: `pnpm -F @proj-airi/server exec vitest run`
- **Expected output**: green run; 91+ tests covering envelope-crypto, env,
config-kv, llm-router/{router,key-rotator,config-loader,error-mapping},
tts-adapters, routes/openai/v1.
- **Actual output** (commit `3a88f4225`, 2026-05-15): 91 tests across 8 files
green; `pnpm -F @proj-airi/server typecheck` exits 0.
- **Last verified**: 2026-05-15.
## Known limitations / follow-up
- **U9 admin HTTP endpoint**: partially shipped 2026-05-18 as
`POST /api/admin/config/router` (see `routes/admin/config/router/index.ts`).
Covers the write path with audit-log fields on the structured logger,
envelope encryption in-process, and cross-instance invalidation publish.
The plan's ETag-based optimistic concurrency control and HMAC-signed
invalidate payload are still deferred; the `config_write` and
`config_invalid_hmac` counters described below remain producerless until
those land.
- ~~**GATEWAY_BASE_URL**: still required in env schema~~. Resolved
2026-05-15: env entry removed, all routes go through `llmRouter.route` /
`routeTts` / `listTtsVoices`. The `LLM_ROUTER_MASTER_KEY` env var is
now required (no graceful skip).
- ~~**Grafana dashboard JSON updates**: the new `airi.gen_ai.gateway.*`
counters … do not yet have panels for them~~. Partially resolved
2026-05-16: `otel/grafana/dashboards/build.ts` generates three router
rows (Health / Trends / Admin Plane) covering the 7 gateway counters
that have live producers: `fallback_count`, `upstream_errors`,
`key_exhausted`, `same_status_exhaustion`, `decrypt_failures`,
`config_reload`, and `subscriber_state` (producer added in the same
PR — `app.ts` now emits `connected` / `error` / `reconnecting` from the
`configkv:invalidate` subscriber). The remaining two counters
(`config_write`, `config_invalid_hmac`) intentionally have no panels
because their producer is the ETag + HMAC slice of the U9 admin
endpoint that has not shipped (see the U9 entry above); they will
rejoin Rows 6.5 / 6.7 when that slice lands.
Alert rules (key.exhausted > 0, fallback ratio > 30%, single-key
error ratio > 80%) are still configured through Grafana UI, not
build.ts — IaC-ifying them is a separate follow-up.
- **knoway compose retention**: keep `/Users/luoling8192/Git/proj-airi/airi-railway/knoway/`
+ the corresponding container entry in `airi-railway/docker-compose.yml`
for **at least 14 days without a P1+ incident** before removing per plan R18.
@@ -1,26 +0,0 @@
# Verification: 服务端 PostHog 转发 + SPA 路由 pageview
Status: **transport 与 pageview 已真实验证;线上 Stripe webhook 端到端待部署后确认**
Owner: Product Analytics
Last updated: 2026-07-08
Environment: commit 689f02ac4 + 本次工作区改动;posthog-node 5.39.4posthog-js 1.306.1Node 26.3.0
## 用户路径
- **场景 1**:用户完成 Stripe 支付 → webhook 写 `product_events` → 服务端把 `payment_completed` 转发到 PostHogdistinctId = Better Auth user id)→ PostHog 付费漏斗在 `checkout_started` 后闭环。
- **场景 2**:用户在 stage-web 内切换路由(如进入 `/settings/flux`)→ PostHog 收到 `$pageview`,带 `$pathname`、上一页路径与停留时长。
## 已验证证据
| 验证项 | 命令 / 方式 | 实际输出 |
|---|---|---|
| 转发白名单与映射 | `pnpm exec vitest run src/services/domain/product-events.test.ts`apps/server | 6 passed`payment_completed` 原名转发、`user_signed_up→signup_completed` 映射、per-request 动作不转发、sink 抛错时 DB 行仍落库且 track 不抛 |
| posthog-node 真实传输 | `node posthog-smoke.mjs`captureImmediate → us.i.posthog.com,生产 project key,事件名 `server_forwarding_smoke_test` | `captureImmediate resolved in 1380ms` + `shutdown clean` |
| SPA 路由 pageview | `VITE_ENABLE_POSTHOG=true pnpm -F @proj-airi/stage-web dev` + agent-browser 两次 `history.pushState` | 两条 `$pageview``$pathname` 分别为 `/settings/flux``/settings/airi-card``navigation_type: pushState`,携带 `$prev_pageview_duration``app_surface: web` super property;批量 POST `us.i.posthog.com/e/` 返回 200 |
| 服务端 typecheck / lint | `pnpm -F @proj-airi/server typecheck`、eslint 改动文件 | 均通过 |
## 注意事项
- 自动化浏览器(`navigator.webdriver = true`)会被 posthog-js 默认 bot 过滤静默丢弃事件;本次浏览器验证通过会话内 `set_config({ opt_out_useragent_filter: true })` 绕过,仅影响该验证会话,生产配置未改。人工复测时用普通浏览器即可,无需任何绕过。
- 服务端转发默认开启:`POSTHOG_PROJECT_KEY` 的默认值就是前端共用的 phc_* project key,置空字符串可关闭。部署后在 PostHog 里确认 `payment_completed` 事件出现在真实支付后,即完成端到端收尾。
- PostHog 项目里会留有一条 `server_forwarding_smoke_test`distinctId `verification-smoke`)测试事件,分析时按事件名过滤。
@@ -1,413 +0,0 @@
# Verification: Product Analytics Smoke Test
Status: **code-level instrumentation verified; live PostHog dashboard updated; Grafana dashboard updated; alert setup pending**
Owner: Community / Product Analytics
Last updated: 2026-07-10
Related:
- [`product-analytics-instrumentation.md`](../product-analytics-instrumentation.md)
- [`product-analytics-dashboard-setup.md`](../product-analytics-dashboard-setup.md)
- [`airi-server-overview-cloud.json`](../../../otel/grafana/dashboards/airi-server-overview-cloud.json)
## 用户路径
- **场景**:验证新增埋点能回答“用户是否能正常开始聊天”“Provider 配置卡在哪里”“哪个 TTS 音色被选择 / 实际播放”“语音输入卡在哪里”“用户是否提交反馈”。
- **预期**PostHog 能看到前端 journey eventsPostgres `product_events` 能看到服务端 TTS metadataGrafana 能看到低基数 server-side product health。
- **当前状态**:代码与 dashboard JSON 已验证;线上 PostHog dashboard 已补官方 Provider / 官方 TTS / paywall 卡片;线上 Grafana `AIRI Server Overview - Product Analytics` (`ad8qbp5`) 已补 TTS blocked reason / Flux bucket 面板;alert 仍需人工配置。
## 已经由代码验证
| Area | Evidence |
|---|---|
| Frontend analytics API | `packages/stage-ui/src/composables/use-analytics.test.ts` 覆盖 activation、model list、provider config、voice selection、voice input、feedback event API |
| Chat activation hooks | `packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts` 覆盖 activation started / succeeded / failed hook |
| Chat round failures | core runtime 与 stage contract tests 覆盖激活前和激活后的 `message_round_failed`,并验证 `conversation_id` / `round_id` / `turn_index` |
| Voice input failures | `packages/stage-ui/src/composables/audio/audio-device.test.ts``packages/stage-ui/src/stores/modules/hearing.analytics.test.ts` 覆盖 permission / device / cancel / STT failed |
| Server TTS metadata | `apps/server/src/routes/openai/v1/route.test.ts``apps/server/src/routes/audio-speech-ws/route.test.ts` 覆盖 REST / WS TTS `voice_id``voice_type``voice_pack_id` metadata |
| Grafana product row | `apps/server/otel/grafana/dashboards/build.test.ts` 覆盖 Product Analytics panels、layout references、PromQL 不包含 high-cardinality voice / user fields |
## Live Smoke Checklist
Run this after deploying a build with the instrumentation changes. The PostHog dashboard and Grafana dashboard shell are already created, but they still need live event traffic from the deployed build.
### 1. PostHog: chat activation
Action:
1. Use a fresh or test account.
2. Start with an official provider.
3. Send the first chat message and wait for the assistant response.
4. Send a second message in the same session.
Expected PostHog events:
```text
chat_activation_started
chat_activation_succeeded
second_turn_started
```
Required properties:
```text
provider_mode = official
provider_id = <official provider id>
model_id = <selected model id>
app_surface = web | mobile | electron
conversation_id = <same application session id across the chat chain>
round_id = <same id across one message round; different between the first and second round>
turn_index = 1 for the first round; 2 for second_turn_started and the second round
```
Fail if:
- `chat_activation_started` appears but `chat_activation_succeeded` never appears for a successful chat.
- The second message is sent but `second_turn_started` does not appear.
- `provider_mode` is missing or always `unknown`.
- `app_surface` is missing.
- Any chat-chain event is missing `conversation_id`, `round_id`, or `turn_index`.
- Events from one round disagree on `round_id`, or two different rounds reuse the same `round_id`.
### 1a. PostHog: message round failure
Action:
1. Complete a successful first chat round.
2. Force the second round to fail before the assistant response completes.
Expected PostHog events:
```text
message_round_failed
```
Required properties:
```text
conversation_id = <same application session id as the successful first round>
round_id = <the failed round's user message id>
turn_index = 2
provider_id = <active provider id>
model_id = <selected model id>
failure_stage = llm_response
error_code = llm_response_failed
app_surface = web | mobile | electron
```
Fail if:
- The failed second round has no `message_round_failed` event.
- The failed round emits `message_round`, `chat_failed`, or `assistant_response_completed` as an alias.
- A new `chat_activation_failed` appears after the conversation already completed its first assistant response.
- Correlation keys disagree with the failed round's preceding message / LLM events.
### 1b. Signup identity ownership
1. Complete an email signup in the auth SPA.
2. Confirm the auth SPA emits `signup_form_completed` with `app_surface = auth`.
3. Confirm the Better Auth user-create hook emits exactly one `signup_completed` with `app_surface = server` and the Better Auth user id as `distinctId`.
Fail if the auth SPA emits `signup_completed`, or if the server event lands on a different PostHog person from later identified onboarding events.
### 1c. PostHog: official provider selection
Action:
1. Sign in with an account that has no active chat provider yet, or switch the chat provider to the official provider in settings.
Expected PostHog events:
```text
official_provider_selected
```
Required properties:
```text
provider_mode = official
provider_id = <official provider id>
source = default_auto | settings
auto_selected = true | false
```
Fail if:
- Default official provider bootstrap reports `auto_selected = false`.
- Manual settings selection reports `auto_selected = true`.
### 2. PostHog: provider config failure
Action:
1. Configure a custom provider with an invalid key or invalid endpoint.
2. Trigger settings validation or manual chat ping.
Expected PostHog events:
```text
provider_config_started
provider_config_failed
```
Required properties:
```text
provider_mode = custom
provider_id = <provider id>
step = settings_auto_validate | manual_chat_ping
error_code = <bounded error code>
```
Fail if:
- Raw error text, API key fragments, endpoint secrets, or stack traces appear in event properties.
- `provider_config_failed` has no `error_code`.
### 3. PostHog: TTS voice selection
Action:
1. Open speech settings.
2. Select an official TTS provider.
3. Select or keep an official voice.
4. Play voice preview once.
Expected PostHog events:
```text
tts_provider_selected
official_tts_exposed
official_tts_preview_started
official_tts_preview_succeeded
voice_selected
voice_preview_played
```
Required properties:
```text
tts_provider_id = <provider id>
tts_model_id = <model id>
voice_id = <catalog voice id or custom>
voice_type = official_default | official_selected | custom_configured | voice_pack | unknown
source = settings | manual_preview
```
Fail if:
- `voice_selected` is missing, because this blocks “哪个 TTS 音色比较多”的核心问题。
- Official TTS preview succeeds in the UI but `official_tts_preview_succeeded` is missing.
- Official default voice is indistinguishable from custom configured voice.
### 3b. PostHog: official TTS auto playback
Action:
1. Enable chat auto TTS with an official TTS provider.
2. Send a chat message and wait for an assistant response that triggers speech playback.
Expected PostHog events:
```text
official_tts_auto_enabled
```
Required properties:
```text
tts_provider_id = <official provider id>
tts_model_id = <model id>
source = chat_auto_tts
enabled = true
```
Fail if:
- Chat auto TTS plays through the official provider but `official_tts_auto_enabled` is missing.
### 4. PostHog: voice input friction
Action:
1. Start voice input.
2. Test one failure path: deny microphone permission, use a browser/device with no microphone, or cancel input.
Expected PostHog events:
```text
voice_input_started
microphone_permission_requested
microphone_permission_denied
audio_device_unavailable
voice_input_cancelled
stt_failed
```
Only the events that match the exercised path need to appear.
Fail if:
- Permission denied or device unavailable is only visible as a generic `stt_failed`.
- `error_code` contains raw browser error text.
### 4b. PostHog: paywall exposure
Action:
1. Open the Flux / plan purchase entry.
2. Use an account with a known low or zero Flux balance if possible.
Expected PostHog events:
```text
paywall_seen
```
Required properties:
```text
entry_surface = settings_flux
reason = manual_topup
flux_balance_bucket = zero | 1_100 | 101_1000 | 1001_10000 | 10000_plus | unknown
```
Fail if:
- The purchase entry is visible but `paywall_seen` is missing.
- A precise balance is sent instead of the bounded `flux_balance_bucket`.
### 5. Postgres: server-side TTS metadata
Action:
1. Trigger one REST TTS request.
2. Trigger one chat/WS TTS request if the deployed environment supports it.
Query:
```sql
SELECT
created_at,
user_id,
source,
provider,
model,
action,
status,
metadata->>'voice_id' AS voice_id,
metadata->>'voice_type' AS voice_type,
metadata->>'voice_pack_id' AS voice_pack_id
FROM product_events
WHERE feature = 'tts'
AND created_at >= now() - interval '1 hour'
ORDER BY created_at DESC
LIMIT 50;
```
Expected:
- `speech_requested` and `speech_succeeded` rows exist for successful TTS.
- `voice_id` is present when the request provided a selected voice.
- `voice_type` distinguishes official default / selected / custom / voice pack where available.
- Blocked rows include bounded `block_reason` and `flux_balance_bucket`.
- Failed rows include bounded `failure_reason`.
Fail if:
- TTS succeeds but no `product_events` row is written.
- Metadata contains raw prompts, message text, API keys, or request bodies.
### 7. Grafana: Product Analytics row
Action:
1. Open `https://projairi.grafana.net/d/ad8qbp5/airi-server-overview`.
2. Open the `Product Analytics` row.
3. Use a 1h time range after running the smoke actions above.
Expected panels:
```text
Product Events (range)
Product Failure %
TTS Success %
TTS Failed / Blocked (range)
TTS Blocked by Reason
TTS Blocked by Flux Bucket
Top Product Actions (range)
Product Event Rate
TTS Event Rate by Source
```
PromQL sanity:
```promql
sum(increase(airi_product_events_total{feature="tts"}[1h]))
```
Expected:
- Query returns a non-zero value after TTS smoke actions.
- Legends only use bounded labels: `feature`, `action`, `status`, `source`, `reason`, `flux_balance_bucket`.
Fail if:
- Prometheus labels contain `voice_id`, `voice_pack_id`, `user_id`, `session_id`, or `request_id`.
- Grafana shows product panels but Postgres has no matching TTS rows.
## Quick Analysis Queries
Top selected voices should come from PostHog `voice_selected` for frontend intent. Server-side playback can be cross-checked from Postgres:
```sql
SELECT
metadata->>'voice_id' AS voice_id,
metadata->>'voice_type' AS voice_type,
provider,
model,
COUNT(*) AS play_count,
COUNT(DISTINCT user_id) AS distinct_users
FROM product_events
WHERE feature = 'tts'
AND action = 'speech_succeeded'
AND created_at >= now() - interval '7 days'
GROUP BY 1, 2, 3, 4
ORDER BY play_count DESC
LIMIT 20;
```
Server-side TTS blocked / failed ranking:
```sql
SELECT
action,
status,
source,
reason,
COUNT(*) AS event_count,
COUNT(DISTINCT user_id) AS distinct_users
FROM product_events
WHERE feature = 'tts'
AND action IN ('speech_failed', 'speech_blocked')
AND created_at >= now() - interval '24 hours'
GROUP BY 1, 2, 3, 4
ORDER BY event_count DESC;
```
## Exit Criteria
| Item | Pass condition |
|---|---|
| Activation | PostHog funnel shows `chat_activation_started -> chat_activation_succeeded` by `provider_mode` |
| Retention proxy | PostHog shows `second_turn_started` for the second message in a successful session |
| Official provider | PostHog shows `official_provider_selected` by `provider_id` and `source` |
| Provider config | Failed custom config emits `provider_config_failed` with bounded `error_code` |
| TTS voice | PostHog can rank `voice_selected` by `voice_id`; official TTS exposure / preview / auto playback events appear; Postgres can rank actual `speech_succeeded` by metadata voice |
| Voice input | Permission / device / cancel paths are distinguishable |
| Feedback | Feedback event API exists with bounded fields; product feedback UI/server submission is split into a separate PR |
| Grafana | Product Analytics row renders TTS reason / Flux bucket panels and uses only bounded Prometheus labels |
## Known Pending Work
- PostHog dashboard cards are created, but the official provider / official TTS / paywall cards need deployed traffic before they show meaningful data.
- Updated Grafana panels are deployed to the production Grafana workspace, but alert rules still need to be configured.
- Discord / QQ ingestion and daily / weekly automation scripts are intentionally excluded from this pass.
@@ -1,231 +0,0 @@
# Bidirectional streaming TTS — verification
Verification artifacts for the airi-side proxy at `/api/v1/audio/speech/ws`
and the unspeech-side bridge at `/v1/audio/speech/stream`, both new in
this session (server-dev branch, 2026-05-15).
## Coverage status
| User path | Code wired | Has fresh evidence |
|---|---|---|
| Unspeech ws upgrade (HTTP 101) | ✅ | ✅ smoke 2026-05-15 |
| Unspeech rejects malformed first frame as JSON `error` event | ✅ | ✅ smoke 2026-05-15 |
| Unspeech rejects unsupported backend as JSON `error` event | ✅ | ✅ smoke 2026-05-15 |
| Unspeech rejects missing `Authorization` as JSON `error` event | ✅ | ✅ smoke + integration `TestBridge_ErrorEventOnMissingApiKey` 2026-05-15 |
| Unspeech post-upgrade errors no longer write to hijacked HTTP conn | ✅ | ✅ smoke (server log clean) + integration error-event test 2026-05-15 |
| Unspeech `finish` waits for upstream completion (codex CRITICAL #1) | ✅ | ✅ integration `TestBridge_FinishWaitsForUpstreamCompletion` 2026-05-15 |
| Unspeech `cancel` after `finish` reaches upstream (codex follow-up) | ✅ | ✅ integration `TestBridge_CancelAfterFinish` 2026-05-15 |
| apps/server proxy forwards start/text/finish + streams audio back | ✅ | ✅ integration `audio-speech-ws route.test.ts > forwards start/text/finish` 2026-05-15 |
| apps/server bills from `usage.text_words` when upstream returns it | ✅ | ✅ integration ditto (asserts `accumulate({units: 42})`) 2026-05-15 |
| apps/server falls back to input-char count when usage absent | ✅ | ✅ integration `falls back to input-char count` 2026-05-15 |
| apps/server pre-flight rejects `insufficient_flux` | ✅ | ✅ integration `refuses ... insufficient_flux` 2026-05-15 |
| apps/server rejects `streaming_tts_not_configured` when config missing | ✅ | ✅ integration `refuses ... streaming_tts_not_configured` 2026-05-15 |
| stage-ui `streamingSynthesize` resolves on session.finished | ✅ | ✅ unit `streaming-session.test.ts > resolves with concatenated audio` 2026-05-15 |
| stage-ui rejects on close-without-session.finished (codex HIGH #2) | ✅ | ✅ unit `rejects when the ws closes before session.finished` 2026-05-15 |
| stage-ui rejects on `error` event with code/message | ✅ | ✅ unit `rejects with the upstream code/message on an error event` 2026-05-15 |
| stage-ui aborts cleanly on signal abort (sends `cancel`) | ✅ | ✅ unit `aborts the session and rejects with AbortError on signal abort` 2026-05-15 |
| Stage.vue streaming provider end-to-end with audio playback | ✅ | ⏳ pending — requires logged-in user + real Volcengine key |
| Full happy path with real Volcengine upstream | ✅ | ⏳ pending — gated `TestBidirectionalStream_Integration` in unspeech (requires `VOLCENGINE_API_KEY`) |
The ⏳ rows are the only paths still requiring live Volcengine
credentials. Every other production code path (post-upgrade error
handling, bridge state machine including the codex-found bugs, proxy
billing, proxy pre-flight, browser-side session lifecycle including the
partial-as-success fix) is covered by automated tests that run on every
`go test` / `pnpm exec vitest run` without external dependencies.
## Smoke: unspeech protocol surface (no upstream call)
- **Scenario**: walk through every error path in the new
`/v1/audio/speech/stream` route and confirm each one delivers a clean
JSON `error` event followed by a policy-violation close frame, instead
of dumping a stack trace over the hijacked websocket bytes (the
pre-fix behavior — codex review item #5 + smoke discovery).
- **Command**:
```bash
cd /Users/luoling8192/Git/moeru-ai/unspeech
go build -o /tmp/unspeech ./cmd/unspeech
/tmp/unspeech & # listens on :5933
node /tmp/smoke-streaming-tts.mjs
kill %1
```
The smoke script is `/tmp/smoke-streaming-tts.mjs` (see "smoke script"
appendix below).
- **Expected output**: three `PASS` lines, each carrying a JSON `error`
event with a stable `code` discriminator.
- **Actual output** (unspeech `26817b6` + WIP, 2026-05-15):
```
[bad-first-frame] PASS
events: [{"event":"error","code":"invalid_first_frame","message":"first frame must be event=start"}]
[unsupported-backend] PASS
events: [{"event":"error","code":"unsupported_backend","message":"streaming is only supported for backend=volcengine"}]
[volcengine-no-auth] PASS
events: [{"event":"error","code":"missing_api_key","message":"missing X-Api-Key in Authorization header"}]
```
- **Server log diff vs pre-fix**: before the post-upgrade error fix,
the same scenarios produced `response.status=500` lines plus
`echo: http: response.WriteHeader on hijacked connection` stack
traces in the unspeech log. After the fix, every request returns
`response.status=200` (handler returns `mo.Ok` so the echo error
middleware never tries to write HTTP). Clean.
- **Environment**: unspeech `26817b6` with WIP from this session, airi
`4f2ed81a3`, Node v24, local macOS.
## Pending: live happy path (operator needs Volcengine key)
The smoke scenarios above cover everything that can run without a real
Volcengine API key. To finalise the verification an operator with a
production-tier Volcengine key needs to run the live happy path. The
exact commands and assertions are below — paste this output back here
once it lands.
### Prerequisite: seed `STREAMING_TTS_UPSTREAM`
```bash
curl -sS -X POST http://localhost:3000/api/admin/config/router \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slices": [{
"kind": "streaming-tts",
"upstreamURL": "ws://airi-unspeech.railway.internal:5933/v1/audio/speech/stream",
"plaintextKey": "<VOLCENGINE_TTS_API_KEY>"
}]
}' | jq
```
The server envelope-encrypts the plaintext key under AAD
`{ modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }` and
writes `STREAMING_TTS_UPSTREAM`. Add `"dryRun": true` to preview the
ciphertext length without committing.
To point at a different unspeech instance later, repeat the call with a
different `upstreamURL`. To rotate the upstream key, pass
`"keyEntryId": "volcengine-prod-N"` (the audio-speech-ws route always
reads `keys[0]`, so a write replaces the active key).
`$ADMIN_TOKEN` is a Bearer token for an account whose email is in
`ADMIN_EMAILS` and is verified.
### Scenario L1: streaming session happy path
- **User path**: user types a chat message → LLM streams → speech
pipeline opens streaming ws → audio plays back as upstream synthesises.
- **Setup**: log in as a user with ≥1 flux. In speech settings, pick
"Official Streaming Speech Provider", model `volcengine/seed-tts-2.0`,
any Volcengine voice (e.g. `zh_female_shuangkuaisisi_moon_bigtts`).
- **Command** (manual): send a chat message. Observe browser devtools
Network tab → WS frames panel.
- **Expected**:
1. Single ws frame `start` (text) sent by client.
2. Single ws frame `text` (text) sent by client carrying the LLM
output as input.
3. Single ws frame `finish` (text) sent by client.
4. Server emits `session.started` (text) within ~500ms.
5. Server emits multiple binary frames totalling > 0 bytes within
~1.5s of `start` (the streaming first-packet latency we care
about — should be lower than a buffered REST round-trip).
6. Server emits `session.finished` (text) with
`payload.usage.text_words > 0`.
7. Client closes the ws with code 1000.
8. Audio plays cleanly through the stage's `playbackManager` (no
truncation, no stuck animation).
- **Billing check** (after the request lands):
```sql
SELECT * FROM flux_transaction WHERE user_id = '<user-id>'
ORDER BY created_at DESC LIMIT 1;
```
Expected: one row with `meter = 'tts'` (or whatever the meter name
resolves to), `amount` matching `floor(text_words / FLUX_PER_1K_CHARS_TTS *
1000)` (cross-check with the ttsMeter accumulate path).
- **Actual output**: ⏳ pending operator run.
### Scenario L2: abort mid-synthesis cancels upstream
- **User path**: user clicks stop while audio is still playing → ws
closes → upstream session terminated → no stray flux debit.
- **Command** (manual): in dev console, trigger the chat abort
controller mid-stream.
- **Expected**:
1. Client sends `cancel` (text) frame.
2. Server bridge forwards `CancelSession` (event=101) upstream.
3. Volcengine emits `SessionCanceled` (event=151); server-side bridge
does NOT block waiting for it (documented v1 limitation).
4. apps/server proxy closes ws cleanly with code 1000.
5. No `session.finished` event reaches client → no billing call
fires → no new `flux_transaction` row (verified via the same SQL
query as L1).
- **Actual output**: ⏳ pending operator run.
### Scenario L3: truncated upstream surfaces as error, not silent success
- **User path**: simulate an upstream truncation (kill unspeech mid-session)
→ client should error out, not play partial-then-go-silent.
- **Command** (manual): start full stack, begin a session, then
`pkill -f unspeech` while audio is still arriving.
- **Expected**:
1. Client's ws receives a close frame without `session.finished`.
2. `streamingSynthesize()` rejects with
`streaming_tts_closed: ... (received N bytes without session.finished)`.
3. Stage.vue catches the rejection, logs the diagnostic, returns
null for the segment.
4. Console shows the new diagnostic line from
`[Speech Pipeline] tts() failed` with provider / model / voice
context. (Codex review fix #6.)
- **Actual output**: ⏳ pending operator run.
## Pre-existing static checks (refreshable on every commit)
- `go build ./...` in `unspeech` — ✅ 2026-05-15.
- `go test ./pkg/backend/volcengine/...` in `unspeech` — ✅ 2026-05-15
(v3frame round-trip tests).
- `pnpm -F @proj-airi/server typecheck` — ✅ 2026-05-15.
- `pnpm -F @proj-airi/server exec vitest run` — ✅ 344/344 pass.
- `pnpm -F @proj-airi/stage-ui typecheck` — ✅ 2026-05-15.
- `pnpm -F @proj-airi/stage-ui exec vitest run --project node` — ✅
375/375 pass. (Browser project not run; pre-existing Playwright env
gap unrelated to this change.)
- `pnpm -F @proj-airi/stage-tamagotchi typecheck` — ✅ 2026-05-15.
- `pnpm -F @proj-airi/stage-web typecheck` — ✅ 2026-05-15.
- `pnpm exec eslint <changed-files>` — ✅ clean after autofix.
## Known v1 limitations (recorded so future verifications track them)
- **No fallback on streaming upstream failure**. Live ws can't
transparently switch upstream mid-session, so v1 uses the first key
only. Codex MEDIUM #3.
- **JWT in `?token=` query**. Same pattern as `/ws/chat`; reusable
bearer in URL is recorded by access logs. Codex MEDIUM #4. Worth
rotating to short-lived tickets in a follow-up.
- **`cancel` ack not surfaced**. Server does not wait for upstream
`SessionCanceled` before closing. Documented in the wire spec.
- **Per-segment ws (not session-level)**. stage-ui opens a fresh ws
per speech segment; future Phase B refactor can keep one ws per LLM
intent and chunk on `sentence.end` for true play-as-you-receive.
## Smoke script appendix
The file `/tmp/smoke-streaming-tts.mjs` used in the smoke run:
```js
import WebSocket from 'ws'
const URL = 'ws://localhost:5933/v1/audio/speech/stream'
function runScenario(name, send, expect) { /* ... see /tmp/... */ }
const scenarios = [
['bad-first-frame', ws => ws.send(JSON.stringify({ event: 'text', text: 'hi' })),],
['unsupported-backend', ws => ws.send(JSON.stringify({ event: 'start', model: 'openai/tts-1', voice: 'alloy' })),],
['volcengine-no-auth', ws => ws.send(JSON.stringify({ event: 'start', model: 'volcengine/seed-tts-2.0', voice: 'zh_female_shuangkuaisisi_moon_bigtts' })),],
]
for (const [name, send, expect] of scenarios)
await runScenario(name, send, expect)
```
This is intentionally a one-shot debug helper, not a CI fixture. If we
want a CI guard, we can move the scenarios into a Go test against an
in-process echo server (with a stub Volcengine ws dialer) and assert
the JSON error events directly — left as a TODO when the protocol
gains more code paths worth regressing against.
@@ -1,122 +0,0 @@
# Workers And Runtime
## 进程角色
入口:`src/bin/run.ts`
- `api`
- 启动 Hono HTTP + WebSocket 服务
- 没有任何"常驻后台 loop",也没有"POST 触发的 fire-and-forget"。所有写路径都在请求线程里同步完成
不再有独立的 `worker` / `billing-consumer` 进程。原先的 Redis Stream billing event 链路、advisory-lock poller、admin flux grant batch 异步处理全部移除。
## API 角色
启动路径:
- `src/bin/run.ts`
- `runApiServer()`
- `createApp()`
启动时会做的事情:
- 解析 env
- 初始化日志
- 可选初始化 OTel
- 连接 Postgres / Redis
- 跑数据库迁移
- 装配服务
- 启动 HTTP server
- 注入 WebSocket
## Admin flux grant:同步执行
详见 [`admin-flux-grants.md`](admin-flux-grants.md)。简要:admin 调 `POST /api/admin/flux-grants`,路由 handler 在请求线程内顺序对每个 recipient 调 `BillingService.creditFlux`,HTTP 响应里直接返回每条的 outcomegranted / skipped / failed)。失败由 admin 看响应自行重发,可选 `idempotencyKey` 让重发安全。
## 失败 / 崩溃恢复
服务端没有需要恢复的"中间状态"。每次 `creditFlux` 自己是一个 DB 事务;要么写进 `flux_transaction` ledger 要么没写,没有第三态。`(user_id, request_id)` partial unique index 保证带 `idempotencyKey` 的重发不会双发。
## 环境变量分层
### 基础运行
- `HOST`
- `PORT`
- `API_SERVER_URL`
- `DATABASE_URL`
- `REDIS_URL`
### Auth
- `AUTH_GOOGLE_CLIENT_ID`
- `AUTH_GOOGLE_CLIENT_SECRET`
- `AUTH_GITHUB_CLIENT_ID`
- `AUTH_GITHUB_CLIENT_SECRET`
- Appleoptional;启用时以下四项必须一起配置)
- `AUTH_APPLE_CLIENT_ID`
- `AUTH_APPLE_APP_BUNDLE_IDENTIFIERS`iOS 原生 identity token 的逗号分隔 audience allowlist
- `AUTH_APPLE_TEAM_ID`
- `AUTH_APPLE_KEY_ID`
- `AUTH_APPLE_PRIVATE_KEY_PEM`
### Stripe
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
### OTel
- `OTEL_SERVICE_NAMESPACE`
- `OTEL_SERVICE_NAME`
- `OTEL_TRACES_SAMPLING_RATIO`
- `OTEL_EXPORTER_OTLP_ENDPOINT`
- `OTEL_EXPORTER_OTLP_HEADERS`
- `OTEL_DEBUG`
> NOTICE: `BILLING_EVENTS_*` 已全部移除。
## 聊天 WebSocket 运行时
`src/routes/chat-ws/index.ts` 是另一种独立运行时:
- 同实例连接保存在进程内 `Map`
- 跨实例 fan-out 通过 Redis Pub/Sub
这意味着:
- WS 广播不具备持久化和重放能力
- 真正补齐消息还是靠 `pullMessages`
- 广播只是为了降低拉取延迟,不代表存在旧式 `sync` 端点
如果要改 Redis key / channel 构造、Pub/Sub payload,先看 `redis-boundaries-and-pubsub.md`
## OpenTelemetry
初始化在 `instrumentation.ts`NodeSDK lifecycle+ `src/otel/index.ts`metric handles+ `src/otel/gauges/*.ts`DB-backed ObservableGauge callbacks,例如 `gauges/active-sessions.ts`)。
启用条件:
- `OTEL_EXPORTER_OTLP_ENDPOINT` 存在
覆盖面:
- HTTP
- Auth
- Chat engagement
- Revenue
- LLM
- DB / Redis instrumentation
重要实现细节:
- `sdk.start()` 必须发生在 `metrics.getMeter()` 之前
- `/livez``/readyz` 会被 HTTP instrumentation 忽略
## 运行时修改建议
- **新增异步工作**:先问三遍"为什么不能在请求线程里同步做完"。绝大多数 admin / webhook / 短 batch 都可以;实在不行也优先 fire-and-forget per-request,而不是引入常驻 loop。
- **真的需要 idle-driven 的活**(清理过期 token、定时聚合等):先评估是否值得。如果是,开 Postgres `pg_cron` 或外部 cron service 调专门的 internal API endpoint,比"进程内常驻 loop"更可观察、更易停。
- **改 Stripe / Flux 写路径**:看 `billing-architecture.md`,所有 ledger 写入都在事务内同步完成
- **改聊天同步**:先区分"持久化消息"与"广播通知"两层
- **改部署限流**:注意当前 `rate-limit.ts` 仍是单实例内存模型
@@ -1,275 +0,0 @@
---
date: 2026-05-15
topic: llm-router-replacement
---
# Internal LLM/TTS Router Replacing knoway
## Summary
`apps/server` 内部新建一个 LLM/TTS 路由模块替换 knoway。LLM 走单格式直传 + 请求内 key fallback;同一逻辑模型可挂多个 upstream**v1 实装 upstream 间 fallback**(一个 upstream 的全部 key 都失败时切下一个 upstream),upstream 间的 LB / 加权分流延后。TTS 用 adapter interface 抽象 providerv1 发 Azure / Alibaba cosyvoice / Volcengine 三家手写 REST 适配器;voice catalog 用提交仓库的静态 JSON。完整 OTel + Grafana + healthz 覆盖,一次性切流配 revert 回滚兜底。Provider key 必须 envelope-encrypted 后再写入 configKVOTel key id 用 SHA-256 前 8 字符脱敏。**审计日志(保留用户请求 / 响应体)暂不做,作为 TODO 留给 v2**。
---
## Problem Frame
今天 `apps/server``/api/v1/openai/*` 是个薄代理,把 chat completions、`audio/speech``audio/voices` 转发给 sidecar 部署的 knoway。
knoway 的两个核心限制把生产推到痛点上:
1. **每个 cluster 只支持一个 upstream(一对 URL + key**。我们近一年在生产上反复遇到 **slow-recovery 型失败**:(a) 上游 key 余额耗尽(要等结算 / 充值才能恢复),(b) 上游 key 被吊销 / 401(要换新 key 才能恢复)。两类都不是几分钟自愈的 429/5xx,是需要人介入的失败。但 knoway 不支持同 cluster 内多 key fallback,意味着这两类失败 = **用户直接看到错误**,从 key 异常到上线下一个 key 期间整条 LLM/TTS 链路对该 cluster 是黑的。
2. knoway 是独立 Go 服务,把 LLM/TTS 这一层的"路由 + 鉴权 header 注入 + 模型重映射"放在 server 外面,**牺牲了一跳网络延迟、多了一个语言运行时和部署单元**,而它实际做的工作(URL rewrite + header 注入 + 多 cluster voices 聚合 + 协议转换)对今天的规模(1 个 LLM cluster + 1 个 TTS cluster)来说是 over-engineered。
复合代价:用户侧黑时间 + 维护两套部署 + ops 在 key 异常时不仅要换 key 还要管 knoway 这层。
---
## Actors
- A1. **End-user 客户端**:经认证用户通过 `/api/v1/openai/*` 调用 chat completions / TTS / voice listing。看不到内部 fallback 细节,只在所有 key 都失败时收到错误。
- A2. **Server-side 路由模块**(新):在 `apps/server` 内执行 key 选择、fallback、TTS 协议转换、voices 列表组装。
- A3. **Upstream provider**OpenRouterLLM)、Azure Speech / DashScope cosyvoice / Volcengine TTSTTS)。每家都有自己的 key 配额、错误码、协议格式。
- A4. **Operator / admin**:通过现有 admin 通道维护 key 列表(增删改)、查看路由器健康。失败发生时通过 Grafana 告警感知 + 手动响应。
---
## Key Flows
- F1. **LLM chat completion,首 key 成功**
- **Trigger**A1 POST `/api/v1/openai/chat/completions`
- **Actors**A1, A2, A3
- **Steps**:A2 按配置顺序取第一个 LLM key → 向 A3 发起请求(含 stream pass-through)→ A3 200 → A2 透传响应给 A1,按现有路径完成计费 + OTel 收尾
- **Outcome**:A1 拿到完整响应;OTel 上报 fallback depth = 0
- **Covered by**R1, R5, R10
- F2. **LLM chat completion,请求内 fallback**
- **Trigger**A1 POST `/api/v1/openai/chat/completions`,配置中存在 ≥ 2 个 key
- **Actors**A1, A2, A3
- **Steps**A2 取 key#1 → A3 返回 fallback 触发码(如 401 / 402 / 429 / 5xx)→ A2 立刻取 key#2 → A3 200 → A2 透传给 A1
- **Outcome**:A1 看到 200A2 记录每次 fallback 的 reason + 来源 key
- **Covered by**R2, R3, R10
- F3. **LLM chat completion,全 key 用尽**
- **Trigger**A1 POST,全部 key 在本次请求中全部失败
- **Actors**A1, A2
- **Steps**:A2 顺序试完所有 key 都拿到 fallback 触发码 → A2 向 A1 返回网关侧错误
- **Outcome**:A1 收到 5xx(具体类型按下文 D1 映射);Grafana key-exhausted 计数器 + 1
- **Covered by**R3, R4, R10, R11
- F4. **TTS speech,单 provider 多 key fallback**
- **Trigger**A1 POST `/api/v1/openai/audio/speech`,请求模型对应一个 TTS providerv1 是 Azure / Alibaba cosyvoice / Volcengine 之一)
- **Actors**A1, A2, A3
- **Steps**A2 调对应 adapter 把 OpenAI 输入翻译成 provider 原生格式 → 顺序试 key → 拿到音频 bytes / stream → 翻译回 OpenAI 响应格式给 A1
- **Outcome**A1 拿到音频;fallback 行为与 LLM 一致
- **Covered by**R6, R7, R8, R10
- F5. **Voices listing**
- **Trigger**A1 GET `/api/v1/openai/audio/voices?model=...`
- **Actors**A1, A2
- **Steps**:A2 根据 model 路由到对应 adapter → adapter 返回该 provider 的静态 voice catalog → A2 合并 configKV 维护的 `DEFAULT_TTS_VOICES` 推荐 map → 返回
- **Outcome**A1 拿到 voices 列表,shape 与 frontend 既有消费者兼容(沿用 `unspeech` 包的 `Voice` / `VoiceFormat` / `VoiceLanguage` 类型)
- **Covered by**R9
- F6. **Client 真错与上游错的分离**
- **Trigger**:A1 送出非法 body(如不存在的 model alias、malformed JSON
- **Actors**A1, A2
- **Steps**A2 在打 upstream 之前的本地校验阶段就拒绝 → 返回 4xx 带具体原因
- **Outcome**:A1 拿到客户端错(4xx);不进 fallback 流程;OTel 不记录 fallback 事件
- **Covered by**R4, R12
---
## Requirements
**LLM 路由**
- R1. 支持 OpenAI 兼容 `/v1/chat/completions`(含 SSE 流式)的代理;客户端契约与 knoway 时代完全一致(status、shape、stream 协议)。
- R2. 配置中允许一个逻辑模型挂多个 key;同一次请求内按配置顺序试 key,任一成功即返回成功。
- R3. 触发请求内 fallback 的条件至少包含:上游 4xx 鉴权 / 余额类(401、402、403)、上游限流(429)、上游 5xx、上游网络超时。具体阈值(超时秒数等)planning 阶段定。
- R4. 当**所有** key 在本次请求中全部失败时,向客户端返回**网关侧错误**(5xx 区间),而不是把上游的 4xx 透传出去。
- R5. Schema 允许一个逻辑模型映射到**多个 upstream**(不同 base URL / 不同 provider)。v1 实装 **upstream 间 fallback**:当某个 upstream 的全部 key 都在本次请求失败后,切换到数组中的下一个 upstream 继续试它的 key 列表。直到所有 upstream 的所有 key 都失败才向客户端返 5xx。v1 **不**实装 upstream 间的负载均衡(按 weight 分流 / latency 路由 / 成本路由)—— LB 留待真有多 upstream 在线后再加。
**TTS 路由**
- R6. 支持 `/v1/audio/speech`OpenAI 兼容);v1 三个 adapterAzure Cognitive Services Speech、Alibaba Cloud DashScope cosyvoice、Volcengine TTS。
- R7. TTS adapter interface 抽象 provider 协议转换(OpenAI 输入 → provider 原生请求;provider 响应 → OpenAI 输出);adapter 自带该 provider 的所需参数(region、sample_rate、voice 映射等)。Interface 必须 self-contained,不依赖 server 内部类型,方便未来抽 package。
- R8. TTS 请求内 fallback 行为与 LLM 一致(R2-R4 适用)。
- R9. 支持 `/v1/audio/voices?model=...` 返回该 provider 的 voice catalogcatalog 用提交仓库的静态 JSON 维护,月级 ops 手动刷新。响应形状沿用 frontend 已用的 `Voice` / `VoiceFormat` / `VoiceLanguage` 类型(来自 `unspeech` 包,纯类型导入,无运行时依赖)。
**Observability**
- R10. OTel 埋点必须覆盖 LLM + TTS 两条路径,使用 GenAI 语义规约(`gen_ai.system``gen_ai.request.model``gen_ai.response.model``gen_ai.usage.*``gen_ai.operation.name`)加上 airi 自定义网关属性:哪个 upstream、哪个 key、fallback 深度、触发原因。**Key 标识必须是 SHA-256(key) 的前 8 字符****不是 raw key 的前 N 字符**。OTel 数据会出本进程到 Grafana / 第三方 backendraw key 前缀外泄等于秘密外泄。
- R11. 新增 metric countersfallback 次数(按 provider / 来源 key / 触发原因维度)、上游错误分布(按 provider / status code)、key 全失败次数(按 provider,告警源)。
- R12. Grafana 告警至少三条规则:P0 key 全死(短窗内 exhausted 计数器 > 阈值 → page);P1 fallback 比例飙升(一段窗口内 fallback / 总请求超过阈值 → 通知);P2 单 key 持续失败(一段窗口内某 key 贡献绝大多数上游错误 → 提醒手动 disable)。具体阈值在第一次上线后基于真实流量调。
**Health 端点**
- R13. 提供两个独立的 health 端点:livenessprocess 活着即 200,永远不依赖外部状态)和 readiness(检查必要外部依赖如 DB / Redis 后才 200)。
- R14. Gateway 层的 key 健康状态**不阻塞** readiness —— 单个 key 抖动不应该让整个 instance 被摘流量。如果想暴露 gateway 自身的健康摘要,走独立端点或 admin 端点。
**配置与运维**
- R15. 配置(哪些 provider 存在、它们的 upstream URL、key 列表、模型 alias 映射)存储沿用 `configKV` 抽象。**注意**:当前 `configKV` 是 Postgres 为 truth + Redis 为 cache 的封装(apps/server/src/services/config-kv.ts),且 `ConfigEntrySchemas` 是 Valibot 闭合常量对象 —— 不支持任意 provider 名的动态 key。本次必须新增一个 composite schema 条目(如 `LLM_ROUTER_CONFIG`)承载整棵路由器配置树。这是 schema design 工作,不是"零配置改动复用现有抽象"。
- R15a. **Key 等敏感字段不得明文存进 configKV 行**。Provider API key 必须先经 envelope encryptionKMS DEK 或 Railway secret variable 派生的 master key)加密后再写入;OTel 中只保留 hash 前缀(见 R10 改进版)。Redis 快照泄露 / Redis 端点错配 / Postgres dump 泄露任一情景下,攻击者拿到的应是密文不是 raw key。
- R16. Key 列表的增删改通过现有 admin endpoint 模式暴露,不为这次单独造新管理系统。配置改动对运行中的 instance 必须**有界传播**propagation 完成时间 ≤ **5 秒**(跨所有 Railway instance),且 ops 必须有可观测信号(如 OTel counter `airi.gateway.config.reload` 按 instance 维度分组)确认每个 instance 已切换到新版本。Key 撤销场景下(最敏感),未传播完成期间的请求**必须**仍能用旧 key 继续服务用户,不能因为传播未完成而 5xx —— 但 ops 必须能基于上述信号判断"还有多少 instance 没切"。具体实现机制(pub/sub 失效本地 cache vs 每请求读 configKV vs Redis TTLplanning 阶段定,但**有界 + 可观测**是 v1 安全级 requirement。
- R16a. 管理 key 的 admin 接口权限模型:本次必须显式回答**单一 admin 角色 vs 分角色**(key 写权限 vs 审计读权限分离);如果保持单一 admin 角色,必须在 Scope Boundaries 显式声明"v1 接受 admin 凭据被攻陷可即时注入恶意 key"风险,作为 known limitation 列入 follow-up。Key 写操作是否需要 step-up auth / 双人确认 / 写入审计行同样属于此处决策。
**迁移**
- R17. 一次性切流:knoway 与新模块**不并跑**。PR 合入即生效;出事走 revert deploy 回滚。本次没有 schema migrationaudit 已移出 v1),所以 revert 是干净的纯代码 git revert,不需要拆 PR。
- R18. knoway 的 docker-compose 配置保留作为回滚兜底,**删除触发条件是数据驱动而不是日历**:连续观察到至少 14 天 OR 至少 1 次峰值流量事件期间路由器没出 P1+ 事故才允许删 compose。出现 P1 即重置计数。
- R19. PR 必须带:
- 覆盖**所有 fallback 路径**的单测(含全 key 死亡返 5xx 路径、单 key 中段失败切下一 key 路径、流式 SSE 中段中断的 finally release 路径、**跨 upstream fallback 切换路径**、全 upstream 列表用尽路径);
- **mock-based 集成测试**mock upstream 401/402/429/5xx/timeout 各种触发码);
- **回滚 runbook**(a) git revert 命令、(b) knoway compose 路径与重启步骤、(c) 已分发的新配置回写为 knoway-compatible 形态的脚本或步骤;
- **部署后首 24 小时主动盯指标清单**(含 P0/P1/P2 alert 都没炸的 baseline、fallback depth 分布、key 错误率分布、跨 upstream fallback 触发率)。
<!-- v1 不做审计日志 — 移出 scope(详见 Scope Boundaries)。Driver 是 "有人刷接口想知道送了什么",
当前服务规模小、暂时不需要补这个能力。Future 支持需要哪些事项见 "Future: Privacy & Audit Support"。 -->
**审计日志(v1 不做 — TODO**
- 见 Scope Boundaries 的 audit 相关条目和 "Future: Privacy & Audit Support" 笔记。
---
## Acceptance Examples
- AE1. **Covers R2, R3.** 给定 chat completion 配置中有 3 个 key,第一个 key 上游返回 429。当请求进入时,路由器立刻切换到 key#2 发起新请求,key#2 成功,客户端拿到 200,OTel 记录 `fallback.depth=1``fallback.reason="429"`
- AE2. **Covers R3, R4, R11.** 给定 chat completion 配置中所有 key 在本次请求都返回 401。路由器按顺序试完所有 key,所有失败,返回 5xx 给客户端(不透传 401);`key.exhausted.count{provider}` 计数 +1。
- AE3. **Covers R4, R12.** 给定客户端送的 body 里 `model` 字段是配置中不存在的 alias。路由器在打 upstream 之前的本地校验阶段拒绝,返回 4xx(客户端错),不进 fallback 流程,OTel 不记录 fallback 事件。
- AE4. **Covers R6, R7.** 给定客户端 POST 一段中文文本到 `/v1/audio/speech`model 指向配置中的 cosyvoice provider。路由器调用 Alibaba adapter 把 OpenAI 输入翻译成 DashScope 原生请求格式,拿到音频后翻译回 OpenAI 响应格式给客户端。客户端无感知协议层差异。
- AE5. **Covers R9.** 给定客户端 GET `/v1/audio/voices?model=microsoft-azure-tts-alias`。路由器返回 Azure provider 的静态 voice catalogshape 与 frontend 现有 `Voice` 类型消费者一致;configKV 里的推荐 voice map 已合并进响应。
- AE6. **Covers R13, R14.** 给定某个 Azure key 持续返回 401(被吊销)但 cosyvoice 和 LLM 路径都正常。Liveness 端点 200readiness 端点 200(不被 gateway 内部某 key 健康状态污染),但 Grafana P2 告警在窗口内触发。
- AE7. **Covers R5.** 给定 chat completion 配置一个逻辑模型挂 2 个 upstreamupstream A 有 keys [a1, a2]、upstream B 有 keys [b1, b2]。本次请求 a1 失败 → a2 失败 → 切到 upstream B → b1 成功。客户端拿到 200OTel 记录 fallback.depth=2、跨 upstream 一次。如果 b1 + b2 都失败,全 upstream 列表用尽返 5xx。
---
## Success Criteria
- 切流后用户**不再**在生产看到由 key 余额耗尽 / key 吊销引起的报错(只要任一备用 key 还活着)。事故型失败的用户黑时间从"换 key 上线之前都黑"降到"全 key 都死了才黑"。
- knoway 容器从 airi-railway compose 删除;server 端到 LLM/TTS upstream 少一跳网络延迟。
- OTel + Grafana 上线后 1-2 个月能基于真实数据回答:"是否需要为 v2 加 key 持久化健康状态";如果数据显示需要,决策依据是观测到的浪费,不是猜测。
- 下一名读这份需求 + 后续 plan 的开发者能在 1 周内把第 4 个 TTS adapter(比如 ElevenLabs)加上而不动 v1 核心代码,证明 adapter 边界对了。**注**v1 上线后必须实际有 1 次"加新 adapter"的演练(哪怕是 stub PR),否则这条 success criteria 不可证伪。
- **SLO 触发 v2 持久化健康状态的硬阈值**:v1 上线后 OTel 数据如果显示**平均 `fallback.depth` > 0.5 持续 24 小时** 或 **某单 key 连续贡献 > 30 分钟 > 80% 的上游错误**,下个 sprint 必须把"持久化死活状态 + admin enable/disable"上线。不是"等数据决定"的开放问题,是触发条件已经约定好的自动晋升。这避免了"interesting graph but no action"的漂移。
---
## Scope Boundaries
- v1 **不**持久化 key 死活状态,**不**做 admin enable/disable key 健康的 UI。是否引入由 v1 上线后的 OTel 数据决定。
- v1 **不**做 cooldown / 半开探测熔断 —— 失败模型是慢恢复型(quota / 401),熔断机制用不上。短瞬故障(429 / 5xx)通过请求内 fallback 处理就够。
- v1 **不**做成本感知路由 / 加权负载均衡(要等多 upstream 真用上)。
- v1 **不**做上游模型可用性自动发现 / 动态 catalog。voices 用静态 JSON。
- v1 **不**改 `/v1` 对客户端的接口契约(保持 OpenAI 兼容,与 knoway 时代完全一致)。
- v1 **不**抽出独立的 gateway 部署单元,**不**引入新语言运行时。
- v1 **不**抽 `packages/llm-router``packages/tts-router` —— 内联在 `apps/server`,无第二 consumeradapter interface self-contained 方便未来真抽。
- v1 **不**引入 Portkey / Vercel AI SDK / Azure WebSocket SDK / DashScope SDK / Volcengine SDK 任何官方包。所有 provider 通信都是 hand-rolled REST。
- v1 TTS **只**发 Azure + Alibaba cosyvoice + Volcengine 三家。ElevenLabs / Player2 / Deepgram 等其他 provider 留给下个版本(schema 已留位置)。
- **不**替代 frontend BYOK 路径用的 unspeech-server。那条路径继续走 unspeech;本次只动 server 端 `/api/v1/openai/audio/*`
- **不**双跑迁移;一次性切流,靠回滚兜底(D17-D18)。
- v1 **不做审计日志**(保留用户请求体 + 响应体)。Driver 是"有人刷接口我们想知道送了啥"用于安全事故复盘,但当前服务规模小,暂时不优先做。作为 v2 TODO 等真出事故再补 —— 详见下方 "Future: Privacy & Audit Support" 笔记列出真要做时需要带哪些配套(最小可用 vs 合规级别)。
- v1 **不**做 LB / 加权分流 / 成本感知路由(要等真有多 upstream 在线后再加;多 upstream fallback 在 v1 做,但 LB 不做)。
- v1 **不**做任何 key 级 cooldown / 限流标记 / 短期黑名单。假设上游是 key-level 限流(详见 D33);如假设错则承担 429 风暴下 fallback 失效的代价,等 D29 SLO 触发器报警后再补 cooldown。
---
## Key Decisions
- **D1. 上游侧失败统一映射为网关侧错误(5xx);客户端真错(pre-upstream 校验失败)保持 4xx**:HTTP 语义上 4xx 意味着客户端的错;把上游的 401 / 402 透传给客户端是在污蔑客户端。客户端应该看到的是"网关那边没把你的请求送达"信号(retry 或 page on-call),而不是去 debug 自己 prompt。具体码段(502 / 503 / 504 怎么分)planning 阶段定。
- **D2. 失败模型决定不做 cooldown / 半开探测**:生产真实失败是慢恢复型(quota、key 吊销),不是几分钟自愈的瞬时型。熔断机制为后者设计,对前者等于摆设。
- **D3. Schema 一次到位(支持多 upstream / 多 provider 数组),实现只用数组首项**:扩展位置在 schema 上零成本(一层数组),多 upstream/provider 间的 fallback/LB 语义延后到真用时再定。Carrying cost 可接受。
- **D4. 请求内 fallback;不持久化 key 死活状态(v1)**:用 OTel 数据驱动决定 v2 是否需要。当前 3-5 key 规模浪费的延迟(每次试一次失败 key ~200ms)可接受。
- **D5/D6/D7. OTel + Grafana + 双 healthz 一次到位**:可观测性是事故型失败响应的核心 —— 没有指标就没法判断 v2 是否要扩。double healthz 分 liveness / readiness 防止 gateway 内部 key 健康污染整个 instance 流量摘除决策。
- **D10. 自写 key rotator,不引 Portkey**Portkey 是 250+ provider 通用网关,多 key 模型也是 leaky(要模成多 virtual provider)。我们 1-3 家 provider 场景下,~50 行自写代码比引入大库 + 把语义掰成 Portkey 形状更直接。
- **D11. 三家 TTS 全 hand-rolled REST,不引官方 SDK**Azure 官方 SDK 是 WebSocket 取向 MB 级;DashScope / Volcengine 官方 SDK 是薄签名器没附加价值。三家 REST 协议都稳定且小。
- **D12. Voice catalog 静态 JSON 提交仓库,月级 ops 手动刷新**Azure ~600 voice 月级变化,cosyvoice / Volcengine 体积更小且更稳定。运行时跨服务聚合是 knoway 当年的过度设计,新模块不复制。
- **D13. 类型层复用 unspeech 的 `Voice` / `VoiceFormat` / `VoiceLanguage`**:保持与 frontend 既有消费者兼容;纯类型导入,无运行时依赖(CLAUDE.md "Import types from the module that owns the contract" 原则)。
- **D14. 内联进 `apps/server`,不抽独立 package**:无第二 consumer = YAGNI。adapter interface 设计成 self-contained,未来真有第二 consumer 抽包成本低。
- **D17. 一次性切流 + revert 回滚兜底**:双跑会增加复杂度(双计费?双埋点?谁是 source of truth?);一次切 + 强测试 + 24h 主动盯 + knoway compose 保留至数据驱动条件满足才删,是更便宜的方案。
- **D20. v1 不做审计日志**driver 是事故复盘("有人刷接口我们想知道送了啥"),但当前服务规模小,对应风险面也小。承担 "短期内出事故无法复盘" 的代价,作为 v2 TODO;真出事时再补,比现在猜需求做高得多。具体未来要做时需要哪些配套见 "Future: Privacy & Audit Support" 笔记。
- **D28. R16 配置热生效升格为安全级 requirement**:未传播完成的窗口期内,撤销的 key 仍可服务请求 = 安全漏洞(admin 删了 leaked key 后还在 N 秒内被使用)。propagation 必须**有界**(≤ 5 秒)且**可观测**OTel counter 按 instance 维度暴露当前生效配置版本)。具体机制 planning 定,但"有界 + 可观测"是 commit。
- **D29. 当前 SLO 触发 v2 的硬阈值已写入 Success Criteria**avg fallback.depth > 0.5/24h OR 单 key > 80% 错误 > 30 分钟)。这避免 D4 的"OTel 数据驱动 v2"漂移成"interesting graph but no action"。理由来自 adversarial (ADV2)。
- **D30. OTel `airi.gateway.key.id` 必须是 SHA-256(key) 前 8 字符**,不是 raw key 前缀。Raw 前缀外泄 = 秘密外泄到 Grafana / 第三方 OTel backend。理由来自 security reviewer (SEC4)。
- **D31. 多 upstream fallback 在 v1 做,LB 不做**:fallback 是核心可靠性需求(一个 provider 挂了能切到另一个);LB 是 nice-to-have,要等真有多 upstream 在线产生流量分配需求时再加。数组语义因此明确为"按顺序 fallback",不是"按 weight 分流"。理由:用户明确表述。
- **D32. unspeech-server 与 server-side TTS adapter 双实现接受不做 sidecar**:用户明确拒绝 sidecar 方案;接受 moeru-ai 在 unspeechGofrontend BYOK 用)和 apps/server 内 TS adapterhosted 路径用)维护两份 OpenAI ↔ provider 协议转换。代价(协议变更两边同步)已知,可接受。理由:用户明确表述。
- **D33. 上游限流粒度按"key-level"假设处理,v1 不做 cooldown 也不做限流,risk-accepted**:四家上游 providerOpenRouter / Azure / DashScope / Volcengine)的限流策略我们不实测、不查文档、不在 v1 处理。**假设错的实际后果**:如果有家是 account-level 限流,429 风暴会同时打死所有 key 让 fallback 失效,用户看到 5xx。**兜底机制**:D29 的 SLO 触发器(avg fallback.depth > 0.5/24h OR 单 key > 80% 错误 > 30 分钟)可同时触发两个动作 —— "持久化 key 死活状态" 或 "加 key 级 cooldown",具体看故障形态决定。出事就修。理由:用户明确表述"先不做不管"。
---
## Future: Privacy & Audit Support
*当前 v1 不做。这里列出真要做审计 / 隐私时需要带哪些配套,作为 v2 起点参考。两档:内部 debug 用就够 / 想真正合规要做的全套。*
### Tier 1 — 内部 debug 用最小可用(出事故能复盘)
只解决你说的 "有人刷接口我们想知道他发了啥",不上合规级别:
- **存哪些**:扩展 `llm_request_log` 表加 `request_body` / `response_body` 两列。
- **截断**:单条上限要定(请求侧锚定 Hono `bodyLimit`、响应侧 8MB 兼容长上下文输出),超限截断 + flag 标记。
- **TTS**:响应是音频,别存音频字节(大且没用),存元数据(命中哪个 voice、sample_rate、哪个 upstream)。
- **流式 LLM**:要在流结束后把 SSE 块拼成完整文本再写入(现有代码只 buffer 末 2KB 用于 token 计费,要升级)。
- **写入语义**best-effort(写失败不影响业务响应,因为响应已经发给用户了),失败计数 + Grafana 告警。
- **保留期**:先 90 天,configKV 可调。
- **清理**:靠 Postgres TTL / 现有 ops cron,不为这事造新定时任务。
- **访问控制**:现有 admin auth 够用。
**这一档需要面对的两个真问题**(reviewer 共识,记在这等做的时候处理):
- 同行耦合:audit body 跟 status/duration/flux 同一行。Audit body 过大让整行写失败 = 同时丢业务元数据。解法:(a) 接受,配合计费 reconciliation 容忍缺失;或 (b) 拆 `llm_request_audit` 子表通过 FK 关联。
- Best-effort 漏写率:高峰期容易掉。如果只是 debug 用,能接受;如果想做合规审查,就得改成同事务或 durable queue(见 Tier 2)。
### Tier 2 — 真正合规级(GDPR / PIPL / SOC2 之类的)
只有真要上合规审查或 EU/中国大陆正式商用前才考虑。每条都要工程 + 法务双投入:
- **用户披露**:隐私政策里明确写"我们保留你的 prompt 多久、为什么、谁能访问"。前端要有可见入口。
- **用户删除权**:用户要能触发"删掉我的所有历史" → 需要 admin endpoint + 跨表的级联删除路径(不只 `llm_request_log`,还有任何带 userId 的 audit 表)。
- **写入语义升级**best-effort 不合格。合规角度的问题永远是"请求 X 的 prompt 在哪?""高峰期掉了"不是能接受的答案。需要改为同事务写入 或 写入 durable queueRedis Streams / 类似)+ cron 兜底入库。
- **跨境传输**:EU 用户的数据流到非 EU 服务器需要 Standard Contractual Clauses 法律文件。中国用户的数据出境需要走《个人信息出境标准合同》。这是法务层面的事,工程上需要可识别"哪条记录是哪个法域用户的"。
- **跟上游 provider 签 DPA**:用户 prompt 会被发给 OpenRouter / Azure / 阿里云 / 火山引擎处理。这几家都各自有 Data Processing Agreement,要签。法务事。
- **PII 自动识别 + redact**:用户的 prompt 可能含手机号 / 身份证 / 邮箱 / 健康信息。合规角度建议存入前先扫一遍打码。开源 lib 有(Microsoft Presidio 之类),但识别准确率和性能要权衡。
- **数据最小化**:不存"为了存而存"的字段。只存能直接回答审计问题的最小集。
- **加密升级**:不只依赖 Postgres / Railway 的磁盘加密。应用层用 envelope encryptionKMS 给的 master key 派生 DEK),密文存库。密钥泄露 ≠ 数据泄露。
- **访问审计**:审计的审计。"谁在 2026-05-15 查了 user X 的 prompt" 这种问题要能答。再加一张 `audit_access_log` 表记录 admin 操作。
- **数据本地化**:中国大陆用户的数据可能要求存中国境内的机器。多区域部署 + 路由策略。
- **保留期审视**:90 天可能过长。合规角度 "存储最小化" 原则下,应该按业务实际需求最短。
**判断什么时候要从 Tier 1 升 Tier 2**:用户量超过 ~1k DAU、有商业合同方要求、有 EU/中国大陆用户付费、出过用户对自己数据的投诉、收到监管问询。任一触发就该开 Tier 2 brainstorm。
### Tier 3 — 滥用检测 / 内容审查(不同需求,单独考虑)
如果 "有人刷接口" 升级到 "有人用我们的服务跑非法内容生成",需要的是**实时**审查而非事后审计:
- 请求侧关键词 / classifier 过滤(输入审查)
- 响应侧 classifier 过滤(输出审查)
- 用户级风控信号(短时间内异常多 / 异常 prompt pattern
- 跟上游 provider 的 content policy 对齐
- 用户封禁机制
这条跟 Tier 1/2 是不同形态的工程(实时管道 vs 事后日志),单独开 brainstorm。
---
## Dependencies / Assumptions
- 依赖 `configKV` 服务(Redis-backed)作为路由配置 + key 列表的存储。沿用 `apps/server/src/services/config-kv.ts` 已有抽象,不新建表。
- 依赖现有 admin endpoint 模式作为 key 管理 UI 入口(具体接口由 planning 阶段定)。
- 依赖现有 OTel pipeline(已配置 `gen_ai.*` 标准属性 + `airi.*` 自定义属性,参见 `apps/server/docs/ai-context/observability-conventions.md`)。
- 依赖现有 Grafana 部署作为告警渲染目标。
- 假设 v1 的 LLM/TTS provider 数量保持在 1-3 家量级(≤ 5);超过此规模需要重新评估"不持久化 key 死活"和"adapter 内联"的选择。
- 假设上游 providerAzure / DashScope / Volcengine)的 REST 协议在 v1 生命周期内(~6 个月)保持稳定;如有重大协议变更需修订对应 adapter。
---
## Outstanding Questions
### Deferred to Planning
- [Affects R3][Technical] 触发 fallback 的具体超时阈值(上游响应超时 vs 全 fallback 链路超时)—— 需基于 knoway 当前 p99 测量数据定。
- [Affects R4, D1][Technical] 上游错误 → 网关 5xx 的具体映射规则(401/402/403/429 → 5035xx → 502?超时 → 504?)—— 实现阶段细化。
- [Affects R12][Needs research] Grafana 告警阈值的初始值 —— 上线后基于真实流量调,第一周阈值靠经验估。
- [Affects R15, R16][Technical] 配置变更的热生效机制(pub/sub 失效本地 cache?还是每次请求读 configKV?)—— 取决于 configKV 当前实现的读延迟特性。
- [Affects R7][Technical] TTS adapter interface 的具体函数签名(统一 `(input, options) → ArrayBuffer | ReadableStream` 还是分 chat-style / TTS-style?)—— planning 阶段定。
- [Affects R6, R8][Technical] DashScope cosyvoice 和 Volcengine TTS 是否支持流式输出?v1 是否一并支持?—— 需要查官方 REST 文档。
<!-- Resolve Before Planning 已清空 — 限流粒度问题作为 risk acceptance 落进 Key Decisions D33。文档现在 zero blockers 可进 plan。 -->
@@ -1,816 +0,0 @@
---
date: 2026-05-15
type: feat
origin: apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md
status: active
deepened:
---
# feat: Internal LLM/TTS router replacing knoway
## Summary
`apps/server` 内新建一个 in-process 路由模块替换 knoway sidecarLLM `/v1/chat/completions` 走 SSE passthrough + 请求内多 key fallback + 跨 upstream fallbackTTS `/v1/audio/speech` 走 adapter interfacev1 三家:Azure / DashScope cosyvoice / Volcengine,非流式 REST 实现);`/v1/audio/voices` 由仓库内静态 JSON 提供;configKV 增 `LLM_ROUTER_CONFIG` composite 条目承载整棵路由器配置;新 envelope encryption 工具加密存储 provider keyOTel 用 `airi.gen_ai.gateway.*` 自定义属性,新增 fallback / key 健康相关 metrics;新增 `/livez` + `/readyz`;一次性切流 + 数据驱动决定何时删 knoway compose。
---
## Problem Frame
详见 `apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md` 的 Problem Frame。简述:当前 `/api/v1/openai/*` 是薄代理转发到 knowayknoway 每 cluster 单 upstream 不支持多 key fallback,生产上的 key 余额耗尽 / 吊销直接打穿到用户层;同时 knoway 是独立 Go 服务,多一跳延迟 + 多一个语言运行时维护成本。
---
## Requirements
R-IDs 沿用 origin 文档(详见 origin 中 R1-R19 描述):
**LLM 路由**
- R1, R2, R3, R4, R5(含 v1 实装跨 upstream fallback 的 D31 决定)
**TTS 路由**
- R6, R7, R8, R9
**Observability**
- R10, R11, R12(告警阈值由 planning 用占位、上线后基于真实流量调)
**Health 端点**
- R13, R14
**配置与运维**
- R15(含 R15a envelope encryption 强制)、R16(含 R16a 角色模型)
**迁移**
- R17, R18, R19
详见 origin 文档(apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md)。
---
## Key Technical Decisions
| # | 决策 | 出处 / 理由 |
|---|---|---|
| KTD-1 | 上游错误 → HTTP 状态码具体映射:**401/402/403 → 502 Bad Gateway**, **429 → 503 Service Unavailable**, **5xx → 502**, **超时 → 504 Gateway Timeout**。**混合-cause 用尽时(多 key 失败原因不同)规则**:**最后一次尝试的状态码胜出**;若最后一次是 timeout 优先返 504(最能向客户端传达 retryability)。这避免客户端 retry 策略因 key 顺序变化而非确定 | origin D1 + reviewer 共识;plan-time 具体化 |
| KTD-2 | Fallback 超时阈值:**单次上游调用 30s**, **整链路最长 60s**configKV 可调) | plan-time 决定;超过即归类 504 |
| KTD-3 | OTel 自定义属性折进 **`airi.gen_ai.gateway.*`** 命名空间(不用 `airi.gateway.*` | 与现有 `airi.gen_ai.stream.interrupted` 命名一致,learnings 报告建议 |
| KTD-4 | 配置热生效用 **Pub/Sub invalidation + Redis TTL 自愈** | learnings 报告引 `redis-boundaries-and-pubsub.md` 契约:Pub/Sub 为 notification-only,丢失消息靠 TTL 重读自愈 |
| KTD-5 | Envelope encryption**AES-256-GCM via `node:crypto`**。Master key 来自新 env var **`LLM_ROUTER_MASTER_KEY`**32 字节 base64**boot-time Valibot 校验长度**+ 可选 **`LLM_ROUTER_MASTER_KEY_PREVIOUS`** 支持双密钥滚动窗口。**AES 密钥派生**:`hkdfSync('sha256', masterKey, salt='llm-router-v1', info='provider-key-encryption', 32)`**不**直接用 master key(单一用途密钥反模式)。**Ciphertext 格式**`v1.<keyId8>.<iv_base64>.<ct_base64>.<tag_base64>`,前缀含版本 + 密钥 id 前 8 字符,未来轮换时可区分新旧密文。**AAD**authenticated additional data)绑定 `{modelName, keyEntryId}` 防 blob-swap 攻击。**密钥旋转 runbook**(写入 U8 transport-and-routes.md):(1) 用 prev key 解 + new key 重加密所有 blob → (2) 写回 configKV → (3) 切 master key env var → (4) 取消 prev key。 | repo 零先例;reviewer (security + adversarial + feasibility) 共识:单用途密钥 + HKDF + 版本前缀 + AAD 是 envelope crypto 工业标准 |
| KTD-6 | TTS adapter 接口签名:`(input: TtsInput, ctx: TtsContext) => Promise<{contentType: string, body: ArrayBuffer \| ReadableStream}>` + `getVoiceCatalog(): Voice[]` | plan-time 决定;self-contained,可未来抽 package |
| KTD-7 | DashScope cosyvoice + Volcengine **v1 只发非流式**Azure 维持现状(既有 REST 一次性返回) | 流式 TTS 各家协议差异大(cosyvoice HTTP/WS 双形态,Volcengine WS 主),v1.5 再处理 |
| KTD-8 | 新增错误工厂 **`createBadGatewayError`**502, `BAD_GATEWAY`)到 `apps/server/src/utils/error.ts` | 现有 helper 没 502 映射;走全局 `app.onError` 自动渲染 |
| KTD-9 | Voice catalog 用 **静态 JSON 提交仓库**`apps/server/src/services/tts-adapters/voices/*.json`),不在运行时跨服务聚合 | origin D12 |
| KTD-10 | LLM/TTS 路由 logic **全部下沉到 `src/services/llm-router/`**,路由层只做 param validation + auth guard + 调 service + 处理响应;现有 `routes/openai/v1/index.ts` 的 TODO `:97-98` 同期解决 | apps/server/CLAUDE.md "Routes: thin — no business logic" |
| KTD-11 | 路由器**不持久化** key 死活状态(origin D33 risk-accepted),但 OTel 上报支持 SLO 触发器(fallback.depth > 0.5 / 24h, 单 key > 80% 错误 / 30min)以便后续手动促 v2 | origin Success Criteria + D29 |
| KTD-12 | `/livez``/readyz` 是**新路径**K8s 风格,post-implementation 决定不保留 legacy `/health`),按现有 `httpInstrumentationMiddleware` 的探针排除规则同样跳过 | apps/server/src/app.ts:126-128 模式 |
| KTD-13 | 跨 upstream fallback 在**同一请求内**触发:upstream A 全 key 失败后切 upstream B 全 key 试,全 upstream 都失败才返 5xx | origin R5 |
---
## High-Level Technical Design
> *This section illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
### Router config tree (shape)
```
LLM_ROUTER_CONFIG (Valibot composite in ConfigEntrySchemas):
{
llm: {
models: {
[logicalModelName]: {
upstreams: [
{
baseURL: string
overrideModel?: string
keys: [{ id: string, ciphertext: base64 }, ...] // envelope-encrypted
headerTemplate: string // e.g. "Bearer {KEY}"
timeoutMs?: number
}, ...
]
fallbackTriggers: { httpCodes: number[], onTimeout: true }
}
}
}
tts: {
models: {
[logicalModelName]: {
provider: "azure" | "dashscope-cosyvoice" | "volcengine"
upstreams: [
{ region/baseURL, keys: [...], adapterParams: {...} }, ...
]
fallbackTriggers: { httpCodes: number[], onTimeout: true }
}
}
}
defaults: {
perAttemptTimeoutMs: 30000
fullChainTimeoutMs: 60000
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504]
}
}
```
### Fallback decision sequence (LLM, pre-first-byte only)
```mermaid
sequenceDiagram
participant C as Client
participant R as Router service
participant K as Key rotator
participant U1 as Upstream A
participant U2 as Upstream B
C->>R: POST /v1/chat/completions
R->>R: load model config (LLM_ROUTER_CONFIG)
R->>R: select upstream A
loop keys in upstream A
R->>K: next key
R->>U1: POST chat/completions (with decrypted key in header)
alt success (2xx)
U1-->>R: response body (Stream or JSON)
R-->>C: pass through (SSE TransformStream)
Note over R,C: post-response: billing + audit log + OTel finalize
else fallback-triggering code (401/402/403/429/5xx) or timeout
R->>R: record OTel fallback event
end
end
Note over R: upstream A exhausted → try upstream B
loop keys in upstream B
R->>U2: POST chat/completions
end
Note over R,C: all upstreams exhausted → 502/503/504 per KTD-1
```
### Adapter dispatch (TTS)
```
TtsAdapter interface:
send(input, ctx) => Promise<{contentType, body}> // protocol translation + REST call
getVoiceCatalog() => Voice[] // static JSON loaded at module init
Adapters:
AzureAdapter (uses SSML XML, region-aware base URL)
DashScopeCosyvoice (uses DashScope multimodal-generation JSON)
VolcengineAdapter (uses Volcengine openspeech JSON)
Router for TTS:
resolve(modelName) → (adapter, upstreamConfig)
iterate keys + (if available) iterate upstreams via same fallback logic as LLM
adapter.send() with selected key+config
return adapter result to client unchanged
```
### Pub/Sub config invalidation contract
```
Channel: "configkv:invalidate"
Payload: { key: "LLM_ROUTER_CONFIG", version: nanoid(), publishedAt: ms }
Each instance:
on receive → invalidate local in-memory cache for that key
next request lazy-reloads from configKV (Postgres+Redis truth chain)
Fallback for missed pub/sub:
in-memory cache has TTL = 5s → forced reload regardless of publish
Per-instance OTel counter:
airi.gen_ai.gateway.config.reload{service_instance_id, source: "pubsub" | "ttl"}
```
---
## Output Structure
```
apps/server/
├── src/
│ ├── routes/openai/v1/
│ │ ├── index.ts # MODIFY: split into thin route handlers
│ │ └── route.test.ts # MODIFY: rewire tests to mock router service
│ ├── services/
│ │ ├── llm-router/ # NEW
│ │ │ ├── index.ts # public factory + types
│ │ │ ├── router.ts # core orchestration (key/upstream selection + fallback loop)
│ │ │ ├── key-rotator.ts # per-request key iterator + envelope decryption hook
│ │ │ ├── config-loader.ts # configKV read + cache + Pub/Sub invalidation
│ │ │ ├── error-mapping.ts # upstream status → ApiError mapping (KTD-1)
│ │ │ ├── types.ts # RouterConfig / Upstream / KeyEntry / RouterContext
│ │ │ ├── router.test.ts
│ │ │ ├── key-rotator.test.ts
│ │ │ ├── config-loader.test.ts
│ │ │ └── error-mapping.test.ts
│ │ └── tts-adapters/ # NEW
│ │ ├── index.ts # adapter registry + dispatch by provider id
│ │ ├── types.ts # TtsAdapter / TtsInput / TtsResult / Voice
│ │ ├── azure.ts
│ │ ├── azure.test.ts
│ │ ├── dashscope-cosyvoice.ts
│ │ ├── dashscope-cosyvoice.test.ts
│ │ ├── volcengine.ts
│ │ ├── volcengine.test.ts
│ │ └── voices/
│ │ ├── azure.json # static voice catalog
│ │ ├── dashscope-cosyvoice.json
│ │ └── volcengine.json
│ ├── utils/
│ │ ├── envelope-crypto.ts # NEW: AES-256-GCM helpers
│ │ ├── envelope-crypto.test.ts # NEW
│ │ ├── error.ts # MODIFY: add createBadGatewayError
│ │ ├── observability.ts # MODIFY: new AIRI_ATTR_GATEWAY_* + METRIC_* constants
│ │ └── redis-keys.ts # MODIFY: add llm-router config invalidate channel
│ ├── otel/
│ │ └── index.ts # MODIFY: prime new gateway metrics; new GatewayMetrics bundle
│ ├── services/
│ │ └── config-kv.ts # MODIFY: add LLM_ROUTER_CONFIG to ConfigEntrySchemas
│ ├── app.ts # MODIFY: DI wiring, /livez + /readyz routes, remove GATEWAY_BASE_URL
│ ├── libs/env.ts # MODIFY: add LLM_ROUTER_MASTER_KEY env var; remove GATEWAY_BASE_URL
│ └── libs/env.test.ts # MODIFY
├── otel/
│ └── grafana/
│ └── dashboards/ # MODIFY: panels + alert rules for gateway metrics
│ ├── build.ts
│ └── airi-server-overview-cloud.json
├── docs/
│ └── ai-context/
│ ├── observability-metrics.md # MODIFY: register new metrics
│ ├── observability-conventions.md # MODIFY: gen_ai.system values + airi.gen_ai.gateway.* namespace
│ ├── transport-and-routes.md # MODIFY: new /livez + /readyz routes; route → service mapping
│ ├── redis-boundaries-and-pubsub.md # MODIFY: configkv:invalidate channel contract
│ └── verifications/
│ └── llm-router.md # NEW: verification doc per AGENTS.md template
```
**Tree is a scope declaration**, not a constraint. Implementer may adjust if implementation reveals a better layout; per-unit `**Files:**` sections are authoritative.
---
## Implementation Units
### U1. Foundation utilities: envelope encryption + router config schema + 502 error helper
**Goal**: Land three independent foundation pieces so subsequent units can depend on them: AES-256-GCM envelope encryption utilities for at-rest key storage; `LLM_ROUTER_CONFIG` composite Valibot schema entry in `ConfigEntrySchemas`; `createBadGatewayError` helper.
**Requirements**: R15, R15a (envelope encryption), R15 (composite config entry), KTD-1 (502 mapping), KTD-5, KTD-8.
**Dependencies**: none.
**Files**:
- `apps/server/src/utils/envelope-crypto.ts` (NEW)
- `apps/server/src/utils/envelope-crypto.test.ts` (NEW)
- `apps/server/src/utils/error.ts` (MODIFY)
- `apps/server/src/services/config-kv.ts` (MODIFY — add `LLM_ROUTER_CONFIG` to `ConfigEntrySchemas`)
- `apps/server/src/libs/env.ts` (MODIFY — add `LLM_ROUTER_MASTER_KEY` env var)
- `apps/server/src/libs/env.test.ts` (MODIFY)
**Approach**:
- `envelope-crypto.ts` exports `encrypt(plaintext: string): string` and `decrypt(ciphertext: string): string`. AES-256-GCM via `node:crypto`. Master key read from `env.LLM_ROUTER_MASTER_KEY` (base64-decoded to 32 bytes). Per-message random IV (96-bit) prepended to ciphertext; auth tag appended. Encoded as base64. Throws `Error` with explicit message on auth-tag verification failure (no fallback path).
- `ConfigEntrySchemas` gets one new entry: `LLM_ROUTER_CONFIG: object({ llm: object({...}), tts: object({...}), defaults: object({...}) })`. Use `valibot` composition. **No `?? default` at call sites** — Valibot defaults live in the schema (apps/server/CLAUDE.md / config-and-naming-conventions).
- `createBadGatewayError(message, details?)` mirrors existing factories in `apps/server/src/utils/error.ts:18-69` — returns `ApiError(502, 'BAD_GATEWAY', message, details)`. Verify global `app.onError` (`apps/server/src/app.ts:160-182`) renders this with no further changes (it already pattern-matches `ApiError.statusCode`).
- `env.LLM_ROUTER_MASTER_KEY`: required when `LLM_ROUTER_CONFIG` is set (graceful boot detection — if env var missing, decryption attempts throw `createServiceUnavailableError('LLM_ROUTER_MASTER_KEY not set', 'CONFIG_NOT_SET')`).
- Old `GATEWAY_BASE_URL` env var: keep for now (U8 removes it once new router is hot path).
**Execution note**: Start with envelope-crypto.test.ts including a known-answer test vector (encrypt → decrypt round-trip + tamper-detect failure). Test-first for crypto primitives is non-negotiable.
**Patterns to follow**:
- Error helpers: `apps/server/src/utils/error.ts:18-69` (existing factories)
- Valibot composite: `apps/server/src/services/config-kv.ts` `STRIPE_PAYMENT_METHOD_OPTIONS` precedent
- Env vars: `apps/server/src/libs/env.ts` existing schema definition pattern
**Test scenarios**:
- **envelope-crypto**: (1) encrypt → decrypt round-trip returns original plaintext; (2) encrypt twice with same plaintext returns *different* ciphertext (IV randomness); (3) decrypt with tampered ciphertext (flip one byte mid-string) throws auth-tag verification error; (4) decrypt with tampered auth tag throws; (5) encrypt with empty string works; (6) decrypt with truncated ciphertext throws explicit "invalid ciphertext length" rather than silent failure.
- **LLM_ROUTER_CONFIG schema**: (1) valid full config parses; (2) missing required field → Valibot ValidationError; (3) defaults section absent → schema-level default applies (no `?? fallback` at call site).
- **createBadGatewayError**: (1) returns ApiError with statusCode 502 and errorCode `BAD_GATEWAY`; (2) global `app.onError` mapping renders correctly in an integration request mock (one Hono route that throws this error, assert response status 502 + body shape).
- **env.LLM_ROUTER_MASTER_KEY**: (1) valid 32-byte base64 parses; (2) missing var is allowed in env schema (router decides at use-site whether it's required).
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/utils/envelope-crypto.test.ts` green
- `pnpm exec vitest run apps/server/src/libs/env.test.ts` green
- New `ApiError(502)` thrown from a test route renders as `{error: "BAD_GATEWAY", message, details}` per `app.ts:160-182`
---
### U2. OTel attribute / metric surface + priming registration
**Goal**: Introduce all new OTel attribute constants and metric handles for the gateway. Register them in the priming list so they show up in Grafana before first hit. Update observability docs in the same unit (repo convention).
**Requirements**: R10, R11 (metrics), KTD-3 (namespace), KTD-11 (SLO support).
**Dependencies**: U1 (uses `createBadGatewayError` indirectly via error-mapping in later units, but compile-only).
**Files**:
- `apps/server/src/utils/observability.ts` (MODIFY — new `AIRI_ATTR_GEN_AI_GATEWAY_*` constants and `METRIC_AIRI_GEN_AI_GATEWAY_*` constants)
- `apps/server/src/otel/index.ts` (MODIFY — new `GatewayMetrics` bundle interface, factory `createGatewayMetrics`, prime list update)
- `apps/server/docs/ai-context/observability-metrics.md` (MODIFY)
- `apps/server/docs/ai-context/observability-conventions.md` (MODIFY — declare canonical `gen_ai.system` values: `openrouter`, `azure.speech`, `dashscope.cosyvoice`, `volcengine.tts`)
- `packages/server-shared/src/observability.ts` (MODIFY — if shared constants live here per learnings report)
- `apps/server/src/otel/index.test.ts` (MODIFY/NEW — prime list assertion)
**Approach**:
- New attribute constants (TS string literals):
- `AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL = 'airi.gen_ai.gateway.upstream.url'`
- `AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID = 'airi.gen_ai.gateway.key.id'` (SHA-256 prefix 8)
- `AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH = 'airi.gen_ai.gateway.fallback.depth'`
- `AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_REASON = 'airi.gen_ai.gateway.fallback.reason'`
- `AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX = 'airi.gen_ai.gateway.upstream.index'` (which upstream in array)
- New metric constants:
- `METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNT`
- `METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS`
- `METRIC_AIRI_GEN_AI_GATEWAY_KEY_EXHAUSTED_COUNT` (alert source)
- `METRIC_AIRI_GEN_AI_GATEWAY_CONFIG_RELOAD` (per-instance, KTD-4)
- `METRIC_AIRI_GEN_AI_GATEWAY_DECRYPT_FAILURES` (envelope crypto failure counter)
- `createGatewayMetrics(meter)` returns `{ fallbackCount, upstreamErrors, keyExhaustedCount, configReload, decryptFailures }` Counters. Each is `meter.createCounter(NAME, {description, unit})` and primed via `primeCounter` calls in `OtelInstance.start` flow.
- `OtelInstance` interface gains `gateway: GatewayMetrics | null` field; `null` when OTel is disabled (mirrors `genAi: GenAiMetrics | null` pattern at `apps/server/src/otel/index.ts:146-154`).
- `observability-conventions.md`: declare the `airi.gen_ai.gateway.*` sub-namespace; document the canonical `gen_ai.system` values for the 4 providers.
- `observability-metrics.md`: register the 5 new metrics with descriptions + when they fire + alert advice.
**Patterns to follow**:
- Attribute / metric constant convention: `apps/server/src/utils/observability.ts:6-90`
- Metric bundle factory: `apps/server/src/otel/index.ts:146-211+` (`createGenAiMetrics` precedent)
- Priming: same file's `primeCounter` calls
- Doc update format: existing entries in `observability-metrics.md`
**Test scenarios**:
- (1) `createGatewayMetrics(meter)` returns expected fields with `meter.createCounter` called for each — assert via `vi.fn` meter spy with call args matching new metric names.
- (2) `OtelInstance.start` primes each gateway counter (test asserts `add(0, {...})` was called once per metric during prime phase).
- (3) Metric names match `airi.gen_ai.gateway.*` prefix — string assertion on constants.
- (4) When OTel is disabled (`null` bundle), router code calling `gateway?.fallbackCount.add(...)` must be no-op (covered indirectly in U3/U4 tests, mentioned here for completeness).
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/otel/index.test.ts` green
- `observability-metrics.md` lists 5 new gateway metrics; manual review
---
### U3. LLM router service
**Goal**: Core in-process router that selects upstream + key, fetches upstream, handles fallback per-request, maps upstream errors to `ApiError` per KTD-1. Pure service — no Hono / route coupling, takes `(request, fetch)` and returns response (or throws).
**Requirements**: R1, R2, R3, R4, R5 (multi-upstream fallback), KTD-1, KTD-2, KTD-13.
**Dependencies**: U1, U2.
**Files**:
- `apps/server/src/services/llm-router/index.ts` (NEW)
- `apps/server/src/services/llm-router/router.ts` (NEW)
- `apps/server/src/services/llm-router/key-rotator.ts` (NEW)
- `apps/server/src/services/llm-router/config-loader.ts` (NEW)
- `apps/server/src/services/llm-router/error-mapping.ts` (NEW)
- `apps/server/src/services/llm-router/types.ts` (NEW)
- `apps/server/src/services/llm-router/router.test.ts` (NEW)
- `apps/server/src/services/llm-router/key-rotator.test.ts` (NEW)
- `apps/server/src/services/llm-router/config-loader.test.ts` (NEW)
- `apps/server/src/services/llm-router/error-mapping.test.ts` (NEW)
**Approach**:
- `createLlmRouterService({ configKV, redis, gateway: GatewayMetrics | null, fetchImpl?: typeof fetch })` returns a factory with method `route({ modelName, body, headers, abortSignal }) → Promise<Response>`. `fetchImpl` defaults to `globalThis.fetch` but is **injectable** for tests (no `vi.mock('node:net')`-style hacks).
- **Fallback loop** (router.ts):
1. Load model config via config-loader (returns ordered upstreams + per-upstream keys).
2. For each upstream in order:
- For each key in order:
- Build request: clone body, inject decrypted key into header per `headerTemplate`, set timeout (KTD-2 single-attempt 30s default).
- `await fetchImpl(...)` with `AbortSignal.timeout(perAttemptTimeoutMs)`.
- If response.ok and **first byte not yet returned to client**: return Response. **First successful response wins; no more fallback after this point** (pre-first-byte gate per learnings #1).
- Else: record OTel fallback event (`gateway.fallbackCount.add(1, {reason, fromKey: keyId, upstream: idx})`); decrement key iterator's "active"; continue inner loop.
- Inner loop exhausted → upstream's `keyExhaustedCount` increments; break to next upstream.
3. All upstreams exhausted → throw `createBadGatewayError('upstream_unavailable', {triedKeys: N, triedUpstreams: M})` or `createServiceUnavailableError` per KTD-1 mapping.
- **error-mapping.ts**: `mapUpstreamError(status: number | 'timeout', context) → ApiError` returning `BAD_GATEWAY` / `SERVICE_UNAVAILABLE` / `GATEWAY_TIMEOUT` per KTD-1.
- **key-rotator.ts**: stateless iterator over decrypted keys for a given upstream. Decrypts via `utils/envelope-crypto` lazily (only when key is selected). Yields `{id, plaintext}` pairs; caller never holds plaintext beyond the request-attempt window.
- **config-loader.ts**: reads `LLM_ROUTER_CONFIG` from `configKV`. In-memory cache with TTL = 5s (KTD-4 fallback). Public `invalidate()` for Pub/Sub trigger (wired in U7). Public `getModelConfig(modelName)` returns parsed config slice.
- **types.ts**: `RouterConfig`, `Upstream`, `KeyEntry`, `LlmRouteRequest`, `LlmRouteContext` (carries OTel span / metrics handle / userId for billing-attribution downstream).
**Execution note**: Test-first. Each of the 4 sub-modules has a focused test before integration test in router.test.ts. Integration test scenarios assemble the full fallback flow.
**Patterns to follow**:
- DI via factory pattern: `apps/server/src/services/billing/billing-service.ts` and other `create*Service` factories
- Fetch injection for tests: `apps/server/src/routes/openai/v1/route.test.ts:78-91` `globalThis.fetch = vi.fn(...)` pattern adapted to DI prop
- Error throwing: only `ApiError` factories, never bare `throw new Error`
- Logging: `useLogger('llm-router').useGlobalConfig()` per existing pattern
**Test scenarios**:
*key-rotator.test.ts*:
- (1) Iterator yields keys in config order; each yielded key has `.plaintext` from envelope-decrypt.
- (2) Iterator stops after final key.
- (3) Decrypt failure on one key surfaces as `createServiceUnavailableError('DECRYPT_FAILED')` and increments `decryptFailures` counter — does NOT silently skip (security: silent skip would hide config-poisoning).
*config-loader.test.ts*:
- (1) First call reads from configKV; subsequent within TTL serve from cache (mock configKV, assert single read call).
- (2) `invalidate()` clears cache; next call re-reads.
- (3) TTL expiry triggers fresh read.
- (4) Missing `LLM_ROUTER_CONFIG` → throws `createServiceUnavailableError('CONFIG_NOT_SET')`.
- (5) Unknown model name → throws `createBadRequestError('unknown_model', {requested, available: [...]})` (client-side error per KTD-1 / origin R4 pre-upstream validation).
*error-mapping.test.ts*:
- (1) `mapUpstreamError(401)` → 502 BAD_GATEWAY; (2) `mapUpstreamError(402)` → 502; (3) `mapUpstreamError(403)` → 502; (4) `mapUpstreamError(429)` → 503 SERVICE_UNAVAILABLE; (5) `mapUpstreamError(500)` → 502; (6) `mapUpstreamError(503)` → 502; (7) `mapUpstreamError('timeout')` → 504 GATEWAY_TIMEOUT; (8) `mapUpstreamError(200)` → throws (programmer error — 2xx shouldn't reach mapper).
*router.test.ts*:
- (1) Happy path: single upstream, single key, 200 response → returns Response, fallback.depth = 0, no fallback metric.
- (2) `Covers AE7 (origin).` Multi-key fallback: upstream A has keys [k1, k2]; k1 returns 401, k2 returns 200. Router tries k1 → records fallback event {reason: 401, fromKey: k1.id} → tries k2 → returns 200. fallback.depth on returned span = 1.
- (3) Cross-upstream fallback: upstream A keys all 401; upstream B key 1 returns 200. Router tries A keys until exhausted, then B[0] → 200. `keyExhaustedCount{upstream: A}` increments by 1.
- (4) Full exhaustion: all upstreams' keys 401 → router throws `createBadGatewayError('upstream_unavailable')` mapped to 502 by `app.onError`. All `keyExhaustedCount` incremented for each upstream.
- (5) Mixed-cause exhaustion: some keys 429, some 500, last one timeout. Router exhausts → throws `createGatewayTimeoutError` (last cause wins for status mapping) or `createBadGatewayError` (per implementer's chosen policy; document it). **Resolve in implementation.**
- (6) `Covers KTD-2.` Single-attempt timeout: upstream hangs 35s, abort fires at 30s; router moves to next key. Total elapsed within full-chain limit.
- (7) `Covers KTD-2.` Full-chain timeout: upstream slow + many keys cause chain > 60s. Router aborts whole flow → throws `createGatewayTimeoutError`.
- (8) Pre-upstream validation: unknown model name → throws `createBadRequestError('unknown_model')` with no fallback attempted, no upstream fetch issued. OTel doesn't record fallback event.
- (9) **Pre-first-byte guarantee**: streaming response (upstream returns 200 + `ReadableStream`). First chunk arrives → router pipes it through. Mid-stream, upstream stream throws. Router does NOT attempt fallback (response already streaming to client). Logs + OTel mid-stream interruption event per existing pattern. Same shape as `apps/server/src/routes/openai/v1/index.ts:194-232`.
- (10) `AbortSignal` from caller (client disconnected) propagates: upstream fetch is aborted; router records cancellation but does not try fallback.
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/services/llm-router/` green
- Coverage on router.ts ≥ 95% lines/branches (vitest config requires 100% globally; deviations need explicit override or full coverage)
---
### U4. Rewire `/v1/chat/completions` to use LLM router service
**Goal**: Replace the inline upstream fetch logic in `apps/server/src/routes/openai/v1/index.ts` `handleCompletion` with a call to the new `LlmRouterService`. Preserve all existing billing / OTel / SSE streaming / first-token latency behavior. Address the TODO at `:97-98` (split mixed concerns).
**Requirements**: R1, R2, R3, R4. Functional equivalence with current `routes/openai/v1/index.ts` for non-failure paths.
**Dependencies**: U3.
**Files**:
- `apps/server/src/routes/openai/v1/index.ts` (MODIFY)
- `apps/server/src/routes/openai/v1/route.test.ts` (MODIFY)
- `apps/server/src/app.ts` (MODIFY — DI: inject `LlmRouterService` into `createV1CompletionsRoutes` factory)
**Approach**:
- `createV1CompletionsRoutes` signature extends to accept `llmRouter: LlmRouterService` (or replace `env.GATEWAY_BASE_URL`-dependent code path).
- `handleCompletion`:
- Keep pre-upstream gates (auth, flux balance via `createPaymentRequiredError`, model alias resolution via `requestModel || env.DEFAULT_CHAT_MODEL`).
- Replace inline `fetch(${baseUrl}chat/completions)` with `await llmRouter.route({ modelName: requestModel, body, headers })`.
- On Router throwing `ApiError` (e.g., `BAD_GATEWAY`): re-throw — global `app.onError` handles client response.
- On router returning Response: keep existing streaming `TransformStream` + tailBuffer + usage extraction + post-response billing path unchanged (lines `:194-330` in current code).
- All existing OTel attributes still set on `tracer.startSpan('llm.gateway.chat', ...)` — additionally span has the new `airi.gen_ai.gateway.*` attrs set by the router (router writes to the active span via `trace.getActiveSpan()`).
- Remove `normalizeBaseUrl`, `getServerConnectionAttributes` calls if they become dead (verify and clean up).
- `env.GATEWAY_BASE_URL`: keep usable for backward compat one cycle (U8 removes), or remove if no longer referenced.
**Execution note**: Run existing `route.test.ts` after rewire and confirm zero behavior regression on happy path; new fallback scenarios are tested in U3 (router level).
**Patterns to follow**:
- Existing SSE streaming pipe pattern in `apps/server/src/routes/openai/v1/index.ts:194-330` — preserve exactly
- DI wiring: `apps/server/src/app.ts` `injeca.provide(...)` for `llmRouter` service + add to `AppDeps`
- Best-effort post-response writes (billing debit, request log): unchanged
**Test scenarios**:
- (1) Existing happy-path test in `route.test.ts`: mock `LlmRouterService.route` to return a non-streaming Response → assert client gets same response shape as before, billing debited, request log written.
- (2) Streaming test: mock router to return SSE Response → assert tailBuffer-driven usage extraction still fires, billing post-stream still works, first-token histogram still recorded.
- (3) Router throws `BAD_GATEWAY` (502): assert client response is 502 with `{error: "BAD_GATEWAY", message, details}` body shape per global onError.
- (4) Router throws `BAD_REQUEST` (unknown model): assert 400 response (pre-upstream rejection — no fallback recorded).
- (5) Pre-flight flux check failure still returns 402 — unchanged.
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/routes/openai/v1/route.test.ts` green
- Manual: spin up dev server with `LLM_ROUTER_CONFIG` set to one OpenRouter upstream + 2 fake keys (one bad, one good). Send curl request to `/api/v1/openai/chat/completions`. Assert 200 + body content. Force k1 invalid → confirm OTel span shows `fallback.depth=1` and 200 still returned.
---
### U5. TTS adapter interface + three REST adapters (Azure / DashScope cosyvoice / Volcengine)
**Goal**: Define the adapter abstraction and implement the three v1 adapters as pure protocol translators. Non-streaming for cosyvoice + Volcengine; Azure remains REST-one-shot (matches current knoway shape).
**Requirements**: R6, R7, KTD-6, KTD-7.
**Dependencies**: U1 (envelope crypto consumed at the router layer, not adapter — adapters receive plaintext key as input).
**Files**:
- `apps/server/src/services/tts-adapters/types.ts` (NEW)
- `apps/server/src/services/tts-adapters/index.ts` (NEW — adapter registry by provider id)
- `apps/server/src/services/tts-adapters/azure.ts` (NEW)
- `apps/server/src/services/tts-adapters/azure.test.ts` (NEW)
- `apps/server/src/services/tts-adapters/dashscope-cosyvoice.ts` (NEW)
- `apps/server/src/services/tts-adapters/dashscope-cosyvoice.test.ts` (NEW)
- `apps/server/src/services/tts-adapters/volcengine.ts` (NEW)
- `apps/server/src/services/tts-adapters/volcengine.test.ts` (NEW)
- `apps/server/package.json` (MODIFY — add `unspeech` to `devDependencies` for type-only import of `Voice` / `VoiceFormat` / `VoiceLanguage`)
**Approach**:
- `TtsAdapter` interface:
- `id: 'azure' | 'dashscope-cosyvoice' | 'volcengine'`
- `send(input: TtsInput, ctx: TtsAdapterContext): Promise<TtsResult>` where `TtsInput = { text, voice?, speed?, responseFormat?, extraOptions? }`, `TtsAdapterContext = { keyPlaintext, baseURL, adapterParams, fetchImpl, abortSignal }`, `TtsResult = { contentType, body: ArrayBuffer | ReadableStream }`.
- `getVoiceCatalog(): Voice[]` — static, loaded at module init (U6 supplies JSON files).
- `azure.ts`: OpenAI `/v1/audio/speech` → Azure `https://<region>.tts.speech.microsoft.com/cognitiveservices/v1`. Build SSML from `{text, voice, speed}` (auto-wrap unless `extraOptions.disableSsml` is true). Headers: `Ocp-Apim-Subscription-Key: <key>`, `X-Microsoft-OutputFormat: <format from responseFormat>`, `Content-Type: application/ssml+xml`. Response is raw audio bytes — return as ArrayBuffer.
- `dashscope-cosyvoice.ts`: POST to `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation` with DashScope JSON body. Headers: `Authorization: Bearer <key>`. Map `responseFormat` to DashScope's `output.format` field. Non-streaming response → ArrayBuffer.
- `volcengine.ts`: POST to `https://openspeech.bytedance.com/api/v1/tts` with Volcengine JSON body (AppID + Token auth in body, not header). Map `voice` to `voice_type`, `responseFormat` to `audio_params.format`. Non-streaming → ArrayBuffer.
- `index.ts`: `getAdapter(id) → TtsAdapter` lookup. Throws `createBadRequestError('unknown_tts_provider', {id})` if missing.
- Each adapter has `fetchImpl` injection point (default `globalThis.fetch`) for tests.
**Patterns to follow**:
- Type import for `Voice` / `VoiceFormat` / `VoiceLanguage` from `unspeech` npm package (type-only — per brainstorm D13)
- Hand-rolled REST + fetch injection — no `dashscope-sdk-nodejs` / `@volcengine/openapi` deps
- Error throwing: `ApiError` factories only
**Test scenarios** (per adapter):
- *azure*: (1) basic text → SSML structure (assert XML shape — root + voice + content); (2) speed >1.0 applies `prosody rate`; (3) `disableSsml=true` passes raw text as SSML body; (4) header has `Ocp-Apim-Subscription-Key` and `X-Microsoft-OutputFormat`; (5) upstream 200 with audio bytes → returns ArrayBuffer + content-type from response; (6) upstream 401 → throws (mapping happens at router layer, adapter just bubbles error info).
- *dashscope-cosyvoice*: (1) request body shape matches DashScope schema (text in `input.messages` per schema); (2) header `Authorization: Bearer` set; (3) `voice` maps to DashScope `voice` param; (4) upstream success → ArrayBuffer + content-type.
- *volcengine*: (1) request body has AppID + Token; (2) `voice_type` mapping; (3) audio_params.format from responseFormat; (4) upstream success → ArrayBuffer.
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/services/tts-adapters/` green
- Coverage per adapter ≥ 90% lines
---
### U6. TTS voice catalogs (static JSON) + voices route
**Goal**: Commit static voice catalog JSON files per provider. Implement `/v1/audio/voices?model=<alias>` route that resolves alias → provider, returns provider's static catalog merged with operator-controlled recommended map (existing `DEFAULT_TTS_VOICES` configKV). Replace current knoway-passthrough `handleListVoices`.
**Requirements**: R9.
**Dependencies**: U5 (adapter interface for `getVoiceCatalog()`).
**Files**:
- `apps/server/src/services/tts-adapters/voices/azure.json` (NEW)
- `apps/server/src/services/tts-adapters/voices/dashscope-cosyvoice.json` (NEW)
- `apps/server/src/services/tts-adapters/voices/volcengine.json` (NEW)
- `apps/server/src/services/tts-adapters/azure.ts` (MODIFY — `getVoiceCatalog()` reads json file at module init)
- (same for dashscope-cosyvoice.ts, volcengine.ts)
- `apps/server/src/routes/openai/v1/index.ts` (MODIFY — replace `handleListVoices` with adapter-dispatch)
- `apps/server/src/routes/openai/v1/route.test.ts` (MODIFY — add tests)
- `apps/server/docs/ai-context/verifications/llm-router.md` (NEW — initial voices refresh runbook section)
**Approach**:
- JSON shape matches `Voice` / `VoiceFormat` / `VoiceLanguage` from `unspeech` package types (type-only import in adapter — no runtime dep on unspeech).
- For initial commit: bootstrap Azure catalog from Microsoft's published voice list (current eastasia region voices, ~80 entries — full ~600 across all regions is overkill for v1; document refresh process in verification doc).
- cosyvoice + Volcengine catalogs: smaller (10-30 voices each), bootstrap from official docs.
- Adapter loads its JSON file at module init via `import voices from './voices/azure.json' with { type: 'json' }` (ESM JSON import). Returns slice on `getVoiceCatalog()`.
- Voices route: read `model` query param → resolve to provider via `LLM_ROUTER_CONFIG.tts.models[model].provider``getAdapter(provider).getVoiceCatalog()` → merge with `configKV.getOptional('DEFAULT_TTS_VOICES')` recommended map → return.
- Removed: passthrough to `${baseUrl}audio/voices` (was knoway-dependent at `apps/server/src/routes/openai/v1/index.ts:441-475`).
**Patterns to follow**:
- Hono Response.json: existing usage at `apps/server/src/routes/openai/v1/index.ts:474`
- Type-only import: `import type { Voice } from 'unspeech'`
- ESM JSON imports: TypeScript 5+ + Node 22+ support; verify in `apps/server/tsconfig.json` (resolveJsonModule)
**Test scenarios**:
- (1) `GET /v1/audio/voices?model=azure-tts` returns 200 + json with `voices: Voice[]` + `recommended: {...}` merged.
- (2) Unknown model param → 400 with `BAD_REQUEST`.
- (3) Empty `model` falls back to `env.DEFAULT_TTS_MODEL` (matches current behavior at `apps/server/src/routes/openai/v1/index.ts:441-446`).
- (4) Adapter `getVoiceCatalog()` returns same array reference structure as committed JSON (no mutation between calls).
- (5) Voice JSON files validate against minimal Voice schema (just shape check — name, language, format fields exist).
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/routes/openai/v1/route.test.ts` green
- Manual: GET `/v1/audio/voices?model=azure-tts` against dev server → assert JSON response with voices count > 0
---
### U7. Pub/Sub config invalidation + `/livez` + `/readyz`
**Goal**: Wire Pub/Sub-driven invalidation of the LLM router config in-memory cache (KTD-4). Add per-instance `config.reload` OTel counter. Add `/livez` (always 200) and `/readyz` (Postgres + Redis ping) endpoints. **Gateway key health does NOT affect readiness** (R14).
**Requirements**: R13, R14, R16, R16a (acknowledged as Outstanding Question — not actively delivered, see "Resolve before merging" below), KTD-4, KTD-12.
**Dependencies**: U3 (consumes `config-loader.invalidate`), U2 (uses `gateway.configReload` counter).
**Files**:
- `apps/server/src/services/llm-router/config-loader.ts` (MODIFY — add `subscribeToInvalidations(redis)` wiring)
- `apps/server/src/utils/redis-keys.ts` (MODIFY — add `configKvInvalidateChannel()` helper)
- `apps/server/src/app.ts` (MODIFY — register `/livez`, `/readyz`, exclude both from `httpInstrumentationMiddleware`; wire config-loader to redis subscriber; admin endpoint for `set LLM_ROUTER_CONFIG` publishes invalidation)
- `apps/server/src/routes/admin/...` (MODIFY — if admin set endpoint exists for configKV; publish on write)
- `apps/server/src/app.test.ts` (NEW — health endpoint tests)
- `apps/server/docs/ai-context/redis-boundaries-and-pubsub.md` (MODIFY — declare `configkv:invalidate` channel)
**Approach**:
- Channel: `configkv:invalidate` (single channel for all configKV keys; payload `{ key: string, version: nanoid(), publishedAt: number }`).
- On `configKV.set('LLM_ROUTER_CONFIG', value)`: publish to channel.
- In `createLlmRouterService` init: subscribe via separate `ioredis` instance (Redis Pub/Sub requires dedicated subscriber connection per ioredis docs). On message matching `key === 'LLM_ROUTER_CONFIG'`: call `config-loader.invalidate()` and increment `gateway.configReload.add(1, {service_instance_id, source: 'pubsub'})`.
- TTL fallback: in-memory cache has TTL = 5s. On TTL expiry next request reloads from configKV (Postgres+Redis source-of-truth chain) and increments counter with `source: 'ttl'`.
- `/livez`: route returns `200 {status: 'live'}` always. No DB / Redis touch. Excluded from `httpInstrumentationMiddleware` (`apps/server/src/app.ts:126-128` pattern).
- `/readyz`: route pings Postgres (`SELECT 1`) + Redis (`PING`). Returns 200 if both ok; 503 otherwise. **Does not check gateway key health** (R14 — single key flap can't take instance out of pool).
- Legacy `/health` endpoint removed post-implementation in favor of K8s-style `/livez` + `/readyz` (single source of truth, no overlap).
**Patterns to follow**:
- ioredis Pub/Sub: dedicated subscriber connection (search for existing pubsub usage in `apps/server``redis-boundaries-and-pubsub.md` references this)
- redis-keys helper: `apps/server/src/utils/redis-keys.ts:11+` `redisKeyFrom` pattern
- Health route skip: existing pattern at `apps/server/src/app.ts:126-128`
**Test scenarios**:
- (1) Config-loader subscribes on init; on Pub/Sub message for `LLM_ROUTER_CONFIG`: cache cleared, next read fetches fresh.
- (2) Pub/Sub message for unrelated key: no invalidation, no counter increment.
- (3) TTL expiry path: cache populated → 5s elapse (mock clock or vitest fake timers) → next read fetches fresh + counter incremented with `source: 'ttl'`.
- (4) `GET /livez` returns 200 + `{status: 'live'}` even when Redis is down (Redis client error mocked).
- (5) `GET /readyz` returns 200 when both Postgres + Redis ping ok.
- (6) `GET /readyz` returns 503 when Postgres down (mock pool query throws).
- (7) `GET /readyz` returns 503 when Redis down (mock ping throws).
- (8) `GET /readyz` returns 200 even with `LLM_ROUTER_CONFIG` missing (gateway state does not block readiness per R14).
- (9) `httpInstrumentationMiddleware` does NOT instrument `/livez` or `/readyz` requests (assert OTel http span count after probe = 0).
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/app.test.ts` green
- Manual: `curl /livez` → 200; `curl /readyz` → 200 with Postgres + Redis up
**Resolve before merging**:
- **R16a admin permission model** is an explicit Outstanding Question in origin; if it's not resolved before this unit ships, the admin set-config endpoint stays behind existing flat-admin-role auth. Note as known limitation in PR description: "Admin endpoint for `set LLM_ROUTER_CONFIG` uses existing flat admin role; role-scoping is follow-up work".
---
### U9. Admin configKV write endpoint for `LLM_ROUTER_CONFIG` + Pub/Sub publish
**Goal**: 解决 doc review P0 blocker —— 现有 `apps/server/src/routes/admin/` 只有 `flux-grants``flux-grant-batches`**没有** configKV.set 的 HTTP 入口。本 unit 新建一个 admin endpoint 承接 `LLM_ROUTER_CONFIG` 的读 / 写 / 部分更新,并在写入后发 Pub/Sub 通知用以触发 U7 的 invalidation。同时实现 R16a 角色模型决策(在此 endpoint 落地)+ 写入审计日志 + 原子性约束。
**Requirements**: R15, R15a, R16, R16a, R17.
**Dependencies**: U1envelope crypto,写入时加密 key blob, U2OTel metric 用于 write audit)。
**Files**:
- `apps/server/src/routes/admin/llm-router-config.ts` (NEW)
- `apps/server/src/routes/admin/llm-router-config.test.ts` (NEW)
- `apps/server/src/app.ts` (MODIFY — mount admin route under `/api/admin/llm-router-config`)
- `apps/server/src/services/llm-router/config-loader.ts` (MODIFY — service 内部 `setLlmRouterConfig(value)` 包装 configKV.set + 加密 + publish 三步)
**Approach**:
- **HTTP surface**
- `GET /api/admin/llm-router-config` — 返回当前 `LLM_ROUTER_CONFIG`**key ciphertext 字段隐去**(只回 `{id, ciphertextFingerprint: SHA-256(ciphertext)[:8]}` 用于识别)。Admin 不需要 raw 密文回读。
- `PUT /api/admin/llm-router-config` — 原子替换整个 config 树。Body 含明文 keysserver 在写入前 envelope encrypt。**乐观锁**:请求必须带 `If-Match: <version-nanoid>` headerversion 来自 `GET` 响应);不匹配返 409 Conflict。
- `POST /api/admin/llm-router-config/rotate-key` — 后续 v1.5 实现密钥旋转 endpoint**v1 不实装**,但 schema / 路由占位。
- **写入流程(原子性)**(1) Valibot 校验请求 body → (2) 加密 key 明文(envelope crypto, U1)→ (3) 单 Postgres tx 写 configKV → (4) 失效本地 Redis cache → (5) `redis.publish('configkv:invalidate', {key: 'LLM_ROUTER_CONFIG', version, publishedAt})`。**注意顺序**:先 tx commit 再 cache invalidate 再 publish,否则 instance 可能 reload 出 pre-commit 旧值。
- **R16a 角色模型** —— v1 拍板:**沿用现有 `ADMIN_EMAILS` 单 admin 角色**(来自 `apps/server/src/app.ts:244`)。不做 step-up auth / 双人确认 / 分角色拆分。承担的风险:admin 凭据被攻陷 = 即时注入恶意 upstream URL / key。**补偿控制**:每次 PUT 都写一行 audit logOTel structured event + Postgres `admin_audit_log` 表?或 stdout JSON 日志)记录 actor email + timestamp + key id list(前 8 字符) + version。具体落库位置 planning 阶段内决定(如果 `admin_audit_log` 表不存在则用 OTel + JSON 日志,留 v2 加表)。
- **OTel**:发 event `airi.gen_ai.gateway.config.write{actor_email, version, key_count}` 每次 PUT 时;counter `airi.gen_ai.gateway.config.write_count{result: 'success' | '4xx' | '5xx'}`
- **createBadGatewayError details 约束**doc review P1):实现层内显式注明 error-mapping.ts 的 `context` 参数**只允许** `{triedKeys: number, triedUpstreams: number, lastStatusCode: number}`,禁止包含上游 response body / header 字符串。
- **HMAC on Pub/Sub payload**doc review P2):payload 加 `hmac = HMAC-SHA256(LLM_ROUTER_MASTER_KEY, JSON.stringify({key, version, publishedAt}))`receiver 校验。这是 cheap defense in depth;如 redis 接受跨租户连接也能侦测到 forged invalidation。
**Patterns to follow**:
- 现有 admin route 风格:`apps/server/src/routes/admin/flux-grants` (HTTP shape + auth guard)
- ADMIN_EMAILS 检查:`apps/server/src/app.ts:244` 现有 pattern
- Valibot 校验请求 bodyreka 全局 onError 渲染失败 schema 为 400
**Test scenarios**:
- (1) GET 返回配置但隐去密文(只回 fingerprint);客户端不能从 admin endpoint 拿回原始密钥
- (2) PUT 不带 `If-Match` → 412 Precondition Required(强制 ETag 工作流)
- (3) PUT 携 stale `If-Match` → 409 Conflict + 当前 version 在响应里
- (4) PUT 成功 → configKV 内密文已加密、不是明文、可被路由器解密
- (5) PUT 成功 → Pub/Sub 收到 invalidate 消息(mock subscriber,断言收到 message
- (6) PUT 成功 → OTel event `config.write` 发出 + audit log JSON 行出现
- (7) PUT 时 Postgres tx 失败 → Pub/Sub 不应该 fire(顺序保证)
- (8) 连续两次 PUT → 两次 Pub/Sub 顺序一致(ioredis 单 channel 保序)
- (9) 非 ADMIN_EMAILS 用户 PUT → 401 / 403(沿用现有 auth guard
- (10) HMAC 校验:receiver 收到伪造 HMAC 的 payload → 不调 invalidate(且发 `airi.gen_ai.gateway.config.invalid_hmac` counter
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm exec vitest run apps/server/src/routes/admin/llm-router-config.test.ts` green
- 手动:本地 dev 跑 PUT → tail 日志看到 audit log JSON → tail Redis MONITOR 看到 publish → 第二个 instance 看到 invalidate
---
### U8. Grafana dashboards + final cutover (DI wiring + env cleanup + verification doc)
**Goal**: Final stitching unit. Add Grafana panels + alert rules for gateway metrics. Finalize DI wiring in `app.ts` (router service + adapters registered, route mounted with new dep). Remove `GATEWAY_BASE_URL` env var (knoway no longer reachable from new code path). Write the verification doc with real curl evidence. Update operational docs.
**Requirements**: R12, R17, R18, R19.
**Dependencies**: U1-U7.
**Files**:
- `apps/server/otel/grafana/dashboards/build.ts` (MODIFY)
- `apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json` (MODIFY)
- `apps/server/src/app.ts` (MODIFY — final DI wiring; remove `GATEWAY_BASE_URL` consumers)
- `apps/server/src/libs/env.ts` (MODIFY — remove `GATEWAY_BASE_URL`)
- `apps/server/src/libs/env.test.ts` (MODIFY)
- `apps/server/scripts/verify-router-config.ts` (NEW — operator script referenced in U1 Migration step; decrypts current `LLM_ROUTER_CONFIG` against `LLM_ROUTER_MASTER_KEY` to validate boot-time correctness)
- `apps/server/src/scripts/otel/llm-router-smoke.ts` (NEW — new smoke fixture that produces traces tagged with `airi.gen_ai.gateway.*` attrs; **note**: the previously-referenced `apps/server/src/scripts/otel/smoke.ts` does not exist — the actual existing file is `ws-smoke.ts` for WebSocket smoke; gateway-specific smoke is new work)
- `apps/server/docs/ai-context/transport-and-routes.md` (MODIFY — route → service mapping update; `/livez` + `/readyz` documented; legacy `/health` removed)
- `apps/server/docs/ai-context/observability-conventions.md` (MODIFY)
- `apps/server/docs/ai-context/verifications/llm-router.md` (FINALIZE — full verification with real evidence)
- (Possibly) `apps/server/scripts/...` (NEW — knoway compose retention policy doc / data-driven trigger criteria)
**Approach**:
- Grafana dashboard JSON: add 3 panels (key exhausted count time series, fallback depth distribution, upstream errors by status code) + 3 alert rules (P0 key.exhausted > 0 in 5min, P1 fallback ratio > 30% in 15min, P2 single key > 80% errors in 30min). Use existing `build.ts` to assemble. Thresholds are placeholders — refine post-launch.
- DI wiring: extend `AppDeps` interface (`apps/server/src/app.ts:70-88`) with `llmRouter` field. Register via `injeca.provide('services:llmRouter', { dependsOn: ['services:configKV', 'libs:redis', 'otel'], build: ... })`. Thread into `createV1CompletionsRoutes` factory.
- `GATEWAY_BASE_URL` removal: delete from env schema; verify no remaining consumers via grep — current consumers per existing brainstorm context: `apps/server/src/libs/env.ts`, `apps/server/src/libs/env.test.ts`, `apps/server/src/routes/openai/v1/index.ts`, `apps/server/src/routes/openai/v1/route.test.ts`, `apps/server/src/scripts/otel/smoke.ts`. Update each.
- Verification doc (`apps/server/docs/ai-context/verifications/llm-router.md`): follow AGENTS.md template — for each user path (chat completions happy / chat completions fallback / TTS speech happy / voices listing / livez / readyz), include scenario / command / expected output / actual output (curl response snippets) / environment (commit SHA + deploy env) / last verified date.
- knoway compose: do not delete yet. Document retention criteria in transport-and-routes.md and PR description: "knoway compose stays until: 14 days post-deploy without P1+ incidents OR 1 peak-traffic event without P1+ incidents. Reset on any P1."
**Patterns to follow**:
- DI wiring: existing `injeca.provide` blocks in `apps/server/src/app.ts:280-487`
- Verification doc format: AGENTS.md template + existing files under `apps/server/docs/ai-context/verifications/`
- Grafana dashboard build: existing `build.ts` + existing panel structures in `airi-server-overview-cloud.json`
**Test scenarios**:
- (1) `env.test.ts`: `GATEWAY_BASE_URL` no longer in schema; old test asserting it must be removed.
- (2) `app.test.ts`: server boots with `LLM_ROUTER_CONFIG` set and `GATEWAY_BASE_URL` absent — no errors.
- (3) Smoke test (new `apps/server/src/scripts/otel/llm-router-smoke.ts`): produces traces tagged with `airi.gen_ai.gateway.*` attrs.
- (4) Grafana dashboard JSON parseable + alert rules validated (use `jq` or existing build.ts assertion).
- (5) Manual end-to-end: dev server with full `LLM_ROUTER_CONFIG` running. `curl /api/v1/openai/chat/completions` returns 200. Kill 1 of 2 keys (invalid token) → response still 200 + OTel trace shows fallback.depth=1 in Grafana.
**Verification**:
- `pnpm -F @proj-airi/server typecheck` passes
- `pnpm -F @proj-airi/server exec vitest run` (full server test suite) green
- `pnpm -F @proj-airi/server build` succeeds
- Verification doc reflects real dev-server output with commit SHA + date
- Grafana dashboard rendering: load JSON into local Grafana via existing docker-compose.otel.yml; visually confirm 3 new panels appear
---
## System-Wide Impact
| Surface | Impact |
|---|---|
| `apps/server` request thread | New synchronous path: configKV cache check → router service → upstream(s) → response. No new background work. |
| `apps/server` startup | New env var `LLM_ROUTER_MASTER_KEY` required. configKV must contain `LLM_ROUTER_CONFIG` before requests succeed (otherwise router throws `CONFIG_NOT_SET` per existing pattern). |
| Postgres | No schema changes in v1. |
| Redis | New Pub/Sub channel `configkv:invalidate`. New in-memory cache (per-instance, not Redis-resident). |
| OTel pipeline | 5 new attribute constants + 5 new metric counters under `airi.gen_ai.gateway.*`. Prime list updated so all show up in Grafana before first hit. |
| Grafana | 3 new panels + 3 new alert rules in `airi-server-overview-cloud.json`. |
| Frontend | No contract change to `/v1/*` API. BYOK path through unspeech is untouched. |
| Operations / on-call | New env var to set in Railway. New alerts to acknowledge in pager rotation. knoway compose retained as rollback artifact. |
| Other services in repo | None. `apps/server` is the only consumer of `LLM_ROUTER_CONFIG`. |
---
## Risks & Mitigations
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| Account-level rate limit on upstream (D33 risk-accepted): 429 storm makes fallback useless | Medium | High | OTel SLO triggers (D29) + knoway compose retained → can ship cooldown as v2 within 1 sprint after first event |
| Envelope crypto bug (zero-precedent) corrupts keys / blocks all requests | Low | High | Test-first crypto (U1) with KAT vectors + tamper-detect tests; staging soak before prod cutover |
| Pub/Sub message drop leads to stale config window > 5s | Low | Medium | TTL self-healing (KTD-4) bounds window to 5s; counter `airi.gen_ai.gateway.config.reload{source:"ttl"}` makes the gap observable |
| SSE first-byte race: fallback decision made after first byte already streamed | Low | High | Explicit pre-first-byte gate in router.ts (U3 test #9); mirrors existing pattern at `apps/server/src/routes/openai/v1/index.ts:194` |
| `node:crypto` `randomFillSync` vs async randomness in high-load: micro-task starvation | Low | Low | Use sync API only at request handler level; envelope encrypt happens lazily per key per request — bounded volume |
| `LLM_ROUTER_MASTER_KEY` lost / rotated: all encrypted keys become decryptable garbage | Low | High | Document rotation procedure (re-encrypt then update) in `transport-and-routes.md`; require backup before rotating |
| Volcengine / DashScope protocol changes break adapter unit tests in unrelated PRs | Medium | Low | Adapters are mocked at fetch boundary; protocol changes are integration concern, surface via verification doc refresh and existing CI |
| Voice catalog JSON drift from real upstream | Medium | Low | Quarterly refresh task in verification doc runbook; not blocking on v1 ship |
| Grafana alert threshold placeholders fire wrongly on day 1 | Medium | Medium | Set thresholds at conservative levels (e.g., 95th percentile of current traffic); refine in week 1 post-launch |
| knoway compose deleted prematurely → revert impossible | Low | High | KTD/R18: data-driven deletion criteria, not calendar; reset counter on any P1 |
---
## Migration & Rollout
1. **Pre-deploy**:
- Set `LLM_ROUTER_MASTER_KEY` env var on Railway (generate 32 bytes base64).
- Encrypt current OpenRouter / Azure / DashScope / Volcengine keys via local script using same crypto module → write encrypted blobs into `LLM_ROUTER_CONFIG` via existing `configKV.set` admin path.
- Verify `LLM_ROUTER_CONFIG` decrypts cleanly in dev env (`pnpm exec tsx scripts/verify-router-config.ts` — script TBD in U8).
2. **Deploy** (one-shot):
- Merge PR.
- Railway picks up new build.
- Multi-instance rollout completes (Railway managed).
- First request to `/v1/chat/completions` hits new router code path.
3. **First 24h watch**:
- Pager + on-call dashboards. Watch fallback.depth distribution, key.exhausted counter, decrypt failures counter.
- If P1: revert deploy via Railway one-click. knoway compose still in repo → no resurrection ceremony needed beyond redeploy of prior version.
4. **Data-driven knoway deletion**:
- 14 consecutive days without P1 OR 1 peak-traffic event without P1.
- Then merge follow-up PR removing `airi-railway/knoway/` directory + compose entry.
- Reset counter on any P1 — restart 14-day window.
---
## Verification Plan
Verification doc lives at `apps/server/docs/ai-context/verifications/llm-router.md`. Covers these user paths (per AGENTS.md format — scenario + command + expected + actual + environment + last-verified):
1. **LLM chat completion happy path**: `curl POST /api/v1/openai/chat/completions` returns 200 + body.
2. **LLM chat completion fallback**: with one bad key in config, same curl still returns 200 + OTel trace shows `fallback.depth=1`.
3. **TTS speech (Azure)**: `curl POST /api/v1/openai/audio/speech model=azure-tts` returns audio bytes + 200.
4. **TTS speech (cosyvoice)**: same against cosyvoice model.
5. **TTS speech (Volcengine)**: same against Volcengine model.
6. **Voices listing**: `curl GET /api/v1/openai/audio/voices?model=azure-tts` returns voice catalog JSON.
7. **Liveness**: `curl /livez` returns 200 + `{status: 'live'}`.
8. **Readiness**: `curl /readyz` returns 200 with Postgres+Redis up; 503 otherwise.
9. **Pre-upstream validation**: `curl POST /api/v1/openai/chat/completions model=unknown` returns 400 with `unknown_model` error code.
10. **All-keys exhaustion**: with all keys invalid, returns 502 (per KTD-1 final-cause mapping).
Each entry needs fresh evidence (commit SHA + curl response + OTel span screenshot or jq excerpt) per Iron Law.
---
## Open Questions (Deferred to Implementation)
Carried from origin Deferred-to-Planning, narrowed by plan-time decisions:
- *(Resolved at plan-time, see KTD-1)*: ~Upstream error → 5xx mapping rules~
- *(Resolved at plan-time, see KTD-2)*: ~Fallback timeout thresholds~
- *(Resolved at plan-time, see KTD-4)*: ~Config hot-reload mechanism~
- *(Resolved at plan-time, see KTD-6)*: ~TTS adapter interface signature~
- *(Resolved at plan-time, see KTD-7)*: ~DashScope cosyvoice + Volcengine streaming support~ (v1 non-streaming only)
- **Grafana alert threshold initial values** — placeholders in U8; refine in week-1 post-launch using real traffic baseline.
- **R16a admin permission model** — left as known limitation; admin endpoint stays under flat admin role until follow-up.
---
## Origin References
- Requirements doc: `apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md`
- Key carried decisions:
- D1 (upstream errors → 5xx)
- D2 + D33 (no cooldown in v1, risk-accepted)
- D4 (no persistent dead state)
- D17, D18 (one-shot cutover + data-driven knoway compose deletion)
- D29 (SLO thresholds for v2 trigger)
- D31 (v1 implements upstream-level fallback; no LB)
- D32 (unspeech double-implementation accepted)
- AGENTS.md / `apps/server/CLAUDE.md` constraints honored:
- Multi-instance Railway (request-thread writes only, no background workers)
- Routes are thin (logic in services)
- configKV defaults centralized (no `?? fallback`)
- Best-effort post-response logging
- OTel naming: no new top-level prefix (`airi.gen_ai.*` reused)
- No backward-compat guards on knoway path (one-shot cutover)
- Repo-relative paths throughout