apps/server/docs/plans/2026-05-15-001-feat-llm-tts-router-replacing-knoway-plan.md
在 apps/server 内新建一个 in-process 路由模块替换 knoway sidecar:LLM /v1/chat/completions 走 SSE passthrough + 请求内多 key fallback + 跨 upstream fallback;TTS /v1/audio/speech 走 adapter interface(v1 三家:Azure / DashScope cosyvoice / Volcengine,非流式 REST 实现);/v1/audio/voices 由仓库内静态 JSON 提供;configKV 增 LLM_ROUTER_CONFIG composite 条目承载整棵路由器配置;新 envelope encryption 工具加密存储 provider key;OTel 用 airi.gen_ai.gateway.* 自定义属性,新增 fallback / key 健康相关 metrics;新增 /livez + /readyz;一次性切流 + 数据驱动决定何时删 knoway compose。
详见 apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md 的 Problem Frame。简述:当前 /api/v1/openai/* 是薄代理转发到 knoway,knoway 每 cluster 单 upstream 不支持多 key fallback,生产上的 key 余额耗尽 / 吊销直接打穿到用户层;同时 knoway 是独立 Go 服务,多一跳延迟 + 多一个语言运行时维护成本。
R-IDs 沿用 origin 文档(详见 origin 中 R1-R19 描述):
LLM 路由
TTS 路由
Observability
Health 端点
配置与运维
迁移
详见 origin 文档(apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md)。
| # | 决策 | 出处 / 理由 |
|---|---|---|
| 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 |
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.
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]
}
}
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
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
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"}
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.
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')).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:
apps/server/src/utils/error.ts:18-69 (existing factories)apps/server/src/services/config-kv.ts STRIPE_PAYMENT_METHOD_OPTIONS precedentapps/server/src/libs/env.ts existing schema definition patternTest scenarios:
?? fallback at call site).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).Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/utils/envelope-crypto.test.ts greenpnpm exec vitest run apps/server/src/libs/env.test.ts greenApiError(502) thrown from a test route renders as {error: "BAD_GATEWAY", message, details} per app.ts:160-182Goal: 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:
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)METRIC_AIRI_GEN_AI_GATEWAY_FALLBACK_COUNTMETRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORSMETRIC_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:
apps/server/src/utils/observability.ts:6-90apps/server/src/otel/index.ts:146-211+ (createGenAiMetrics precedent)primeCounter callsobservability-metrics.mdTest scenarios:
createGatewayMetrics(meter) returns expected fields with meter.createCounter called for each — assert via vi.fn meter spy with call args matching new metric names.OtelInstance.start primes each gateway counter (test asserts add(0, {...}) was called once per metric during prime phase).airi.gen_ai.gateway.* prefix — string assertion on constants.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 passespnpm exec vitest run apps/server/src/otel/index.test.ts greenobservability-metrics.md lists 5 new gateway metrics; manual reviewGoal: 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).headerTemplate, set timeout (KTD-2 single-attempt 30s default).await fetchImpl(...) with AbortSignal.timeout(perAttemptTimeoutMs).gateway.fallbackCount.add(1, {reason, fromKey: keyId, upstream: idx})); decrement key iterator's "active"; continue inner loop.keyExhaustedCount increments; break to next upstream.createBadGatewayError('upstream_unavailable', {triedKeys: N, triedUpstreams: M}) or createServiceUnavailableError per KTD-1 mapping.mapUpstreamError(status: number | 'timeout', context) → ApiError returning BAD_GATEWAY / SERVICE_UNAVAILABLE / GATEWAY_TIMEOUT per KTD-1.utils/envelope-crypto lazily (only when key is selected). Yields {id, plaintext} pairs; caller never holds plaintext beyond the request-attempt window.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.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:
apps/server/src/services/billing/billing-service.ts and other create*Service factoriesapps/server/src/routes/openai/v1/route.test.ts:78-91 globalThis.fetch = vi.fn(...) pattern adapted to DI propApiError factories, never bare throw new ErroruseLogger('llm-router').useGlobalConfig() per existing patternTest scenarios:
key-rotator.test.ts:
.plaintext from envelope-decrypt.createServiceUnavailableError('DECRYPT_FAILED') and increments decryptFailures counter — does NOT silently skip (security: silent skip would hide config-poisoning).config-loader.test.ts:
invalidate() clears cache; next call re-reads.LLM_ROUTER_CONFIG → throws createServiceUnavailableError('CONFIG_NOT_SET').createBadRequestError('unknown_model', {requested, available: [...]}) (client-side error per KTD-1 / origin R4 pre-upstream validation).error-mapping.test.ts:
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:
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.keyExhaustedCount{upstream: A} increments by 1.createBadGatewayError('upstream_unavailable') mapped to 502 by app.onError. All keyExhaustedCount incremented for each upstream.createGatewayTimeoutError (last cause wins for status mapping) or createBadGatewayError (per implementer's chosen policy; document it). Resolve in implementation.Covers KTD-2. Single-attempt timeout: upstream hangs 35s, abort fires at 30s; router moves to next key. Total elapsed within full-chain limit.Covers KTD-2. Full-chain timeout: upstream slow + many keys cause chain > 60s. Router aborts whole flow → throws createGatewayTimeoutError.createBadRequestError('unknown_model') with no fallback attempted, no upstream fetch issued. OTel doesn't record fallback event.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.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 passespnpm exec vitest run apps/server/src/services/llm-router/ green/v1/chat/completions to use LLM router serviceGoal: 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:
createPaymentRequiredError, model alias resolution via requestModel || env.DEFAULT_CHAT_MODEL).fetch(${baseUrl}chat/completions) with await llmRouter.route({ modelName: requestModel, body, headers }).ApiError (e.g., BAD_GATEWAY): re-throw — global app.onError handles client response.TransformStream + tailBuffer + usage extraction + post-response billing path unchanged (lines :194-330 in current code).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()).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:
apps/server/src/routes/openai/v1/index.ts:194-330 — preserve exactlyapps/server/src/app.ts injeca.provide(...) for llmRouter service + add to AppDepsTest scenarios:
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.BAD_GATEWAY (502): assert client response is 502 with {error: "BAD_GATEWAY", message, details} body shape per global onError.BAD_REQUEST (unknown model): assert 400 response (pre-upstream rejection — no fallback recorded).Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/routes/openai/v1/route.test.ts greenLLM_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.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.fetchImpl injection point (default globalThis.fetch) for tests.Patterns to follow:
Voice / VoiceFormat / VoiceLanguage from unspeech npm package (type-only — per brainstorm D13)dashscope-sdk-nodejs / @volcengine/openapi depsApiError factories onlyTest scenarios (per adapter):
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).input.messages per schema); (2) header Authorization: Bearer set; (3) voice maps to DashScope voice param; (4) upstream success → ArrayBuffer + content-type.voice_type mapping; (3) audio_params.format from responseFormat; (4) upstream success → ArrayBuffer.Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/services/tts-adapters/ greenGoal: 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)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:
Voice / VoiceFormat / VoiceLanguage from unspeech package types (type-only import in adapter — no runtime dep on unspeech).import voices from './voices/azure.json' with { type: 'json' } (ESM JSON import). Returns slice on getVoiceCatalog().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.${baseUrl}audio/voices (was knoway-dependent at apps/server/src/routes/openai/v1/index.ts:441-475).Patterns to follow:
apps/server/src/routes/openai/v1/index.ts:474import type { Voice } from 'unspeech'apps/server/tsconfig.json (resolveJsonModule)Test scenarios:
GET /v1/audio/voices?model=azure-tts returns 200 + json with voices: Voice[] + recommended: {...} merged.BAD_REQUEST.model falls back to env.DEFAULT_TTS_MODEL (matches current behavior at apps/server/src/routes/openai/v1/index.ts:441-446).getVoiceCatalog() returns same array reference structure as committed JSON (no mutation between calls).Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/routes/openai/v1/route.test.ts green/v1/audio/voices?model=azure-tts against dev server → assert JSON response with voices count > 0/livez + /readyzGoal: 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:
configkv:invalidate (single channel for all configKV keys; payload { key: string, version: nanoid(), publishedAt: number }).configKV.set('LLM_ROUTER_CONFIG', value): publish to channel.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'}).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)./health endpoint removed post-implementation in favor of K8s-style /livez + /readyz (single source of truth, no overlap).Patterns to follow:
apps/server — redis-boundaries-and-pubsub.md references this)apps/server/src/utils/redis-keys.ts:11+ redisKeyFrom patternapps/server/src/app.ts:126-128Test scenarios:
LLM_ROUTER_CONFIG: cache cleared, next read fetches fresh.source: 'ttl'.GET /livez returns 200 + {status: 'live'} even when Redis is down (Redis client error mocked).GET /readyz returns 200 when both Postgres + Redis ping ok.GET /readyz returns 503 when Postgres down (mock pool query throws).GET /readyz returns 503 when Redis down (mock ping throws).GET /readyz returns 200 even with LLM_ROUTER_CONFIG missing (gateway state does not block readiness per R14).httpInstrumentationMiddleware does NOT instrument /livez or /readyz requests (assert OTel http span count after probe = 0).Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/app.test.ts greencurl /livez → 200; curl /readyz → 200 with Postgres + Redis upResolve before merging:
set LLM_ROUTER_CONFIG uses existing flat admin role; role-scoping is follow-up work".LLM_ROUTER_CONFIG + Pub/Sub publishGoal: 解决 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: U1(envelope crypto,写入时加密 key blob), U2(OTel 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:
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 含明文 keys,server 在写入前 envelope encrypt。乐观锁:请求必须带 If-Match: <version-nanoid> header(version 来自 GET 响应);不匹配返 409 Conflict。POST /api/admin/llm-router-config/rotate-key — 后续 v1.5 实现密钥旋转 endpoint,v1 不实装,但 schema / 路由占位。redis.publish('configkv:invalidate', {key: 'LLM_ROUTER_CONFIG', version, publishedAt})。注意顺序:先 tx commit 再 cache invalidate 再 publish,否则 instance 可能 reload 出 pre-commit 旧值。ADMIN_EMAILS 单 admin 角色(来自 apps/server/src/app.ts:244)。不做 step-up auth / 双人确认 / 分角色拆分。承担的风险:admin 凭据被攻陷 = 即时注入恶意 upstream URL / key。补偿控制:每次 PUT 都写一行 audit log(OTel structured event + Postgres admin_audit_log 表?或 stdout JSON 日志)记录 actor email + timestamp + key id list(前 8 字符) + version。具体落库位置 planning 阶段内决定(如果 admin_audit_log 表不存在则用 OTel + JSON 日志,留 v2 加表)。airi.gen_ai.gateway.config.write{actor_email, version, key_count} 每次 PUT 时;counter airi.gen_ai.gateway.config.write_count{result: 'success' | '4xx' | '5xx'}。context 参数只允许 {triedKeys: number, triedUpstreams: number, lastStatusCode: number},禁止包含上游 response body / header 字符串。hmac = HMAC-SHA256(LLM_ROUTER_MASTER_KEY, JSON.stringify({key, version, publishedAt})),receiver 校验。这是 cheap defense in depth;如 redis 接受跨租户连接也能侦测到 forged invalidation。Patterns to follow:
apps/server/src/routes/admin/flux-grants (HTTP shape + auth guard)apps/server/src/app.ts:244 现有 patternTest scenarios:
If-Match → 412 Precondition Required(强制 ETag 工作流)If-Match → 409 Conflict + 当前 version 在响应里config.write 发出 + audit log JSON 行出现airi.gen_ai.gateway.config.invalid_hmac counter)Verification:
pnpm -F @proj-airi/server typecheck passespnpm exec vitest run apps/server/src/routes/admin/llm-router-config.test.ts greenGoal: 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)apps/server/scripts/... (NEW — knoway compose retention policy doc / data-driven trigger criteria)Approach:
build.ts to assemble. Thresholds are placeholders — refine post-launch.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.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.Patterns to follow:
injeca.provide blocks in apps/server/src/app.ts:280-487apps/server/docs/ai-context/verifications/build.ts + existing panel structures in airi-server-overview-cloud.jsonTest scenarios:
env.test.ts: GATEWAY_BASE_URL no longer in schema; old test asserting it must be removed.app.test.ts: server boots with LLM_ROUTER_CONFIG set and GATEWAY_BASE_URL absent — no errors.apps/server/src/scripts/otel/llm-router-smoke.ts): produces traces tagged with airi.gen_ai.gateway.* attrs.jq or existing build.ts assertion).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 passespnpm -F @proj-airi/server exec vitest run (full server test suite) greenpnpm -F @proj-airi/server build succeeds| 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. |
| 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 |
LLM_ROUTER_MASTER_KEY env var on Railway (generate 32 bytes base64).LLM_ROUTER_CONFIG via existing configKV.set admin path.LLM_ROUTER_CONFIG decrypts cleanly in dev env (pnpm exec tsx scripts/verify-router-config.ts — script TBD in U8)./v1/chat/completions hits new router code path.airi-railway/knoway/ directory + compose entry.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):
curl POST /api/v1/openai/chat/completions returns 200 + body.fallback.depth=1.curl POST /api/v1/openai/audio/speech model=azure-tts returns audio bytes + 200.curl GET /api/v1/openai/audio/voices?model=azure-tts returns voice catalog JSON.curl /livez returns 200 + {status: 'live'}.curl /readyz returns 200 with Postgres+Redis up; 503 otherwise.curl POST /api/v1/openai/chat/completions model=unknown returns 400 with unknown_model error code.Each entry needs fresh evidence (commit SHA + curl response + OTel span screenshot or jq excerpt) per Iron Law.
Carried from origin Deferred-to-Planning, narrowed by plan-time decisions:
apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.mdapps/server/CLAUDE.md constraints honored:
?? fallback)airi.gen_ai.* reused)