diff --git a/.env.example b/.env.example index b6c6d72b8..c3a579ae3 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,7 @@ ENABLE_API_KEY_REDIS_CACHE="true" # 是否启用 API Key Redis 缓存( # 降低该值会按签发时间收紧已签发 ADMIN_TOKEN 签名 cookie 的剩余寿命,且不会延长其原始 exp。 AUTH_SESSION_TTL_SECONDS=604800 # Web UI 登录态过期时间(秒,默认 604800 = 7 天,范围 60-31536000) SESSION_TTL=300 # 代理请求上下文缓存时间(秒,默认 300 = 5 分钟;不控制 Web UI 登录态) +SESSION_SNAPSHOT_ROOT=./data/session-snapshots # filesystem Session 快照目录 STORE_SESSION_MESSAGES=false # 会话消息存储模式(默认:false) # - false:存储请求/响应体但对 message 内容脱敏 [REDACTED] # - true:原样存储 message 内容(注意隐私和存储空间影响) @@ -209,6 +210,8 @@ ENDPOINT_PROBE_SCHEDULER_ENABLED=true # ENDPOINT_PROBE_INTERVAL_MS controls the base interval. Single-vendor interval is fixed. ENDPOINT_PROBE_INTERVAL_MS=60000 ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS=10000 +# Consecutive failures back off exponentially from the timeout retry interval, capped here. +ENDPOINT_PROBE_FAILURE_BACKOFF_MAX_MS=600000 # When no endpoints are due, scheduler will still poll DB periodically to pick up config changes. # Default: min(ENDPOINT_PROBE_INTERVAL_MS, 30000) ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS=30000 diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index fd707fbd6..4dc09b1e6 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -9,9 +9,9 @@ deploy/k8s/ ├── namespace.yaml # 命名空间 ├── app/ # 应用层 -│ ├── deployment.yaml # Deployment (2 副本基线;迁移由 PG advisory lock 串行化) +│ ├── deployment.yaml # Deployment (2 副本基线;Session 快照使用节点 hostPath) │ ├── service.yaml # Service (可渲染为 ClusterIP/NodePort) -│ ├── hpa.yaml # HPA (CPU 70% / 内存 80%) +│ ├── hpa.yaml # HPA (CPU 70%) │ ├── pdb.yaml # PodDisruptionBudget (maxUnavailable=1) │ └── networkpolicy.yaml # NetworkPolicy (仅在 Ingress 模式应用) ├── postgres/ # PostgreSQL StatefulSet @@ -54,6 +54,11 @@ deploy/k8s/ | `{{INGRESS_CLASS}}` | Ingress className | 自动探测 | | `{{TIMEZONE}}` | 容器时区 | `Asia/Shanghai` | +> App 的 filesystem Session 快照不使用 PVC,而是挂载节点本地 +> `/var/lib/claude-code-hub/session-snapshots` hostPath。这个目录只在同一节点上的 Pod 间共享; +> 默认配置适用于单节点 k3s,或明确保证所有 App Pod 位于同一节点的部署。多节点集群应在系统设置中 +> 切换为 Redis,或自行提供真正的共享文件系统。PostgreSQL/Redis 仍使用 StorageClass/PVC。 + > NodePort 回落模式下,`scripts/deploy-k8s.sh` 会自动跳过 `app/networkpolicy.yaml`, > 避免默认的 Ingress 命名空间白名单阻断外部访问。 diff --git a/deploy/k8s/app/deployment.yaml b/deploy/k8s/app/deployment.yaml index 430bb5029..97c342c0f 100644 --- a/deploy/k8s/app/deployment.yaml +++ b/deploy/k8s/app/deployment.yaml @@ -52,7 +52,7 @@ spec: - name: AUTO_MIGRATE value: "true" - name: DB_POOL_MAX - value: "24" + value: "8" - name: DB_POOL_IDLE_TIMEOUT value: "20" - name: DB_POOL_CONNECT_TIMEOUT @@ -78,6 +78,10 @@ spec: value: "604800" - name: SESSION_TTL value: "300" + - name: SESSION_SNAPSHOT_ROOT + value: /var/lib/claude-code-hub/session-snapshots + - name: DASHBOARD_LOGS_POLL_INTERVAL_MS + value: "10000" - name: MESSAGE_REQUEST_WRITE_MODE value: "async" - name: MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS @@ -90,14 +94,14 @@ spec: value: {{TIMEZONE}} resources: requests: - cpu: 500m - memory: 512Mi + cpu: 250m + memory: 2Gi limits: - cpu: "4" - memory: 4Gi + cpu: "2" + memory: 5Gi livenessProbe: httpGet: - path: /api/actions/health + path: /api/health/live port: 3000 initialDelaySeconds: 15 periodSeconds: 15 @@ -105,15 +109,15 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: /api/actions/health + path: /api/health/ready port: 3000 initialDelaySeconds: 5 periodSeconds: 5 - timeoutSeconds: 3 + timeoutSeconds: 5 failureThreshold: 2 startupProbe: httpGet: - path: /api/actions/health + path: /api/health/live port: 3000 initialDelaySeconds: 5 periodSeconds: 5 @@ -131,3 +135,11 @@ spec: capabilities: drop: - ALL + volumeMounts: + - name: session-snapshots + mountPath: /var/lib/claude-code-hub/session-snapshots + volumes: + - name: session-snapshots + hostPath: + path: /var/lib/claude-code-hub/session-snapshots + type: DirectoryOrCreate diff --git a/deploy/k8s/app/hpa.yaml b/deploy/k8s/app/hpa.yaml index d71ab9fc3..bab7c9e23 100644 --- a/deploy/k8s/app/hpa.yaml +++ b/deploy/k8s/app/hpa.yaml @@ -17,12 +17,6 @@ spec: target: type: Utilization averageUtilization: 70 - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: 80 behavior: scaleUp: stabilizationWindowSeconds: 60 diff --git a/docs/k8s-deployment.md b/docs/k8s-deployment.md index fcd6798ec..fcd6e9585 100644 --- a/docs/k8s-deployment.md +++ b/docs/k8s-deployment.md @@ -17,7 +17,7 @@ └────────┬─────────┘ │ ┌────────▼─────────┐ - │ Deployment │ HPA 2~6 副本,CPU 70% / Memory 80% + │ Deployment │ HPA 2~6 副本,CPU 70% │ claude-code-hub │ PDB maxUnavailable=1 └────┬─────────┬────┘ │ │ @@ -31,11 +31,11 @@ | 维度 | Docker Compose | Kubernetes | |------|----------------|-----------| | 高可用 | 单容器 | HPA + PDB,滚动更新不中断 | -| 存储 | 本地卷 | PVC (由集群 StorageClass 管理) | +| 存储 | 本地卷 | PostgreSQL/Redis 使用 PVC;Session 快照默认使用节点 hostPath | | 域名 | Caddy (可选) | Ingress / Traefik IngressRoute / NodePort | | 密钥 | `.env` 文件 | Kubernetes Secret | | 升级 | `docker compose pull` | `cch update` (带迁移 + 回滚) | -| 适用 | 个人/小团队 | 生产 / 多节点 / 企业 | +| 适用 | 个人/小团队 | 单节点 k3s;多节点需为 Session 快照改用 Redis 或共享存储 | --- @@ -59,6 +59,11 @@ - **macOS** 只用于管理端 (kubectl),集群侧建议 Linux - 磁盘 ≥ 80GB (PostgreSQL 50GB + Redis 10GB + 系统) +> 默认 `sessionSnapshotStore=filesystem`,App Pod 挂载节点本地 +> `/var/lib/claude-code-hub/session-snapshots`。普通 `hostPath` 不跨节点共享,因此默认模板要求单节点, +> 或由运维明确保证所有 App Pod 调度到同一节点。多节点集群必须把 Session 快照切换为 Redis, +> 或自行替换为共享文件系统;数据库 PVC 不受这一限制。 + ### 集群选型 **选项 A — 单机 k3s (推荐新手 / 家用 / 自建)** @@ -213,6 +218,9 @@ bash scripts/deploy-k8s.sh --replicas 3 --hpa-min 3 --hpa-max 10 -y 默认模板保留 `replicas=2`,但 `AUTO_MIGRATE` 入口 `src/instrumentation.ts` 会先获取 PostgreSQL advisory lock, 因此首次多副本启动时迁移会串行执行。如果你更关心首启速度,也可以先用 `--replicas 1` 部署,确认健康后再扩容。 +filesystem Session 快照的 `hostPath` 只能在同一节点上的副本间共享。不要在普通多节点调度下依赖它 +读取其他节点写入的快照;多节点部署请在 Dashboard 系统设置中切换到 Redis,或提供共享文件系统。 + ### Codex `/v1/responses` WebSocket 反代 `/v1/responses` 端点对 Codex 客户端会走 **WebSocket 升级**(其余路径仍是 HTTP)。 @@ -307,7 +315,9 @@ WS,务必把 `/v1/responses` 排除在缓存规则之外。 ### 修改应用环境变量 -`deploy/k8s/app/deployment.yaml` 里枚举了常用环境变量(连接池、超时、限流开关、MESSAGE_REQUEST 批量参数等)。建议流程: +`deploy/k8s/app/deployment.yaml` 里枚举了常用环境变量(连接池、超时、限流开关、MESSAGE_REQUEST 批量参数等)。 +当前 App 默认 `DB_POOL_MAX=8`、`DASHBOARD_LOGS_POLL_INTERVAL_MS=10000`,Session 快照根目录为 +`/var/lib/claude-code-hub/session-snapshots`。建议流程: 1. 修改 `deploy/k8s/app/deployment.yaml` 模板 2. 运行 `bash scripts/deploy-k8s.sh -y` 走升级分支(自动保留 Secret) @@ -435,7 +445,7 @@ cch backup ```bash # 1. 停 app (避免写入):先删掉 HPA,再直接 kubectl 缩到 0 (cch scale 要求 >=1) -# CPU/内存 HPA 不支持直接把 minReplicas 改成 0 +# HPA 不支持直接把 minReplicas 改成 0 kubectl -n claude-code-hub delete hpa claude-code-hub 2>/dev/null || true kubectl -n claude-code-hub scale deployment/claude-code-hub --replicas=0 @@ -540,7 +550,7 @@ deploy/k8s/ ├── app/ │ ├── deployment.yaml # replicas, env, resources, 3 个 probe, preStop sleep │ ├── service.yaml # ClusterIP / NodePort -│ ├── hpa.yaml # CPU 70% / 内存 80%, scaleUp/Down 策略 +│ ├── hpa.yaml # CPU 70%, scaleUp/Down 策略 │ ├── pdb.yaml # maxUnavailable=1 │ └── networkpolicy.yaml # 仅在 Ingress 模式应用 ├── postgres/ @@ -586,6 +596,8 @@ deploy/k8s/ | 端口 | `23000:3000` | Ingress / NodePort | | 持久化 | `./data/postgres`、`./data/redis` | PVC (StorageClass) | +App 的 Session 快照是例外:默认使用节点本地 hostPath,不新增 PVC。它只保证同节点共享,不提供跨节点一致性。 + ### 进一步阅读 - 主 README: [README.md](../README.md) diff --git a/docs/performance-optimization-roadmap.md b/docs/performance-optimization-roadmap.md new file mode 100644 index 000000000..e45bad8d3 --- /dev/null +++ b/docs/performance-optimization-roadmap.md @@ -0,0 +1,139 @@ +# CCH 性能优化落地方案 + +## 1. 结论 + +本轮性能问题的主因不是 CPU 或宿主机算力不足,而是上游低质量请求被重试、hedge、长超时和大上下文放大后,进一步推高 Node 常驻内存、Redis 大对象、连接池和多副本后台任务数量。 + +本轮优先落地不新增 Pod、sidecar、PVC、Redis、PgBouncer、CronJob 或其他外部组件的优化。实施原则是先消除确定性的资源放大器,再为重试总 deadline 和 AvailableModels 上游缓存建立独立契约,避免在一个 PR 中同时改变多套转发语义。 + +## 2. 本轮已落地 + +### 2.1 TTFT 与 TTFB 分离 + +- `TTFB` 定义为网关收到请求到最终 winner 的响应头到达。 +- `TTFT` 定义为网关收到请求到首个协议有效内容到达,不把 SSE comment、metadata、usage-only、空 delta 或 terminal frame 当作首 token。 +- 新口径写入 `timingSemanticsVersion = 2`。历史混合口径不回填,不参与 TTFB、TTFT 和 TPS 聚合。 +- 输出速率使用 `outputTokens / (durationMs - ttftMs)`。TTFT 不可用时输出速率也不可用。 +- 请求详情、普通日志表、虚拟日志表、排行榜、Public Status 和 Langfuse 均使用统一口径。 + +收益:避免把旧 TTFT 数值错误展示为 TTFB,也避免用错误的首字时间计算输出速率和排行榜指标。 + +### 2.2 排行榜成功率和缓存系数 + +- redirect 后的供应商和模型继续计算成功率,并标记统计口径为 original 或 redirected。 +- 只有没有可计数 outcome 时才显示“不适用”。 +- 流式、非流式和 Gemini terminal 路径均写入 cache effectiveness 所需字段。 +- 聚合任务使用持久 cursor、幂等窗口 upsert 和数据库 advisory lock,避免多实例重复聚合及重复窗口。 + +收益:修复排行榜成功率全部“不适用”和缓存系数缺失,同时把缓存效果聚合从副本级重复工作收敛为单次有效工作。 + +### 2.3 Session 快照默认落盘 + +- 系统设置支持 `filesystem`、`redis` 和 `disabled`,默认 `filesystem`。 +- filesystem store 使用 gzip level 1、单异步 worker、atomic rename、文件锁和跨 Pod 清理锁。 +- TTL 继承 `SESSION_TTL`,过期文件自动删除。 +- 默认单逻辑快照上限 8 MiB、单 Pod pending 上限 64 MiB、目录上限 10 GiB。 +- 写入在请求热路径上只做有界入队;超大快照、队列预算不足、损坏文件或磁盘读取失败均 fail-open,不阻塞网关转发。 +- backend 切换会原子更新 settings cache 并重配置 store,避免保存设置后短暂回退到 filesystem。 + +收益:把完整大请求快照从 Redis 内存和 AOF 中移出,同时以明确的大小、队列和目录预算限制本地磁盘风险。 + +限制:Kubernetes 默认使用节点本地 `hostPath`,只保证同一节点上的副本可共享。多节点部署应切换为 Redis 或自行提供共享文件系统。本轮不新增共享存储组件。 + +### 2.4 多副本后台任务治理 + +- cache effectiveness 使用 PostgreSQL transaction advisory lock。 +- cloud price sync、replay cleanup 和 probe-log cleanup 使用 PostgreSQL advisory lock,并在锁被占用时直接跳过。 +- Redis leader lock 在 production 中 fail-closed;Redis 未 ready 或获取异常时不回退为每 Pod 的 memory lock。 +- replay 和 cache effectiveness scheduler 增加防重入、current promise、stop flag 和有界 shutdown quiescence。 +- shutdown 在关闭 PostgreSQL 和 Redis 前先停止 scheduler 并等待在途任务退出。 + +收益:副本数量增加时,聚合、清理和同步任务不再按副本线性放大,也避免资源关闭期间后台任务继续访问已关闭连接。 + +### 2.5 Endpoint probe 指数退避 + +- endpoint 保存连续探测失败次数。 +- 成功后归零;失败时由 SQL 原子加一。 +- 调度间隔按 `10s -> 20s -> 40s -> 80s -> ...` 退避,默认上限 10 分钟。 +- `ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS` 已接入实际调度器。 + +收益:持续超时或不可达的 endpoint 不再以固定高频率被所有调度周期重复探测,减少无效连接、日志和定时任务压力;恢复成功后自动回到正常频率。 + +### 2.6 Kubernetes 资源与连接池 + +- `DB_POOL_MAX` 从 18/24 级别收敛到 8,降低副本扩张时的 PostgreSQL 理论连接上限。 +- App request 调整为 `250m CPU / 2Gi memory`,limit 调整为 `2 CPU / 5Gi memory`。 +- HPA 删除 memory metric,仅保留 CPU 70%,避免 Node 常驻内存直接触发无收益扩容。 +- liveness/startup 使用 `/api/health/live`,readiness 使用 `/api/health/ready`,readiness timeout 为 5 秒。 +- dashboard 日志轮询间隔调整为 10 秒。 +- Session snapshot 目录挂载到节点本地 hostPath。 + +收益:避免低 CPU、高常驻内存场景持续扩容,降低连接池和后台任务随副本数放大的风险,同时保留针对进程存活与依赖就绪的独立健康检查。 + +## 3. 当前建议直接执行的运维动作 + +这些动作不需要代码或新增组件,收益高且风险低: + +1. 禁用欠费、quota 用尽、分组停用、持续 5xx 或持续超时的 provider/endpoint。 +2. 对错误率接近 100% 或长期达到 probe 退避上限的 endpoint 建立人工复核清单,不依赖大量 fallback 掩盖故障。 +3. 为超大上下文设置更低并发或独立 provider group,优先控制同时驻留的大请求数量。 +4. 观察 Redis `used_memory`、AOF rewrite、session key 大小和 eviction 是否在切换 filesystem 后持续下降。 +5. 观察 PostgreSQL CCH 连接数是否随 `DB_POOL_MAX=8` 稳定在安全区间。 + +## 4. 建议拆分为后续独立 PR + +### 4.1 Request-local first-content deadline + +这是下一项最高优先级优化,但不应在本轮同时实现。推荐契约: + +```text +一个 request-local absolute first-content deadline +所有 provider retry、hedge、rectifier 和 transport fallback 共享剩余时间 +H2 -> H1、proxy -> direct、WS -> HTTP 不计为 provider attempt +首个协议有效内容到达后解除 first-content deadline +客户端 abort 继续保持 499,provider timeout 和 deadline timeout 使用稳定的独立分类 +``` + +收益:从根源限制请求长时间驻留,避免失败 provider 通过多层 transport fallback 和 provider fallback 把总耗时扩大到 60-100 秒以上。 + +拆分原因:legacy serial、legacy hedge、Discovery、H2/H1、proxy/direct、Responses WS/HTTP 和 rectifier 都要共享同一 absolute deadline;粗暴加入最大 attempt 数会把 transport fallback 错计为 provider retry,存在明显兼容风险。 + +### 4.2 AvailableModels per-provider cache、singleflight 和共享限并发 + +推荐契约: + +```text +只缓存每个 provider 的成功模型结果,不缓存用户或 group 聚合结果 +同一 provider/config fingerprint 的并发请求 singleflight +使用进程级共享并发上限,结果按原 provider 顺序回填 +失败不覆盖成功 cache,瞬时失败不做长 negative cache +provider 配置变更时通过现有 invalidation 广播清理本地缓存 +group、活动时间窗和用户可见性仍在每次请求实时过滤 +``` + +收益:减少上游 `/models` 重复请求和无界 fan-out,降低多个客户端同时刷新模型列表时的 stampede。 + +拆分原因:必须同时保证 group 隔离、活动时间窗、allowlist、配置失效、失败缓存和结果顺序;只加一个 TTL Map 容易返回已禁用 provider 或跨 group 的旧模型。 + +## 5. 明确不做 + +- 不新增 Pod、sidecar、CronJob、PgBouncer、Redis 实例、metrics adapter 或共享存储组件。 +- 不用 SQLite 保存 Session 快照。当前数据模型是按 sessionId 读取和合并的压缩 JSON blob,filesystem atomic file 的写放大和清理路径更短,也避免 SQLite WAL、vacuum 和跨进程锁竞争。 +- 不继续使用 memory HPA 作为主要扩容信号。后续如需改为活跃流、并发或 RPS,应先复用现有可观测数据或平台能力,不能为本优化额外引入组件。 +- 不在本轮加入统一 outbound attempt counter。 +- 不回填历史 TTFT/TTFB 混合数据,以免产生看似精确但语义错误的指标。 + +## 6. 验收与观察指标 + +上线后建议按 1 小时、24 小时和 7 天三个窗口对比: + +- Redis session/snapshot key 总字节、最大 key、eviction、AOF 大小和 rewrite 时长。 +- 单 Pod 与总 Node 内存、HPA 副本数、CPU utilization、readiness failure。 +- PostgreSQL CCH 连接数和连接等待。 +- endpoint probe 次数、持续失败 endpoint 的实际探测间隔。 +- cache effectiveness scheduler 每窗口实际执行次数和重复窗口数。 +- TTFB P50/P95/P99、TTFT P50/P95/P99、TTFB 与 TTFT 的差值。 +- 请求 attempt/hedge 分布、499、`STREAM_RESPONSE_TIMEOUT` 和“所有供应商暂时不可用”数量。 +- 大上下文请求的并发数、驻留时长和单请求 snapshot 大小。 + +如果 Redis 内存和后台重复任务明显下降,但请求 P95/P99 仍主要由多轮 fallback 决定,应优先实施 request-local first-content deadline,而不是继续增加副本或连接池。 diff --git a/drizzle/0116_lying_marvel_apes.sql b/drizzle/0116_lying_marvel_apes.sql new file mode 100644 index 000000000..89dbbcaf9 --- /dev/null +++ b/drizzle/0116_lying_marvel_apes.sql @@ -0,0 +1,206 @@ +CREATE TABLE IF NOT EXISTS "background_task_cursor" ( + "task_key" varchar(128) PRIMARY KEY NOT NULL, + "cursor_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "ttft_ms" integer;--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "timing_semantics_version" integer;--> statement-breakpoint +ALTER TABLE "provider_endpoints" ADD COLUMN IF NOT EXISTS "consecutive_probe_failures" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "session_snapshot_store" varchar(16) DEFAULT 'filesystem' NOT NULL;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "ttft_ms" integer;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "timing_semantics_version" integer;--> statement-breakpoint +DO $migration$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'message_request' + AND column_name = 'first_byte_ms' + ) THEN + EXECUTE $sql$ + UPDATE "message_request" + SET + "ttft_ms" = "ttfb_ms", + "ttfb_ms" = "first_byte_ms", + "timing_semantics_version" = 2 + WHERE "first_byte_ms" IS NOT NULL + $sql$; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'usage_ledger' + AND column_name = 'first_byte_ms' + ) THEN + EXECUTE $sql$ + UPDATE "usage_ledger" + SET + "ttft_ms" = "ttfb_ms", + "ttfb_ms" = "first_byte_ms", + "timing_semantics_version" = 2 + WHERE "first_byte_ms" IS NOT NULL + $sql$; + END IF; +END +$migration$;--> statement-breakpoint +WITH ranked_windows AS ( + SELECT + "id", + row_number() OVER ( + PARTITION BY "provider_id", "model", "cache_ttl_bucket", "window_start", "window_end" + ORDER BY "created_at" DESC NULLS LAST, "id" DESC + ) AS row_number + FROM "provider_cache_effectiveness" +) +DELETE FROM "provider_cache_effectiveness" AS effectiveness +USING ranked_windows +WHERE effectiveness."id" = ranked_windows."id" + AND ranked_windows.row_number > 1;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uq_provider_cache_effectiveness_window" ON "provider_cache_effectiveness" USING btree ("provider_id","model","cache_ttl_bucket","window_start","window_end");--> statement-breakpoint +-- Mirror of src/lib/ledger-backfill/trigger.sql. +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_is_success boolean; + v_success_rate_outcome varchar; +BEGIN + v_success_rate_outcome := fn_compute_message_request_success_rate_outcome( + NEW.blocked_by, + NEW.status_code, + NEW.error_message, + NEW.provider_chain + ); + + IF NEW.blocked_by = 'warmup' THEN + UPDATE usage_ledger + SET blocked_by = 'warmup', + success_rate_outcome = v_success_rate_outcome, + actual_response_model = NEW.actual_response_model + WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF LOWER(REGEXP_REPLACE(COALESCE(NEW.endpoint, ''), '/+$', '')) + IN ('/v1/messages/count_tokens', '/v1/responses/compact') THEN + DELETE FROM usage_ledger WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = '') + AND (NEW.status_code IS NULL OR NEW.status_code < 400); + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, actual_response_model, endpoint, api_type, session_id, + status_code, is_success, success_rate_outcome, blocked_by, + cost_usd, cost_multiplier, group_cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, ttft_ms, timing_semantics_version, client_ip, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.status_code, v_is_success, v_success_rate_outcome, NEW.blocked_by, + NEW.cost_usd, NEW.cost_multiplier, NEW.group_cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.ttft_ms, NEW.timing_semantics_version, NEW.client_ip, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + actual_response_model = EXCLUDED.actual_response_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + success_rate_outcome = EXCLUDED.success_rate_outcome, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + group_cost_multiplier = EXCLUDED.group_cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + ttft_ms = EXCLUDED.ttft_ms, + timing_semantics_version = EXCLUDED.timing_semantics_version, + client_ip = EXCLUDED.client_ip; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +DROP TRIGGER IF EXISTS trg_upsert_usage_ledger ON message_request;--> statement-breakpoint +CREATE TRIGGER trg_upsert_usage_ledger +AFTER INSERT OR UPDATE OF + blocked_by, + status_code, + error_message, + provider_chain, + actual_response_model, + endpoint, + provider_id, + user_id, + "key", + model, + original_model, + api_type, + session_id, + cost_usd, + cost_multiplier, + group_cost_multiplier, + input_tokens, + output_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens, + cache_ttl_applied, + context_1m_applied, + swap_cache_ttl_applied, + duration_ms, + ttfb_ms, + ttft_ms, + timing_semantics_version, + client_ip, + created_at +ON message_request +FOR EACH ROW +EXECUTE FUNCTION fn_upsert_usage_ledger();--> statement-breakpoint +ALTER TABLE "message_request" DROP COLUMN IF EXISTS "first_byte_ms";--> statement-breakpoint +ALTER TABLE "usage_ledger" DROP COLUMN IF EXISTS "first_byte_ms"; diff --git a/drizzle/meta/0114_snapshot.json b/drizzle/meta/0114_snapshot.json index cc95d2cd2..6f39db254 100644 --- a/drizzle/meta/0114_snapshot.json +++ b/drizzle/meta/0114_snapshot.json @@ -5172,4 +5172,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0116_snapshot.json b/drizzle/meta/0116_snapshot.json new file mode 100644 index 000000000..3d6fa9f5f --- /dev/null +++ b/drizzle/meta/0116_snapshot.json @@ -0,0 +1,5272 @@ +{ + "id": "025e70f2-d761-440f-8e15-6919e9c0ba77", + "prevId": "6ef3f512-a210-4d69-801a-9cd1ee9e1e52", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_task_cursor": { + "name": "background_task_cursor", + "schema": "", + "columns": { + "task_key": { + "name": "task_key", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "cursor_at": { + "name": "cursor_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "routing_trace": { + "name": "routing_trace", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timing_semantics_version": { + "name": "timing_semantics_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_compatibility_key": { + "name": "cache_compatibility_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "cache_score_eligible": { + "name": "cache_score_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_score_excluded_reason": { + "name": "cache_score_excluded_reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_batch_apply_operations": { + "name": "provider_batch_apply_operations", + "schema": "", + "columns": { + "claim_key": { + "name": "claim_key", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "preview_token": { + "name": "preview_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "payload_fingerprint": { + "name": "payload_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_token": { + "name": "undo_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_expires_at": { + "name": "undo_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "undo_consumed_at": { + "name": "undo_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_batch_apply_operations_preview_token": { + "name": "uniq_provider_batch_apply_operations_preview_token", + "columns": [ + { + "expression": "preview_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_operation_id": { + "name": "uniq_provider_batch_apply_operations_operation_id", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_undo_token": { + "name": "uniq_provider_batch_apply_operations_undo_token", + "columns": [ + { + "expression": "undo_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_batch_apply_operations_expires_at": { + "name": "idx_provider_batch_apply_operations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_cache_effectiveness": { + "name": "provider_cache_effectiveness", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sample_count": { + "name": "sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eligible_count": { + "name": "eligible_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observed_cache_read_tokens": { + "name": "observed_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_effectiveness_bp": { + "name": "raw_effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confidence_bp": { + "name": "confidence_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "effectiveness_bp": { + "name": "effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_cache_effectiveness_window": { + "name": "idx_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_provider_cache_effectiveness_window": { + "name": "uq_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cache_ttl_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_probe_failures": { + "name": "consecutive_probe_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replay_payloads": { + "name": "replay_payloads", + "schema": "", + "columns": { + "replay_id": { + "name": "replay_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "verifier": { + "name": "verifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scope_tag": { + "name": "scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "headers_json": { + "name": "headers_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_request_id": { + "name": "source_message_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_replay_payloads_key_id": { + "name": "idx_replay_payloads_key_id", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_replay_payloads_expires_at": { + "name": "idx_replay_payloads_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'CC Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stream_gate_mode": { + "name": "stream_gate_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'enforce'" + }, + "affinity_ignore_client_session_id": { + "name": "affinity_ignore_client_session_id", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replay_enabled": { + "name": "replay_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_effectiveness_enabled": { + "name": "cache_effectiveness_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "session_snapshot_store": { + "name": "session_snapshot_store", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'filesystem'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timing_semantics_version": { + "name": "timing_semantics_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 45894cb76..6a6de5675 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -813,6 +813,13 @@ "when": 1785418573335, "tag": "0115_breezy_polaris", "breakpoints": true + }, + { + "idx": 116, + "version": "7", + "when": 1785435225926, + "tag": "0116_lying_marvel_apes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 5fbc5d622..352861b46 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -348,20 +348,24 @@ "performance": { "title": "Performance", "ttfb": "TTFB", - "tfft": "TFFT", + "ttft": "TTFT", + "timingUnavailable": "This request used the legacy mixed timing semantics, so TTFB and TTFT cannot be recovered reliably.", "duration": "Total Duration", "outputRate": "Output Rate", "outputTokens": "Output Tokens" }, "performanceTab": { "noPerformanceData": "No performance data available", - "tfftGauge": "Time to First Token", + "ttfbGauge": "Time to First Byte", + "ttftGauge": "Time to First Valid Content", "outputRateGauge": "Output Rate", "latencyBreakdown": "Latency Breakdown", "generationTime": "Generation Time", - "segmentTtfb": "TTFB", - "segmentTfft": "Token Wait", - "segmentTotal": "Total", + "ttfb": "TTFB", + "ttft": "TTFT", + "firstByteToFirstToken": "Headers to First Valid Content", + "generationAfterFirstToken": "Generation After First Content", + "totalDuration": "Total Duration", "assessment": { "excellent": "Excellent", "good": "Good", @@ -678,13 +682,15 @@ "totalConsumedAmount": "Total Spend", "successRate": "Success Rate", "avgResponseTime": "Avg Response Time", - "avgTtfbMs": "Avg TFFT", + "avgTtfbMs": "Avg TTFB", + "avgTtftMs": "Avg TTFT", + "timingUnavailable": "Unavailable", "avgTokensPerSecond": "Avg tok/s", "avgCostPerRequest": "Avg Cost/Req", "avgCostPerMillionTokens": "Avg Cost/1M Tokens", "unknownModel": "Unknown", - "successRateUnavailable": "N/A", - "successRateBasisDisclosure": "Success rate is unavailable here because redirected billing mode can merge multiple original models into one row." + "successRateUnavailable": "No countable outcomes", + "successRateBasisDisclosure": "This success rate is grouped by redirected model, so one row may combine outcomes from multiple original models." }, "expandModelStats": "Expand model details", "collapseModelStats": "Collapse model details", diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index dcff573c3..2cbf85996 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -173,7 +173,14 @@ "replayEnabled": "Request Replay", "replayEnabledDesc": "Caches upstream responses and reuses upstream connections: identical concurrent or reconnecting requests attach to the in-flight stream instead of re-hitting the provider. Follows the ENABLE_REQUEST_REPLAY environment variable until saved here. Default off.", "cacheEffectivenessEnabled": "Prefix Cache Simulation", - "cacheEffectivenessEnabledDesc": "Simulates longest-prefix cache hit rates (theoretical vs actual) for observability only; never affects routing. Follows the ENABLE_CACHE_EFFECTIVENESS environment variable until saved here. Default on." + "cacheEffectivenessEnabledDesc": "Simulates longest-prefix cache hit rates (theoretical vs actual) for observability only; never affects routing. Follows the ENABLE_CACHE_EFFECTIVENESS environment variable until saved here. Default on.", + "sessionSnapshotStore": "Session Snapshot Storage", + "sessionSnapshotStoreDesc": "Request detail snapshots are retained for {ttl} seconds. Filesystem mode uses a bounded asynchronous queue, compression, and automatic cleanup so large request bodies no longer occupy Redis by default.", + "sessionSnapshotStoreOptions": { + "filesystem": "Filesystem (default)", + "redis": "Redis (compatibility)", + "disabled": "Disable snapshots" + } }, "ipLogging": { "title": "IP logging & extraction", diff --git a/messages/en/settings/statusPage.json b/messages/en/settings/statusPage.json index 38d4d2fc8..57f2f6144 100644 --- a/messages/en/settings/statusPage.json +++ b/messages/en/settings/statusPage.json @@ -46,7 +46,8 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "Updated", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "history": "History", "freshnessWindow": "Snapshot freshness", @@ -76,7 +77,8 @@ }, "tooltip": { "availability": "Availability", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "samples": "Samples", "inferredFromNeighbors": "No requests in this window — inferred from neighbors", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 2176634b5..68de5816d 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -348,20 +348,24 @@ "performance": { "title": "パフォーマンス", "ttfb": "TTFB", - "tfft": "TFFT", + "ttft": "TTFT", + "timingUnavailable": "このリクエストは旧版の混合タイミング定義で記録されているため、TTFB と TTFT を正確に復元できません。", "duration": "総所要時間", "outputRate": "出力速度", "outputTokens": "出力トークン" }, "performanceTab": { "noPerformanceData": "パフォーマンスデータがありません", - "tfftGauge": "初トークン到達時間", + "ttfbGauge": "初バイト到達時間", + "ttftGauge": "最初の有効コンテンツ到達時間", "outputRateGauge": "出力速度", "latencyBreakdown": "レイテンシ内訳", "generationTime": "生成時間", - "segmentTtfb": "TTFB", - "segmentTfft": "トークン待機", - "segmentTotal": "合計", + "ttfb": "TTFB", + "ttft": "TTFT", + "firstByteToFirstToken": "応答ヘッダーから最初の有効コンテンツまで", + "generationAfterFirstToken": "最初の有効コンテンツ後の生成時間", + "totalDuration": "総所要時間", "assessment": { "excellent": "優秀", "good": "良好", @@ -678,13 +682,15 @@ "totalConsumedAmount": "総消費額", "successRate": "成功率(%)", "avgResponseTime": "平均応答時間", - "avgTtfbMs": "平均TFFT", + "avgTtfbMs": "平均TTFB", + "avgTtftMs": "平均TTFT", + "timingUnavailable": "利用不可", "avgTokensPerSecond": "平均トークン/秒", "avgCostPerRequest": "平均リクエスト単価", "avgCostPerMillionTokens": "100万トークンあたりコスト", "unknownModel": "不明", - "successRateUnavailable": "利用不可", - "successRateBasisDisclosure": "redirected 課金モデルでは 1 行に複数の元モデルが混在しうるため、誤解を避けるためここでは成功率を表示しません。" + "successRateUnavailable": "集計可能な結果なし", + "successRateBasisDisclosure": "この成功率は redirected model 単位で集計されるため、1 行に複数の original model の結果が含まれる場合があります。" }, "expandModelStats": "モデル詳細を展開", "collapseModelStats": "モデル詳細を折りたたむ", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index c1007ad2b..5586ab8fb 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -173,7 +173,14 @@ "replayEnabled": "リクエスト Replay", "replayEnabledDesc": "上流レスポンスをキャッシュし上流接続を再利用します。同一リクエストの並行実行や再接続は進行中のストリームに追随し、プロバイダーへ再送しません。保存するまでは環境変数 ENABLE_REQUEST_REPLAY に従います。デフォルトはオフ。", "cacheEffectivenessEnabled": "プレフィックスキャッシュシミュレーション", - "cacheEffectivenessEnabledDesc": "最長プレフィックス一致のキャッシュヒット率(理論値 vs 実測値)を観測目的でシミュレートします。ルーティングには影響しません。保存するまでは環境変数 ENABLE_CACHE_EFFECTIVENESS に従います。デフォルトはオン。" + "cacheEffectivenessEnabledDesc": "最長プレフィックス一致のキャッシュヒット率(理論値 vs 実測値)を観測目的でシミュレートします。ルーティングには影響しません。保存するまでは環境変数 ENABLE_CACHE_EFFECTIVENESS に従います。デフォルトはオン。", + "sessionSnapshotStore": "Session スナップショット保存先", + "sessionSnapshotStoreDesc": "リクエスト詳細スナップショットを {ttl} 秒保持します。ファイルシステムは上限付き非同期キュー、圧縮、自動削除を使用し、大きなリクエスト本文を既定で Redis に保存しません。", + "sessionSnapshotStoreOptions": { + "filesystem": "ファイルシステム(既定)", + "redis": "Redis(互換モード)", + "disabled": "スナップショットを無効化" + } }, "ipLogging": { "title": "IP ログと抽出", diff --git a/messages/ja/settings/statusPage.json b/messages/ja/settings/statusPage.json index e6574d771..425a4ff10 100644 --- a/messages/ja/settings/statusPage.json +++ b/messages/ja/settings/statusPage.json @@ -46,7 +46,8 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "更新", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "history": "履歴", "freshnessWindow": "スナップショット有効期限", @@ -76,7 +77,8 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "samples": "サンプル数", "inferredFromNeighbors": "この期間はリクエストがないため、隣接データから推定", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 454fe0803..5c983f123 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -348,20 +348,24 @@ "performance": { "title": "Производительность", "ttfb": "TTFB", - "tfft": "TFFT", + "ttft": "TTFT", + "timingUnavailable": "Запрос записан со старой смешанной семантикой времени, поэтому TTFB и TTFT нельзя надежно восстановить.", "duration": "Общее время", "outputRate": "Скорость вывода", - "outputTokens": "Токены вывода" + "outputTokens": "Выходные токены" }, "performanceTab": { "noPerformanceData": "Нет данных о производительности", - "tfftGauge": "Время до первого токена", + "ttfbGauge": "Время до первого байта", + "ttftGauge": "Время до первого валидного содержимого", "outputRateGauge": "Скорость вывода", "latencyBreakdown": "Разбивка задержки", "generationTime": "Время генерации", - "segmentTtfb": "TTFB", - "segmentTfft": "Ожидание токена", - "segmentTotal": "Всего", + "ttfb": "TTFB", + "ttft": "TTFT", + "firstByteToFirstToken": "От заголовков до первого валидного содержимого", + "generationAfterFirstToken": "Генерация после первого содержимого", + "totalDuration": "Общее время", "assessment": { "excellent": "Отлично", "good": "Хорошо", @@ -678,13 +682,15 @@ "totalConsumedAmount": "Общие расходы", "successRate": "Процент успеха", "avgResponseTime": "Среднее время ответа", - "avgTtfbMs": "Средний TFFT", + "avgTtfbMs": "Средний TTFB", + "avgTtftMs": "Средний TTFT", + "timingUnavailable": "Недоступно", "avgTokensPerSecond": "Средн. ток/с", "avgCostPerRequest": "Ср. стоимость/запрос", "avgCostPerMillionTokens": "Ср. стоимость/1М токенов", "unknownModel": "Неизвестно", - "successRateUnavailable": "Н/Д", - "successRateBasisDisclosure": "В режиме redirected billing одна строка может объединять несколько исходных моделей, поэтому здесь успешность скрыта, чтобы не вводить в заблуждение." + "successRateUnavailable": "Нет учитываемых результатов", + "successRateBasisDisclosure": "Процент успеха сгруппирован по redirected model, поэтому одна строка может объединять результаты нескольких original model." }, "expandModelStats": "Развернуть модели", "collapseModelStats": "Свернуть модели", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index eeb0d775b..95dec1512 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -173,7 +173,14 @@ "replayEnabled": "Replay запросов", "replayEnabledDesc": "Кэширует ответы провайдера и переиспользует соединения: одинаковые параллельные или переподключающиеся запросы присоединяются к текущему потоку вместо повторного обращения к провайдеру. До сохранения следует переменной окружения ENABLE_REQUEST_REPLAY. По умолчанию выключено.", "cacheEffectivenessEnabled": "Симуляция префиксного кэша", - "cacheEffectivenessEnabledDesc": "Симулирует хит-рейт кэша по наибольшему префиксу (теория и факт) только для наблюдаемости; не влияет на маршрутизацию. До сохранения следует переменной окружения ENABLE_CACHE_EFFECTIVENESS. По умолчанию включено." + "cacheEffectivenessEnabledDesc": "Симулирует хит-рейт кэша по наибольшему префиксу (теория и факт) только для наблюдаемости; не влияет на маршрутизацию. До сохранения следует переменной окружения ENABLE_CACHE_EFFECTIVENESS. По умолчанию включено.", + "sessionSnapshotStore": "Хранилище снимков Session", + "sessionSnapshotStoreDesc": "Снимки деталей запроса хранятся {ttl} с. Файловый режим использует ограниченную асинхронную очередь, сжатие и автоматическую очистку, поэтому большие тела запросов по умолчанию не занимают Redis.", + "sessionSnapshotStoreOptions": { + "filesystem": "Файловая система (по умолчанию)", + "redis": "Redis (режим совместимости)", + "disabled": "Отключить снимки" + } }, "ipLogging": { "title": "Журналирование и извлечение IP", diff --git a/messages/ru/settings/statusPage.json b/messages/ru/settings/statusPage.json index 640c0984d..8024cc399 100644 --- a/messages/ru/settings/statusPage.json +++ b/messages/ru/settings/statusPage.json @@ -46,7 +46,8 @@ "heroPrimary": "AI SERVICES", "heroSecondary": "SERVICE STATUS DASHBOARD", "generatedAt": "Обновлено", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "history": "История", "freshnessWindow": "Свежесть снимка", @@ -76,7 +77,8 @@ }, "tooltip": { "availability": "Доступность", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "samples": "Выборки", "inferredFromNeighbors": "Запросов нет — состояние выведено из соседних интервалов", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index f314c58ec..ef29f5ad4 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -348,20 +348,24 @@ "performance": { "title": "性能数据", "ttfb": "首字节时间(TTFB)", - "tfft": "首 Token 时间(TFFT)", + "ttft": "首个有效内容时间(TTFT)", + "timingUnavailable": "该记录使用旧版混合计时口径,无法可靠恢复 TTFB 和 TTFT。", "duration": "总耗时", "outputRate": "输出速率", - "outputTokens": "输出 Tokens" + "outputTokens": "输出 Token" }, "performanceTab": { "noPerformanceData": "暂无性能数据", - "tfftGauge": "首 Token 时间", + "ttfbGauge": "首字节时间", + "ttftGauge": "首个有效内容时间", "outputRateGauge": "输出速率", "latencyBreakdown": "延迟分解", "generationTime": "生成时间", - "segmentTtfb": "TTFB", - "segmentTfft": "等待首 Token", - "segmentTotal": "总计", + "ttfb": "TTFB", + "ttft": "TTFT", + "firstByteToFirstToken": "响应头到首个有效内容", + "generationAfterFirstToken": "首个有效内容后的生成时间", + "totalDuration": "总耗时", "assessment": { "excellent": "优秀", "good": "良好", @@ -678,13 +682,15 @@ "totalConsumedAmount": "总消耗金额", "successRate": "成功率", "avgResponseTime": "平均响应时间", - "avgTtfbMs": "平均 TFFT", + "avgTtfbMs": "平均 TTFB", + "avgTtftMs": "平均 TTFT", + "timingUnavailable": "不可用", "avgTokensPerSecond": "平均输出速率", "avgCostPerRequest": "平均单次请求成本", "avgCostPerMillionTokens": "平均百万 Token 成本", "unknownModel": "未知", - "successRateUnavailable": "不适用", - "successRateBasisDisclosure": "在 redirected 计费模型模式下,一行可能合并多个原始模型,因此这里不展示成功率以避免口径误导。" + "successRateUnavailable": "无可统计结果", + "successRateBasisDisclosure": "此成功率按 redirected model 分组统计,一行可能合并多个 original model 的请求结果。" }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 3d56741b4..2fc369772 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -98,6 +98,13 @@ "replayEnabledDesc": "缓存上游响应并复用上游连接:并发或断线重连的相同请求直接跟尾在途流,不再重复请求供应商。保存前跟随环境变量 ENABLE_REQUEST_REPLAY,默认关闭。", "cacheEffectivenessEnabled": "前缀缓存模拟", "cacheEffectivenessEnabledDesc": "模拟最长前缀匹配的缓存命中率(理论 vs 实际),仅用于观测,不影响路由。保存前跟随环境变量 ENABLE_CACHE_EFFECTIVENESS,默认开启。", + "sessionSnapshotStore": "Session 快照存储", + "sessionSnapshotStoreDesc": "请求详情快照保留 {ttl} 秒。文件系统使用有界异步队列、压缩与自动清理,默认不再把大请求体写入 Redis。", + "sessionSnapshotStoreOptions": { + "filesystem": "文件系统(默认)", + "redis": "Redis(兼容模式)", + "disabled": "关闭快照" + }, "affinityIgnoreClientSessionId": "忽略客户端 Session ID", "affinityIgnoreClientSessionIdDesc": "开启后,可指纹化的请求强制使用最长前缀亲和做供应商粘性(跳过客户端 Session ID 绑定);不可指纹化的请求仍走会话复用。默认开启。", "fakeStreaming": { diff --git a/messages/zh-CN/settings/statusPage.json b/messages/zh-CN/settings/statusPage.json index 992c4f387..c862673a9 100644 --- a/messages/zh-CN/settings/statusPage.json +++ b/messages/zh-CN/settings/statusPage.json @@ -46,7 +46,8 @@ "heroPrimary": "AI 服务", "heroSecondary": "服务状态面板", "generatedAt": "更新于", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "history": "历史", "freshnessWindow": "快照新鲜期剩余", @@ -76,7 +77,8 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "samples": "样本数", "inferredFromNeighbors": "该时段无请求,根据相邻时段状态推断", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e8e5fee6f..aaa742706 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -348,20 +348,24 @@ "performance": { "title": "效能資料", "ttfb": "首字節時間(TTFB)", - "tfft": "首 Token 時間(TFFT)", + "ttft": "首個有效內容時間(TTFT)", + "timingUnavailable": "此記錄使用舊版混合計時口徑,無法可靠還原 TTFB 與 TTFT。", "duration": "總耗時", "outputRate": "輸出速率", - "outputTokens": "輸出 Tokens" + "outputTokens": "輸出 Token" }, "performanceTab": { "noPerformanceData": "暫無效能資料", - "tfftGauge": "首 Token 時間", + "ttfbGauge": "首字節時間", + "ttftGauge": "首個有效內容時間", "outputRateGauge": "輸出速率", "latencyBreakdown": "延遲分解", "generationTime": "生成時間", - "segmentTtfb": "TTFB", - "segmentTfft": "等待首 Token", - "segmentTotal": "總計", + "ttfb": "TTFB", + "ttft": "TTFT", + "firstByteToFirstToken": "回應標頭到首個有效內容", + "generationAfterFirstToken": "首個有效內容後的生成時間", + "totalDuration": "總耗時", "assessment": { "excellent": "優秀", "good": "良好", @@ -678,13 +682,15 @@ "totalConsumedAmount": "總消耗金額", "successRate": "成功率(%)", "avgResponseTime": "平均回覆時間", - "avgTtfbMs": "平均 TFFT(ms)", + "avgTtfbMs": "平均 TTFB(ms)", + "avgTtftMs": "平均 TTFT(ms)", + "timingUnavailable": "不可用", "avgTokensPerSecond": "平均輸出速率", "avgCostPerRequest": "平均每次請求成本", "avgCostPerMillionTokens": "平均每百萬 Token 成本", "unknownModel": "未知", - "successRateUnavailable": "不適用", - "successRateBasisDisclosure": "在 redirected 計費模型模式下,一列可能合併多個原始模型,因此這裡不顯示成功率以避免口徑誤導。" + "successRateUnavailable": "無可統計結果", + "successRateBasisDisclosure": "此成功率按 redirected model 分組統計,一列可能合併多個 original model 的請求結果。" }, "expandModelStats": "展開模型詳情", "collapseModelStats": "收起模型詳情", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 1a3475e3e..90bdeef1e 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -173,7 +173,14 @@ "replayEnabled": "請求 Replay", "replayEnabledDesc": "快取上游回應並重用上游連線:並發或斷線重連的相同請求直接跟尾在途串流,不再重複請求供應商。儲存前跟隨環境變數 ENABLE_REQUEST_REPLAY,預設關閉。", "cacheEffectivenessEnabled": "前綴快取模擬", - "cacheEffectivenessEnabledDesc": "模擬最長前綴匹配的快取命中率(理論 vs 實際),僅用於觀測,不影響路由。儲存前跟隨環境變數 ENABLE_CACHE_EFFECTIVENESS,預設開啟。" + "cacheEffectivenessEnabledDesc": "模擬最長前綴匹配的快取命中率(理論 vs 實際),僅用於觀測,不影響路由。儲存前跟隨環境變數 ENABLE_CACHE_EFFECTIVENESS,預設開啟。", + "sessionSnapshotStore": "Session 快照儲存", + "sessionSnapshotStoreDesc": "請求詳情快照保留 {ttl} 秒。檔案系統使用有界非同步佇列、壓縮與自動清理,預設不再把大型請求內容寫入 Redis。", + "sessionSnapshotStoreOptions": { + "filesystem": "檔案系統(預設)", + "redis": "Redis(相容模式)", + "disabled": "關閉快照" + } }, "ipLogging": { "title": "IP 記錄與提取", diff --git a/messages/zh-TW/settings/statusPage.json b/messages/zh-TW/settings/statusPage.json index c6d879e49..a94027c38 100644 --- a/messages/zh-TW/settings/statusPage.json +++ b/messages/zh-TW/settings/statusPage.json @@ -46,7 +46,8 @@ "heroPrimary": "AI 服務", "heroSecondary": "服務狀態面板", "generatedAt": "更新於", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "history": "歷史", "freshnessWindow": "快照新鮮期剩餘", @@ -76,7 +77,8 @@ }, "tooltip": { "availability": "可用率", - "ttfb": "TFFT", + "ttfb": "TTFB", + "ttft": "TTFT", "tps": "TPS", "samples": "樣本數", "inferredFromNeighbors": "該時段無請求,依相鄰時段狀態推斷", diff --git a/src/actions/public-status.ts b/src/actions/public-status.ts index 9a641ff1c..f1b480201 100644 --- a/src/actions/public-status.ts +++ b/src/actions/public-status.ts @@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; import { locales } from "@/i18n/config"; import { getSession } from "@/lib/auth"; -import { invalidateSystemSettingsCache } from "@/lib/config"; +import { primeSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; import { collectEnabledPublicStatusGroups, @@ -193,7 +193,7 @@ export async function savePublicStatusSettings(input: SavePublicStatusSettingsIn } } - invalidateSystemSettingsCache(); + primeSystemSettingsCache(settings); invalidateConfiguredPublicStatusGroupsCache(); for (const locale of locales) { diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 639f6dbe0..999ba9226 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -5,7 +5,7 @@ import { ZodError } from "zod"; import { locales } from "@/i18n/config"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; -import { invalidateSystemSettingsCache } from "@/lib/config"; +import { primeSystemSettingsCache } from "@/lib/config"; import { DEFAULT_SETTINGS } from "@/lib/config/system-settings-cache"; import { logger } from "@/lib/logger"; import { publishCurrentPublicStatusConfigProjection } from "@/lib/public-status/config-publisher"; @@ -27,6 +27,7 @@ import type { CodexPriorityBillingSource, FakeStreamingWhitelistEntry, ResponseFixerConfig, + SessionSnapshotStoreSetting, StreamGateSettingMode, SystemSettings, } from "@/types/system-config"; @@ -107,6 +108,7 @@ export async function saveSystemSettings(formData: { affinityIgnoreClientSessionId?: boolean; replayEnabled?: boolean | null; cacheEffectivenessEnabled?: boolean | null; + sessionSnapshotStore?: SessionSnapshotStoreSetting; enableCodexSessionIdCompletion?: boolean; enableClaudeMetadataUserIdInjection?: boolean; enableResponseFixer?: boolean; @@ -197,6 +199,7 @@ export async function saveSystemSettings(formData: { affinityIgnoreClientSessionId: validated.affinityIgnoreClientSessionId, replayEnabled: validated.replayEnabled, cacheEffectivenessEnabled: validated.cacheEffectivenessEnabled, + sessionSnapshotStore: validated.sessionSnapshotStore, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, @@ -213,8 +216,13 @@ export async function saveSystemSettings(formData: { ipGeoLookupEnabled: validated.ipGeoLookupEnabled, }); - // Invalidate the system settings cache so proxy requests get fresh settings - invalidateSystemSettingsCache(); + primeSystemSettingsCache(updated); + if (validated.sessionSnapshotStore !== undefined) { + const { reconfigureSessionSnapshotStore } = await import("@/lib/session-snapshot/store"); + await reconfigureSessionSnapshotStore(updated.sessionSnapshotStore).catch((error) => { + logger.warn("[SystemSettings] Failed to reconfigure session snapshot store", { error }); + }); + } const { invalidateProviderSelectorSystemSettingsCache } = await import( "@/app/v1/_lib/proxy/provider-selector-settings-cache" ); diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 7183ff0f9..a0b2ad289 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -21,6 +21,7 @@ import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_component import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { TagInput } from "@/components/ui/tag-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Link } from "@/i18n/routing"; import { getAllUserKeyGroups, getAllUserTags } from "@/lib/api-client/v1/actions/users"; import { formatTokenAmount } from "@/lib/utils"; @@ -344,7 +345,25 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { className: "text-right", cell: (row) => { const val = row.avgTtfbMs; - return val && val > 0 ? `${Math.round(val).toLocaleString()} ms` : "-"; + if (val == null || val <= 0) return "-"; + return ( + + + {Math.round(val).toLocaleString()} ms + + +
+ {t("columns.avgTtfbMs")}: {Math.round(val).toLocaleString()} ms +
+
+ {t("columns.avgTtftMs")}:{" "} + {row.avgTtftMs == null + ? t("columns.timingUnavailable") + : `${Math.round(row.avgTtftMs).toLocaleString()} ms`} +
+
+
+ ); }, sortKey: "avgTtfbMs", getValue: (row) => row.avgTtfbMs ?? 0, @@ -383,11 +402,11 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { header: t("columns.cacheCoefficient"), className: "text-right", cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; + const bp = row.cacheCoefficientBp; return bp == null ? "–" : (bp / 10000).toFixed(2); }, sortKey: "cacheCoefficientBp", - getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), + getValue: (row) => row.cacheCoefficientBp, }, ]; @@ -428,11 +447,11 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { header: t("columns.cacheCoefficient"), className: "text-right", cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; + const bp = row.cacheCoefficientBp; return bp == null ? "–" : (bp / 10000).toFixed(2); }, sortKey: "cacheCoefficientBp", - getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), + getValue: (row) => row.cacheCoefficientBp, }, { header: t("columns.cacheReadTokens"), diff --git a/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts b/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts index 2ebe620f4..9bdb7959e 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts +++ b/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts @@ -15,12 +15,12 @@ export function getSuccessRateCellDisplay( if (typeof row.successRate === "number") { return { label: `${(Number(row.successRate) * 100).toFixed(1)}%`, - title: undefined, + title: row.basisDisclosureRequired ? t("columns.successRateBasisDisclosure") : undefined, }; } return { label: t("columns.successRateUnavailable"), - title: row.basisDisclosureRequired ? t("columns.successRateBasisDisclosure") : undefined, + title: undefined, }; } diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 66f1e9ff6..6b14e1566 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -255,20 +255,24 @@ const messages = { performance: { title: "Performance", ttfb: "TTFB", - tfft: "TFFT", + ttft: "TTFT", + timingUnavailable: "Timing unavailable", duration: "Duration", outputRate: "Output rate", - outputTokens: "Output Tokens", + outputTokens: "Output tokens", }, performanceTab: { noPerformanceData: "No performance data", - tfftGauge: "Time to First Token", + ttfb: "TTFB", + ttft: "TTFT", + ttfbGauge: "Time to First Byte", + ttftGauge: "Time to First Token", outputRateGauge: "Output Rate", latencyBreakdown: "Latency Breakdown", generationTime: "Generation Time", - segmentTtfb: "TTFB", - segmentTfft: "Token Wait", - segmentTotal: "Total", + firstByteToFirstToken: "First byte to first token", + generationAfterFirstToken: "Generation after first token", + totalDuration: "Total duration", assessment: { excellent: "Excellent", good: "Good", @@ -526,8 +530,9 @@ describe("error-details-dialog layout", () => { inputTokens={100} outputTokens={80} durationMs={900} - tfftMs={100} - firstByteMs={100} + ttfbMs={100} + ttftMs={250} + timingSemanticsVersion={2} /> ); @@ -547,8 +552,9 @@ describe("error-details-dialog layout", () => { inputTokens={100} outputTokens={0} durationMs={null} - tfftMs={null} - firstByteMs={null} + ttfbMs={null} + ttftMs={null} + timingSemanticsVersion={2} /> ); @@ -568,8 +574,9 @@ describe("error-details-dialog layout", () => { inputTokens={null} outputTokens={80} durationMs={900} - tfftMs={100} - firstByteMs={100} + ttfbMs={100} + ttftMs={100} + timingSemanticsVersion={2} /> ); @@ -577,9 +584,9 @@ describe("error-details-dialog layout", () => { expect(html).toContain("100.0 tok/s"); }); - test("hides tok/s when TTFB is close to duration and rate is abnormally high", () => { + test("hides tok/s when TTFT is close to duration and rate is abnormally high", () => { // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, ttftMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide const html = renderWithIntl( { inputTokens={null} outputTokens={300} durationMs={1000} - tfftMs={950} - firstByteMs={950} + ttfbMs={100} + ttftMs={950} + timingSemanticsVersion={2} /> ); @@ -605,7 +613,7 @@ describe("error-details-dialog layout", () => { }); test("shows tok/s in dialog when conditions are normal", () => { - // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, ttftMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show const html = renderWithIntl( { inputTokens={null} outputTokens={50} durationMs={1000} - tfftMs={500} - firstByteMs={500} + ttfbMs={100} + ttftMs={500} + timingSemanticsVersion={2} /> ); @@ -1199,8 +1208,9 @@ describe("error-details-dialog tabs", () => { providerChain={null} sessionId={null} durationMs={1000} - tfftMs={200} - firstByteMs={200} + ttfbMs={200} + ttftMs={400} + timingSemanticsVersion={2} outputTokens={500} /> ); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx index 48e56939a..eb0c7f64d 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LatencyBreakdownBar.tsx @@ -4,10 +4,10 @@ import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; interface LatencyBreakdownBarProps { - /** Time to first byte in milliseconds (null on rows persisted before it was recorded) */ - firstByteMs: number | null; - /** Time to first token in milliseconds */ - tfftMs: number | null; + /** Time to first byte in milliseconds */ + ttfbMs: number | null; + /** Time to first protocol-valid content in milliseconds */ + ttftMs: number | null; /** Total duration in milliseconds */ durationMs: number | null; /** Optional className */ @@ -24,8 +24,8 @@ function formatMs(ms: number): string { } export function LatencyBreakdownBar({ - firstByteMs, - tfftMs, + ttfbMs, + ttftMs, durationMs, className, showLabels = true, @@ -34,89 +34,82 @@ export function LatencyBreakdownBar({ // Handle null/invalid values if ( - tfftMs === null || + ttfbMs === null || + ttftMs === null || durationMs === null || - tfftMs < 0 || + ttfbMs < 0 || + ttftMs < ttfbMs || durationMs <= 0 || - tfftMs > durationMs + ttftMs > durationMs ) { return null; } - // 历史行没有真 TTFB:首段退化为整个 TFFT,中间段消失 - const ttfbMs = - firstByteMs !== null && firstByteMs >= 0 && firstByteMs <= tfftMs ? firstByteMs : tfftMs; - const tokenWaitMs = tfftMs - ttfbMs; - const generationMs = durationMs - tfftMs; - - const percent = (ms: number) => (ms / durationMs) * 100; - // Minimum width for visibility (3%) - const minWidth = 3; - const width = (ms: number) => Math.max(percent(ms), ms > 0 ? minWidth : 0); - - const segments = [ - { - key: "ttfb", - ms: ttfbMs, - label: t("segmentTtfb"), - barClass: "bg-blue-500", - dotClass: "bg-blue-500", - }, - { - key: "tokenWait", - ms: tokenWaitMs, - label: t("segmentTfft"), - barClass: "bg-violet-500", - dotClass: "bg-violet-500", - }, - { - key: "generation", - ms: generationMs, - label: t("generationTime"), - barClass: "bg-emerald-500", - dotClass: "bg-emerald-500", - }, - ]; + const firstByteToFirstTokenMs = ttftMs - ttfbMs; + const generationMs = durationMs - ttftMs; + const ttfbPercent = (ttfbMs / durationMs) * 100; + const firstByteToFirstTokenPercent = (firstByteToFirstTokenMs / durationMs) * 100; + const generationPercent = (generationMs / durationMs) * 100; return (
{/* Bar container */}
- {segments.map((segment) => - segment.ms > 0 ? ( -
- {percent(segment.ms) >= 15 && {segment.label}} -
- ) : null + {/* TTFB segment */} + {ttfbMs > 0 && ( +
+ {ttfbPercent >= 15 && TTFB} +
+ )} + {firstByteToFirstTokenMs > 0 && ( +
+ {firstByteToFirstTokenPercent >= 15 && {t("ttft")}} +
+ )} + {/* Generation segment */} + {generationMs > 0 && ( +
+ {generationPercent >= 15 && {t("generationAfterFirstToken")}} +
)}
{/* Labels */} {showLabels && ( -
- {segments.map((segment) => - segment.ms > 0 ? ( -
-
- {segment.label}: - {formatMs(segment.ms)} -
- ) : null - )} +
+
+
+ {t("ttfb")}: + {formatMs(ttfbMs)} +
+
+
+ {t("firstByteToFirstToken")}: + {formatMs(firstByteToFirstTokenMs)} +
+
+
+ {t("generationAfterFirstToken")}: + {formatMs(generationMs)} +
)} {/* Total */}
- {t("segmentTotal")}: {formatMs(durationMs)} + {t("totalDuration")}: {formatMs(durationMs)}
); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx index b7d73813f..cecad9d1e 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/PerformanceTab.tsx @@ -10,31 +10,31 @@ import type { PerformanceTabProps } from "../types"; import { LatencyBreakdownBar } from "./LatencyBreakdownBar"; /** - * Get TFFT performance assessment + * Get TTFB performance assessment * Thresholds: <1s excellent, <2s good, <3s warning, >=3s poor */ -function getTfftAssessment(tfftMs: number | null): { +function getTtfbAssessment(ttfbMs: number | null): { label: string; color: string; bgColor: string; } | null { - if (tfftMs === null) return null; + if (ttfbMs === null) return null; - if (tfftMs < 1000) { + if (ttfbMs < 1000) { return { label: "excellent", color: "text-emerald-600", bgColor: "bg-emerald-50 dark:bg-emerald-950/20", }; } - if (tfftMs < 2000) { + if (ttfbMs < 2000) { return { label: "good", color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", }; } - if (tfftMs < 3000) { + if (ttfbMs < 3000) { return { label: "warning", color: "text-amber-600", @@ -88,36 +88,37 @@ function getOutputRateAssessment(rate: number | null): { export function PerformanceTab({ durationMs, - tfftMs, - firstByteMs, + ttfbMs, + ttftMs, + timingSemanticsVersion, outputTokens, }: PerformanceTabProps) { const t = useTranslations("dashboard.logs.details"); - // Normalize undefined to null for consistent handling + const hasCurrentTimingSemantics = timingSemanticsVersion === 2; const normalizedDurationMs = durationMs ?? null; - const normalizedTfftMs = tfftMs ?? null; - const normalizedFirstByteMs = firstByteMs ?? null; + const normalizedTtfbMs = hasCurrentTimingSemantics ? (ttfbMs ?? null) : null; + const normalizedTtftMs = hasCurrentTimingSemantics ? (ttftMs ?? null) : null; const normalizedOutputTokens = outputTokens ?? null; const outputRate = calculateOutputRate( normalizedOutputTokens, normalizedDurationMs, - normalizedFirstByteMs + normalizedTtftMs ); - const hideRate = shouldHideOutputRate(outputRate, normalizedDurationMs, normalizedFirstByteMs); + const hideRate = shouldHideOutputRate(outputRate, normalizedDurationMs, normalizedTtftMs); const generationMs = - normalizedDurationMs !== null && normalizedTfftMs !== null - ? normalizedDurationMs - normalizedTfftMs + normalizedDurationMs !== null && normalizedTtftMs !== null + ? normalizedDurationMs - normalizedTtftMs : null; - - const tfftAssessment = getTfftAssessment(normalizedTfftMs); + const ttfbAssessment = getTtfbAssessment(normalizedTtfbMs); + const ttftAssessment = getTtfbAssessment(normalizedTtftMs); const rateAssessment = getOutputRateAssessment(outputRate); const hasData = normalizedDurationMs !== null || - normalizedTfftMs !== null || - normalizedFirstByteMs !== null || + normalizedTtfbMs !== null || + normalizedTtftMs !== null || (outputRate !== null && !hideRate) || normalizedOutputTokens !== null; @@ -132,19 +133,25 @@ export function PerformanceTab({ return (
+ {!hasCurrentTimingSemantics && (ttfbMs != null || ttftMs != null) && ( +
+ {t("performance.timingUnavailable")} +
+ )} + {/* Gauges Row */} -
- {/* TFFT Gauge */} - {normalizedTfftMs !== null && ( +
+ {/* TTFB Gauge */} + {normalizedTtfbMs !== null && (
-

{t("performanceTab.tfftGauge")}

+

{t("performanceTab.ttfbGauge")}

- {normalizedTfftMs >= 1000 - ? `${(normalizedTfftMs / 1000).toFixed(2)}s` - : `${Math.round(normalizedTfftMs)}ms`} + {normalizedTtfbMs >= 1000 + ? `${(normalizedTtfbMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTtfbMs)}ms`} +

+ {ttfbAssessment && ( + + {t(`performanceTab.assessment.${ttfbAssessment.label}`)} + + )} +
+
+ )} + + {normalizedTtftMs !== null && ( +
+
+ +
+ +
+
+
+

{t("performanceTab.ttftGauge")}

+

+ {normalizedTtftMs >= 1000 + ? `${(normalizedTtftMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTtftMs)}ms`}

- {tfftAssessment && ( - - {t(`performanceTab.assessment.${tfftAssessment.label}`)} + {ttftAssessment && ( + + {t(`performanceTab.assessment.${ttftAssessment.label}`)} )}
@@ -206,7 +250,7 @@ export function PerformanceTab({
{/* Latency Breakdown Bar */} - {normalizedTfftMs !== null && normalizedDurationMs !== null && ( + {normalizedTtfbMs !== null && normalizedTtftMs !== null && normalizedDurationMs !== null && (

@@ -214,8 +258,8 @@ export function PerformanceTab({

@@ -226,30 +270,30 @@ export function PerformanceTab({

{t("performance.title")}

- {normalizedFirstByteMs !== null && ( + {normalizedTtfbMs !== null && (
{t("performance.ttfb")} - {normalizedFirstByteMs >= 1000 - ? `${(normalizedFirstByteMs / 1000).toFixed(2)}s` - : `${Math.round(normalizedFirstByteMs)}ms`} + {normalizedTtfbMs >= 1000 + ? `${(normalizedTtfbMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTtfbMs)}ms`}
)} - {normalizedTfftMs !== null && ( -
- {t("performance.tfft")} - - {normalizedTfftMs >= 1000 - ? `${(normalizedTfftMs / 1000).toFixed(2)}s` - : `${Math.round(normalizedTfftMs)}ms`} + {normalizedTtftMs !== null && ( +
+ {t("performance.ttft")} + + {normalizedTtftMs >= 1000 + ? `${(normalizedTtftMs / 1000).toFixed(2)}s` + : `${Math.round(normalizedTtftMs)}ms`}
)} {generationMs !== null && (
- {t("performanceTab.generationTime")} + {t("performanceTab.generationAfterFirstToken")} {generationMs >= 1000 diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 50f344ace..0ec500afe 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -64,7 +64,8 @@ export function SummaryTab({ routingTrace, context1mApplied, durationMs, - firstByteMs, + ttftMs, + timingSemanticsVersion, sessionId, requestSequence, userAgent, @@ -80,12 +81,9 @@ export function SummaryTab({ const isSuccess = isSuccessStatus(statusCode); const isInProgress = isInProgressStatus(statusCode); - const outputRate = calculateOutputRate( - outputTokens ?? null, - durationMs ?? null, - firstByteMs ?? null - ); - const hideRate = shouldHideOutputRate(outputRate, durationMs ?? null, firstByteMs ?? null); + const currentTtftMs = timingSemanticsVersion === 2 ? (ttftMs ?? null) : null; + const outputRate = calculateOutputRate(outputTokens ?? null, durationMs ?? null, currentTtftMs); + const hideRate = shouldHideOutputRate(outputRate, durationMs ?? null, currentTtftMs); const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0); const hasRedirect = originalModel && currentModel && originalModel !== currentModel; const modelAudit = resolveModelAuditDisplay({ diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx index d690debf6..6ba4e789b 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx @@ -49,8 +49,9 @@ interface ErrorDetailsDialogProps { hedgeLosers?: HedgeLoserBilling[] | null; context1mApplied?: boolean | null; durationMs?: number | null; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; externalOpen?: boolean; onExternalOpenChange?: (open: boolean) => void; scrollToRedirect?: boolean; @@ -95,8 +96,9 @@ export function ErrorDetailsDialog({ hedgeLosers, context1mApplied, durationMs, - tfftMs, - firstByteMs, + ttfbMs, + ttftMs, + timingSemanticsVersion, externalOpen, onExternalOpenChange, scrollToRedirect, @@ -246,8 +248,9 @@ export function ErrorDetailsDialog({ hedgeLosers, context1mApplied, durationMs, - tfftMs, - firstByteMs, + ttfbMs, + ttftMs, + timingSemanticsVersion, }; return ( diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index a5b95850e..099ed17a7 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -72,10 +72,10 @@ export interface TabSharedProps { context1mApplied?: boolean | null; /** Total request duration in ms */ durationMs?: number | null; - /** Time to first token in ms */ - tfftMs?: number | null; - /** Time to first byte in ms (null on rows persisted before it was recorded) */ - firstByteMs?: number | null; + /** Time to first byte in ms */ + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; } /** diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 301ed256a..19ba3c3ac 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -79,8 +79,9 @@ function makeLog(overrides: Partial): UsageLogRow { costBreakdown: null, hedgeLosers: null, durationMs: 100, - tfftMs: 50, - firstByteMs: 50, + ttfbMs: 50, + ttftMs: 60, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, @@ -413,15 +414,13 @@ describe("usage-logs-table multiplier badge", () => { container.remove(); }); - test("hides tok/s when TTFB is close to duration and rate is abnormally high", () => { + test("hides tok/s when TTFT is close to duration and rate is abnormally high", () => { // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, ttftMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide const html = renderToStaticMarkup( { // tok/s should NOT appear expect(html).not.toContain("tok/s"); // TFFT 行仍应出现 - expect(html).toContain("logs.details.performance.tfft"); + expect(html).toContain("logs.details.performance.ttfb"); }); test("shows tok/s when conditions are normal", () => { - // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, ttftMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show const html = renderToStaticMarkup( { // tok/s should appear expect(html).toContain("tok/s"); // TFFT 行同样应出现 - expect(html).toContain("logs.details.performance.tfft"); + expect(html).toContain("logs.details.performance.ttfb"); }); test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 866e31ab8..b11bbbfd5 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -581,20 +581,19 @@ export function UsageLogsTable({ {(() => { + const currentTiming = log.timingSemanticsVersion === 2; + const displayTtfbMs = currentTiming ? log.ttfbMs : null; + const displayTtftMs = currentTiming ? log.ttftMs : null; const rate = calculateOutputRate( log.outputTokens, log.durationMs, - log.firstByteMs - ); - const hideRate = shouldHideOutputRate( - rate, - log.durationMs, - log.firstByteMs + displayTtftMs ); + const hideRate = shouldHideOutputRate(rate, log.durationMs, displayTtftMs); const secondLine = [ - log.tfftMs != null && - log.tfftMs > 0 && - `${t("logs.details.performance.tfft")} ${formatDuration(log.tfftMs)}`, + displayTtfbMs != null && + displayTtfbMs > 0 && + `TTFB ${formatDuration(displayTtfbMs)}`, rate !== null && !hideRate && `${rate.toFixed(0)} tok/s`, ] .filter(Boolean) @@ -618,18 +617,20 @@ export function UsageLogsTable({ {t("logs.details.performance.duration")}:{" "} {formatDuration(log.durationMs)}
- {log.tfftMs != null && ( + {displayTtfbMs != null && (
- {t("logs.details.performance.tfft")}:{" "} - {formatDuration(log.tfftMs)} + {t("logs.details.performance.ttfb")}:{" "} + {formatDuration(displayTtfbMs)}
)} - {log.firstByteMs != null && ( + {displayTtftMs != null ? (
- {t("logs.details.performance.ttfb")}:{" "} - {formatDuration(log.firstByteMs)} + {t("logs.details.performance.ttft")}:{" "} + {formatDuration(displayTtftMs)}
- )} + ) : !currentTiming ? ( +
{t("logs.details.performance.timingUnavailable")}
+ ) : null} {rate !== null && !hideRate && (
{t("logs.details.performance.outputRate")}: {rate.toFixed(1)}{" "} @@ -676,8 +677,9 @@ export function UsageLogsTable({ hedgeLosers={log.hedgeLosers} context1mApplied={log.context1mApplied} durationMs={log.durationMs} - tfftMs={log.tfftMs} - firstByteMs={log.firstByteMs} + ttfbMs={log.ttfbMs} + ttftMs={log.ttftMs} + timingSemanticsVersion={log.timingSemanticsVersion} externalOpen={dialogState.logId === log.id ? true : undefined} onExternalOpenChange={(open) => { if (!open) setDialogState({ logId: null, scrollToRedirect: false }); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index e7f57f5b2..c45141159 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -143,8 +143,9 @@ function makeLog(overrides: Partial): UsageLogRow { costBreakdown: null, hedgeLosers: null, durationMs: 100, - tfftMs: 50, - firstByteMs: 50, + ttfbMs: 50, + ttftMs: 60, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, @@ -485,7 +486,7 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).toContain("logs.table.loadingMore"); }); - test("hides tok/s when TTFB is close to duration and rate is abnormally high", () => { + test("hides tok/s when TTFT is close to duration and rate is abnormally high", () => { mockIsLoading = false; mockIsError = false; mockError = null; @@ -493,11 +494,9 @@ describe("virtualized-logs-table multiplier badge", () => { mockIsFetchingNextPage = false; // Rule: generationTimeMs / durationMs < 0.1 && outputRate > 5000 => hide tok/s - // durationMs=1000, firstByteMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 + // durationMs=1000, ttftMs=950 => generationTimeMs=50, ratio=0.05 < 0.1 // outputTokens=300 => rate = 300 / 0.05 = 6000 > 5000 => should hide - mockLogs = [ - makeLog({ id: 1, durationMs: 1000, tfftMs: 950, firstByteMs: 950, outputTokens: 300 }), - ]; + mockLogs = [makeLog({ id: 1, durationMs: 1000, ttftMs: 950, outputTokens: 300 })]; const html = renderToStaticMarkup( ); @@ -505,7 +504,7 @@ describe("virtualized-logs-table multiplier badge", () => { // tok/s should NOT appear expect(html).not.toContain("tok/s"); // TFFT 行仍应出现 - expect(html).toContain("logs.details.performance.tfft"); + expect(html).toContain("logs.details.performance.ttfb"); }); test("shows tok/s when conditions are normal", () => { @@ -515,11 +514,9 @@ describe("virtualized-logs-table multiplier badge", () => { mockHasNextPage = false; mockIsFetchingNextPage = false; - // durationMs=1000, firstByteMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 + // durationMs=1000, ttftMs=500 => generationTimeMs=500, ratio=0.5 >= 0.1 // outputTokens=50 => rate = 50 / 0.5 = 100 <= 5000 => should show - mockLogs = [ - makeLog({ id: 1, durationMs: 1000, tfftMs: 500, firstByteMs: 500, outputTokens: 50 }), - ]; + mockLogs = [makeLog({ id: 1, durationMs: 1000, ttftMs: 500, outputTokens: 50 })]; const html = renderToStaticMarkup( ); @@ -527,7 +524,7 @@ describe("virtualized-logs-table multiplier badge", () => { // tok/s should appear expect(html).toContain("tok/s"); // TFFT 行同样应出现 - expect(html).toContain("logs.details.performance.tfft"); + expect(html).toContain("logs.details.performance.ttfb"); }); test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index e0f150534..88ad073cd 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1154,19 +1154,22 @@ export function VirtualizedLogsTable({ {hidePerformanceColumn ? null : (
{(() => { + const currentTiming = log.timingSemanticsVersion === 2; + const displayTtfbMs = currentTiming ? log.ttfbMs : null; + const displayTtftMs = currentTiming ? log.ttftMs : null; const rate = calculateOutputRate( log.outputTokens, log.durationMs, - log.firstByteMs + displayTtftMs ); const hideRate = shouldHideOutputRate( rate, log.durationMs, - log.firstByteMs + displayTtftMs ); - const tfftLine = - log.tfftMs != null && log.tfftMs > 0 - ? `${t("logs.details.performance.tfft")} ${formatDuration(log.tfftMs)}` + const ttfbLine = + displayTtfbMs != null && displayTtfbMs > 0 + ? `TTFB ${formatDuration(displayTtfbMs)}` : null; const rateLine = rate !== null && !hideRate ? `${rate.toFixed(0)} tok/s` : null; @@ -1177,9 +1180,9 @@ export function VirtualizedLogsTable({
{formatDuration(log.durationMs)} - {tfftLine && ( + {ttfbLine && ( - {tfftLine} + {ttfbLine} )} {rateLine && ( @@ -1194,18 +1197,20 @@ export function VirtualizedLogsTable({ {t("logs.details.performance.duration")}:{" "} {formatDuration(log.durationMs)}
- {log.tfftMs != null && ( + {displayTtfbMs != null && (
- {t("logs.details.performance.tfft")}:{" "} - {formatDuration(log.tfftMs)} + {t("logs.details.performance.ttfb")}:{" "} + {formatDuration(displayTtfbMs)}
)} - {log.firstByteMs != null && ( + {displayTtftMs != null ? (
- {t("logs.details.performance.ttfb")}:{" "} - {formatDuration(log.firstByteMs)} + {t("logs.details.performance.ttft")}:{" "} + {formatDuration(displayTtftMs)}
- )} + ) : !currentTiming ? ( +
{t("logs.details.performance.timingUnavailable")}
+ ) : null} {rate !== null && !hideRate && (
{t("logs.details.performance.outputRate")}: {rate.toFixed(1)}{" "} @@ -1258,8 +1263,9 @@ export function VirtualizedLogsTable({ hedgeLosers={log.hedgeLosers} context1mApplied={log.context1mApplied} durationMs={log.durationMs} - tfftMs={log.tfftMs} - firstByteMs={log.firstByteMs} + ttfbMs={log.ttfbMs} + ttftMs={log.ttftMs} + timingSemanticsVersion={log.timingSemanticsVersion} externalOpen={dialogState.logId === log.id ? true : undefined} onExternalOpenChange={(open) => { if (!open) setDialogState({ logId: null, scrollToRedirect: false }); diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index 1969576cf..09196945a 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -11,6 +11,7 @@ import { Filter, Gauge, Globe, + HardDrive, MapPin, Network, Pencil, @@ -57,6 +58,7 @@ import type { BillingModelSource, CodexPriorityBillingSource, FakeStreamingWhitelistEntry, + SessionSnapshotStoreSetting, StreamGateSettingMode, SystemSettings, } from "@/types/system-config"; @@ -99,6 +101,7 @@ interface SystemSettingsFormProps { | "affinityIgnoreClientSessionId" | "replayEnabled" | "cacheEffectivenessEnabled" + | "sessionSnapshotStore" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -220,6 +223,9 @@ export function SystemSettingsForm({ const [cacheEffectivenessEnabled, setCacheEffectivenessEnabled] = useState( initialSettings.cacheEffectivenessEnabled ); + const [sessionSnapshotStore, setSessionSnapshotStore] = useState( + initialSettings.sessionSnapshotStore + ); const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState( initialSettings.enableThinkingBudgetRectifier ); @@ -407,6 +413,7 @@ export function SystemSettingsForm({ affinityIgnoreClientSessionId, replayEnabled, cacheEffectivenessEnabled, + sessionSnapshotStore, enableThinkingBudgetRectifier, enableThinkingEffortConflictRectifier, enableGeminiFunctionIdRectifier, @@ -473,6 +480,7 @@ export function SystemSettingsForm({ setAffinityIgnoreClientSessionId(result.data.affinityIgnoreClientSessionId); setReplayEnabled(result.data.replayEnabled ?? null); setCacheEffectivenessEnabled(result.data.cacheEffectivenessEnabled ?? null); + setSessionSnapshotStore(result.data.sessionSnapshotStore); setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier); setEnableThinkingEffortConflictRectifier(result.data.enableThinkingEffortConflictRectifier); setEnableGeminiFunctionIdRectifier(result.data.enableGeminiFunctionIdRectifier); @@ -1217,6 +1225,42 @@ export function SystemSettingsForm({ />
+
+
+
+ +
+
+

{t("sessionSnapshotStore")}

+

+ {t("sessionSnapshotStoreDesc", { ttl: sessionTtlSeconds })} +

+
+
+
+ +
+
+ {/* Enable Codex Session ID Completion */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 7da0a1264..4c90ea4e3 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -82,6 +82,7 @@ async function SettingsConfigContent({ locale }: { locale: string }) { affinityIgnoreClientSessionId: settings.affinityIgnoreClientSessionId, replayEnabled: settings.replayEnabled, cacheEffectivenessEnabled: settings.cacheEffectivenessEnabled, + sessionSnapshotStore: settings.sessionSnapshotStore, enableCodexSessionIdCompletion: settings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: settings.enableClaudeMetadataUserIdInjection, enableResponseFixer: settings.enableResponseFixer, diff --git a/src/app/[locale]/status/[slug]/page.tsx b/src/app/[locale]/status/[slug]/page.tsx index 2190592df..0095c5d00 100644 --- a/src/app/[locale]/status/[slug]/page.tsx +++ b/src/app/[locale]/status/[slug]/page.tsx @@ -70,6 +70,7 @@ export default async function PublicStatusGroupPage({ history: t("statusPage.public.history"), availability: t("statusPage.public.availability"), ttfb: t("statusPage.public.ttfb"), + ttft: t("statusPage.public.ttft"), freshnessWindow: t("statusPage.public.freshnessWindow"), fresh: t("statusPage.public.fresh"), stale: t("statusPage.public.stale"), @@ -93,6 +94,7 @@ export default async function PublicStatusGroupPage({ tooltip: { availability: t("statusPage.public.tooltip.availability"), ttfb: t("statusPage.public.tooltip.ttfb"), + ttft: t("statusPage.public.tooltip.ttft"), tps: t("statusPage.public.tooltip.tps"), historyAriaLabel: t("statusPage.public.tooltip.historyAriaLabel"), }, diff --git a/src/app/[locale]/status/_components/public-status-timeline.tsx b/src/app/[locale]/status/_components/public-status-timeline.tsx index d41293847..235c9fce7 100644 --- a/src/app/[locale]/status/_components/public-status-timeline.tsx +++ b/src/app/[locale]/status/_components/public-status-timeline.tsx @@ -8,6 +8,7 @@ import { formatTtfb } from "../_lib/format-ttfb"; export interface PublicStatusTimelineLabels { availability: string; ttfb: string; + ttft: string; tps: string; noData: string; historyAriaLabel: string; @@ -90,6 +91,7 @@ export function PublicStatusTimeline({ availability: activeBucket.availabilityPct === null ? "—" : `${activeBucket.availabilityPct.toFixed(2)}%`, ttfb: formatTtfb(activeBucket.ttfbMs), + ttft: formatTtfb(activeBucket.ttftMs), tps: activeBucket.tps === null ? "—" : activeBucket.tps.toFixed(1), }; }, [activeBucket, activeIsPlaceholder, locale, timeZone]); @@ -132,7 +134,7 @@ export function PublicStatusTimeline({ {activeSummary.range ? (

{activeSummary.range}

) : null} -
+
{labels.availability}{" "} {activeSummary.availability} @@ -140,6 +142,9 @@ export function PublicStatusTimeline({ {labels.ttfb} {activeSummary.ttfb} + + {labels.ttft} {activeSummary.ttft} + {labels.tps} {activeSummary.tps} diff --git a/src/app/[locale]/status/_components/public-status-view.tsx b/src/app/[locale]/status/_components/public-status-view.tsx index 36be96a78..65863bbc4 100644 --- a/src/app/[locale]/status/_components/public-status-view.tsx +++ b/src/app/[locale]/status/_components/public-status-view.tsx @@ -18,6 +18,7 @@ import { import { Activity } from "lucide-react"; import { startTransition, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { PublicStatusPayload } from "@/lib/public-status/payload"; import { type PublicStatusRouteResponse, @@ -40,6 +41,7 @@ import { import { CHART_BUCKETS, computeAvgTtfb, + computeAvgTtft, computeUptimePct, sliceTimelineForChart, } from "../_lib/timeline-windows"; @@ -71,6 +73,7 @@ interface PublicStatusViewProps { history: string; availability: string; ttfb: string; + ttft: string; freshnessWindow: string; fresh: string; stale: string; @@ -89,6 +92,7 @@ interface PublicStatusViewProps { tooltip: { availability: string; ttfb: string; + ttft: string; tps: string; historyAriaLabel: string; }; @@ -273,8 +277,9 @@ export function PublicStatusView({ ? model.availabilityPct : computeUptimePct(model.timeline); const ttfb24h = computeAvgTtfb(model.timeline); + const ttft24h = computeAvgTtft(model.timeline); const latest = deriveCurrentModelState(viewModel); - return { model, chartCells, uptime24h, ttfb24h, latest }; + return { model, chartCells, uptime24h, ttfb24h, ttft24h, latest }; }); const issueCount = derivedModels.filter((d) => d.latest === "failed").length; const groupState = aggregateByFailed(derivedModels.map((d) => d.latest)); @@ -363,6 +368,7 @@ export function PublicStatusView({ const timelineLabels: PublicStatusTimelineLabels = { availability: labels.tooltip.availability, ttfb: labels.tooltip.ttfb, + ttft: labels.tooltip.ttft, tps: labels.tooltip.tps, noData: labels.noData, historyAriaLabel: labels.tooltip.historyAriaLabel, @@ -459,7 +465,7 @@ export function PublicStatusView({ >
{entry.derivedModels.map( - ({ model, chartCells, uptime24h, ttfb24h, latest }) => { + ({ model, chartCells, uptime24h, ttfb24h, ttft24h, latest }) => { const variant = badgeVariant(latest); const { Icon } = getPublicStatusVendorIconComponent({ modelName: model.publicModelKey, @@ -509,17 +515,29 @@ export function PublicStatusView({ {uptime24h === null ? "—" : `${uptime24h.toFixed(2)}%`}
-
-
- {labels.ttfb}{" "} - - ({rangeHours}H) - -
-
- {formatTtfb(ttfb24h)} -
-
+ + +
+
+ {labels.ttfb}{" "} + + ({rangeHours}H) + +
+
+ {formatTtfb(ttfb24h)} +
+
+
+ +
+ {labels.tooltip.ttfb}: {formatTtfb(ttfb24h)} +
+
+ {labels.tooltip.ttft}: {formatTtfb(ttft24h)} +
+
+
0 && bucket.ttftMs !== null) { + weightedSum += bucket.ttftMs * bucket.sampleCount; + sampleTotal += bucket.sampleCount; + } + } + if (sampleTotal === 0) { + return null; + } + return Math.round(weightedSum / sampleTotal); +} diff --git a/src/app/[locale]/status/page.tsx b/src/app/[locale]/status/page.tsx index baa0c4f83..a1f27e600 100644 --- a/src/app/[locale]/status/page.tsx +++ b/src/app/[locale]/status/page.tsx @@ -39,6 +39,7 @@ export default async function PublicStatusPage({ history: t("history"), availability: t("availability"), ttfb: t("ttfb"), + ttft: t("ttft"), freshnessWindow: t("freshnessWindow"), fresh: t("fresh"), stale: t("stale"), @@ -62,6 +63,7 @@ export default async function PublicStatusPage({ tooltip: { availability: t("tooltip.availability"), ttfb: t("tooltip.ttfb"), + ttft: t("tooltip.ttft"), tps: t("tooltip.tps"), historyAriaLabel: t("tooltip.historyAriaLabel"), }, diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index bdf9cc76b..1c2c11e83 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { getSession } from "@/lib/auth"; -import { invalidateSystemSettingsCache } from "@/lib/config"; +import { primeSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; import { invalidateAllLeaderboardCaches, @@ -109,6 +109,9 @@ export async function POST(req: Request) { enableResponseInputRectifier: validated.enableResponseInputRectifier, streamGateMode: validated.streamGateMode, affinityIgnoreClientSessionId: validated.affinityIgnoreClientSessionId, + replayEnabled: validated.replayEnabled, + cacheEffectivenessEnabled: validated.cacheEffectivenessEnabled, + sessionSnapshotStore: validated.sessionSnapshotStore, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, @@ -125,7 +128,13 @@ export async function POST(req: Request) { userId: session.user.id, changes: validated, }); - invalidateSystemSettingsCache(); + primeSystemSettingsCache(updated); + if (validated.sessionSnapshotStore !== undefined) { + const { reconfigureSessionSnapshotStore } = await import("@/lib/session-snapshot/store"); + await reconfigureSessionSnapshotStore(updated.sessionSnapshotStore).catch((error) => { + logger.warn("[SystemSettings] Failed to reconfigure session snapshot store", { error }); + }); + } const { invalidateProviderSelectorSystemSettingsCache } = await import( "@/app/v1/_lib/proxy/provider-selector-settings-cache" ); diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 8e78cfdea..5afbf2930 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -321,6 +321,8 @@ type StreamingHedgeAttempt = { thresholdTimer: NodeJS.Timeout | null; reader: ReadableStreamDefaultReader | null; response: Response | null; + headersElapsedMs: number | null; + ttftElapsedMs: number | null; releaseAgent: (() => void) | null; agentReleased: boolean; /** When true, this losing attempt is kept alive, drained, and billed instead of cancelled. */ @@ -335,8 +337,6 @@ type StreamingHedgeAttempt = { firstChunk: Uint8Array | null; /** F1 门控提交标记(该 attempt 门控提交时记录,随 hedge_winner 链条目落库)。 */ gateAudit?: ProviderChainItem["streamGate"]; - /** 该 attempt 首字节到达时刻(epoch ms);只有赢家的值会被记为 session TTFB。 */ - firstByteAt?: number | null; /** * Billing context snapshot for the INITIAL provider's losing attempt, captured BEFORE * commitWinner overwrites the shared session's model/context with the winner's. Null for @@ -1671,6 +1671,7 @@ export class ProxyForwarder { endpointAudit, attemptCount ); + const responseHeadersElapsedMs = Math.max(0, Date.now() - session.startTime); // ========== 空响应检测(仅非流式)========== const contentType = response.headers.get("content-type") || ""; @@ -1716,9 +1717,6 @@ export class ProxyForwarder { }; const gateReader = response.body.getReader(); const gateStartedAt = Date.now(); - // TTFB 只在门控提交后写入 session:提交前失败的尝试不会被服务, - // 记下它的首字节会低估 TTFB 并放大 TPS 的分母。 - let gateFirstByteAt: number | null = null; const gate = await runStreamContentGate(gateReader, { family: gateFamily, providerId: currentProvider.id, @@ -1727,7 +1725,6 @@ export class ProxyForwarder { // 首字节到达即清除首字节计时器,保持「首字节超时」的原始语义—— // 思考型模型可在首个内容帧前长时间输出中性帧,不应触发该计时器 onFirstByte: () => { - gateFirstByteAt ??= Date.now(); runtime.clearResponseTimeout?.(); }, // 门控等待期沿用供应商静默超时(与提交后 response-handler 的行为对齐) @@ -1773,10 +1770,8 @@ export class ProxyForwarder { throw gate.error; } - if (gateFirstByteAt !== null) { - session.recordFirstByte(gateFirstByteAt); - } - session.recordTfft(); + session.recordTtfb(responseHeadersElapsedMs); + session.recordTtft(); if (gate.commitMarker) { gateChainAudit = { @@ -1834,6 +1829,8 @@ export class ProxyForwarder { statusCode: response.status, }); + session.recordTtfb(responseHeadersElapsedMs); + return streamingResponse; } @@ -2118,6 +2115,7 @@ export class ProxyForwarder { statusCode: response.status, }); + session.recordTtfb(responseHeadersElapsedMs); return response; // ⭐ 成功:立即返回,结束所有循环 } catch (error) { lastError = error as Error; @@ -4755,6 +4753,7 @@ export class ProxyForwarder { attempt.releaseAgent = attemptRuntime.releaseAgent ?? null; attempt.clearResponseTimeout?.(); attempt.response = response; + attempt.headersElapsedMs = Math.max(0, Date.now() - session.startTime); if (!response.body) { await handleAttemptFailure( @@ -4783,10 +4782,6 @@ export class ProxyForwarder { providerId: attempt.provider.id, providerName: attempt.provider.name, ...resolveStreamGateCaps(), - // 首字节时刻先挂在 attempt 上,由 commitWinner 决定是否记为 session TTFB - onFirstByte: () => { - attempt.firstByteAt ??= Date.now(); - }, // 竞速路径首字节计时器已在响应头到达时清除;门控等待期沿用供应商静默超时 idleTimeoutMs: attempt.provider.streamingIdleTimeoutMs, captureCommitMarker: !session.isHighConcurrencyModeEnabled(), @@ -4809,7 +4804,8 @@ export class ProxyForwarder { } // 保留完整门控前缀:若本 attempt 落败且需要计费,drain 时补回前缀里的 usage。 attempt.firstChunk = concatChunks(gate.prefixChunks); - await commitWinner(attempt, gate.prefixChunks, true); + attempt.ttftElapsedMs = Math.max(0, Date.now() - session.startTime); + await commitWinner(attempt, gate.prefixChunks); } else { const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); if (firstChunk.done) { @@ -4822,7 +4818,7 @@ export class ProxyForwarder { // 保留首块:若本 attempt 落败且需要计费,drain 时需要补回首块的 usage。 attempt.firstChunk = firstChunk.value; - await commitWinner(attempt, [firstChunk.value], false); + await commitWinner(attempt, [firstChunk.value]); } // 本 attempt 读到首块却落败(winner 已先提交,commitWinner 早退): @@ -5092,24 +5088,13 @@ export class ProxyForwarder { await finishIfExhausted(); }; - const commitWinner = async ( - attempt: StreamingHedgeAttempt, - prefixChunks: Uint8Array[], - contentGateCommitted: boolean - ) => { + const commitWinner = async (attempt: StreamingHedgeAttempt, prefixChunks: Uint8Array[]) => { if (settled || winnerCommitted || attempt.settled || !attempt.response || !attempt.reader) return; winnerCommitted = true; winnerAttempt = attempt; - if (attempt.firstByteAt != null) { - session.recordFirstByte(attempt.firstByteAt); - } - if (contentGateCommitted) { - session.recordTfft(); - } - if (attempt.thresholdTimer) { clearTimeout(attempt.thresholdTimer); attempt.thresholdTimer = null; @@ -5148,6 +5133,10 @@ export class ProxyForwarder { detailSnapshotSession.detailSnapshotResponseBefore ); session.setProvider(attempt.provider); + session.recordTtfb(attempt.headersElapsedMs ?? undefined); + if (attempt.ttftElapsedMs !== null) { + session.recordTtft(attempt.ttftElapsedMs); + } // Determine if this is truly a hedge winner or just a regular success // Only mark as hedge_winner when an actual hedge race occurred @@ -5333,6 +5322,8 @@ export class ProxyForwarder { thresholdTimer: null, reader: null, response: null, + headersElapsedMs: null, + ttftElapsedMs: null, releaseAgent: null, agentReleased: false, // Only keep a loser alive for billing if there is a request row to bill back to; @@ -6126,20 +6117,22 @@ export class ProxyForwarder { outcome: "winner", statusCode: attempt.response.status, }); + session.recordTtfb(attempt.headersElapsedMs ?? undefined); + if (attempt.ttftElapsedMs !== null) { + session.recordTtft(attempt.ttftElapsedMs); + } session.setRoutingTraceSummary( discoveryMetrics.snapshot({ outcome: "success", statusCode: attempt.response.status, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, winnerOrigin, winnerProviderId: attempt.provider.id, winnerRound: attempt.traceRound, }) ); session.setProvider(attempt.provider); - if (attempt.firstByteAt != null) { - session.recordFirstByte(attempt.firstByteAt); - } - session.recordTfft(); if (attempt.session !== session) ProxyForwarder.syncWinningAttemptSession(session, attempt.session); @@ -6486,6 +6479,8 @@ export class ProxyForwarder { thresholdTimer: null, reader: null, response: null, + headersElapsedMs: null, + ttftElapsedMs: null, releaseAgent: null, agentReleased: false, billAsLoser: false, @@ -6568,6 +6563,7 @@ export class ProxyForwarder { attempt.releaseAgent = runtime.releaseAgent ?? null; attempt.clearResponseTimeout?.(); attempt.response = response; + attempt.headersElapsedMs = Math.max(0, Date.now() - session.startTime); if (!attempt.pending || committed || settled) { if (response.body && !attempt.reader) attempt.reader = response.body.getReader(); cleanupAttempt(attempt, attempt.cancellationKind); @@ -6587,9 +6583,6 @@ export class ProxyForwarder { throw new EmptyResponseError(provider.id, provider.name, "empty_body"); } if (!item.value || item.value.byteLength === 0) continue; - // 首字节时刻先挂在 attempt 上;DiscoveryValidityParser 的 ready 判定同样基于内容, - // 不在此记录会让 discovery 模式的 TTFB 恒等于 TFFT。 - attempt.firstByteAt ??= Date.now(); attempt.chunks.push(item.value); const validity = attempt.parser.push(item.value); // A single read can contain both deliverable content and the @@ -6605,6 +6598,9 @@ export class ProxyForwarder { } if (validity.error || (validity.terminal && !validity.ready)) throw new ProxyError("Invalid upstream discovery response", 502); + if (validity.ready && attempt.ttftElapsedMs === null) { + attempt.ttftElapsedMs = Math.max(0, Date.now() - session.startTime); + } if (!validity.ready) continue; attempt.ready = true; session.appendRoutingTraceEvent({ diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index d143eaf82..d3cfbe142 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -75,7 +75,11 @@ import { peekDeferredStreamingFinalization, } from "./stream-finalization"; import { mapProviderTypeToFamily } from "./stream-gate/frame-classifier"; -import { createShadowGateObserver, resolveStreamGateMode } from "./stream-gate/stream-content-gate"; +import { + createContentTimingObserver, + createShadowGateObserver, + resolveStreamGateMode, +} from "./stream-gate/stream-content-gate"; import { createStreamProtocolObserver, type StreamProtocolObservation, @@ -2677,8 +2681,9 @@ export class ProxyResponseHandler { details: { statusCode: finalizedStatusCode, ...errorDetails, - tfftMs: session.tfftMs ?? duration, - firstByteMs: session.firstByteMs ?? duration, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(finalizedStatusCode), model: session.getCurrentModel() ?? undefined, @@ -2875,8 +2880,9 @@ export class ProxyResponseHandler { const terminalDetails: MessageRequestTerminalDetails = { statusCode: finalizedStatusCode, ...errorDetails, - tfftMs: session.tfftMs ?? duration, - firstByteMs: session.firstByteMs ?? duration, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(finalizedStatusCode), model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 @@ -3222,8 +3228,9 @@ export class ProxyResponseHandler { statusCode: statusCode, inputTokens: usageMetrics?.input_tokens, outputTokens: usageMetrics?.output_tokens, - tfftMs: session.tfftMs ?? duration, - firstByteMs: session.firstByteMs ?? duration, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, cacheCreationInputTokens: usageMetrics?.cache_creation_input_tokens, cacheReadInputTokens: usageMetrics?.cache_read_input_tokens, cacheCreation5mInputTokens: usageMetrics?.cache_creation_5m_input_tokens, @@ -3672,7 +3679,7 @@ export class ProxyResponseHandler { clearIdleTimer(); if (isFirstChunk) { isFirstChunk = false; - session.recordTfft(); + session.recordTtft(); clearResponseTimeoutOnce(value.byteLength); } streamTextAccumulator.pushBytes(value); @@ -4630,8 +4637,9 @@ export class ProxyResponseHandler { durationMs: duration, inputTokens: usageForCost?.input_tokens, outputTokens: usageForCost?.output_tokens, - tfftMs: session.tfftMs, - firstByteMs: session.firstByteMs, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, cacheCreationInputTokens: usageForCost?.cache_creation_input_tokens, cacheReadInputTokens: usageForCost?.cache_read_input_tokens, cacheCreation5mInputTokens: usageForCost?.cache_creation_5m_input_tokens, @@ -4692,6 +4700,16 @@ export class ProxyResponseHandler { }); })(); + const contentTimingObserver = (() => { + if (session.getEndpointPolicy().kind === "raw_passthrough") return null; + const family = mapProviderTypeToFamily(provider.providerType); + if (!family) return null; + return createContentTimingObserver({ + family, + onContent: () => session.recordTtft(), + }); + })(); + // F2 owner spool:guard 阶段已抢到 owner 租约的请求,把客户端可见字节 // write-behind 喂入 Redis 热层,供并发/断线的相同请求 attach 跟尾。 const replaySpool = createReplaySpoolIfOwner(session, response); @@ -4715,6 +4733,7 @@ export class ProxyResponseHandler { clearIdleTimer(); streamTextAccumulator.pushBytes(value); AsyncTaskManager.touch(taskId); + contentTimingObserver?.observe(value); shadowGateObserver?.observe(value); replayProtocolObserver?.observe(value); replaySpool?.observe(value); @@ -4728,7 +4747,9 @@ export class ProxyResponseHandler { }); if (isFirstChunk) { - session.recordTfft(); + if (!contentTimingObserver) { + session.recordTtft(); + } isFirstChunk = false; if (clearResponseTimeoutOnce()) { logger.debug("ResponseHandler: First chunk received, response timeout cleared", { @@ -6219,12 +6240,22 @@ export async function finalizeRequestStats( }); } + const cacheScoreFields = isCacheEffectivenessEnabled() + ? computeCacheScoreFields({ + affinity: session.affinity, + succeeded: statusCode >= 200 && statusCode < 300, + usageObservable: false, + streamTruncated: false, + cacheTtl: null, + }) + : undefined; const terminalDetails = { statusCode: statusCode, durationMs: duration, ...(errorMessage ? { errorMessage } : {}), - tfftMs: session.tfftMs ?? duration, - firstByteMs: session.firstByteMs ?? duration, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(statusCode), model: session.getCurrentModel() ?? undefined, @@ -6237,6 +6268,7 @@ export async function finalizeRequestStats( context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, specialSettings: session.getSpecialSettings() ?? undefined, + ...(cacheScoreFields ?? {}), }; if (onCommitted) { await updateMessageRequestDetailsDurably(messageContext.id, terminalDetails, { @@ -6332,13 +6364,23 @@ export async function finalizeRequestStats( } // 7. 更新请求详情 + const cacheScoreFields = isCacheEffectivenessEnabled() + ? computeCacheScoreFields({ + affinity: session.affinity, + succeeded: statusCode >= 200 && statusCode < 300, + usageObservable: normalizedUsage.input_tokens != null, + streamTruncated: false, + cacheTtl: normalizedUsage.cache_ttl ?? null, + }) + : undefined; const terminalDetails = { statusCode: statusCode, durationMs: duration, inputTokens: normalizedUsage.input_tokens, outputTokens: normalizedUsage.output_tokens, - tfftMs: session.tfftMs ?? duration, - firstByteMs: session.firstByteMs ?? duration, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, cacheCreationInputTokens: normalizedUsage.cache_creation_input_tokens, cacheReadInputTokens: normalizedUsage.cache_read_input_tokens, cacheCreation5mInputTokens: normalizedUsage.cache_creation_5m_input_tokens, @@ -6357,6 +6399,7 @@ export async function finalizeRequestStats( context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, specialSettings: session.getSpecialSettings() ?? undefined, + ...(cacheScoreFields ?? {}), }; if (onCommitted) { await updateMessageRequestDetailsDurably(messageContext.id, terminalDetails, { onCommitted }); @@ -6596,8 +6639,9 @@ async function persistRequestFailure(options: { errorMessage, errorStack, errorCause, - tfftMs: phase === "non-stream" ? (session.tfftMs ?? duration) : session.tfftMs, - firstByteMs: phase === "non-stream" ? (session.firstByteMs ?? duration) : session.firstByteMs, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, + timingSemanticsVersion: 2, providerChain: session.getProviderChain(), routingTrace: session.finalizeRoutingTrace(statusCode), model: session.getCurrentModel() ?? undefined, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 46093ca29..2e66728b4 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -142,13 +142,11 @@ export class ProxySession { provider: Provider | null; messageContext: MessageContext | null; - // Time To First Token (ms). Streaming: first chunk handed to the response handler, - // which under an enforcing stream gate is the first *content* frame. Non-stream: equals durationMs. - tfftMs: number | null = null; + // End-to-end time from gateway ingress to the final winner's HTTP response headers. + ttfbMs: number | null = null; - // Time To First Byte (ms). First body byte from the upstream, reported by the stream gate. - // Equals tfftMs whenever no gate ran (gate off/shadow, raw passthrough, non-SSE). - firstByteMs: number | null = null; + // End-to-end time from gateway ingress to the first protocol-valid content frame. + ttftMs: number | null = null; // Timestamp when guard pipeline finished and forwarding started (epoch ms). forwardStartTime: number | null = null; @@ -556,44 +554,27 @@ export class ProxySession { } } - /** - * Record Time To First Token (TFFT) for streaming responses. - * - * Definition: first body chunk handed to the response handler. With the stream content - * gate enforcing, that chunk is the first content frame, so this is TFFT, not TTFB. - * Non-stream responses should persist TFFT as `durationMs` at finalize time. - * - * Doubles as the TTFB fallback: paths where no gate ran never call `recordFirstByte`, - * and there TTFB and TFFT are the same moment. - */ - recordTfft(): number { - if (this.tfftMs !== null) { - return this.tfftMs; - } - - const value = Math.max(0, Date.now() - this.startTime); - this.tfftMs = value; - if (this.firstByteMs === null) { - this.firstByteMs = value; + /** Record the final winner's end-to-end HTTP response-header latency. */ + recordTtfb(elapsedMs?: number): number { + if (this.ttfbMs !== null) { + return this.ttfbMs; } + const value = Math.max(0, elapsedMs ?? Date.now() - this.startTime); + this.ttfbMs = value; this.persistLiveChain(); return value; } - /** - * Record Time To First Byte (TTFB) from an upstream first-byte timestamp. - * - * Callers must only commit the timestamp of the attempt that actually gets served — - * committing a failed attempt's first byte would understate TTFB and inflate the - * generation window that TPS divides by. - */ - recordFirstByte(atEpochMs: number): void { - if (this.firstByteMs !== null) { - return; + /** Record the first protocol-valid content latency for the final winner. */ + recordTtft(elapsedMs?: number): number { + if (this.ttftMs !== null) { + return this.ttftMs; } - this.firstByteMs = Math.max(0, atEpochMs - this.startTime); + const value = Math.max(0, elapsedMs ?? Date.now() - this.startTime); + this.ttftMs = value; this.persistLiveChain(); + return value; } /** @@ -980,7 +961,8 @@ export class ProxySession { outcome: resolvedOutcome, statusCode, durationMs: Math.max(0, now - this.routingTrace.startedAt), - ttfbMs: this.tfftMs, + ttfbMs: this.ttfbMs, + ttftMs: this.ttftMs, }; } const terminalEvent = this.routingTrace.events.find( @@ -1025,6 +1007,7 @@ export class ProxySession { winnerRound: summary.winnerRound, elapsedMs: summary.durationMs, ttfbMs: summary.ttfbMs, + ttftMs: summary.ttftMs ?? null, attemptsPerRequest: summary.attemptsPerRequest, maxActiveAttempts: summary.maxActiveAttempts, rounds: summary.rounds, diff --git a/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts b/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts index e286306a4..47a7a85bd 100644 --- a/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts +++ b/src/app/v1/_lib/proxy/stream-gate/stream-content-gate.ts @@ -328,6 +328,41 @@ export interface ShadowGateObserver { observe(chunk: Uint8Array): void; } +export interface ContentTimingObserver { + observe(chunk: Uint8Array): void; +} + +/** Lightweight protocol parser used only to timestamp the first valid content frame. */ +export function createContentTimingObserver(context: { + family: ProtocolFamily; + onContent: () => void; +}): ContentTimingObserver { + const parser = new SseFrameParser(); + let settled = false; + + return { + observe(chunk: Uint8Array): void { + if (settled) return; + try { + for (const frame of parser.push(chunk)) { + const verdict = classifyFrame(context.family, frame.eventName, frame.data); + if (verdict === "content") { + settled = true; + context.onContent(); + return; + } + if (verdict === "error" || verdict === "malformed" || verdict === "terminal") { + settled = true; + return; + } + } + } catch { + settled = true; + } + }, + }; +} + export function createShadowGateObserver(context: { family: ProtocolFamily; providerId: number; diff --git a/src/app/v1/_lib/proxy/warmup-guard.ts b/src/app/v1/_lib/proxy/warmup-guard.ts index 997313ca7..f2b03dd22 100644 --- a/src/app/v1/_lib/proxy/warmup-guard.ts +++ b/src/app/v1/_lib/proxy/warmup-guard.ts @@ -80,8 +80,9 @@ export class ProxyWarmupGuard { messagesCount: session.getMessagesLength(), statusCode: 200, durationMs, - tfftMs: durationMs, - firstByteMs: durationMs, + ttfbMs: null, + ttftMs: null, + timingSemanticsVersion: 2, // 不计费:显式写 NULL,避免前端误显示 “$0” costUsd: null, blockedBy: "warmup", diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 67310fdc4..05caeff3e 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -475,6 +475,7 @@ export const providerEndpoints = pgTable('provider_endpoints', { lastProbeLatencyMs: integer('last_probe_latency_ms'), lastProbeErrorType: varchar('last_probe_error_type', { length: 64 }), lastProbeErrorMessage: text('last_probe_error_message'), + consecutiveProbeFailures: integer('consecutive_probe_failures').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), @@ -579,11 +580,9 @@ export const messageRequest = pgTable('message_request', { // Token 使用信息 inputTokens: bigint('input_tokens', { mode: 'number' }), outputTokens: bigint('output_tokens', { mode: 'number' }), - // 首 Token 时间(TFFT)。列名 ttfb_ms 是历史遗留:流式输出门禁上线后, - // 这个时间戳打在首个内容帧上,语义已是 TFFT 而非 TTFB。真 TTFB 见 firstByteMs。 - tfftMs: integer('ttfb_ms'), - // 首字节时间(TTFB):上游响应体第一个字节到达。门禁旁路时等于 tfftMs。 - firstByteMs: integer('first_byte_ms'), + ttfbMs: integer('ttfb_ms'), + ttftMs: integer('ttft_ms'), + timingSemanticsVersion: integer('timing_semantics_version'), cacheCreationInputTokens: bigint('cache_creation_input_tokens', { mode: 'number' }), cacheReadInputTokens: bigint('cache_read_input_tokens', { mode: 'number' }), cacheCreation5mInputTokens: bigint('cache_creation_5m_input_tokens', { mode: 'number' }), @@ -1030,6 +1029,11 @@ export const systemSettings = pgTable('system_settings', { // F3b 最长前缀匹配缓存模拟开关覆写(null = 跟随环境变量 ENABLE_CACHE_EFFECTIVENESS) cacheEffectivenessEnabled: boolean('cache_effectiveness_enabled'), + // Session 调试快照后端:默认使用异步 filesystem,避免大对象挤占 Redis。 + sessionSnapshotStore: varchar('session_snapshot_store', { length: 16 }) + .notNull() + .default('filesystem'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }); @@ -1176,9 +1180,9 @@ export const usageLedger = pgTable('usage_ledger', { context1mApplied: boolean('context_1m_applied').default(false), swapCacheTtlApplied: boolean('swap_cache_ttl_applied').default(false), durationMs: integer('duration_ms'), - // 列名 ttfb_ms 存的是 TFFT,见 messageRequest.tfftMs 的说明 - tfftMs: integer('ttfb_ms'), - firstByteMs: integer('first_byte_ms'), + ttfbMs: integer('ttfb_ms'), + ttftMs: integer('ttft_ms'), + timingSemanticsVersion: integer('timing_semantics_version'), // 客户端 IP(从 message_request 拷贝;永久保留,避免被清理任务删除) clientIp: varchar('client_ip', { length: 45 }), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), @@ -1295,6 +1299,14 @@ export const replayPayloads = pgTable('replay_payloads', { replayPayloadsExpiresAtIdx: index('idx_replay_payloads_expires_at').on(table.expiresAt), })); +// 多副本后台聚合任务的持久进度。cursor 与聚合结果在同一事务内推进, +// 避免空窗口反复扫描或进程重启后丢失进度。 +export const backgroundTaskCursors = pgTable('background_task_cursor', { + taskKey: varchar('task_key', { length: 128 }).primaryKey(), + cursorAt: timestamp('cursor_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + // F3b 缓存效果窗口聚合历史:按 provider + model + TTL 桶统计理论 vs 实际缓存命中。 // 定点整数(万分比 bp),禁浮点;仅指标展示,不参与路由。 export const providerCacheEffectiveness = pgTable('provider_cache_effectiveness', { @@ -1317,6 +1329,9 @@ export const providerCacheEffectiveness = pgTable('provider_cache_effectiveness' }, (table) => ({ providerCacheEffectivenessWindowIdx: index('idx_provider_cache_effectiveness_window') .on(table.providerId, table.model, table.windowStart.desc()), + providerCacheEffectivenessWindowUnique: uniqueIndex( + 'uq_provider_cache_effectiveness_window' + ).on(table.providerId, table.model, table.cacheTtlBucket, table.windowStart, table.windowEnd), })); // Relations diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 8cebeaab8..8b1e8e901 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -21,8 +21,12 @@ const instrumentationState = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; __CCH_CACHE_EFFECTIVENESS_STARTED__?: boolean; __CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__?: ReturnType; + __CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__?: Promise; + __CCH_CACHE_EFFECTIVENESS_STOP_REQUESTED__?: boolean; __CCH_REPLAY_CLEANUP_STARTED__?: boolean; __CCH_REPLAY_CLEANUP_INTERVAL_ID__?: ReturnType; + __CCH_REPLAY_CLEANUP_CURRENT_PROMISE__?: Promise; + __CCH_REPLAY_CLEANUP_STOP_REQUESTED__?: boolean; __CCH_API_KEY_VF_SYNC_STARTED__?: boolean; __CCH_API_KEY_VF_SYNC_CLEANUP__?: (() => void) | null; __CCH_LIFECYCLE_MARKERS_LOGGED__?: boolean; @@ -268,19 +272,36 @@ async function startCacheEffectivenessScheduler(): Promise { const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); const intervalMs = 5 * 60 * 1000; - instrumentationState.__CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__ = setInterval(() => { - void (async () => { + instrumentationState.__CCH_CACHE_EFFECTIVENESS_STOP_REQUESTED__ = false; + const tick = () => { + if ( + instrumentationState.__CCH_CACHE_EFFECTIVENESS_STOP_REQUESTED__ || + instrumentationState.__CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__ + ) { + return; + } + const current = (async () => { const settings = await getProxyRuntimeSettings(); if (!settings.cacheEffectivenessEnabled) return; await aggregateCacheEffectiveness(); - })().catch((error) => { - logger.warn("[Instrumentation] Cache effectiveness aggregation tick failed", { - error: error instanceof Error ? error.message : String(error), + })() + .catch((error) => { + logger.warn("[Instrumentation] Cache effectiveness aggregation tick failed", { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + if (instrumentationState.__CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__ === current) { + instrumentationState.__CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__ = undefined; + } }); - }); - }, intervalMs); + instrumentationState.__CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__ = current; + }; + + instrumentationState.__CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__ = setInterval(tick, intervalMs); instrumentationState.__CCH_CACHE_EFFECTIVENESS_STARTED__ = true; + tick(); logger.info("[Instrumentation] Cache effectiveness scheduler started", { intervalSeconds: intervalMs / 1000, }); @@ -306,19 +327,40 @@ async function startReplayCleanupScheduler(): Promise { return; } const { getReplayStore } = await import("@/app/v1/_lib/proxy/replay/replay-store"); + const { withAdvisoryLock } = await import("@/lib/migrate"); const intervalMs = 10 * 60 * 1000; - instrumentationState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = setInterval(() => { - void getReplayStore() - .cleanupExpired() + instrumentationState.__CCH_REPLAY_CLEANUP_STOP_REQUESTED__ = false; + const tick = () => { + if ( + instrumentationState.__CCH_REPLAY_CLEANUP_STOP_REQUESTED__ || + instrumentationState.__CCH_REPLAY_CLEANUP_CURRENT_PROMISE__ + ) { + return; + } + const current = withAdvisoryLock( + "claude-code-hub:replay-cleanup", + () => getReplayStore().cleanupExpired(), + { skipIfLocked: true } + ) .catch((error) => { logger.warn("[Instrumentation] Replay cleanup tick failed", { error: error instanceof Error ? error.message : String(error), }); + }) + .then(() => undefined) + .finally(() => { + if (instrumentationState.__CCH_REPLAY_CLEANUP_CURRENT_PROMISE__ === current) { + instrumentationState.__CCH_REPLAY_CLEANUP_CURRENT_PROMISE__ = undefined; + } }); - }, intervalMs); + instrumentationState.__CCH_REPLAY_CLEANUP_CURRENT_PROMISE__ = current; + }; + + instrumentationState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = setInterval(tick, intervalMs); instrumentationState.__CCH_REPLAY_CLEANUP_STARTED__ = true; + tick(); logger.info("[Instrumentation] Replay cleanup scheduler started", { intervalSeconds: intervalMs / 1000, }); @@ -423,6 +465,16 @@ export async function register() { bindLifecycleGlobals(); } + try { + const { startSessionSnapshotStore } = await import("@/lib/session-snapshot/store"); + await startSessionSnapshotStore(); + logger.info("[Instrumentation] Session snapshot store started"); + } catch (error) { + logger.warn("[Instrumentation] Session snapshot store unavailable; snapshots disabled", { + error: error instanceof Error ? error.message : String(error), + }); + } + // 生产环境: 执行完整初始化(迁移 + 价格表 + 清理任务 + 通知任务) if (process.env.NODE_ENV === "production") { const { checkDatabaseConnection, runMigrations, withAdvisoryLock } = await import( diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 4de6b384e..5e14a462f 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -12244,6 +12244,11 @@ export interface operations { replayEnabled: boolean | null; /** @description Longest-prefix cache-effectiveness simulation override (observability only). Null follows the ENABLE_CACHE_EFFECTIVENESS environment variable. */ cacheEffectivenessEnabled: boolean | null; + /** + * @description Session detail snapshot backend. Filesystem is the default bounded asynchronous store; Redis is retained for compatibility. + * @enum {string} + */ + sessionSnapshotStore: "disabled" | "filesystem" | "redis"; /** * Format: date-time * @description Creation time. @@ -12530,6 +12535,11 @@ export interface operations { replayEnabled?: boolean | null; /** @description Longest-prefix cache-effectiveness simulation override (observability only). Null follows the ENABLE_CACHE_EFFECTIVENESS environment variable. */ cacheEffectivenessEnabled?: boolean | null; + /** + * @description Session detail snapshot backend. Filesystem is the default bounded asynchronous store; Redis is retained for compatibility. + * @enum {string} + */ + sessionSnapshotStore?: "disabled" | "filesystem" | "redis"; }; }; }; @@ -12691,6 +12701,11 @@ export interface operations { replayEnabled: boolean | null; /** @description Longest-prefix cache-effectiveness simulation override (observability only). Null follows the ENABLE_CACHE_EFFECTIVENESS environment variable. */ cacheEffectivenessEnabled: boolean | null; + /** + * @description Session detail snapshot backend. Filesystem is the default bounded asynchronous store; Redis is retained for compatibility. + * @enum {string} + */ + sessionSnapshotStore: "disabled" | "filesystem" | "redis"; /** * Format: date-time * @description Creation time. diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index c3c06dd70..0b8215206 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -234,6 +234,11 @@ export const SystemSettingsSchema = z .describe( "Longest-prefix cache-effectiveness simulation override (observability only). Null follows the ENABLE_CACHE_EFFECTIVENESS environment variable." ), + sessionSnapshotStore: z + .enum(["disabled", "filesystem", "redis"]) + .describe( + "Session detail snapshot backend. Filesystem is the default bounded asynchronous store; Redis is retained for compatibility." + ), createdAt: IsoDateTimeStringSchema.describe("Creation time."), updatedAt: IsoDateTimeStringSchema.describe("Last update time."), }) diff --git a/src/lib/cache-effectiveness/service.ts b/src/lib/cache-effectiveness/service.ts index 434336b18..1e6af1716 100644 --- a/src/lib/cache-effectiveness/service.ts +++ b/src/lib/cache-effectiveness/service.ts @@ -20,6 +20,7 @@ import { logger } from "@/lib/logger"; */ const LOCK_KEY = 20260722; +const TASK_KEY = "cache-effectiveness"; /** * 终态迟到缓冲:窗口终点留 15 分钟余量,避免统计到未完成结算的行。 * message_request.updated_at 无 $onUpdate 自动更新语义,只能按 created_at 过滤; @@ -59,15 +60,22 @@ export async function aggregateCacheEffectiveness( }; } - const windowEnd = new Date(Date.now() - WINDOW_SAFETY_LAG_MS); - const lastWindowResult = await tx.execute(sql` - SELECT MAX(window_end) AS last_end FROM provider_cache_effectiveness + const now = Date.now(); + const windowEnd = new Date(now - WINDOW_SAFETY_LAG_MS); + const initialCursor = new Date(now - INITIAL_LOOKBACK_MS); + await tx.execute(sql` + INSERT INTO background_task_cursor (task_key, cursor_at, updated_at) + VALUES (${TASK_KEY}, ${initialCursor}, NOW()) + ON CONFLICT (task_key) DO NOTHING `); - const lastEndRaw = (lastWindowResult as unknown as Array<{ last_end: string | Date | null }>)[0] - ?.last_end; - const windowStart = lastEndRaw - ? new Date(lastEndRaw) - : new Date(Date.now() - INITIAL_LOOKBACK_MS); + const cursorResult = await tx.execute(sql` + SELECT cursor_at + FROM background_task_cursor + WHERE task_key = ${TASK_KEY} + FOR UPDATE + `); + const cursorAt = (cursorResult as unknown as Array<{ cursor_at: string | Date }>)[0]?.cursor_at; + const windowStart = cursorAt ? new Date(cursorAt) : initialCursor; if (windowStart >= windowEnd) { return { @@ -139,10 +147,24 @@ export async function aggregateCacheEffectiveness( ((s.observable_bp * s.sample_factor_bp) / 10000)::int, ((s.raw_bp * ((s.observable_bp * s.sample_factor_bp) / 10000)) / 10000)::int FROM scored s + ON CONFLICT (provider_id, model, cache_ttl_bucket, window_start, window_end) + DO UPDATE SET + sample_count = EXCLUDED.sample_count, + eligible_count = EXCLUDED.eligible_count, + theoretical_cache_tokens = EXCLUDED.theoretical_cache_tokens, + observed_cache_read_tokens = EXCLUDED.observed_cache_read_tokens, + raw_effectiveness_bp = EXCLUDED.raw_effectiveness_bp, + confidence_bp = EXCLUDED.confidence_bp, + effectiveness_bp = EXCLUDED.effectiveness_bp RETURNING id `); const groupsWritten = Array.isArray(inserted) ? inserted.length : 0; + await tx.execute(sql` + UPDATE background_task_cursor + SET cursor_at = ${windowEnd}, updated_at = NOW() + WHERE task_key = ${TASK_KEY} + `); if (groupsWritten > 0) { logger.info("[CacheEffectiveness] window aggregated", { windowStart: windowStart.toISOString(), diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index f27619049..8849d95fb 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -9,4 +9,5 @@ export { getCachedSystemSettingsOnlyCache, invalidateSystemSettingsCache, isHttp2Enabled, + primeSystemSettingsCache, } from "./system-settings-cache"; diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 63b2d376e..daeacbfcd 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -61,6 +61,16 @@ export function getCachedSystemSettingsOnlyCache(): SystemSettings | null { return cachedSettings; } +/** + * Replace the in-memory cache with a freshly persisted settings row. + * Update paths use this to avoid a cache-miss window after a successful save. + */ +export function primeSystemSettingsCache(settings: SystemSettings): void { + cachedSettings = settings; + cachedAt = Date.now(); + logger.info("[SystemSettingsCache] Cache primed from persisted settings"); +} + /** Default settings used when cache fetch fails */ export const DEFAULT_SETTINGS: Pick< SystemSettings, @@ -93,6 +103,7 @@ export const DEFAULT_SETTINGS: Pick< | "stickySlaMs" | "racingTotalTimeoutMs" | "stickyTimeoutCooldownMs" + | "sessionSnapshotStore" > = { enableHttp2: false, enableOpenaiResponsesWebsocket: true, @@ -132,6 +143,7 @@ export const DEFAULT_SETTINGS: Pick< stickySlaMs: 20_000, racingTotalTimeoutMs: 60_000, stickyTimeoutCooldownMs: 300_000, + sessionSnapshotStore: "filesystem", }; /** @@ -218,6 +230,7 @@ export async function getCachedSystemSettings(): Promise { affinityIgnoreClientSessionId: DEFAULT_SETTINGS.affinityIgnoreClientSessionId, replayEnabled: null, cacheEffectivenessEnabled: null, + sessionSnapshotStore: DEFAULT_SETTINGS.sessionSnapshotStore, discoveryEnabled: DEFAULT_SETTINGS.discoveryEnabled, discoveryConcurrency: DEFAULT_SETTINGS.discoveryConcurrency, maxDiscoveryRounds: DEFAULT_SETTINGS.maxDiscoveryRounds, diff --git a/src/lib/langfuse/emit-proxy-trace.ts b/src/lib/langfuse/emit-proxy-trace.ts index 99d5fb508..4ff76a94e 100644 --- a/src/lib/langfuse/emit-proxy-trace.ts +++ b/src/lib/langfuse/emit-proxy-trace.ts @@ -77,8 +77,8 @@ function buildLangfuseSessionSnapshot(session: ProxySession): ProxySession { userAgent: session.userAgent, provider: session.provider, messageContext: session.messageContext, - tfftMs: session.tfftMs, - firstByteMs: session.firstByteMs, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, forwardStartTime: session.forwardStartTime, forwardedRequestBody, sessionId: session.sessionId, diff --git a/src/lib/langfuse/trace-proxy-request.ts b/src/lib/langfuse/trace-proxy-request.ts index c69ab3441..98e2fdf83 100644 --- a/src/lib/langfuse/trace-proxy-request.ts +++ b/src/lib/langfuse/trace-proxy-request.ts @@ -190,11 +190,15 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { guardPipelineMs, upstreamTotalMs: guardPipelineMs != null ? Math.max(0, durationMs - guardPipelineMs) : durationMs, - tfftFromForwardMs: - guardPipelineMs != null && session.tfftMs != null - ? Math.max(0, session.tfftMs - guardPipelineMs) + ttfbFromForwardMs: + guardPipelineMs != null && session.ttfbMs != null + ? Math.max(0, session.ttfbMs - guardPipelineMs) : null, - tokenGenerationMs: session.tfftMs != null ? Math.max(0, durationMs - session.tfftMs) : null, + ttftFromForwardMs: + guardPipelineMs != null && session.ttftMs != null + ? Math.max(0, session.ttftMs - guardPipelineMs) + : null, + tokenGenerationMs: session.ttftMs != null ? Math.max(0, durationMs - session.ttftMs) : null, failedAttempts: session.getProviderChain().filter((i) => !isSuccessReason(i.reason)).length, providersAttempted: new Set(session.getProviderChain().map((i) => i.id)).size, }; @@ -278,8 +282,8 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { keyName: messageContext?.key?.name, // Timing durationMs, - tfftMs: session.tfftMs, - firstByteMs: session.firstByteMs, + ttfbMs: session.ttfbMs, + ttftMs: session.ttftMs, timingBreakdown, // Flags isStreaming, @@ -433,10 +437,10 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { { asType: "generation", startTime: generationStartTime } as { asType: "generation" } ); - // Set TTFB as completionStartTime - if (session.tfftMs != null) { + // Langfuse completionStartTime is the first generated content, not HTTP headers. + if (session.ttftMs != null) { generation.update({ - completionStartTime: new Date(session.startTime + session.tfftMs), + completionStartTime: new Date(session.startTime + session.ttftMs), }); } diff --git a/src/lib/ledger-backfill/service.ts b/src/lib/ledger-backfill/service.ts index e4a720cb1..3290b6aa1 100644 --- a/src/lib/ledger-backfill/service.ts +++ b/src/lib/ledger-backfill/service.ts @@ -90,8 +90,9 @@ export async function backfillUsageLedger( mr.context_1m_applied, mr.swap_cache_ttl_applied, mr.duration_ms, - mr.ttfb_ms, - mr.first_byte_ms, + mr.ttfb_ms, + mr.ttft_ms, + mr.timing_semantics_version, mr.created_at, ul.request_id AS existing_request_id FROM message_request mr @@ -122,7 +123,7 @@ export async function backfillUsageLedger( cache_creation_input_tokens, cache_read_input_tokens, cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, first_byte_ms, created_at + duration_ms, ttfb_ms, ttft_ms, timing_semantics_version, created_at ) SELECT batch.id, @@ -152,8 +153,9 @@ export async function backfillUsageLedger( batch.context_1m_applied, batch.swap_cache_ttl_applied, batch.duration_ms, - batch.ttfb_ms, - batch.first_byte_ms, + batch.ttfb_ms, + batch.ttft_ms, + batch.timing_semantics_version, batch.created_at FROM batch ON CONFLICT (request_id) DO UPDATE SET diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 7d474aaad..77dc68c56 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -202,7 +202,7 @@ BEGIN cache_creation_input_tokens, cache_read_input_tokens, cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, first_byte_ms, client_ip, created_at + duration_ms, ttfb_ms, ttft_ms, timing_semantics_version, client_ip, created_at ) VALUES ( NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, @@ -212,7 +212,7 @@ BEGIN NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, - NEW.duration_ms, NEW.ttfb_ms, NEW.first_byte_ms, NEW.client_ip, NEW.created_at + NEW.duration_ms, NEW.ttfb_ms, NEW.ttft_ms, NEW.timing_semantics_version, NEW.client_ip, NEW.created_at ) ON CONFLICT (request_id) DO UPDATE SET user_id = EXCLUDED.user_id, @@ -243,7 +243,8 @@ BEGIN swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, duration_ms = EXCLUDED.duration_ms, ttfb_ms = EXCLUDED.ttfb_ms, - first_byte_ms = EXCLUDED.first_byte_ms, + ttft_ms = EXCLUDED.ttft_ms, + timing_semantics_version = EXCLUDED.timing_semantics_version, client_ip = EXCLUDED.client_ip; -- created_at deliberately NOT updated on conflict: it represents the -- original insert time of the ledger row, which is immutable by design. @@ -286,7 +287,8 @@ AFTER INSERT OR UPDATE OF swap_cache_ttl_applied, duration_ms, ttfb_ms, - first_byte_ms, + ttft_ms, + timing_semantics_version, client_ip, created_at ON message_request diff --git a/src/lib/lifecycle/shutdown.ts b/src/lib/lifecycle/shutdown.ts index 23443ee95..238b2cc36 100644 --- a/src/lib/lifecycle/shutdown.ts +++ b/src/lib/lifecycle/shutdown.ts @@ -100,6 +100,33 @@ export async function runApplicationCleanup( (async () => { const { stopCacheCleanup } = await import("@/lib/cache/session-cache"); stopCacheCleanup(); + const { stopSessionSnapshotStores } = await import("@/lib/session-snapshot/store"); + await stopSessionSnapshotStores(); + const schedulerState = globalThis as typeof globalThis & { + __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; + __CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__?: ReturnType; + __CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__?: Promise; + __CCH_CACHE_EFFECTIVENESS_STOP_REQUESTED__?: boolean; + __CCH_REPLAY_CLEANUP_INTERVAL_ID__?: ReturnType; + __CCH_REPLAY_CLEANUP_CURRENT_PROMISE__?: Promise; + __CCH_REPLAY_CLEANUP_STOP_REQUESTED__?: boolean; + }; + schedulerState.__CCH_CACHE_EFFECTIVENESS_STOP_REQUESTED__ = true; + schedulerState.__CCH_REPLAY_CLEANUP_STOP_REQUESTED__ = true; + for (const intervalId of [ + schedulerState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__, + schedulerState.__CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__, + schedulerState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__, + ]) { + if (intervalId) clearInterval(intervalId); + } + schedulerState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__ = undefined; + schedulerState.__CCH_CACHE_EFFECTIVENESS_INTERVAL_ID__ = undefined; + schedulerState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = undefined; + await Promise.allSettled([ + schedulerState.__CCH_CACHE_EFFECTIVENESS_CURRENT_PROMISE__, + schedulerState.__CCH_REPLAY_CLEANUP_CURRENT_PROMISE__, + ]); const stopRoutingTraceOutboxReplayScheduler = ( globalThis as typeof globalThis & { __CCH_STOP_ROUTING_TRACE_OUTBOX__?: (options?: { diff --git a/src/lib/observability/discovery-metrics.ts b/src/lib/observability/discovery-metrics.ts index 0e0706907..486d65b9f 100644 --- a/src/lib/observability/discovery-metrics.ts +++ b/src/lib/observability/discovery-metrics.ts @@ -103,6 +103,8 @@ export class DiscoveryRequestMetrics { snapshot(context: { outcome: "success" | "failed" | "client_abort" | "deadline"; statusCode: number; + ttfbMs?: number | null; + ttftMs?: number | null; winnerOrigin?: DiscoveryWinnerOrigin; winnerProviderId?: number | null; winnerRound?: number | null; @@ -113,7 +115,8 @@ export class DiscoveryRequestMetrics { outcome: context.outcome, statusCode: context.statusCode, durationMs: elapsedMs, - ttfbMs: context.outcome === "success" ? elapsedMs : null, + ttfbMs: context.ttfbMs ?? null, + ttftMs: context.ttftMs ?? null, attemptsPerRequest: this.attempts, maxActiveAttempts: this.maxActive, rounds: this.maxRound, @@ -129,7 +132,8 @@ export class DiscoveryRequestMetrics { ...this.identity, ...context, elapsedMs, - ttfbMs: context.outcome === "success" ? elapsedMs : null, + ttfbMs: context.ttfbMs ?? null, + ttftMs: context.ttftMs ?? null, attemptsPerRequest: this.attempts, maxActiveAttempts: this.maxActive, rounds: this.maxRound, diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts index 0258fae8f..7a4f3dab6 100644 --- a/src/lib/price-sync/cloud-price-updater.ts +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -204,7 +204,26 @@ export function requestCloudPriceTableSync(options: { taskId, async () => { try { - const result = await syncCloudPriceTableToDatabase(); + const { withAdvisoryLock } = await import("@/lib/migrate"); + const locked = await withAdvisoryLock( + "claude-code-hub:cloud-price-sync", + () => syncCloudPriceTableToDatabase(), + { skipIfLocked: true } + ); + if (!locked.ran) { + logger.debug("[PriceSync] Cloud price sync skipped; another instance owns the lock", { + reason: options.reason, + }); + return; + } + + const result = locked.result; + if (!result) { + logger.warn("[PriceSync] Cloud price sync lock completed without a result", { + reason: options.reason, + }); + return; + } if (!result.ok) { logger.warn("[PriceSync] Cloud price sync task failed", { reason: options.reason, diff --git a/src/lib/provider-endpoints/leader-lock.ts b/src/lib/provider-endpoints/leader-lock.ts index d9691de9c..ebef8757e 100644 --- a/src/lib/provider-endpoints/leader-lock.ts +++ b/src/lib/provider-endpoints/leader-lock.ts @@ -11,6 +11,10 @@ export interface LeaderLock { const inMemoryLocks = new Map(); +function allowMemoryFallback(): boolean { + return process.env.NODE_ENV !== "production"; +} + function generateLockId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } @@ -40,13 +44,21 @@ export async function acquireLeaderLock(key: string, ttlMs: number): Promise; - __CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__?: LeaderLock; __CCH_ENDPOINT_PROBE_LOG_CLEANUP_RUNNING__?: boolean; __CCH_ENDPOINT_PROBE_LOG_CLEANUP_CURRENT_PROMISE__?: Promise; __CCH_ENDPOINT_PROBE_LOG_CLEANUP_STOP_REQUESTED__?: boolean; @@ -42,64 +35,33 @@ async function runCleanupOnce(): Promise { cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_RUNNING__ = true; - let lock: LeaderLock | null = null; - let leadershipLost = false; - let stopKeepAlive: (() => void) | undefined; - try { - lock = await acquireLeaderLock(LOCK_KEY, LOCK_TTL_MS); - if (!lock) { - return; - } - - cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = lock; - - stopKeepAlive = startLeaderLockKeepAlive({ - getLock: () => cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__, - clearLock: () => { - cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = undefined; - }, - ttlMs: LOCK_TTL_MS, - logTag: "EndpointProbeLogCleanup", - onLost: () => { - leadershipLost = true; + const locked = await withAdvisoryLock( + LOCK_KEY, + async () => { + const now = Date.now(); + const retentionMs = Math.max(0, RETENTION_DAYS) * 24 * 60 * 60 * 1000; + const beforeDate = new Date(now - retentionMs); + + let totalDeleted = 0; + while (!cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_STOP_REQUESTED__) { + const deleted = await deleteProviderEndpointProbeLogsBeforeDateBatch({ + beforeDate, + batchSize: CLEANUP_BATCH_SIZE, + }); + if (deleted <= 0) break; + totalDeleted += deleted; + if (deleted < CLEANUP_BATCH_SIZE) break; + } + return totalDeleted; }, - }).stop; - - const now = Date.now(); - const retentionMs = Math.max(0, RETENTION_DAYS) * 24 * 60 * 60 * 1000; - const beforeDate = new Date(now - retentionMs); - - let totalDeleted = 0; - while (true) { - if (leadershipLost || cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_STOP_REQUESTED__) { - return; - } - - const deleted = await deleteProviderEndpointProbeLogsBeforeDateBatch({ - beforeDate, - batchSize: CLEANUP_BATCH_SIZE, - }); + { skipIfLocked: true } + ); - if (cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_STOP_REQUESTED__) { - return; - } - - if (deleted <= 0) { - break; - } - - totalDeleted += deleted; - - if (deleted < CLEANUP_BATCH_SIZE) { - break; - } - } - - if (totalDeleted > 0) { + if (locked.ran && (locked.result ?? 0) > 0) { logger.info("[EndpointProbeLogCleanup] Completed", { retentionDays: RETENTION_DAYS, - totalDeleted, + totalDeleted: locked.result, }); } } catch (error) { @@ -107,13 +69,7 @@ async function runCleanupOnce(): Promise { error: error instanceof Error ? error.message : String(error), }); } finally { - stopKeepAlive?.(); cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_RUNNING__ = false; - - if (lock) { - cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = undefined; - await releaseLeaderLock(lock); - } } } @@ -157,10 +113,4 @@ export async function stopEndpointProbeLogCleanup(): Promise { cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_STARTED__ = false; await cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_CURRENT_PROMISE__; - - const lock = cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__; - cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = undefined; - if (lock) { - await releaseLeaderLock(lock); - } } diff --git a/src/lib/provider-endpoints/probe-scheduler.ts b/src/lib/provider-endpoints/probe-scheduler.ts index 649dba6ae..be8eaf37c 100644 --- a/src/lib/provider-endpoints/probe-scheduler.ts +++ b/src/lib/provider-endpoints/probe-scheduler.ts @@ -94,10 +94,22 @@ const TIMEOUT_OVERRIDE_INTERVAL_MS = parsePositiveIntWithDefault( 10_000, "ENDPOINT_PROBE_TIMEOUT_RETRY_INTERVAL_MS" ); +const FAILURE_BACKOFF_MAX_MS = parsePositiveIntWithDefault( + process.env.ENDPOINT_PROBE_FAILURE_BACKOFF_MAX_MS, + 10 * 60_000, + "ENDPOINT_PROBE_FAILURE_BACKOFF_MAX_MS" +); // Scheduler tick interval - use shortest possible interval to support timeout override const TICK_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, TIMEOUT_OVERRIDE_INTERVAL_MS); // Max idle DB polling interval (bounded by base interval) -const IDLE_DB_POLL_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, 30_000); +const IDLE_DB_POLL_INTERVAL_MS = Math.min( + BASE_INTERVAL_MS, + parsePositiveIntWithDefault( + process.env.ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS, + 30_000, + "ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS" + ) +); const TIMEOUT_MS = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_TIMEOUT_MS, 5_000)); const CONCURRENCY = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_CONCURRENCY, 10)); const CYCLE_JITTER_MS = Math.max( @@ -150,21 +162,20 @@ function countEndpointsByVendorType(endpoints: ProviderEndpointProbeTarget[]): M /** * Calculate effective interval for an endpoint based on: - * 1. Timeout override (10s) - if lastProbeErrorType === "timeout" and lastProbeOk !== true + * 1. Failure backoff (10s, 20s, 40s... capped) while the endpoint remains unhealthy * 2. Single-vendor interval (10min) - if vendor has only 1 enabled endpoint * 3. Base interval (60s) - default * - * Priority: timeout override > single-vendor > base + * Priority: failure backoff > single-vendor > base */ function getEffectiveIntervalMs( endpoint: ProviderEndpointProbeTarget, vendorEndpointCounts: Map ): number { - // Timeout override takes highest priority - const hasTimeoutError = - endpoint.lastProbeErrorType === "timeout" && endpoint.lastProbeOk !== true; - if (hasTimeoutError) { - return TIMEOUT_OVERRIDE_INTERVAL_MS; + if (endpoint.lastProbeOk === false) { + const failures = Math.max(1, endpoint.consecutiveProbeFailures); + const exponent = Math.min(30, failures - 1); + return Math.min(TIMEOUT_OVERRIDE_INTERVAL_MS * 2 ** exponent, FAILURE_BACKOFF_MAX_MS); } // Single-vendor interval @@ -359,6 +370,9 @@ async function runProbeCycle(): Promise { endpoint.lastProbedAt = new Date(); endpoint.lastProbeOk = result.ok; endpoint.lastProbeErrorType = result.ok ? null : result.errorType; + endpoint.consecutiveProbeFailures = result.ok + ? 0 + : Math.max(0, endpoint.consecutiveProbeFailures) + 1; } catch (error) { logger.warn("[EndpointProbeScheduler] Probe failed", { endpointId: endpoint.id, @@ -427,6 +441,7 @@ export function startEndpointProbeScheduler(): void { baseIntervalMs: BASE_INTERVAL_MS, singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, + failureBackoffMaxMs: FAILURE_BACKOFF_MAX_MS, tickIntervalMs: TICK_INTERVAL_MS, idleDbPollIntervalMs: IDLE_DB_POLL_INTERVAL_MS, timeoutMs: TIMEOUT_MS, @@ -464,6 +479,7 @@ export function getEndpointProbeSchedulerStatus(): { baseIntervalMs: number; singleVendorIntervalMs: number; timeoutOverrideIntervalMs: number; + failureBackoffMaxMs: number; tickIntervalMs: number; idleDbPollIntervalMs: number; timeoutMs: number; @@ -478,6 +494,7 @@ export function getEndpointProbeSchedulerStatus(): { baseIntervalMs: BASE_INTERVAL_MS, singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, + failureBackoffMaxMs: FAILURE_BACKOFF_MAX_MS, tickIntervalMs: TICK_INTERVAL_MS, idleDbPollIntervalMs: IDLE_DB_POLL_INTERVAL_MS, timeoutMs: TIMEOUT_MS, diff --git a/src/lib/public-status/aggregation-core.ts b/src/lib/public-status/aggregation-core.ts index e16811863..b1941b606 100644 --- a/src/lib/public-status/aggregation-core.ts +++ b/src/lib/public-status/aggregation-core.ts @@ -14,15 +14,14 @@ export interface PublicStatusConfiguredGroup { } /** - * TPS = 输出 token / 生成窗口,生成窗口以真 TTFB 为起点。 + * TPS = 输出 token / 生成窗口,生成窗口以 TTFT 为起点。 * - * firstByteMs 缺失即返回 null:流式门禁上线前的历史行只有 TFFT,用它当分母会排除 - * 上游排队/中性帧窗口,系统性高估 TPS。 + * ttftMs 缺失即返回 null:旧 timing 语义无法可靠还原首个有效内容时刻,不能参与 TPS。 */ export function computeTokensPerSecond(input: { outputTokens?: number | null; durationMs?: number | null; - firstByteMs?: number | null; + ttftMs?: number | null; }): number | null { if (!input.outputTokens || input.outputTokens <= 0) { return null; @@ -32,11 +31,10 @@ export function computeTokensPerSecond(input: { return null; } - if (input.firstByteMs == null) { + if (input.ttftMs == null) { return null; } - - const generationMs = input.durationMs - input.firstByteMs; + const generationMs = input.durationMs - input.ttftMs; if (generationMs <= 0) { return null; } diff --git a/src/lib/public-status/aggregation.ts b/src/lib/public-status/aggregation.ts index b403e002d..073079144 100644 --- a/src/lib/public-status/aggregation.ts +++ b/src/lib/public-status/aggregation.ts @@ -39,8 +39,9 @@ export interface PublicStatusRequestRow { model?: string | null; originalModel?: string | null; durationMs?: number | null; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; outputTokens?: number | null; providerChain?: PublicStatusRequestChainItem[] | null; } @@ -178,6 +179,7 @@ export function buildPublicStatusPayloadFromRequests(input: { successCount: number; failureCount: number; ttfbValues: number[]; + ttftValues: number[]; tpsValues: number[]; }; @@ -217,6 +219,7 @@ export function buildPublicStatusPayloadFromRequests(input: { successCount: 0, failureCount: 0, ttfbValues: [], + ttftValues: [], tpsValues: [], })), }, @@ -305,7 +308,7 @@ export function buildPublicStatusPayloadFromRequests(input: { const tps = computeTokensPerSecond({ outputTokens: request.outputTokens, durationMs: request.durationMs, - firstByteMs: request.firstByteMs, + ttftMs: request.timingSemanticsVersion === 2 ? request.ttftMs : null, }); for (const [sourceGroupName, outcome] of groupOutcome.entries()) { @@ -324,9 +327,19 @@ export function buildPublicStatusPayloadFromRequests(input: { bucket.failureCount += 1; } - // ttfbValues -> bucket.ttfbMs 是对外 payload 字段,装的是 TFFT - if (outcome === "success" && typeof request.tfftMs === "number") { - bucket.ttfbValues.push(request.tfftMs); + if ( + outcome === "success" && + request.timingSemanticsVersion === 2 && + typeof request.ttfbMs === "number" + ) { + bucket.ttfbValues.push(request.ttfbMs); + } + if ( + outcome === "success" && + request.timingSemanticsVersion === 2 && + typeof request.ttftMs === "number" + ) { + bucket.ttftValues.push(request.ttftMs); } if (outcome === "success" && typeof tps === "number") { bucket.tpsValues.push(tps); @@ -352,6 +365,7 @@ export function buildPublicStatusPayloadFromRequests(input: { }); let latestTtfbMs: number | null = null; + let latestTtftMs: number | null = null; let latestTps: number | null = null; const timeline: PublicStatusTimelineBucket[] = (modelState?.buckets ?? []).map( @@ -368,11 +382,15 @@ export function buildPublicStatusPayloadFromRequests(input: { : null : Number(((bucket.successCount / total) * 100).toFixed(2)); const ttfbMs = median(bucket.ttfbValues); + const ttftMs = median(bucket.ttftValues); const computedTps = median(bucket.tpsValues); if (ttfbMs !== null) { latestTtfbMs = ttfbMs; } + if (ttftMs !== null) { + latestTtftMs = ttftMs; + } if (computedTps !== null) { latestTps = computedTps; } @@ -388,6 +406,7 @@ export function buildPublicStatusPayloadFromRequests(input: { : "no_data", availabilityPct, ttfbMs, + ttftMs, tps: computedTps, sampleCount: total, }; @@ -417,6 +436,7 @@ export function buildPublicStatusPayloadFromRequests(input: { latestState, availabilityPct, latestTtfbMs, + latestTtftMs, latestTps, timeline, } satisfies PublicStatusPayload["groups"][number]["models"][number]; @@ -457,8 +477,9 @@ export async function queryPublicStatusRequests(input: { model: messageRequest.model, originalModel: messageRequest.originalModel, durationMs: messageRequest.durationMs, - tfftMs: messageRequest.tfftMs, - firstByteMs: messageRequest.firstByteMs, + ttfbMs: messageRequest.ttfbMs, + ttftMs: messageRequest.ttftMs, + timingSemanticsVersion: messageRequest.timingSemanticsVersion, outputTokens: messageRequest.outputTokens, statusCode: messageRequest.statusCode, errorMessage: messageRequest.errorMessage, @@ -498,8 +519,9 @@ export async function queryPublicStatusRequests(input: { model: row.model, originalModel: row.originalModel, durationMs: row.durationMs, - tfftMs: row.tfftMs, - firstByteMs: row.firstByteMs, + ttfbMs: row.ttfbMs, + ttftMs: row.ttftMs, + timingSemanticsVersion: row.timingSemanticsVersion, outputTokens: row.outputTokens, providerChain: existingChain, }, diff --git a/src/lib/public-status/openapi.ts b/src/lib/public-status/openapi.ts index 9b0db65f6..8a638e1de 100644 --- a/src/lib/public-status/openapi.ts +++ b/src/lib/public-status/openapi.ts @@ -22,6 +22,7 @@ const publicStatusTimelineBucketSchema = { "state", "availabilityPct", "ttfbMs", + "ttftMs", "tps", "sampleCount", ], @@ -34,6 +35,7 @@ const publicStatusTimelineBucketSchema = { }, availabilityPct: { type: ["number", "null"] }, ttfbMs: { type: ["number", "null"] }, + ttftMs: { type: ["number", "null"] }, tps: { type: ["number", "null"] }, sampleCount: { type: "number" }, }, @@ -49,6 +51,7 @@ const publicStatusModelSchema = { "latestState", "availabilityPct", "latestTtfbMs", + "latestTtftMs", "latestTps", "timeline", ], @@ -63,6 +66,7 @@ const publicStatusModelSchema = { }, availabilityPct: { type: ["number", "null"] }, latestTtfbMs: { type: ["number", "null"] }, + latestTtftMs: { type: ["number", "null"] }, latestTps: { type: ["number", "null"] }, timeline: { type: "array", diff --git a/src/lib/public-status/payload.ts b/src/lib/public-status/payload.ts index b339432c1..dc55b8081 100644 --- a/src/lib/public-status/payload.ts +++ b/src/lib/public-status/payload.ts @@ -8,6 +8,7 @@ export interface PublicStatusTimelineBucket { state: PublicStatusTimelineState; availabilityPct: number | null; ttfbMs: number | null; + ttftMs: number | null; tps: number | null; sampleCount: number; } @@ -20,6 +21,7 @@ export interface PublicStatusModelSnapshot { latestState: PublicStatusTimelineState; availabilityPct: number | null; latestTtfbMs: number | null; + latestTtftMs: number | null; latestTps: number | null; timeline: PublicStatusTimelineBucket[]; } diff --git a/src/lib/public-status/read-store.ts b/src/lib/public-status/read-store.ts index 4dec7e75d..c614e9261 100644 --- a/src/lib/public-status/read-store.ts +++ b/src/lib/public-status/read-store.ts @@ -124,6 +124,7 @@ function sanitizeTimelineBuckets(input: unknown): PublicStatusTimelineBucket[] { state: normalizeTimelineState(value.state), availabilityPct: normalizeNullableNumber(value.availabilityPct), ttfbMs: normalizeNullableNumber(value.ttfbMs), + ttftMs: normalizeNullableNumber(value.ttftMs), tps: normalizeNullableNumber(value.tps), sampleCount: value.sampleCount, }, @@ -160,6 +161,7 @@ function sanitizeModelSnapshots(input: unknown): PublicStatusModelSnapshot[] { latestState: normalizeTimelineState(value.latestState), availabilityPct: normalizeNullableNumber(value.availabilityPct), latestTtfbMs: normalizeNullableNumber(value.latestTtfbMs), + latestTtftMs: normalizeNullableNumber(value.latestTtftMs), latestTps: normalizeNullableNumber(value.latestTps), timeline: sanitizeTimelineBuckets(value.timeline), }, diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts index 3026afc2c..d4da8427b 100644 --- a/src/lib/public-status/rollup-store.ts +++ b/src/lib/public-status/rollup-store.ts @@ -31,6 +31,8 @@ export type PublicStatusRollupMetric = | "failure" | "ttfb_sum" | "ttfb_count" + | "ttft_sum" + | "ttft_count" | "tps_sum" | "tps_count"; @@ -39,8 +41,9 @@ export interface PublicStatusRollupEvent { model?: string | null; originalModel?: string | null; durationMs?: number | null; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; outputTokens?: number | null; providerChain?: ProviderChainItem[] | null; } @@ -160,6 +163,8 @@ export function parsePublicStatusRollupField( metric !== "failure" && metric !== "ttfb_sum" && metric !== "ttfb_count" && + metric !== "ttft_sum" && + metric !== "ttft_count" && metric !== "tps_sum" && metric !== "tps_count" ) { @@ -340,12 +345,13 @@ export function buildPublicStatusRollupIncrements(input: { } } - // ttfb_sum / ttfb_count 是既有 rollup 键名,存的是 TFFT(改名会作废已积累的桶) - const tfftMs = normalizeNumber(input.event.tfftMs); + const hasCurrentTimingSemantics = input.event.timingSemanticsVersion === 2; + const ttfbMs = hasCurrentTimingSemantics ? normalizeNumber(input.event.ttfbMs) : null; + const ttftMs = hasCurrentTimingSemantics ? normalizeNumber(input.event.ttftMs) : null; const tps = computeTokensPerSecond({ outputTokens: input.event.outputTokens, durationMs: input.event.durationMs, - firstByteMs: normalizeNumber(input.event.firstByteMs), + ttftMs, }); const increments: PublicStatusRollupIncrement[] = []; @@ -369,12 +375,18 @@ export function buildPublicStatusRollupIncrements(input: { metric: outcome === "success" ? "success" : "failure", value: 1, }); - if (outcome === "success" && tfftMs !== null) { + if (outcome === "success" && ttfbMs !== null) { increments.push( - { groupId, modelKey, metric: "ttfb_sum", value: tfftMs }, + { groupId, modelKey, metric: "ttfb_sum", value: ttfbMs }, { groupId, modelKey, metric: "ttfb_count", value: 1 } ); } + if (outcome === "success" && ttftMs !== null) { + increments.push( + { groupId, modelKey, metric: "ttft_sum", value: ttftMs }, + { groupId, modelKey, metric: "ttft_count", value: 1 } + ); + } if (outcome === "success" && tps !== null) { increments.push( { groupId, modelKey, metric: "tps_sum", value: tps }, @@ -677,6 +689,18 @@ export function buildPublicStatusPayloadFromRollups(input: { modelKey: model.publicModelKey, metric: "ttfb_count", }); + acc.ttftSum += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttft_sum", + }); + acc.ttftCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttft_count", + }); acc.tpsSum += getRollupValue({ bucket, groupId, @@ -698,6 +722,8 @@ export function buildPublicStatusPayloadFromRollups(input: { failureCount: 0, ttfbSum: 0, ttfbCount: 0, + ttftSum: 0, + ttftCount: 0, tpsSum: 0, tpsCount: 0, } @@ -714,6 +740,7 @@ export function buildPublicStatusPayloadFromRollups(input: { const filledTimeline = applyBoundedGapFill({ timeline: rawTimeline }); let latestTtfbMs: number | null = null; + let latestTtftMs: number | null = null; let latestTps: number | null = null; const timeline: PublicStatusTimelineBucket[] = aggregateBuckets.map((bucket, index) => { const bucketStartMs = Date.parse(bucket.bucketStart); @@ -727,11 +754,15 @@ export function buildPublicStatusPayloadFromRollups(input: { : null : Number(((bucket.successCount / total) * 100).toFixed(2)); const ttfbMs = average(bucket.ttfbSum, bucket.ttfbCount); + const ttftMs = average(bucket.ttftSum, bucket.ttftCount); const tps = average(bucket.tpsSum, bucket.tpsCount); if (ttfbMs !== null) { latestTtfbMs = ttfbMs; } + if (ttftMs !== null) { + latestTtftMs = ttftMs; + } if (tps !== null) { latestTps = tps; } @@ -747,6 +778,7 @@ export function buildPublicStatusPayloadFromRollups(input: { : "no_data", availabilityPct, ttfbMs, + ttftMs, tps, sampleCount: total, }; @@ -789,6 +821,7 @@ export function buildPublicStatusPayloadFromRollups(input: { : "no_data", availabilityPct, latestTtfbMs, + latestTtftMs, latestTps, timeline, } satisfies PublicStatusPayload["groups"][number]["models"][number]; diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index b3f6c0280..83b948869 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -65,9 +65,9 @@ export interface LeaderboardFilters { /** * 缓存值 shape 版本:条目结构变更时递增,避免 60s TTL 内新旧 payload 混用。 - * v2: provider / providerCacheHitRate 条目新增 cacheCoefficientBp + * v3: 修正缓存系数填充与 redirected 成功率口径 */ -const CACHE_SHAPE_VERSION = "v2"; +const CACHE_SHAPE_VERSION = "v3"; /** * 构建缓存键 @@ -103,22 +103,22 @@ function buildCacheKey( const prefix = `leaderboard:${CACHE_SHAPE_VERSION}:${scope}`; if (period === "custom" && dateRange) { - // leaderboard:v2:{scope}:custom:2025-01-01_2025-01-15:USD + // leaderboard:v3:{scope}:custom:2025-01-01_2025-01-15:USD return `${prefix}:custom:${dateRange.startDate}_${dateRange.endDate}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { - // leaderboard:v2:{scope}:daily:2025-01-15:USD + // leaderboard:v3:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); return `${prefix}:daily:${dateStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { - // leaderboard:v2:{scope}:weekly:2025-W03:USD (ISO week) + // leaderboard:v3:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); return `${prefix}:weekly:${weekStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { - // leaderboard:v2:{scope}:monthly:2025-01:USD + // leaderboard:v3:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); return `${prefix}:monthly:${monthStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { - // allTime: leaderboard:v2:{scope}:allTime:USD (no date component) + // allTime: leaderboard:v3:{scope}:allTime:USD (no date component) return `${prefix}:allTime:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } diff --git a/src/lib/session-manager-detail-snapshots.test.ts b/src/lib/session-manager-detail-snapshots.test.ts index c03e150d2..94040cfa0 100644 --- a/src/lib/session-manager-detail-snapshots.test.ts +++ b/src/lib/session-manager-detail-snapshots.test.ts @@ -49,6 +49,28 @@ vi.mock("@/lib/redis", () => ({ getRedisClient: () => redisMock, })); +const snapshotStore = new Map>(); +const snapshotStoreMock = { + enqueuePatch: vi.fn( + ( + key: { sessionId: string; sequence: number; kind: string; phase: string }, + patch: Record + ) => { + const id = `${key.sessionId}:${key.sequence}:${key.kind}:${key.phase}`; + snapshotStore.set(id, { ...(snapshotStore.get(id) ?? {}), ...patch }); + return true; + } + ), + get: vi.fn( + async (key: { sessionId: string; sequence: number; kind: string; phase: string }) => + snapshotStore.get(`${key.sessionId}:${key.sequence}:${key.kind}:${key.phase}`) ?? null + ), +}; + +vi.mock("@/lib/session-snapshot/store", () => ({ + getSessionSnapshotStore: () => snapshotStoreMock, +})); + let mockStoreMessages = false; let mockStoreSessionResponseBody = true; @@ -66,6 +88,7 @@ describe("SessionManager detail snapshots", () => { beforeEach(() => { vi.clearAllMocks(); redisStore.clear(); + snapshotStore.clear(); mockStoreMessages = false; mockStoreSessionResponseBody = true; }); @@ -235,13 +258,16 @@ describe("SessionManager detail snapshots", () => { }, }); - const keys = redisMock.setex.mock.calls.map((call) => call[0]); - expect(keys).toContain("session:sess_snap:req:1:snapshot:request:before:body"); - expect(keys).toContain("session:sess_snap:req:1:snapshot:request:before:messages"); - expect(keys).toContain("session:sess_snap:req:1:snapshot:request:after:headers"); - expect(keys).toContain("session:sess_snap:req:1:snapshot:response:before:meta"); - expect(keys).toContain("session:sess_snap:req:1:snapshot:response:after:body"); - expect(redisMock.setex.mock.calls.every((call) => call[1] === 300)).toBe(true); + expect(snapshotStoreMock.enqueuePatch).toHaveBeenCalledWith( + { sessionId: "sess_snap", sequence: 1, kind: "request", phase: "before" }, + expect.objectContaining({ body: expect.any(Object), messages: expect.any(Array) }), + 300 + ); + expect(snapshotStoreMock.enqueuePatch).toHaveBeenCalledWith( + { sessionId: "sess_snap", sequence: 1, kind: "response", phase: "after" }, + expect.objectContaining({ body: expect.any(String), meta: expect.any(Object) }), + 300 + ); }); it("returns null when a specific phase snapshot is absent", async () => { @@ -293,11 +319,8 @@ describe("SessionManager detail snapshots", () => { 1 ); - expect(redisMock.setex).not.toHaveBeenCalledWith( - "session:sess_no_response_body:req:1:snapshot:response:after:body", - expect.anything(), - expect.anything() - ); + const responsePatch = snapshotStoreMock.enqueuePatch.mock.calls.at(-1)?.[1]; + expect(responsePatch).not.toHaveProperty("body"); expect( await SessionManager.getSessionResponsePhaseSnapshot("sess_no_response_body", "after", 1) ).toEqual({ diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index afa19641d..ccadffd83 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -7,6 +7,7 @@ import { RESERVED_INTERNAL_HEADERS } from "@/app/v1/_lib/responses-ws/internal-s import { parseClaudeMetadataUserId } from "@/lib/claude-code/metadata-user-id"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; +import { getSessionSnapshotStore } from "@/lib/session-snapshot/store"; import { redactMessages, redactRequestBody, @@ -135,8 +136,6 @@ type SessionResponseMeta = { statusCode: number; }; -type SessionDetailSnapshotKind = "request" | "response"; -type SessionDetailSnapshotField = "body" | "messages" | "headers" | "meta"; type SessionDetailSnapshotHeadersInput = Headers | Record | null; type SessionDetailRequestSnapshotInput = Omit, "headers"> & { headers?: SessionDetailSnapshotHeadersInput; @@ -148,16 +147,6 @@ type SessionDetailResponseSnapshotInput = Omit< headers?: SessionDetailSnapshotHeadersInput; }; -function buildSessionDetailSnapshotKey( - sessionId: string, - sequence: number, - kind: SessionDetailSnapshotKind, - phase: SessionDetailViewMode, - field: SessionDetailSnapshotField -): string { - return `session:${sessionId}:req:${sequence}:snapshot:${kind}:${phase}:${field}`; -} - function normalizeSnapshotHeaders( headers: Headers | Record | null | undefined ): Record | null { @@ -186,9 +175,9 @@ function parseJsonStringIfPossible(value: unknown): unknown { } } -function parseSessionDetailRequestMeta(value: string): SessionDetailRequestMeta | null { +function parseSessionDetailRequestMeta(value: unknown): SessionDetailRequestMeta | null { try { - const parsed: unknown = JSON.parse(value); + const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } @@ -205,9 +194,9 @@ function parseSessionDetailRequestMeta(value: string): SessionDetailRequestMeta } } -function parseSessionDetailResponseMeta(value: string): SessionDetailResponseMeta | null { +function parseSessionDetailResponseMeta(value: unknown): SessionDetailResponseMeta | null { try { - const parsed: unknown = JSON.parse(value); + const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } @@ -223,6 +212,11 @@ function parseSessionDetailResponseMeta(value: string): SessionDetailResponseMet } } +function parseStoredSnapshotHeaders(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return normalizeSnapshotHeaders(value as Record); +} + function buildTenantContentHashSessionKey(keyId: number, contentHash: string): string { return `hash:${keyId}:${contentHash}:session`; } @@ -2461,25 +2455,16 @@ export class SessionManager { snapshot: SessionDetailRequestSnapshotInput, requestSequence?: number ): Promise { - const redis = getRedisClient(); - if (redis?.status !== "ready") return; - try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; - const writes: Array> = []; + const patch: Record = {}; if ("body" in snapshot) { const normalizedBody = parseJsonStringIfPossible(snapshot.body ?? null); const bodyToStore = SessionManager.STORE_MESSAGES ? normalizedBody : redactRequestBody(normalizedBody); - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "body"), - SessionManager.SESSION_TTL, - JSON.stringify(bodyToStore) - ) - ); + patch.body = bodyToStore; } if ("messages" in snapshot) { @@ -2487,46 +2472,34 @@ export class SessionManager { const messagesToStore = SessionManager.STORE_MESSAGES ? normalizedMessages : redactMessages(normalizedMessages); - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "messages"), - SessionManager.SESSION_TTL, - JSON.stringify(messagesToStore) - ) - ); + patch.messages = messagesToStore; } if ("headers" in snapshot) { - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "headers"), - SessionManager.SESSION_TTL, - JSON.stringify(normalizeSnapshotHeaders(snapshot.headers)) - ) - ); + patch.headers = normalizeSnapshotHeaders(snapshot.headers); } if ("meta" in snapshot) { - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "meta"), - SessionManager.SESSION_TTL, - JSON.stringify({ - clientUrl: - typeof snapshot.meta?.clientUrl === "string" - ? sanitizeUrl(snapshot.meta.clientUrl) - : null, - upstreamUrl: - typeof snapshot.meta?.upstreamUrl === "string" - ? sanitizeUrl(snapshot.meta.upstreamUrl) - : null, - method: snapshot.meta?.method ?? null, - } satisfies SessionDetailRequestMeta) - ) - ); + patch.meta = { + clientUrl: + typeof snapshot.meta?.clientUrl === "string" + ? sanitizeUrl(snapshot.meta.clientUrl) + : null, + upstreamUrl: + typeof snapshot.meta?.upstreamUrl === "string" + ? sanitizeUrl(snapshot.meta.upstreamUrl) + : null, + method: snapshot.meta?.method ?? null, + } satisfies SessionDetailRequestMeta; } - await Promise.all(writes); + if (Object.keys(patch).length > 0) { + getSessionSnapshotStore().enqueuePatch( + { sessionId, sequence, kind: "request", phase }, + patch, + SessionManager.SESSION_TTL + ); + } } catch (error) { logger.error("SessionManager: Failed to store request detail snapshot", { error, @@ -2541,37 +2514,25 @@ export class SessionManager { phase: SessionDetailViewMode, requestSequence?: number ): Promise { - const redis = getRedisClient(); - if (redis?.status !== "ready") return null; - try { const sequence = normalizeRequestSequence(requestSequence); if (!sequence) return null; - - const [bodyValue, messagesValue, headersValue, metaValue] = await Promise.all([ - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "body")), - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "messages")), - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "headers")), - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "request", phase, "meta")), - ]); - - if ( - bodyValue === null && - messagesValue === null && - headersValue === null && - metaValue === null - ) { - return null; - } + const stored = await getSessionSnapshotStore().get({ + sessionId, + sequence, + kind: "request", + phase, + }); + if (!stored) return null; return { - body: bodyValue === null ? null : (JSON.parse(bodyValue) as unknown), - messages: messagesValue === null ? null : (JSON.parse(messagesValue) as unknown), - headers: headersValue === null ? null : parseHeaderRecord(headersValue), + body: "body" in stored ? stored.body : null, + messages: "messages" in stored ? stored.messages : null, + headers: parseStoredSnapshotHeaders(stored.headers), meta: - metaValue === null + stored.meta == null ? { clientUrl: null, upstreamUrl: null, method: null } - : (parseSessionDetailRequestMeta(metaValue) ?? { + : (parseSessionDetailRequestMeta(stored.meta) ?? { clientUrl: null, upstreamUrl: null, method: null, @@ -2597,12 +2558,9 @@ export class SessionManager { snapshot: SessionDetailResponseSnapshotInput, requestSequence?: number ): Promise { - const redis = getRedisClient(); - if (redis?.status !== "ready") return; - try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; - const writes: Array> = []; + const patch: Record = {}; if ("body" in snapshot) { if (!getEnvConfig().STORE_SESSION_RESPONSE_BODY) { @@ -2627,44 +2585,32 @@ export class SessionManager { } if (bodyToStore !== null) { - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "body"), - SessionManager.SESSION_TTL, - bodyToStore - ) - ); + patch.body = bodyToStore; } } } if ("headers" in snapshot) { - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "headers"), - SessionManager.SESSION_TTL, - JSON.stringify(normalizeSnapshotHeaders(snapshot.headers)) - ) - ); + patch.headers = normalizeSnapshotHeaders(snapshot.headers); } if ("meta" in snapshot) { - writes.push( - redis.setex( - buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "meta"), - SessionManager.SESSION_TTL, - JSON.stringify({ - upstreamUrl: - typeof snapshot.meta?.upstreamUrl === "string" - ? sanitizeUrl(snapshot.meta.upstreamUrl) - : null, - statusCode: snapshot.meta?.statusCode ?? null, - } satisfies SessionDetailResponseMeta) - ) - ); + patch.meta = { + upstreamUrl: + typeof snapshot.meta?.upstreamUrl === "string" + ? sanitizeUrl(snapshot.meta.upstreamUrl) + : null, + statusCode: snapshot.meta?.statusCode ?? null, + } satisfies SessionDetailResponseMeta; } - await Promise.all(writes); + if (Object.keys(patch).length > 0) { + getSessionSnapshotStore().enqueuePatch( + { sessionId, sequence, kind: "response", phase }, + patch, + SessionManager.SESSION_TTL + ); + } } catch (error) { logger.error("SessionManager: Failed to store response detail snapshot", { error, @@ -2679,30 +2625,24 @@ export class SessionManager { phase: SessionDetailViewMode, requestSequence?: number ): Promise { - const redis = getRedisClient(); - if (redis?.status !== "ready") return null; - try { const sequence = normalizeRequestSequence(requestSequence); if (!sequence) return null; - - const [bodyValue, headersValue, metaValue] = await Promise.all([ - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "body")), - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "headers")), - redis.get(buildSessionDetailSnapshotKey(sessionId, sequence, "response", phase, "meta")), - ]); - - if (bodyValue === null && headersValue === null && metaValue === null) { - return null; - } + const stored = await getSessionSnapshotStore().get({ + sessionId, + sequence, + kind: "response", + phase, + }); + if (!stored) return null; return { - body: bodyValue, - headers: headersValue === null ? null : parseHeaderRecord(headersValue), + body: typeof stored.body === "string" ? stored.body : null, + headers: parseStoredSnapshotHeaders(stored.headers), meta: - metaValue === null + stored.meta == null ? { upstreamUrl: null, statusCode: null } - : (parseSessionDetailResponseMeta(metaValue) ?? { + : (parseSessionDetailResponseMeta(stored.meta) ?? { upstreamUrl: null, statusCode: null, }), diff --git a/src/lib/session-snapshot/filesystem-store.ts b/src/lib/session-snapshot/filesystem-store.ts new file mode 100644 index 000000000..acbc81100 --- /dev/null +++ b/src/lib/session-snapshot/filesystem-store.ts @@ -0,0 +1,428 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + open, + readdir, + readFile, + rename, + rm, + stat, + unlink, + utimes, +} from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { gunzip, gzip } from "node:zlib"; +import { logger } from "@/lib/logger"; +import type { + SessionSnapshotData, + SessionSnapshotKey, + SessionSnapshotStore, + StoredSessionSnapshotEnvelope, +} from "./types"; + +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); + +const DEFAULT_MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024; +const DEFAULT_MAX_PENDING_BYTES = 64 * 1024 * 1024; +const DEFAULT_MAX_DIRECTORY_BYTES = 10 * 1024 * 1024 * 1024; +const FILE_LOCK_WAIT_MS = 2_000; +const FILE_LOCK_STALE_MS = 60_000; +const TEMP_FILE_STALE_MS = 10 * 60_000; + +interface FilesystemStoreOptions { + root: string; + maxSnapshotBytes?: number; + maxPendingBytes?: number; + maxDirectoryBytes?: number; + cleanupIntervalMs?: number; +} + +interface PendingWrite { + key: SessionSnapshotKey; + patch: SessionSnapshotData; + bytes: number; + ttlSeconds: number; + completion: () => void; +} + +interface SnapshotFileEntry { + path: string; + size: number; + expiresAt: number; +} + +export class FilesystemSessionSnapshotStore implements SessionSnapshotStore { + private readonly root: string; + private readonly maxSnapshotBytes: number; + private readonly maxPendingBytes: number; + private readonly maxDirectoryBytes: number; + private readonly cleanupIntervalMs: number; + private readonly queue: PendingWrite[] = []; + private readonly keyTails = new Map>(); + private pendingBytes = 0; + private accepting = true; + private started = false; + private draining = false; + private startPromise: Promise | null = null; + private stopPromise: Promise | null = null; + private cleanupTimer: ReturnType | null = null; + private lastDropWarningAt = 0; + + constructor(options: FilesystemStoreOptions) { + this.root = path.resolve(options.root); + this.maxSnapshotBytes = options.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES; + this.maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES; + this.maxDirectoryBytes = options.maxDirectoryBytes ?? DEFAULT_MAX_DIRECTORY_BYTES; + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60_000; + } + + enqueuePatch(key: SessionSnapshotKey, patch: SessionSnapshotData, ttlSeconds: number): boolean { + if (!this.accepting || ttlSeconds <= 0 || key.sequence <= 0) return false; + + let serialized: Buffer; + try { + serialized = Buffer.from(JSON.stringify(patch), "utf8"); + } catch (error) { + this.warnDropped("serialization_failed", error); + return false; + } + + if (serialized.byteLength > this.maxSnapshotBytes) { + this.warnDropped("snapshot_too_large", { bytes: serialized.byteLength }); + return false; + } + if (this.pendingBytes + serialized.byteLength > this.maxPendingBytes) { + this.warnDropped("pending_budget_exceeded", { + bytes: serialized.byteLength, + pendingBytes: this.pendingBytes, + }); + return false; + } + + const filePath = this.resolveFilePath(key); + let complete!: () => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const prior = this.keyTails.get(filePath) ?? Promise.resolve(); + const tail = prior.then(() => completion); + this.keyTails.set(filePath, tail); + void tail.finally(() => { + if (this.keyTails.get(filePath) === tail) this.keyTails.delete(filePath); + }); + + this.pendingBytes += serialized.byteLength; + this.queue.push({ + key, + patch, + bytes: serialized.byteLength, + ttlSeconds, + completion: complete, + }); + void this.start().then(() => this.drain()); + return true; + } + + async get(key: SessionSnapshotKey): Promise { + if (!this.accepting) return null; + await this.start(); + const filePath = this.resolveFilePath(key); + await this.keyTails.get(filePath); + const envelope = await this.readEnvelope(filePath); + if (!envelope) return null; + if (envelope.expiresAt <= Date.now()) { + await unlink(filePath).catch(() => undefined); + return null; + } + return envelope.data; + } + + async start(): Promise { + if (this.stopPromise) await this.stopPromise; + if (this.started && this.accepting) return; + if (this.startPromise) return await this.startPromise; + + this.startPromise = (async () => { + this.accepting = true; + await this.ensureRoot(); + this.started = true; + if (this.cleanupIntervalMs > 0) { + this.cleanupTimer = setInterval(() => { + void this.cleanup().catch((error) => { + logger.warn("[SessionSnapshot] Filesystem cleanup failed", { error }); + }); + }, this.cleanupIntervalMs); + this.cleanupTimer.unref?.(); + } + void this.cleanup().catch((error) => { + logger.warn("[SessionSnapshot] Initial filesystem cleanup failed", { error }); + }); + })(); + + try { + await this.startPromise; + } catch (error) { + this.accepting = false; + logger.error("[SessionSnapshot] Filesystem store failed to start", { + root: this.root, + error, + }); + throw error; + } finally { + this.startPromise = null; + } + } + + async stop(): Promise { + if (this.stopPromise) return await this.stopPromise; + this.accepting = false; + this.stopPromise = (async () => { + if (this.startPromise) { + await this.startPromise.catch(() => undefined); + } + if (this.cleanupTimer) clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + await Promise.race([this.waitForDrain(), delay(5_000)]); + this.started = false; + })(); + + try { + await this.stopPromise; + } finally { + this.stopPromise = null; + } + } + + async cleanup(): Promise { + await this.ensureRoot(); + const cleanupLock = path.join(this.root, ".cleanup.lock"); + if (!(await this.acquireCleanupLock(cleanupLock))) return; + + try { + const entries = await this.collectSnapshotFiles(path.join(this.root, "v1")); + const now = Date.now(); + const remaining: SnapshotFileEntry[] = []; + for (const entry of entries) { + if (entry.expiresAt <= now) { + await unlink(entry.path).catch(() => undefined); + } else { + remaining.push(entry); + } + } + + let totalBytes = remaining.reduce((sum, entry) => sum + entry.size, 0); + if (totalBytes > this.maxDirectoryBytes) { + remaining.sort((a, b) => a.expiresAt - b.expiresAt); + for (const entry of remaining) { + if (totalBytes <= this.maxDirectoryBytes) break; + await unlink(entry.path).catch(() => undefined); + totalBytes -= entry.size; + } + } + } finally { + await rm(cleanupLock, { recursive: true, force: true }).catch(() => undefined); + } + } + + private async drain(): Promise { + if (this.draining || !this.started) return; + this.draining = true; + try { + while (this.queue.length > 0) { + const job = this.queue.shift(); + if (!job) break; + this.pendingBytes = Math.max(0, this.pendingBytes - job.bytes); + try { + await this.writePatch(job); + } catch (error) { + logger.warn("[SessionSnapshot] Filesystem write dropped", { + error, + kind: job.key.kind, + phase: job.key.phase, + sequence: job.key.sequence, + }); + } finally { + job.completion(); + } + } + } finally { + this.draining = false; + if (this.queue.length > 0) void this.drain(); + } + } + + private async waitForDrain(): Promise { + while (this.draining || this.queue.length > 0) { + await delay(10); + } + } + + private async writePatch(job: PendingWrite): Promise { + const filePath = this.resolveFilePath(job.key); + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const release = await this.acquireFileLock(`${filePath}.lock`); + if (!release) throw new Error("snapshot file lock timeout"); + + try { + const existing = await this.readEnvelope(filePath); + const now = Date.now(); + const envelope: StoredSessionSnapshotEnvelope = { + version: 1, + expiresAt: now + job.ttlSeconds * 1000, + updatedAt: now, + data: { + ...(existing && existing.expiresAt > now ? existing.data : {}), + ...job.patch, + }, + }; + const payload = Buffer.from(JSON.stringify(envelope), "utf8"); + if (payload.byteLength > this.maxSnapshotBytes) { + throw new Error(`snapshot exceeds ${this.maxSnapshotBytes} bytes`); + } + const compressed = await gzipAsync(payload, { level: 1 }); + const tempPath = `${filePath}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`; + const handle = await open(tempPath, "wx", 0o600); + try { + await handle.writeFile(compressed); + await handle.sync(); + } finally { + await handle.close(); + } + await chmod(tempPath, 0o600); + await utimes(tempPath, now / 1000, envelope.expiresAt / 1000); + await rename(tempPath, filePath); + } finally { + await release(); + } + } + + private async readEnvelope(filePath: string): Promise { + try { + const fileStat = await lstat(filePath); + if (!fileStat.isFile() || fileStat.isSymbolicLink()) return null; + const compressed = await readFile(filePath); + const decompressed = await gunzipAsync(compressed, { + maxOutputLength: this.maxSnapshotBytes, + }); + const parsed: unknown = JSON.parse(decompressed.toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const envelope = parsed as Partial; + if ( + envelope.version !== 1 || + typeof envelope.expiresAt !== "number" || + typeof envelope.updatedAt !== "number" || + !envelope.data || + typeof envelope.data !== "object" || + Array.isArray(envelope.data) + ) { + return null; + } + return envelope as StoredSessionSnapshotEnvelope; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + logger.warn("[SessionSnapshot] Filesystem read ignored invalid snapshot", { + filePath, + error, + }); + return null; + } + } + + private resolveFilePath(key: SessionSnapshotKey): string { + const sessionHash = createHash("sha256").update(key.sessionId).digest("hex"); + const relative = path.join( + "v1", + sessionHash.slice(0, 2), + sessionHash, + String(key.sequence), + `${key.kind}-${key.phase}.json.gz` + ); + const resolved = path.resolve(this.root, relative); + if (!resolved.startsWith(`${this.root}${path.sep}`)) { + throw new Error("snapshot path escapes configured root"); + } + return resolved; + } + + private async ensureRoot(): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const rootStat = await lstat(this.root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error("session snapshot root must be a real directory"); + } + await chmod(this.root, 0o700); + } + + private async acquireFileLock(lockPath: string): Promise<(() => Promise) | null> { + const deadline = Date.now() + FILE_LOCK_WAIT_MS; + while (Date.now() < deadline) { + try { + await mkdir(lockPath, { mode: 0o700 }); + return async () => { + await rm(lockPath, { recursive: true, force: true }); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const lockStat = await stat(lockPath).catch(() => null); + if (lockStat && Date.now() - lockStat.mtimeMs > FILE_LOCK_STALE_MS) { + await rm(lockPath, { recursive: true, force: true }).catch(() => undefined); + continue; + } + await delay(10); + } + } + return null; + } + + private async acquireCleanupLock(lockPath: string): Promise { + try { + await mkdir(lockPath, { mode: 0o700 }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const lockStat = await stat(lockPath).catch(() => null); + if (lockStat && Date.now() - lockStat.mtimeMs > FILE_LOCK_STALE_MS) { + await rm(lockPath, { recursive: true, force: true }).catch(() => undefined); + return await this.acquireCleanupLock(lockPath); + } + return false; + } + } + + private async collectSnapshotFiles(directory: string): Promise { + const entries: SnapshotFileEntry[] = []; + const children = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const child of children) { + const childPath = path.join(directory, child.name); + if (child.isSymbolicLink()) continue; + if (child.isDirectory()) { + entries.push(...(await this.collectSnapshotFiles(childPath))); + continue; + } + if (!child.isFile()) continue; + const fileStat = await stat(childPath).catch(() => null); + if (!fileStat) continue; + if (child.name.includes(".tmp-")) { + if (Date.now() - fileStat.mtimeMs > TEMP_FILE_STALE_MS) { + await unlink(childPath).catch(() => undefined); + } + continue; + } + if (!child.name.endsWith(".json.gz")) continue; + entries.push({ path: childPath, size: fileStat.size, expiresAt: fileStat.mtimeMs }); + } + return entries; + } + + private warnDropped(reason: string, details: unknown): void { + const now = Date.now(); + if (now - this.lastDropWarningAt < 30_000) return; + this.lastDropWarningAt = now; + logger.warn("[SessionSnapshot] Filesystem snapshot dropped", { reason, details }); + } +} diff --git a/src/lib/session-snapshot/store.ts b/src/lib/session-snapshot/store.ts new file mode 100644 index 000000000..44b800ab7 --- /dev/null +++ b/src/lib/session-snapshot/store.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import { + getCachedSystemSettings, + getCachedSystemSettingsOnlyCache, +} from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import type { SessionSnapshotStoreSetting } from "@/types/system-config"; +import { FilesystemSessionSnapshotStore } from "./filesystem-store"; +import type { SessionSnapshotData, SessionSnapshotKey, SessionSnapshotStore } from "./types"; + +class DisabledSessionSnapshotStore implements SessionSnapshotStore { + enqueuePatch(): boolean { + return false; + } + async get(): Promise { + return null; + } + async start(): Promise {} + async stop(): Promise {} +} + +class RedisSessionSnapshotStore implements SessionSnapshotStore { + enqueuePatch(key: SessionSnapshotKey, patch: SessionSnapshotData, ttlSeconds: number): boolean { + const redis = getRedisClient(); + if (redis?.status !== "ready") return false; + const redisKey = this.buildKey(key); + const pipeline = redis.pipeline(); + for (const [field, value] of Object.entries(patch)) { + pipeline.hset(redisKey, field, JSON.stringify(value)); + } + pipeline.expire(redisKey, ttlSeconds); + void pipeline.exec().catch((error) => { + logger.warn("[SessionSnapshot] Redis snapshot write dropped", { error }); + }); + return true; + } + + async get(key: SessionSnapshotKey): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready") return null; + const values = await redis.hgetall(this.buildKey(key)); + if (Object.keys(values).length === 0) return null; + const result: SessionSnapshotData = {}; + for (const [field, value] of Object.entries(values)) { + try { + result[field] = JSON.parse(value) as unknown; + } catch { + result[field] = null; + } + } + return result; + } + + async start(): Promise {} + async stop(): Promise {} + + private buildKey(key: SessionSnapshotKey): string { + return `session:${key.sessionId}:req:${key.sequence}:snapshot:${key.kind}:${key.phase}:v2`; + } +} + +const filesystemStore = new FilesystemSessionSnapshotStore({ + root: process.env.SESSION_SNAPSHOT_ROOT ?? path.join(process.cwd(), "data", "session-snapshots"), +}); +const redisStore = new RedisSessionSnapshotStore(); +const disabledStore = new DisabledSessionSnapshotStore(); + +let activeSelection: SessionSnapshotStoreSetting = "filesystem"; +let activeStore: SessionSnapshotStore = filesystemStore; +let reconfigurePromise: Promise = Promise.resolve(); +let shuttingDown = false; + +function resolveStore(selected: SessionSnapshotStoreSetting): SessionSnapshotStore { + if (selected === "redis") return redisStore; + if (selected === "disabled") return disabledStore; + return filesystemStore; +} + +export function getSessionSnapshotStore(): SessionSnapshotStore { + if (shuttingDown) return disabledStore; + const selected = getCachedSystemSettingsOnlyCache()?.sessionSnapshotStore ?? activeSelection; + if (selected !== activeSelection) { + void reconfigureSessionSnapshotStore(selected).catch((error) => { + logger.warn("[SessionSnapshot] Runtime store reconfiguration failed", { error }); + }); + return resolveStore(selected); + } + return activeStore; +} + +export function reconfigureSessionSnapshotStore( + selected: SessionSnapshotStoreSetting +): Promise { + if (shuttingDown) return Promise.resolve(); + + const target = resolveStore(selected); + activeSelection = selected; + activeStore = target; + const previousTransition = reconfigurePromise.catch(() => undefined); + reconfigurePromise = previousTransition.then(async () => { + await target.start(); + if (activeStore !== target) return; + + const inactiveStores = [filesystemStore, redisStore, disabledStore].filter( + (store) => store !== target + ); + await Promise.all(inactiveStores.map((store) => store.stop())); + }); + return reconfigurePromise; +} + +export async function startSessionSnapshotStore(): Promise { + shuttingDown = false; + const settings = await getCachedSystemSettings(); + await reconfigureSessionSnapshotStore(settings.sessionSnapshotStore); +} + +export async function stopSessionSnapshotStores(): Promise { + shuttingDown = true; + activeSelection = "disabled"; + activeStore = disabledStore; + await reconfigurePromise.catch(() => undefined); + await Promise.all([filesystemStore.stop(), redisStore.stop(), disabledStore.stop()]); +} + +export type { SessionSnapshotData, SessionSnapshotKey, SessionSnapshotStore } from "./types"; diff --git a/src/lib/session-snapshot/types.ts b/src/lib/session-snapshot/types.ts new file mode 100644 index 000000000..60408ea0a --- /dev/null +++ b/src/lib/session-snapshot/types.ts @@ -0,0 +1,26 @@ +import type { SessionDetailViewMode } from "@/types/session"; + +export type SessionSnapshotKind = "request" | "response"; + +export interface SessionSnapshotKey { + sessionId: string; + sequence: number; + kind: SessionSnapshotKind; + phase: SessionDetailViewMode; +} + +export type SessionSnapshotData = Record; + +export interface SessionSnapshotStore { + enqueuePatch(key: SessionSnapshotKey, patch: SessionSnapshotData, ttlSeconds: number): boolean; + get(key: SessionSnapshotKey): Promise; + start(): Promise; + stop(): Promise; +} + +export interface StoredSessionSnapshotEnvelope { + version: 1; + expiresAt: number; + updatedAt: number; + data: SessionSnapshotData; +} diff --git a/src/lib/utils/performance-formatter.test.ts b/src/lib/utils/performance-formatter.test.ts index fb13f6c4e..fd5cee459 100644 --- a/src/lib/utils/performance-formatter.test.ts +++ b/src/lib/utils/performance-formatter.test.ts @@ -2,23 +2,23 @@ import { describe, expect, it } from "vitest"; import { calculateOutputRate, shouldHideOutputRate } from "./performance-formatter"; describe("calculateOutputRate", () => { - it("以真 TTFB 为生成窗口起点", () => { - // 1000ms 总耗时,TTFB 500ms => 生成窗口 0.5s,50 tokens => 100 tok/s + it("以 TTFT 为生成窗口起点", () => { + // 1000ms 总耗时,TTFT 500ms => 生成窗口 0.5s,50 tokens => 100 tok/s expect(calculateOutputRate(50, 1000, 500)).toBe(100); }); - it("firstByteMs 缺失返回 null,不再回退到总耗时", () => { - // 门禁上线前的历史行只有 TFFT。用总耗时兜底会把上游排队算进生成时间。 + it("ttftMs 缺失返回 null,不再回退到总耗时", () => { + // 旧 timing 语义无法还原 TTFT。用总耗时兜底会把上游排队算进生成时间。 expect(calculateOutputRate(50, 1000, null)).toBeNull(); }); - it("TTFB 大于 TFFT 会让 TPS 偏高,TTFB 基准才是准确值", () => { - const basedOnTfft = calculateOutputRate(50, 1000, 900); - const basedOnTtfb = calculateOutputRate(50, 1000, 200); + it("TTFT 越接近总耗时,计算出的生成速率越高", () => { + const lateTtft = calculateOutputRate(50, 1000, 900); + const earlyTtft = calculateOutputRate(50, 1000, 200); - expect(basedOnTfft).toBe(500); - expect(basedOnTtfb).toBe(62.5); - expect(basedOnTtfb!).toBeLessThan(basedOnTfft!); + expect(lateTtft).toBe(500); + expect(earlyTtft).toBe(62.5); + expect(earlyTtft!).toBeLessThan(lateTtft!); }); it("生成窗口非正、无 token、无耗时都返回 null", () => { @@ -42,7 +42,7 @@ describe("shouldHideOutputRate", () => { expect(shouldHideOutputRate(100, 1000, 950)).toBe(false); }); - it("缺少速率或 firstByteMs 时不隐藏(由 calculateOutputRate 决定是否展示)", () => { + it("缺少速率或 ttftMs 时不隐藏(由 calculateOutputRate 决定是否展示)", () => { expect(shouldHideOutputRate(null, 1000, 950)).toBe(false); expect(shouldHideOutputRate(6000, 1000, null)).toBe(false); expect(shouldHideOutputRate(Number.POSITIVE_INFINITY, 1000, 950)).toBe(false); diff --git a/src/lib/utils/performance-formatter.ts b/src/lib/utils/performance-formatter.ts index 28c29a830..f69d5b365 100644 --- a/src/lib/utils/performance-formatter.ts +++ b/src/lib/utils/performance-formatter.ts @@ -45,19 +45,24 @@ export function formatDuration(durationMs: number | null): string { /** * 计算输出速率(tokens/second) * - * 生成窗口以真 TTFB 为起点。firstByteMs 缺失(流式门禁上线前的历史行)返回 null, - * 不再退回总耗时——那会把上游排队和中性帧窗口算进生成时间,高估速率。 + * 生成窗口以 TTFT 为起点。ttftMs 缺失时返回 null,不再退回总耗时,避免把上游 + * 排队和首个有效内容之前的等待时间算进生成时间。 */ export function calculateOutputRate( outputTokens: number | null, durationMs: number | null, - firstByteMs: number | null + ttftMs: number | null ): number | null { - if (outputTokens == null || outputTokens <= 0 || durationMs == null || durationMs <= 0) { + if ( + outputTokens == null || + outputTokens <= 0 || + durationMs == null || + durationMs <= 0 || + ttftMs == null + ) { return null; } - if (firstByteMs == null) return null; - const generationTimeMs = durationMs - firstByteMs; + const generationTimeMs = durationMs - ttftMs; if (generationTimeMs <= 0) return null; return outputTokens / (generationTimeMs / 1000); } @@ -65,23 +70,23 @@ export function calculateOutputRate( /** * Determine if output rate should be hidden due to blocked streaming request. * Rule: Hide when generationTimeMs / durationMs < 0.1 AND outputRate > 5000 - * This indicates TTFB is very close to total duration with abnormally high tok/s. + * This indicates TTFT is very close to total duration with abnormally high tok/s. */ export function shouldHideOutputRate( outputRate: number | null, durationMs: number | null, - firstByteMs: number | null + ttftMs: number | null ): boolean { if ( outputRate == null || !Number.isFinite(outputRate) || durationMs == null || durationMs <= 0 || - firstByteMs == null + ttftMs == null ) { return false; } - const generationTimeMs = durationMs - firstByteMs; + const generationTimeMs = durationMs - ttftMs; if (generationTimeMs <= 0) return false; const ratio = generationTimeMs / durationMs; return ratio < 0.1 && outputRate > 5000; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index c5bba2c71..72d1f64e7 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -1122,6 +1122,9 @@ export const UpdateSystemSettingsSchema = z replayEnabled: z.boolean().nullable().optional(), // F3b 最长前缀匹配缓存模拟(可选;null = 跟随环境变量) cacheEffectivenessEnabled: z.boolean().nullable().optional(), + sessionSnapshotStore: z + .enum(["disabled", "filesystem", "redis"], { message: "不支持的 Session 快照后端" }) + .optional(), // Codex Session ID 补全(可选) enableCodexSessionIdCompletion: z.boolean().optional(), // Claude metadata.user_id 注入(可选) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index e9fcbca37..53744c614 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -319,6 +319,12 @@ export function toSystemSettings(dbSettings: any): SystemSettings { affinityIgnoreClientSessionId: dbSettings?.affinityIgnoreClientSessionId ?? true, replayEnabled: dbSettings?.replayEnabled ?? null, cacheEffectivenessEnabled: dbSettings?.cacheEffectivenessEnabled ?? null, + sessionSnapshotStore: + dbSettings?.sessionSnapshotStore === "disabled" || + dbSettings?.sessionSnapshotStore === "redis" || + dbSettings?.sessionSnapshotStore === "filesystem" + ? dbSettings.sessionSnapshotStore + : "filesystem", createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(), updatedAt: dbSettings?.updatedAt ? new Date(dbSettings.updatedAt) : new Date(), }; diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 7f404b38d..3559b53dc 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -13,6 +13,7 @@ import { } from "./_shared/ledger-conditions"; import { getProviderCacheCoefficients, + getProviderModelCacheCoefficients, resolveLeaderboardWindow, } from "./provider-cache-effectiveness"; import { getSystemSettings } from "./system-config"; @@ -68,7 +69,8 @@ export interface ProviderLeaderboardEntry { totalCost: number; totalTokens: number; successRate: number | null; // 0-1 之间的小数,UI 层负责格式化为百分比 - avgTtfbMs: number; // 毫秒 + avgTtfbMs: number | null; // 毫秒;旧口径窗口无可用样本时为 null + avgTtftMs: number | null; // 毫秒 avgTokensPerSecond: number; // tok/s(仅统计流式且可计算的请求) avgCostPerRequest: number | null; // totalCost / totalRequests, null when totalRequests === 0 avgCostPerMillionTokens: number | null; // totalCost * 1_000_000 / totalTokens, null when totalTokens === 0 @@ -91,15 +93,17 @@ export interface ModelProviderStat { totalCost: number; totalTokens: number; successRate: number | null; // 0-1 - avgTtfbMs: number; // 毫秒 + avgTtfbMs: number | null; // 毫秒 + avgTtftMs: number | null; // 毫秒 avgTokensPerSecond: number; // tok/s avgCostPerRequest: number | null; avgCostPerMillionTokens: number | null; + /** 重定向模型口径的缓存系数;original 口径无法可靠映射时为 null */ + cacheCoefficientBp: number | null; rowIdentityBasis?: BillingModelSource; - successRateBasis?: "original" | "unavailable"; + successRateBasis?: BillingModelSource; costTokensBasis?: BillingModelSource; basisDisclosureRequired?: boolean; - successRateUnavailableReason?: "redirected_billing_model"; } /** @@ -111,6 +115,8 @@ export interface ModelCacheHitStat { cacheReadTokens: number; totalInputTokens: number; cacheHitRate: number; // 0-1 + /** 重定向模型口径的缓存系数;original 口径无法可靠映射时为 null */ + cacheCoefficientBp: number | null; } /** @@ -170,10 +176,9 @@ export interface ModelLeaderboardEntry { totalTokens: number; successRate: number | null; // 0-1 之间的小数,UI 层负责格式化为百分比 rowIdentityBasis?: BillingModelSource; - successRateBasis?: "original" | "unavailable"; + successRateBasis?: BillingModelSource; costTokensBasis?: BillingModelSource; basisDisclosureRequired?: boolean; - successRateUnavailableReason?: "redirected_billing_model"; } /** @@ -663,19 +668,27 @@ async function findProviderLeaderboardWithTimezone( 0::double precision )`; const successRateExpr = LEDGER_SUCCESS_RATE_EXPR; - // 展示用的均值走 ttfb_ms 列,该列存的是 TFFT(见 schema.ts) - const avgTtfbMsExpr = sql`COALESCE(avg(${usageLedger.tfftMs})::double precision, 0::double precision)`; - // TPS 必须以真 TTFB 为基准;first_byte_ms 为 NULL 的历史行由 IS NOT NULL 排除 + const avgTtfbMsExpr = sql`avg( + CASE + WHEN ${usageLedger.timingSemanticsVersion} = 2 THEN ${usageLedger.ttfbMs} + END + )::double precision`; + const avgTtftMsExpr = sql`avg( + CASE + WHEN ${usageLedger.timingSemanticsVersion} = 2 THEN ${usageLedger.ttftMs} + END + )::double precision`; const avgTokensPerSecondExpr = sql`COALESCE( avg( CASE WHEN ${usageLedger.outputTokens} > 0 AND ${usageLedger.durationMs} IS NOT NULL - AND ${usageLedger.firstByteMs} IS NOT NULL - AND ${usageLedger.firstByteMs} < ${usageLedger.durationMs} - AND (${usageLedger.durationMs} - ${usageLedger.firstByteMs}) >= 100 + AND ${usageLedger.timingSemanticsVersion} = 2 + AND ${usageLedger.ttftMs} IS NOT NULL + AND ${usageLedger.ttftMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.ttftMs}) >= 100 THEN (${usageLedger.outputTokens}::double precision) - / ((${usageLedger.durationMs} - ${usageLedger.firstByteMs}) / 1000.0) + / ((${usageLedger.durationMs} - ${usageLedger.ttftMs}) / 1000.0) END )::double precision, 0::double precision @@ -695,6 +708,7 @@ async function findProviderLeaderboardWithTimezone( totalTokens: totalTokensExpr, successRate: successRateExpr, avgTtfbMs: avgTtfbMsExpr, + avgTtftMs: avgTtftMsExpr, avgTokensPerSecond: avgTokensPerSecondExpr, }) .from(usageLedger) @@ -725,7 +739,8 @@ async function findProviderLeaderboardWithTimezone( totalCost, totalTokens, successRate: clampRatio01Nullable(entry.successRate), - avgTtfbMs: entry.avgTtfbMs ?? 0, + avgTtfbMs: entry.avgTtfbMs ?? null, + avgTtftMs: entry.avgTtftMs ?? null, avgTokensPerSecond: entry.avgTokensPerSecond ?? 0, cacheCoefficientBp: cacheCoefficients.get(entry.providerId)?.coefficientBp ?? null, ...avgCosts, @@ -737,6 +752,10 @@ async function findProviderLeaderboardWithTimezone( // Model breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; + const modelCacheCoefficientsPromise = + billingModelSource === "redirected" + ? getProviderModelCacheCoefficients(resolveLeaderboardWindow(period, timezone, dateRange)) + : Promise.resolve(new Map()); const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` @@ -752,6 +771,7 @@ async function findProviderLeaderboardWithTimezone( totalTokens: totalTokensExpr, successRate: successRateExpr, avgTtfbMs: avgTtfbMsExpr, + avgTtftMs: avgTtftMsExpr, avgTokensPerSecond: avgTokensPerSecondExpr, }) .from(usageLedger) @@ -764,6 +784,7 @@ async function findProviderLeaderboardWithTimezone( ) .groupBy(usageLedger.finalProviderId, modelField) .orderBy(desc(sql`COALESCE(sum(${usageLedger.costUsd}), 0)`), desc(sql`count(*)`)); + const modelCacheCoefficients = await modelCacheCoefficientsPromise; const modelStatsByProvider = new Map(); for (const row of modelRows) { @@ -779,16 +800,16 @@ async function findProviderLeaderboardWithTimezone( totalRequests, totalCost, totalTokens, - successRate: basisDisclosureRequired ? null : clampRatio01Nullable(row.successRate), - avgTtfbMs: row.avgTtfbMs ?? 0, + successRate: clampRatio01Nullable(row.successRate), + avgTtfbMs: row.avgTtfbMs ?? null, + avgTtftMs: row.avgTtftMs ?? null, avgTokensPerSecond: row.avgTokensPerSecond ?? 0, + cacheCoefficientBp: + modelCacheCoefficients.get(row.providerId)?.get(row.model)?.coefficientBp ?? null, rowIdentityBasis: billingModelSource, - successRateBasis: basisDisclosureRequired ? "unavailable" : "original", + successRateBasis: billingModelSource, costTokensBasis: billingModelSource, basisDisclosureRequired, - successRateUnavailableReason: basisDisclosureRequired - ? "redirected_billing_model" - : undefined, ...avgCosts, }); modelStatsByProvider.set(row.providerId, stats); @@ -870,6 +891,10 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Model-level cache hit breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; + const modelCacheCoefficientsPromise = + billingModelSource === "redirected" + ? getProviderModelCacheCoefficients(resolveLeaderboardWindow(period, timezone, dateRange)) + : Promise.resolve(new Map()); const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` @@ -902,6 +927,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( ) .groupBy(usageLedger.finalProviderId, modelField) .orderBy(desc(modelCacheHitRate), desc(sql`count(*)`)); + const modelCacheCoefficients = await modelCacheCoefficientsPromise; // Group model stats by providerId const modelStatsByProvider = new Map(); @@ -914,6 +940,8 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( cacheReadTokens: row.cacheReadTokens, totalInputTokens: row.totalInputTokens, cacheHitRate: clampRatio01(row.cacheHitRate), + cacheCoefficientBp: + modelCacheCoefficients.get(row.providerId)?.get(row.model)?.coefficientBp ?? null, }); modelStatsByProvider.set(row.providerId, stats); } @@ -1195,14 +1223,11 @@ async function findModelLeaderboardWithTimezone( totalRequests: entry.totalRequests, totalCost: parseFloat(entry.totalCost), totalTokens: entry.totalTokens, - successRate: - billingModelSource === "original" ? clampRatio01Nullable(entry.successRate) : null, + successRate: clampRatio01Nullable(entry.successRate), rowIdentityBasis: billingModelSource, - successRateBasis: billingModelSource === "original" ? "original" : "unavailable", + successRateBasis: billingModelSource, costTokensBasis: billingModelSource, basisDisclosureRequired: billingModelSource !== "original", - successRateUnavailableReason: - billingModelSource !== "original" ? "redirected_billing_model" : undefined, })); } diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index 2f517a31b..dbc5a207f 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -17,8 +17,9 @@ export type MessageRequestUpdatePatch = { statusCode?: number; inputTokens?: number; outputTokens?: number; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; cacheCreation5mInputTokens?: number; @@ -268,9 +269,9 @@ const COLUMN_MAP: Record = { statusCode: "status_code", inputTokens: "input_tokens", outputTokens: "output_tokens", - // ttfb_ms 是 TFFT 的历史列名,见 schema.ts 的说明 - tfftMs: "ttfb_ms", - firstByteMs: "first_byte_ms", + ttfbMs: "ttfb_ms", + ttftMs: "ttft_ms", + timingSemanticsVersion: "timing_semantics_version", cacheCreationInputTokens: "cache_creation_input_tokens", cacheReadInputTokens: "cache_read_input_tokens", cacheCreation5mInputTokens: "cache_creation_5m_input_tokens", diff --git a/src/repository/message.ts b/src/repository/message.ts index fa0627b2a..1ac3a0bae 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -44,8 +44,9 @@ type PublicStatusFinalDetails = { durationMs?: number; statusCode?: number; outputTokens?: number; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; providerChain?: CreateMessageRequestData["provider_chain"]; errorMessage?: string; model?: string; @@ -191,8 +192,9 @@ function queuePublicStatusRollupForFinalDetails( model: details.model ?? seed.model, originalModel: seed.originalModel, durationMs: seed.durationMs, - tfftMs: details.tfftMs, - firstByteMs: details.firstByteMs, + ttfbMs: details.ttfbMs, + ttftMs: details.ttftMs, + timingSemanticsVersion: details.timingSemanticsVersion, outputTokens: details.outputTokens, providerChain: details.providerChain, }, @@ -495,8 +497,9 @@ export type MessageRequestDetailsUpdate = { statusCode?: number; inputTokens?: number; outputTokens?: number; - tfftMs?: number | null; - firstByteMs?: number | null; + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; cacheCreation5mInputTokens?: number; @@ -556,11 +559,14 @@ export async function updateMessageRequestDetails( if (details.outputTokens !== undefined) { updateData.outputTokens = details.outputTokens; } - if (details.tfftMs !== undefined) { - updateData.tfftMs = details.tfftMs; + if (details.ttfbMs !== undefined) { + updateData.ttfbMs = details.ttfbMs; } - if (details.firstByteMs !== undefined) { - updateData.firstByteMs = details.firstByteMs; + if (details.ttftMs !== undefined) { + updateData.ttftMs = details.ttftMs; + } + if (details.timingSemanticsVersion !== undefined) { + updateData.timingSemanticsVersion = details.timingSemanticsVersion; } if (details.cacheCreationInputTokens !== undefined) { updateData.cacheCreationInputTokens = details.cacheCreationInputTokens; @@ -841,8 +847,9 @@ export async function findMessageRequestById(id: number): Promise +>; + // tsconfig target ES2017 禁 BigInt 字面量,统一用 BigInt() 构造 const BIG_ZERO = BigInt(0); const BP_SCALE = BigInt(10000); @@ -164,3 +173,56 @@ export async function getProviderCacheCoefficients({ } return coefficients; } + +/** + * 聚合排行榜周期内的 provider + model 缓存系数。 + * + * message_request.model 保存重定向后的实际请求模型,因此本结果只用于 + * billingModelSource=redirected 的模型子行;original 口径无法可靠映射时保持无数据。 + */ +export async function getProviderModelCacheCoefficients({ + start, + end, +}: { + start: Date; + end: Date; +}): Promise { + const normalizedModel = sql`TRIM(${providerCacheEffectiveness.model})`; + const rows = await db + .select({ + providerId: providerCacheEffectiveness.providerId, + model: normalizedModel, + sampleCount: sql`COALESCE(sum(${providerCacheEffectiveness.sampleCount}), 0)::bigint`, + eligibleCount: sql`COALESCE(sum(${providerCacheEffectiveness.eligibleCount}), 0)::bigint`, + theoreticalCacheTokens: sql`COALESCE(sum(${providerCacheEffectiveness.theoreticalCacheTokens}), 0)::bigint`, + observedCacheReadTokens: sql`COALESCE(sum(${providerCacheEffectiveness.observedCacheReadTokens}), 0)::bigint`, + }) + .from(providerCacheEffectiveness) + .where( + and( + gt(providerCacheEffectiveness.windowEnd, start), + lte(providerCacheEffectiveness.windowEnd, end), + sql`${normalizedModel} <> ''` + ) + ) + .groupBy(providerCacheEffectiveness.providerId, normalizedModel); + + const coefficients: ProviderModelCacheCoefficientMap = new Map(); + for (const row of rows) { + const sample = BigInt(row.sampleCount); + const providerModels = coefficients.get(row.providerId) ?? new Map(); + providerModels.set(row.model, { + providerId: row.providerId, + model: row.model, + coefficientBp: computeCoefficientBp( + sample, + BigInt(row.eligibleCount), + BigInt(row.theoreticalCacheTokens), + BigInt(row.observedCacheReadTokens) + ), + sampleCount: Number(sample), + }); + coefficients.set(row.providerId, providerModels); + } + return coefficients; +} diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index 25edacfde..d37b48ab9 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -209,6 +209,7 @@ function toProviderEndpoint(row: any): ProviderEndpoint { lastProbeLatencyMs: row.lastProbeLatencyMs ?? null, lastProbeErrorType: row.lastProbeErrorType ?? null, lastProbeErrorMessage: row.lastProbeErrorMessage ?? null, + consecutiveProbeFailures: row.consecutiveProbeFailures ?? 0, createdAt: toDate(row.createdAt), updatedAt: toDate(row.updatedAt), deletedAt: toNullableDate(row.deletedAt), @@ -229,6 +230,7 @@ const providerEndpointSelectFields = { lastProbeLatencyMs: providerEndpoints.lastProbeLatencyMs, lastProbeErrorType: providerEndpoints.lastProbeErrorType, lastProbeErrorMessage: providerEndpoints.lastProbeErrorMessage, + consecutiveProbeFailures: providerEndpoints.consecutiveProbeFailures, createdAt: providerEndpoints.createdAt, updatedAt: providerEndpoints.updatedAt, deletedAt: providerEndpoints.deletedAt, @@ -320,7 +322,14 @@ function toProviderEndpointProbeLog(row: any): ProviderEndpointProbeLog { export type ProviderEndpointProbeTarget = Pick< ProviderEndpoint, - "id" | "url" | "vendorId" | "providerType" | "lastProbedAt" | "lastProbeOk" | "lastProbeErrorType" + | "id" + | "url" + | "vendorId" + | "providerType" + | "lastProbedAt" + | "lastProbeOk" + | "lastProbeErrorType" + | "consecutiveProbeFailures" >; export async function findEnabledProviderEndpointsForProbing(): Promise< @@ -345,7 +354,8 @@ export async function findEnabledProviderEndpointsForProbing(): Promise< e.provider_type AS "providerType", e.last_probed_at AS "lastProbedAt", e.last_probe_ok AS "lastProbeOk", - e.last_probe_error_type AS "lastProbeErrorType" + e.last_probe_error_type AS "lastProbeErrorType", + e.consecutive_probe_failures AS "consecutiveProbeFailures" FROM ${providerEndpoints} e INNER JOIN enabled_vendor_types vt ON vt.vendor_id = e.vendor_id @@ -367,6 +377,7 @@ export async function findEnabledProviderEndpointsForProbing(): Promise< lastProbedAt: toNullableDate(row.lastProbedAt), lastProbeOk: (row.lastProbeOk as boolean | null) ?? null, lastProbeErrorType: (row.lastProbeErrorType as string | null) ?? null, + consecutiveProbeFailures: Number(row.consecutiveProbeFailures ?? 0), })); } @@ -2179,6 +2190,9 @@ export async function recordProviderEndpointProbeResult(input: { lastProbeLatencyMs: input.latencyMs ?? null, lastProbeErrorType: input.ok ? null : (input.errorType ?? null), lastProbeErrorMessage: input.ok ? null : (input.errorMessage ?? null), + consecutiveProbeFailures: input.ok + ? 0 + : sql`${providerEndpoints.consecutiveProbeFailures} + 1`, updatedAt: new Date(), }) .where(and(eq(providerEndpoints.id, input.endpointId), isNull(providerEndpoints.deletedAt))) diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index ae2a0eb1d..c6a003628 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -204,6 +204,7 @@ function createFallbackSettings(): SystemSettings { affinityIgnoreClientSessionId: true, replayEnabled: null, cacheEffectivenessEnabled: null, + sessionSnapshotStore: "filesystem", createdAt: now, updatedAt: now, }; @@ -282,6 +283,12 @@ const RECENT_COLUMN_LADDER: ReadonlyArray<{ // 本层更新失败(仍有列缺失)时记录的告警 updateWarn: string; }> = [ + { + key: "sessionSnapshotStore", + column: systemSettings.sessionSnapshotStore, + selectWarn: "system_settings 表除 sessionSnapshotStore 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 sessionSnapshotStore 外仍有列缺失,继续降级更新。", + }, { key: "cacheEffectivenessEnabled", column: systemSettings.cacheEffectivenessEnabled, @@ -901,6 +908,10 @@ export async function updateSystemSettings( updates.cacheEffectivenessEnabled = payload.cacheEffectivenessEnabled; } + if (payload.sessionSnapshotStore !== undefined) { + updates.sessionSnapshotStore = payload.sessionSnapshotStore; + } + let updated; try { [updated] = await executor diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 9a20754b8..39e1346a7 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -73,8 +73,9 @@ export interface UsageLogRow { costBreakdown: StoredCostBreakdown | null; // 费用明细 hedgeLosers: HedgeLoserBilling[] | null; // 竞速输家计费明细(费用已计入 costUsd 总额) durationMs: number | null; - tfftMs: number | null; - firstByteMs: number | null; + ttfbMs: number | null; + ttftMs: number | null; + timingSemanticsVersion: number | null; errorMessage: string | null; providerChain: ProviderChainItem[] | null; routingTrace?: RoutingTraceV1 | null; @@ -216,8 +217,9 @@ export async function findUsageLogsBatch( costBreakdown: messageRequest.costBreakdown, hedgeLosers: messageRequest.hedgeLosers, durationMs: messageRequest.durationMs, - tfftMs: messageRequest.tfftMs, - firstByteMs: messageRequest.firstByteMs, + ttfbMs: messageRequest.ttfbMs, + ttftMs: messageRequest.ttftMs, + timingSemanticsVersion: messageRequest.timingSemanticsVersion, errorMessage: messageRequest.errorMessage, providerChain: messageRequest.providerChain, routingTrace: messageRequest.routingTrace, @@ -398,8 +400,9 @@ export async function findUsageLogsBatch( costMultiplier: usageLedger.costMultiplier, groupCostMultiplier: usageLedger.groupCostMultiplier, durationMs: usageLedger.durationMs, - tfftMs: usageLedger.tfftMs, - firstByteMs: usageLedger.firstByteMs, + ttfbMs: usageLedger.ttfbMs, + ttftMs: usageLedger.ttftMs, + timingSemanticsVersion: usageLedger.timingSemanticsVersion, clientIp: usageLedger.clientIp, context1mApplied: usageLedger.context1mApplied, swapCacheTtlApplied: usageLedger.swapCacheTtlApplied, @@ -455,8 +458,9 @@ export async function findUsageLogsBatch( costBreakdown: null, hedgeLosers: null, durationMs: row.durationMs, - tfftMs: row.tfftMs, - firstByteMs: row.firstByteMs, + ttfbMs: row.ttfbMs, + ttftMs: row.ttftMs, + timingSemanticsVersion: row.timingSemanticsVersion, errorMessage: null, providerChain: null, routingTrace: null, @@ -1000,8 +1004,9 @@ function mapUsageLogRowFromMessageResult(row: { costBreakdown: StoredCostBreakdown | null; hedgeLosers: HedgeLoserBilling[] | null; durationMs: number | null; - tfftMs: number | null; - firstByteMs: number | null; + ttfbMs: number | null; + ttftMs: number | null; + timingSemanticsVersion: number | null; errorMessage: string | null; providerChain: ProviderChainItem[] | null; routingTrace: RoutingTraceV1 | null; @@ -1071,8 +1076,9 @@ function mapUsageLogRowFromLedgerResult(row: { costMultiplier: string | null | { toString(): string }; groupCostMultiplier: string | null | { toString(): string }; durationMs: number | null; - tfftMs: number | null; - firstByteMs: number | null; + ttfbMs: number | null; + ttftMs: number | null; + timingSemanticsVersion: number | null; clientIp: string | null; context1mApplied: boolean | null; swapCacheTtlApplied: boolean | null; @@ -1109,8 +1115,9 @@ function mapUsageLogRowFromLedgerResult(row: { groupCostMultiplier: row.groupCostMultiplier?.toString() ?? null, costBreakdown: null, durationMs: row.durationMs, - tfftMs: row.tfftMs, - firstByteMs: row.firstByteMs, + ttfbMs: row.ttfbMs, + ttftMs: row.ttftMs, + timingSemanticsVersion: row.timingSemanticsVersion, errorMessage: null, providerChain: null, routingTrace: null, @@ -1167,8 +1174,9 @@ export async function findReadonlyUsageLogsBatchForKey( costBreakdown: messageRequest.costBreakdown, hedgeLosers: messageRequest.hedgeLosers, durationMs: messageRequest.durationMs, - tfftMs: messageRequest.tfftMs, - firstByteMs: messageRequest.firstByteMs, + ttfbMs: messageRequest.ttfbMs, + ttftMs: messageRequest.ttftMs, + timingSemanticsVersion: messageRequest.timingSemanticsVersion, errorMessage: messageRequest.errorMessage, providerChain: messageRequest.providerChain, routingTrace: messageRequest.routingTrace, @@ -1216,8 +1224,9 @@ export async function findReadonlyUsageLogsBatchForKey( costMultiplier: usageLedger.costMultiplier, groupCostMultiplier: usageLedger.groupCostMultiplier, durationMs: usageLedger.durationMs, - tfftMs: usageLedger.tfftMs, - firstByteMs: usageLedger.firstByteMs, + ttfbMs: usageLedger.ttfbMs, + ttftMs: usageLedger.ttftMs, + timingSemanticsVersion: usageLedger.timingSemanticsVersion, clientIp: usageLedger.clientIp, context1mApplied: usageLedger.context1mApplied, swapCacheTtlApplied: usageLedger.swapCacheTtlApplied, @@ -1421,8 +1430,9 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis costBreakdown: messageRequest.costBreakdown, // 费用明细 hedgeLosers: messageRequest.hedgeLosers, // 竞速输家计费明细 durationMs: messageRequest.durationMs, - tfftMs: messageRequest.tfftMs, - firstByteMs: messageRequest.firstByteMs, + ttfbMs: messageRequest.ttfbMs, + ttftMs: messageRequest.ttftMs, + timingSemanticsVersion: messageRequest.timingSemanticsVersion, errorMessage: messageRequest.errorMessage, providerChain: messageRequest.providerChain, routingTrace: messageRequest.routingTrace, diff --git a/src/types/message.ts b/src/types/message.ts index 858d919b1..ae950aa01 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -277,8 +277,9 @@ export interface MessageRequest { key: string; model?: string; durationMs?: number; - tfftMs?: number | null; // 首 Token 时间(DB 列名为历史遗留的 ttfb_ms) - firstByteMs?: number | null; // 首字节时间(真 TTFB) + ttfbMs?: number | null; + ttftMs?: number | null; + timingSemanticsVersion?: number | null; costUsd?: string; // 单次请求费用(美元),保持高精度字符串表示 // 供应商倍率(记录该请求使用的 cost_multiplier) diff --git a/src/types/provider.ts b/src/types/provider.ts index 56e7a12df..801e6fd10 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -758,6 +758,7 @@ export interface ProviderEndpoint { lastProbeLatencyMs: number | null; lastProbeErrorType: string | null; lastProbeErrorMessage: string | null; + consecutiveProbeFailures: number; createdAt: Date; updatedAt: Date; deletedAt: Date | null; diff --git a/src/types/routing-trace.ts b/src/types/routing-trace.ts index 13b556201..d1c0fece8 100644 --- a/src/types/routing-trace.ts +++ b/src/types/routing-trace.ts @@ -63,6 +63,7 @@ export interface RoutingTraceSummaryV1 { statusCode: number; durationMs: number; ttfbMs: number | null; + ttftMs?: number | null; attemptsPerRequest: number; maxActiveAttempts: number; rounds: number; @@ -237,6 +238,11 @@ function normalizeRoutingTraceSummary(value: unknown): RoutingTraceSummaryV1 | u !winnerOrigins.has(summary.winnerOrigin as RoutingTraceWinnerOrigin) || numericKeys.some((key) => finiteNumber(summary[key]) === undefined) || !(summary.ttfbMs === null || finiteNumber(summary.ttfbMs) !== undefined) || + !( + summary.ttftMs === undefined || + summary.ttftMs === null || + finiteNumber(summary.ttftMs) !== undefined + ) || !(summary.winnerProviderId === null || finiteNumber(summary.winnerProviderId) !== undefined) || !(summary.winnerRound === null || finiteNumber(summary.winnerRound) !== undefined) ) { @@ -247,6 +253,7 @@ function normalizeRoutingTraceSummary(value: unknown): RoutingTraceSummaryV1 | u statusCode: summary.statusCode as number, durationMs: summary.durationMs as number, ttfbMs: summary.ttfbMs as number | null, + ttftMs: (summary.ttftMs as number | null | undefined) ?? null, attemptsPerRequest: summary.attemptsPerRequest as number, maxActiveAttempts: summary.maxActiveAttempts as number, rounds: summary.rounds as number, diff --git a/src/types/system-config.ts b/src/types/system-config.ts index f3bfe0e42..ead5e6f3d 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -7,6 +7,7 @@ export type CodexPriorityBillingSource = "requested" | "actual"; // F1 流式内容门控模式: 'off' (关闭) | 'shadow' (仅旁路统计) | 'enforce' (启用) export type StreamGateSettingMode = "off" | "shadow" | "enforce"; +export type SessionSnapshotStoreSetting = "disabled" | "filesystem" | "redis"; export interface ResponseFixerConfig { fixTruncatedJson: boolean; @@ -166,6 +167,9 @@ export interface SystemSettings { // null = 跟随环境变量 ENABLE_CACHE_EFFECTIVENESS(默认 true) cacheEffectivenessEnabled: boolean | null; + // Session 调试快照后端。filesystem 默认写入共享目录,redis 用于兼容,disabled 完全关闭。 + sessionSnapshotStore: SessionSnapshotStoreSetting; + /** Bounded streaming Discovery settings. */ discoveryEnabled: boolean; discoveryConcurrency: number; @@ -298,4 +302,6 @@ export interface UpdateSystemSettingsInput { // F3b 缓存模拟开关(可选;null = 清除覆写跟随环境变量) cacheEffectivenessEnabled?: boolean | null; + + sessionSnapshotStore?: SessionSnapshotStoreSetting; } diff --git a/tests/integration/proxy-hedge-lifecycle.test.ts b/tests/integration/proxy-hedge-lifecycle.test.ts index 50b2d2700..497a8c271 100644 --- a/tests/integration/proxy-hedge-lifecycle.test.ts +++ b/tests/integration/proxy-hedge-lifecycle.test.ts @@ -638,7 +638,7 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p pathName: "first-byte hedge", }, ])( - "records TFFT at the enforced Responses gate commit before downstream reads ($pathName path)", + "records TTFB and TTFT at the enforced Responses gate commit before downstream reads ($pathName path)", async ({ expectedMode, firstByteTimeoutStreamingMs }) => { const upstream = await startUpstream(); const client = new AbortController(); @@ -659,19 +659,19 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p now.mockReturnValue(10_050); await upstream.write(stream.neutralPrefix.join("")); await neutralPrefixConsumption.consumed; - expect(session.firstByteMs).toBeNull(); - expect(session.tfftMs).toBeNull(); + expect(session.ttfbMs).toBeNull(); + expect(session.ttftMs).toBeNull(); // When: sequence 5 arrives, it is the first user-visible content boundary. now.mockReturnValue(10_125); await upstream.write(stream.firstContent); const forwardedResponse = await forwarded; - // Then: TTFB and TFFT remain distinct before any downstream read occurs. - expect(session.firstByteMs).toBe(50); - expect(session.tfftMs).toBe(125); + // Then: response headers and first valid content remain distinct before downstream reads. + expect(session.ttfbMs).toBe(50); + expect(session.ttftMs).toBe(125); expect(session.getRoutingTrace()?.mode).toBe(expectedMode); - const firstByteMsAtCommit = session.firstByteMs; + const ttfbMsAtCommit = session.ttfbMs; now.mockReturnValue(10_900); const downstream = await ProxyResponseHandler.dispatch(session, forwardedResponse); @@ -682,8 +682,8 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p await settleTasks(); await agents.released; - expect(session.firstByteMs).toBe(firstByteMsAtCommit); - expect(session.tfftMs).toBe(125); + expect(session.ttfbMs).toBe(ttfbMsAtCommit); + expect(session.ttftMs).toBe(125); expect(session.getProviderChain()).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -704,7 +704,7 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p } ); - it("records TFFT when a Discovery Responses winner commits before downstream reads", async () => { + it("records TTFB and TTFT when a Discovery Responses winner commits before downstream reads", async () => { const [loser, winner] = await Promise.all([startUpstream(), startUpstream()]); const client = new AbortController(); const now = vi.spyOn(Date, "now"); @@ -729,17 +729,17 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p now.mockReturnValue(10_050); await winner.write(stream.neutralPrefix.join("")); await neutralPrefixConsumption.consumed; - expect(session.tfftMs).toBeNull(); + expect(session.ttftMs).toBeNull(); // When: sequence 5 makes the alternative ready and Discovery commits it. now.mockReturnValue(10_125); await winner.write(stream.firstContent); const forwardedResponse = await forwarded; - // Then: TFFT is fixed at winner commit, before ResponseHandler reads the stream. - expect(session.firstByteMs).toBe(50); - expect(session.tfftMs).toBe(125); - const firstByteMsAtCommit = session.firstByteMs; + // Then: both timings are fixed at winner commit, before ResponseHandler reads the stream. + expect(session.ttfbMs).toBe(50); + expect(session.ttftMs).toBe(125); + const ttfbMsAtCommit = session.ttfbMs; now.mockReturnValue(10_900); const downstream = await ProxyResponseHandler.dispatch(session, forwardedResponse); @@ -751,8 +751,8 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p await loser.terminated; await agents.released; - expect(session.firstByteMs).toBe(firstByteMsAtCommit); - expect(session.tfftMs).toBe(125); + expect(session.ttfbMs).toBe(ttfbMsAtCommit); + expect(session.ttftMs).toBe(125); expect(loser.abortCount()).toBe(1); expect(winner.abortCount()).toBe(0); expect(agents.pool.getPoolStats().activeRequests).toBe(0); diff --git a/tests/integration/public-status/config-publish.test.ts b/tests/integration/public-status/config-publish.test.ts index c90c284fa..3673207c7 100644 --- a/tests/integration/public-status/config-publish.test.ts +++ b/tests/integration/public-status/config-publish.test.ts @@ -9,7 +9,7 @@ const mockUpdateProviderGroup = vi.hoisted(() => vi.fn()); const mockFindLatestPricesByModels = vi.hoisted(() => vi.fn()); const mockPublishCurrentPublicStatusConfigProjection = vi.hoisted(() => vi.fn()); const mockSchedulePublicStatusRebuild = vi.hoisted(() => vi.fn()); -const mockInvalidateSystemSettingsCache = vi.hoisted(() => vi.fn()); +const mockPrimeSystemSettingsCache = vi.hoisted(() => vi.fn()); const mockRevalidatePath = vi.hoisted(() => vi.fn()); const mockLoggerInfo = vi.hoisted(() => vi.fn()); const mockLoggerError = vi.hoisted(() => vi.fn()); @@ -52,7 +52,7 @@ vi.mock("@/lib/public-status/rebuild-hints", () => ({ })); vi.mock("@/lib/config", () => ({ - invalidateSystemSettingsCache: mockInvalidateSystemSettingsCache, + primeSystemSettingsCache: mockPrimeSystemSettingsCache, })); vi.mock("next/cache", () => ({ @@ -174,7 +174,9 @@ describe("public-status config publish integration", () => { rangeHours: 24, reason: "config-updated", }); - expect(mockInvalidateSystemSettingsCache).toHaveBeenCalledTimes(1); + expect(mockPrimeSystemSettingsCache).toHaveBeenCalledWith( + expect.objectContaining({ publicStatusWindowHours: 24 }) + ); expect(mockRevalidatePath).toHaveBeenCalled(); }); diff --git a/tests/unit/actions/system-config-fake-streaming-setting.test.ts b/tests/unit/actions/system-config-fake-streaming-setting.test.ts index 9df9b7b33..aafe78343 100644 --- a/tests/unit/actions/system-config-fake-streaming-setting.test.ts +++ b/tests/unit/actions/system-config-fake-streaming-setting.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const getSystemSettingsMock = vi.fn(); const loggerWarnMock = vi.fn(); const invalidateSystemSettingsCacheMock = vi.fn(); +const primeSystemSettingsCacheMock = vi.fn(); const updateSystemSettingsMock = vi.fn(); const getSessionMock = vi.fn(); @@ -36,6 +37,7 @@ vi.mock("@/lib/config", async (importOriginal) => { return { ...actual, invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), + primeSystemSettingsCache: (...args: unknown[]) => primeSystemSettingsCacheMock(...args), }; }); @@ -285,7 +287,7 @@ describe("fake streaming whitelist system setting", () => { }); describe("save action", () => { - test("saves fake streaming whitelist entry for all groups and invalidates cache", async () => { + test("saves fake streaming whitelist entry for all groups and primes cache", async () => { const persisted = [ { model: "gpt-image-2", groupTags: [] }, { model: "gpt-image-1.5", groupTags: ["group-a", "group-b"] }, @@ -306,7 +308,9 @@ describe("fake streaming whitelist system setting", () => { fakeStreamingWhitelist: persisted, }) ); - expect(invalidateSystemSettingsCacheMock).toHaveBeenCalledTimes(1); + expect(primeSystemSettingsCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ fakeStreamingWhitelist: persisted }) + ); if (result.ok) { expect(result.data.fakeStreamingWhitelist).toEqual(persisted); } diff --git a/tests/unit/actions/system-config-non-chat-retry-setting.test.ts b/tests/unit/actions/system-config-non-chat-retry-setting.test.ts index 991c2d768..1ce6f1580 100644 --- a/tests/unit/actions/system-config-non-chat-retry-setting.test.ts +++ b/tests/unit/actions/system-config-non-chat-retry-setting.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const getSystemSettingsMock = vi.fn(); const loggerWarnMock = vi.fn(); const invalidateSystemSettingsCacheMock = vi.fn(); +const primeSystemSettingsCacheMock = vi.fn(); const updateSystemSettingsMock = vi.fn(); const getSessionMock = vi.fn(); @@ -36,6 +37,7 @@ vi.mock("@/lib/config", async (importOriginal) => { return { ...actual, invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), + primeSystemSettingsCache: (...args: unknown[]) => primeSystemSettingsCacheMock(...args), }; }); @@ -173,7 +175,7 @@ describe("non-chat fallback system setting", () => { expect(cachedFallback.allowNonConversationEndpointProviderFallback).toBe(false); }); - test("persists update and invalidates cache", async () => { + test("persists update and primes cache", async () => { updateSystemSettingsMock.mockResolvedValueOnce( createSettings({ allowNonConversationEndpointProviderFallback: false, @@ -191,7 +193,9 @@ describe("non-chat fallback system setting", () => { allowNonConversationEndpointProviderFallback: false, }) ); - expect(invalidateSystemSettingsCacheMock).toHaveBeenCalledTimes(1); + expect(primeSystemSettingsCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ allowNonConversationEndpointProviderFallback: false }) + ); expect(result).toMatchObject({ ok: true, data: { diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts index 89c19dd30..46e59a162 100644 --- a/tests/unit/actions/system-config-save.test.ts +++ b/tests/unit/actions/system-config-save.test.ts @@ -4,7 +4,7 @@ import { locales } from "@/i18n/config"; // Mock dependencies const getSessionMock = vi.fn(); const revalidatePathMock = vi.fn(); -const invalidateSystemSettingsCacheMock = vi.fn(); +const primeSystemSettingsCacheMock = vi.fn(); const updateSystemSettingsMock = vi.fn(); const getSystemSettingsMock = vi.fn(); const publishCurrentPublicStatusConfigProjectionMock = vi.fn(); @@ -19,7 +19,7 @@ vi.mock("next/cache", () => ({ })); vi.mock("@/lib/config", () => ({ - invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), + primeSystemSettingsCache: (...args: unknown[]) => primeSystemSettingsCacheMock(...args), })); vi.mock("@/lib/logger", () => ({ @@ -179,10 +179,12 @@ describe("saveSystemSettings", () => { expect(updateSystemSettingsMock).not.toHaveBeenCalled(); }); - it("should invalidate system settings cache after successful save", async () => { + it("should prime system settings cache after successful save", async () => { await saveSystemSettings({ siteTitle: "New Title" }); - expect(invalidateSystemSettingsCacheMock).toHaveBeenCalled(); + expect(primeSystemSettingsCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ siteTitle: "Test Site" }) + ); }); it("should republish the public-status projection and queue a rebuild for relevant config changes", async () => { diff --git a/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts b/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts index 93802a3e8..799566941 100644 --- a/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts +++ b/tests/unit/actions/system-config-stream-gate-affinity-settings.test.ts @@ -2,19 +2,25 @@ import { describe, expect, test, vi } from "vitest"; vi.mock("server-only", () => ({})); -describe("streamGateMode / affinityIgnoreClientSessionId system settings", () => { +describe("stream gate, affinity, and session snapshot system settings", () => { test("default to enforce / enabled in the DB-row transformer", async () => { const { toSystemSettings } = await import("@/repository/_shared/transformers"); expect(toSystemSettings(undefined).streamGateMode).toBe("enforce"); expect(toSystemSettings(undefined).affinityIgnoreClientSessionId).toBe(true); - expect(toSystemSettings({ id: 1, siteTitle: "CC Hub" }).streamGateMode).toBe("enforce"); - expect(toSystemSettings({ id: 1, siteTitle: "CC Hub" }).affinityIgnoreClientSessionId).toBe( - true - ); + expect(toSystemSettings(undefined).sessionSnapshotStore).toBe("filesystem"); + const persisted = toSystemSettings({ id: 1, siteTitle: "CC Hub" }); + expect(persisted.streamGateMode).toBe("enforce"); + expect(persisted.affinityIgnoreClientSessionId).toBe(true); expect(toSystemSettings({ id: 1, streamGateMode: "shadow" }).streamGateMode).toBe("shadow"); // varchar 脏值回落产品默认 expect(toSystemSettings({ id: 1, streamGateMode: "bogus" }).streamGateMode).toBe("enforce"); + expect(toSystemSettings({ id: 1, sessionSnapshotStore: "redis" }).sessionSnapshotStore).toBe( + "redis" + ); + expect(toSystemSettings({ id: 1, sessionSnapshotStore: "bogus" }).sessionSnapshotStore).toBe( + "filesystem" + ); expect( toSystemSettings({ id: 1, affinityIgnoreClientSessionId: false }) .affinityIgnoreClientSessionId @@ -27,15 +33,19 @@ describe("streamGateMode / affinityIgnoreClientSessionId system settings", () => const parsed = UpdateSystemSettingsSchema.parse({ streamGateMode: "shadow", affinityIgnoreClientSessionId: false, + sessionSnapshotStore: "filesystem", }); expect(parsed.streamGateMode).toBe("shadow"); expect(parsed.affinityIgnoreClientSessionId).toBe(false); + expect(parsed.sessionSnapshotStore).toBe("filesystem"); expect(() => UpdateSystemSettingsSchema.parse({ streamGateMode: "bogus" })).toThrow(); + expect(() => UpdateSystemSettingsSchema.parse({ sessionSnapshotStore: "disk" })).toThrow(); const empty = UpdateSystemSettingsSchema.parse({}); expect(empty.streamGateMode).toBeUndefined(); expect(empty.affinityIgnoreClientSessionId).toBeUndefined(); + expect(empty.sessionSnapshotStore).toBeUndefined(); }); test("are exposed by the v1 system settings response schema", async () => { @@ -43,5 +53,6 @@ describe("streamGateMode / affinityIgnoreClientSessionId system settings", () => expect(Object.keys(SystemSettingsSchema.shape)).toContain("streamGateMode"); expect(Object.keys(SystemSettingsSchema.shape)).toContain("affinityIgnoreClientSessionId"); + expect(Object.keys(SystemSettingsSchema.shape)).toContain("sessionSnapshotStore"); }); }); diff --git a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx index c67dbfb3e..abee05401 100644 --- a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx +++ b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx @@ -50,7 +50,9 @@ vi.mock("@/actions/usage-logs", () => ({ costUsd: "0.000001", costMultiplier: null, durationMs: 10, - tfftMs: 5, + ttfbMs: 2, + ttftMs: 5, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, diff --git a/tests/unit/dashboard-logs-warmup-ui.test.tsx b/tests/unit/dashboard-logs-warmup-ui.test.tsx index 1f78f16a3..0720e27b7 100644 --- a/tests/unit/dashboard-logs-warmup-ui.test.tsx +++ b/tests/unit/dashboard-logs-warmup-ui.test.tsx @@ -75,7 +75,9 @@ describe("UsageLogsTable - warmup 跳过展示", () => { costUsd: null, costMultiplier: null, durationMs: 0, - tfftMs: 0, + ttfbMs: null, + ttftMs: null, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: "warmup", @@ -128,7 +130,9 @@ describe("UsageLogsTable - cache badge alignment", () => { costUsd: "0.000001", costMultiplier: null, durationMs: 10, - tfftMs: 5, + ttfbMs: 2, + ttftMs: 5, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, diff --git a/tests/unit/dashboard/leaderboard-success-rate-display.test.ts b/tests/unit/dashboard/leaderboard-success-rate-display.test.ts index c117be05b..4a7dbe0b7 100644 --- a/tests/unit/dashboard/leaderboard-success-rate-display.test.ts +++ b/tests/unit/dashboard/leaderboard-success-rate-display.test.ts @@ -15,18 +15,32 @@ describe("getSuccessRateCellDisplay", () => { }); }); - it("shows unavailable label with disclosure when basis diverges", () => { + it("keeps a numeric redirected-basis success rate visible with disclosure", () => { expect( getSuccessRateCellDisplay( { - successRate: null, + successRate: 0.875, basisDisclosureRequired: true, }, t as never ) ).toEqual({ - label: "N/A", + label: "87.5%", title: "basis disclosure", }); }); + + it("shows unavailable only when there is no countable outcome", () => { + expect( + getSuccessRateCellDisplay( + { + successRate: null, + }, + t as never + ) + ).toEqual({ + label: "N/A", + title: undefined, + }); + }); }); diff --git a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx index 59e659059..dab9f0fd4 100644 --- a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx @@ -102,6 +102,16 @@ describe("LeaderboardView cache coefficient column", () => { providerId: 1, providerName: "with-coefficient", cacheCoefficientBp: 8600, + modelStats: [ + { + model: "model-with-coefficient", + totalRequests: 6, + cacheReadTokens: 400, + totalInputTokens: 700, + cacheHitRate: 0.57, + cacheCoefficientBp: 6400, + }, + ], }), cacheHitEntry({ providerId: 2, @@ -122,6 +132,16 @@ describe("LeaderboardView cache coefficient column", () => { expect(text).toContain("columns.cacheCoefficient"); expect(text).toContain("0.86"); expect(text).toContain("–"); + + const expandButton = container!.querySelector( + 'button[aria-label="expandModelStats"]' + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + await act(async () => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container!.textContent).toContain("model-with-coefficient"); + expect(container!.textContent).toContain("0.64"); }); it("renders the coefficient column on the provider usage board too", async () => { @@ -145,7 +165,21 @@ describe("LeaderboardView cache coefficient column", () => { avgCostPerRequest: 0.35, avgCostPerMillionTokens: 1750, cacheCoefficientBp: 1234, - modelStats: [], + modelStats: [ + { + model: "usage-model", + totalRequests: 8, + totalCost: 2.8, + totalTokens: 1600, + successRate: 0.92, + avgTtfbMs: 140, + avgTtftMs: 220, + avgTokensPerSecond: 45, + avgCostPerRequest: 0.35, + avgCostPerMillionTokens: 1750, + cacheCoefficientBp: 5700, + }, + ], }, ], } as Response; @@ -160,5 +194,15 @@ describe("LeaderboardView cache coefficient column", () => { const text = container!.textContent ?? ""; expect(text).toContain("columns.cacheCoefficient"); expect(text).toContain("0.12"); + + const expandButton = container!.querySelector( + 'button[aria-label="expandModelStats"]' + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + await act(async () => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container!.textContent).toContain("usage-model"); + expect(container!.textContent).toContain("0.57"); }); }); diff --git a/tests/unit/error-details-dialog-warmup-ui.test.tsx b/tests/unit/error-details-dialog-warmup-ui.test.tsx index eacb43e36..9ead93ee0 100644 --- a/tests/unit/error-details-dialog-warmup-ui.test.tsx +++ b/tests/unit/error-details-dialog-warmup-ui.test.tsx @@ -164,7 +164,9 @@ describe("ErrorDetailsDialog - warmup skip indicator", () => { costMultiplier={null} context1mApplied={false} durationMs={null} - tfftMs={null} + ttfbMs={null} + ttftMs={null} + timingSemanticsVersion={2} externalOpen /> ); diff --git a/tests/unit/k8s-deploy-assets-review-fixes.test.ts b/tests/unit/k8s-deploy-assets-review-fixes.test.ts index c4b4070ff..8077b5087 100644 --- a/tests/unit/k8s-deploy-assets-review-fixes.test.ts +++ b/tests/unit/k8s-deploy-assets-review-fixes.test.ts @@ -47,6 +47,29 @@ describe("k8s deploy review regressions", () => { expect(traefikIngressRoute).toContain("forwardedHeaders.trustedIPs"); }); + it("bounds app resources and stores session snapshots without adding a component", () => { + const deployment = readRepoFile("deploy/k8s/app/deployment.yaml"); + const hpa = readRepoFile("deploy/k8s/app/hpa.yaml"); + + expect(deployment).toContain('- name: DB_POOL_MAX\n value: "8"'); + expect(deployment).toContain("- name: SESSION_SNAPSHOT_ROOT"); + expect(deployment).toContain("value: /var/lib/claude-code-hub/session-snapshots"); + expect(deployment).toContain( + '- name: DASHBOARD_LOGS_POLL_INTERVAL_MS\n value: "10000"' + ); + expect(deployment).toContain("cpu: 250m\n memory: 2Gi"); + expect(deployment).toContain('cpu: "2"\n memory: 5Gi'); + expect(deployment).toContain("path: /api/health/live"); + expect(deployment).toContain("path: /api/health/ready"); + expect(deployment).toContain( + "hostPath:\n path: /var/lib/claude-code-hub/session-snapshots" + ); + expect(deployment).toContain("type: DirectoryOrCreate"); + expect(deployment).not.toContain("kind: PersistentVolumeClaim"); + expect(hpa).toContain("name: cpu"); + expect(hpa).not.toContain("name: memory"); + }); + it("documents and enforces the NodePort-safe deployment path", () => { const deployScript = readRepoFile("scripts/deploy-k8s.sh"); @@ -101,7 +124,7 @@ describe("k8s deploy review regressions", () => { expect(cchScript).toContain("if detect_runtime; then"); }); - it("keeps the restore playbook compatible with CPU/memory HPA", () => { + it("keeps the restore playbook compatible with CPU HPA and documents hostPath limits", () => { const docs = readRepoFile("docs/k8s-deployment.md"); const k8sReadme = readRepoFile("deploy/k8s/README.md"); @@ -116,6 +139,7 @@ describe("k8s deploy review regressions", () => { ); expect(docs).toContain("kubectl -n claude-code-hub delete hpa claude-code-hub"); expect(docs).toContain("恢复到 max(升级前实际副本数, HPA minReplicas)"); + expect(docs).toContain("普通 `hostPath` 不跨节点共享"); expect(docs).toContain(""); expect(docs).not.toContain('minReplicas":0'); expect(docs).toContain("```text"); @@ -126,5 +150,6 @@ describe("k8s deploy review regressions", () => { expect(k8sReadme).toContain("X-Forwarded-For"); expect(k8sReadme).toContain("allow-snippet-annotations=false"); expect(k8sReadme).toContain("proxy-real-ip"); + expect(k8sReadme).toContain("这个目录只在同一节点上的 Pod 间共享"); }); }); diff --git a/tests/unit/langfuse/langfuse-trace.test.ts b/tests/unit/langfuse/langfuse-trace.test.ts index 0c4bd8262..a894ba4cc 100644 --- a/tests/unit/langfuse/langfuse-trace.test.ts +++ b/tests/unit/langfuse/langfuse-trace.test.ts @@ -100,8 +100,8 @@ function createMockSession(overrides: Record = {}) { user: { id: 7, name: "testuser" }, key: { name: "default-key" }, }, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, forwardStartTime: startTime + 5, forwardedRequestBody: null, getEndpoint: () => "/v1/messages", @@ -464,12 +464,12 @@ describe("traceProxyRequest", () => { expect(llmCall[1].metadata.originalModel).toBe("claude-sonnet-4-20250514"); }); - test("should set completionStartTime from tfftMs", async () => { + test("should set completionStartTime from ttftMs", async () => { const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); const startTime = Date.now() - 500; await traceProxyRequest({ - session: createMockSession({ startTime, tfftMs: 200 }), + session: createMockSession({ startTime, ttfbMs: 100, ttftMs: 200 }), responseHeaders: new Headers(), durationMs: 500, statusCode: 200, @@ -890,8 +890,8 @@ describe("traceProxyRequest", () => { session: createMockSession({ startTime, forwardStartTime, - tfftMs: 105, - firstByteMs: 105, + ttfbMs: 105, + ttftMs: 205, getProviderChain: () => [ { id: 1, name: "p1", reason: "retry_failed", timestamp: startTime + 50 }, { id: 2, name: "p2", reason: "request_success", timestamp: startTime + 100 }, @@ -906,8 +906,9 @@ describe("traceProxyRequest", () => { const expectedTimingBreakdown = { guardPipelineMs: 5, upstreamTotalMs: 495, - tfftFromForwardMs: 100, // tfftMs(105) - guardPipelineMs(5) - tokenGenerationMs: 395, // durationMs(500) - tfftMs(105) + ttfbFromForwardMs: 100, // ttfbMs(105) - guardPipelineMs(5) + ttftFromForwardMs: 200, // ttftMs(205) - guardPipelineMs(5) + tokenGenerationMs: 295, // durationMs(500) - ttftMs(205) failedAttempts: 1, // only retry_failed is non-success providersAttempted: 2, // 2 unique provider ids }; diff --git a/tests/unit/lib/cache-effectiveness-service.test.ts b/tests/unit/lib/cache-effectiveness-service.test.ts new file mode 100644 index 000000000..cd441bad1 --- /dev/null +++ b/tests/unit/lib/cache-effectiveness-service.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + transaction: mocks.transaction, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + }, +})); + +function sqlToString(sqlObject: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(walk).join(""); + if (typeof node === "object") { + const record = node as Record; + if (record.value !== undefined) return walk(record.value); + if (record.queryChunks !== undefined) return walk(record.queryChunks); + } + return ""; + }; + + return walk(sqlObject); +} + +describe("aggregateCacheEffectiveness", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T12:00:00.000Z")); + mocks.transaction.mockImplementation(async (callback: (tx: object) => unknown) => + callback({ execute: mocks.execute }) + ); + }); + + it("skips without touching cursor when another replica owns the advisory lock", async () => { + mocks.execute.mockResolvedValueOnce([{ acquired: false }]); + const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); + + const result = await aggregateCacheEffectiveness(); + + expect(result.skipped).toBe(true); + expect(mocks.execute).toHaveBeenCalledTimes(1); + }); + + it("advances the persistent cursor even when the aggregation window is empty", async () => { + mocks.execute + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ cursor_at: new Date("2026-07-30T11:00:00.000Z") }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); + + const result = await aggregateCacheEffectiveness(); + + expect(result).toMatchObject({ groupsWritten: 0, skipped: false }); + expect(mocks.execute).toHaveBeenCalledTimes(5); + expect(sqlToString(mocks.execute.mock.calls[4]?.[0])).toContain( + "UPDATE background_task_cursor" + ); + }); + + it("uses an idempotent unique window upsert before advancing the cursor", async () => { + mocks.execute + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ cursor_at: new Date("2026-07-30T11:00:00.000Z") }]) + .mockResolvedValueOnce([{ id: 1 }]) + .mockResolvedValueOnce([]); + const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); + + const result = await aggregateCacheEffectiveness(); + + expect(result.groupsWritten).toBe(1); + const aggregateSql = sqlToString(mocks.execute.mock.calls[3]?.[0]); + expect(aggregateSql).toContain( + "ON CONFLICT (provider_id, model, cache_ttl_bucket, window_start, window_end)" + ); + }); + + it("rejects the transaction when cursor persistence fails", async () => { + mocks.execute + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ cursor_at: new Date("2026-07-30T11:00:00.000Z") }]) + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error("cursor write failed")); + const { aggregateCacheEffectiveness } = await import("@/lib/cache-effectiveness/service"); + + await expect(aggregateCacheEffectiveness()).rejects.toThrow("cursor write failed"); + }); +}); diff --git a/tests/unit/lib/config/system-settings-cache.test.ts b/tests/unit/lib/config/system-settings-cache.test.ts index ffdf98166..30ebfc69d 100644 --- a/tests/unit/lib/config/system-settings-cache.test.ts +++ b/tests/unit/lib/config/system-settings-cache.test.ts @@ -76,7 +76,9 @@ async function loadCache() { getCachedSystemSettings: mod.getCachedSystemSettings, isHttp2Enabled: mod.isHttp2Enabled, isOpenaiResponsesWebsocketEnabled: mod.isOpenaiResponsesWebsocketEnabled, + getCachedSystemSettingsOnlyCache: mod.getCachedSystemSettingsOnlyCache, invalidateSystemSettingsCache: mod.invalidateSystemSettingsCache, + primeSystemSettingsCache: mod.primeSystemSettingsCache, }; } @@ -181,6 +183,18 @@ describe("SystemSettingsCache", () => { expect(getSystemSettingsMock).toHaveBeenCalledTimes(2); }); + test("primeSystemSettingsCache 应原子替换缓存且不触发数据库读取", async () => { + const settings = createSettings({ id: 403 }); + const { getCachedSystemSettings, getCachedSystemSettingsOnlyCache, primeSystemSettingsCache } = + await loadCache(); + + primeSystemSettingsCache(settings); + + expect(getCachedSystemSettingsOnlyCache()).toBe(settings); + expect(await getCachedSystemSettings()).toBe(settings); + expect(getSystemSettingsMock).not.toHaveBeenCalled(); + }); + test("isHttp2Enabled 应读取缓存并返回 enableHttp2", async () => { getSystemSettingsMock.mockResolvedValueOnce(createSettings({ id: 501, enableHttp2: true })); const { isHttp2Enabled } = await loadCache(); diff --git a/tests/unit/lib/filesystem-session-snapshot-store.test.ts b/tests/unit/lib/filesystem-session-snapshot-store.test.ts new file mode 100644 index 000000000..583742097 --- /dev/null +++ b/tests/unit/lib/filesystem-session-snapshot-store.test.ts @@ -0,0 +1,173 @@ +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FilesystemSessionSnapshotStore } from "@/lib/session-snapshot/filesystem-store"; + +const roots: string[] = []; + +async function createRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "cch-session-snapshot-")); + roots.push(root); + return root; +} + +async function findSnapshotFile(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) { + const nested = await findSnapshotFile(child); + if (nested) return nested; + } else if (entry.isFile() && entry.name.endsWith(".json.gz")) { + return child; + } + } + return null; +} + +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("FilesystemSessionSnapshotStore", () => { + it("merges partial writes and publishes a gzip snapshot atomically", async () => { + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ root, cleanupIntervalMs: 0 }); + const key = { + sessionId: "../../secret", + sequence: 1, + kind: "request" as const, + phase: "after" as const, + }; + + expect(store.enqueuePatch(key, { body: { model: "gpt" } }, 300)).toBe(true); + expect(store.enqueuePatch(key, { meta: { method: "POST" } }, 300)).toBe(true); + + await expect(store.get(key)).resolves.toEqual({ + body: { model: "gpt" }, + meta: { method: "POST" }, + }); + const file = await findSnapshotFile(root); + expect(file).not.toBeNull(); + expect(file).not.toContain("secret"); + expect(file).toMatch(/\.json\.gz$/); + await store.stop(); + }); + + it("rejects a logical snapshot and pending queue that exceed configured budgets", async () => { + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ + root, + maxSnapshotBytes: 128, + maxPendingBytes: 100, + cleanupIntervalMs: 0, + }); + const first = { + sessionId: "one", + sequence: 1, + kind: "request" as const, + phase: "before" as const, + }; + const second = { ...first, sessionId: "two" }; + + expect(store.enqueuePatch(first, { body: "x".repeat(70) }, 300)).toBe(true); + expect(store.enqueuePatch(second, { body: "y".repeat(70) }, 300)).toBe(false); + expect(store.enqueuePatch(second, { body: "z".repeat(200) }, 300)).toBe(false); + await store.stop(); + }); + + it("removes expired snapshots using the inherited TTL", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T12:00:00.000Z")); + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ root, cleanupIntervalMs: 0 }); + const key = { + sessionId: "ttl", + sequence: 1, + kind: "response" as const, + phase: "after" as const, + }; + + expect(store.enqueuePatch(key, { body: "ok" }, 1)).toBe(true); + await expect(store.get(key)).resolves.toEqual({ body: "ok" }); + vi.setSystemTime(new Date("2026-07-30T12:00:02.000Z")); + await store.cleanup(); + await expect(store.get(key)).resolves.toBeNull(); + await store.stop(); + }); + + it("treats corrupt compressed files as unavailable", async () => { + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ root, cleanupIntervalMs: 0 }); + const key = { + sessionId: "corrupt", + sequence: 1, + kind: "request" as const, + phase: "before" as const, + }; + + expect(store.enqueuePatch(key, { body: "ok" }, 300)).toBe(true); + await store.get(key); + const file = await findSnapshotFile(root); + expect(file).not.toBeNull(); + await writeFile(file!, "not-gzip"); + await expect(store.get(key)).resolves.toBeNull(); + await store.stop(); + }); + + it("can restart after a backend reconfiguration stops the store", async () => { + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ root, cleanupIntervalMs: 0 }); + const first = { + sessionId: "restart", + sequence: 1, + kind: "request" as const, + phase: "before" as const, + }; + const second = { ...first, sequence: 2 }; + + expect(store.enqueuePatch(first, { body: "before-stop" }, 300)).toBe(true); + await expect(store.get(first)).resolves.toEqual({ body: "before-stop" }); + await store.stop(); + expect(store.enqueuePatch(second, { body: "while-stopped" }, 300)).toBe(false); + + for (let sequence = second.sequence; sequence < second.sequence + 20; sequence += 1) { + const key = { ...second, sequence }; + await store.start(); + expect(store.enqueuePatch(key, { body: `after-restart-${sequence}` }, 300)).toBe(true); + await expect(store.get(key)).resolves.toEqual({ body: `after-restart-${sequence}` }); + await store.stop(); + } + }); + + it("serializes an overlapping stop and restart", async () => { + const root = await createRoot(); + const store = new FilesystemSessionSnapshotStore({ root, cleanupIntervalMs: 0 }); + await store.start(); + + let releaseDrain!: () => void; + const drainGate = new Promise((resolve) => { + releaseDrain = resolve; + }); + const internals = store as unknown as { + accepting: boolean; + ensureRoot(): Promise; + started: boolean; + waitForDrain(): Promise; + }; + vi.spyOn(internals, "waitForDrain").mockReturnValue(drainGate); + vi.spyOn(internals, "ensureRoot").mockResolvedValue(); + + const stopping = store.stop(); + const restarting = store.start(); + await new Promise((resolve) => setImmediate(resolve)); + releaseDrain(); + await Promise.all([stopping, restarting]); + + expect(internals.accepting).toBe(true); + expect(internals.started).toBe(true); + await store.stop(); + }); +}); diff --git a/tests/unit/lib/performance-formatter-timing.test.ts b/tests/unit/lib/performance-formatter-timing.test.ts new file mode 100644 index 000000000..978478e3a --- /dev/null +++ b/tests/unit/lib/performance-formatter-timing.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { calculateOutputRate, shouldHideOutputRate } from "@/lib/utils/performance-formatter"; + +describe("performance timing metrics", () => { + it("calculates output rate only from TTFT to completion", () => { + expect(calculateOutputRate(100, 2_000, 500)).toBeCloseTo(66.6667, 3); + }); + + it("does not fabricate output rate when TTFT is unavailable", () => { + expect(calculateOutputRate(100, 2_000, null)).toBeNull(); + }); + + it("uses TTFT when detecting implausibly short generation windows", () => { + const rate = calculateOutputRate(300, 1_000, 950); + expect(shouldHideOutputRate(rate, 1_000, 950)).toBe(true); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/leader-lock.test.ts b/tests/unit/lib/provider-endpoints/leader-lock.test.ts new file mode 100644 index 000000000..04253e20b --- /dev/null +++ b/tests/unit/lib/provider-endpoints/leader-lock.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getRedisClientMock = vi.fn(); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: (...args: unknown[]) => getRedisClientMock(...args), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +const originalNodeEnv = process.env.NODE_ENV; + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; +}); + +describe("provider endpoint leader lock", () => { + it("fails closed in production when Redis is unavailable", async () => { + process.env.NODE_ENV = "production"; + getRedisClientMock.mockReturnValue(null); + const { acquireLeaderLock } = await import("@/lib/provider-endpoints/leader-lock"); + + await expect(acquireLeaderLock("locks:test", 1_000)).resolves.toBeNull(); + }); + + it("fails closed in production when Redis acquisition throws", async () => { + process.env.NODE_ENV = "production"; + getRedisClientMock.mockReturnValue({ + status: "ready", + eval: vi.fn(async () => { + throw new Error("redis down"); + }), + }); + const { acquireLeaderLock } = await import("@/lib/provider-endpoints/leader-lock"); + + await expect(acquireLeaderLock("locks:test", 1_000)).resolves.toBeNull(); + }); + + it("allows memory fallback outside production", async () => { + process.env.NODE_ENV = "test"; + getRedisClientMock.mockReturnValue(null); + const { acquireLeaderLock, releaseLeaderLock } = await import( + "@/lib/provider-endpoints/leader-lock" + ); + + const lock = await acquireLeaderLock("locks:test", 1_000); + expect(lock).toMatchObject({ key: "locks:test", lockType: "memory" }); + await expect(acquireLeaderLock("locks:test", 1_000)).resolves.toBeNull(); + await releaseLeaderLock(lock!); + await expect(acquireLeaderLock("locks:test", 1_000)).resolves.toMatchObject({ + lockType: "memory", + }); + }); + + it("keeps Redis lock semantics when the distributed lock is acquired", async () => { + process.env.NODE_ENV = "production"; + const evalMock = vi.fn(async () => "OK"); + getRedisClientMock.mockReturnValue({ status: "ready", eval: evalMock }); + const { acquireLeaderLock } = await import("@/lib/provider-endpoints/leader-lock"); + + await expect(acquireLeaderLock("locks:test", 1_000)).resolves.toMatchObject({ + key: "locks:test", + lockType: "redis", + }); + expect(evalMock).toHaveBeenCalledWith( + expect.stringContaining("SET"), + 1, + "locks:test", + expect.any(String), + "1000" + ); + }); +}); diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index f2142995e..b041a71c6 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts @@ -2,9 +2,11 @@ type ProbeTarget = { id: number; url: string; vendorId: number; + providerType: "claude"; lastProbedAt: Date | null; lastProbeOk: boolean | null; lastProbeErrorType: string | null; + consecutiveProbeFailures: number; }; type ProbeResult = { @@ -21,9 +23,11 @@ function makeEndpoint(id: number, overrides: Partial = {}): ProbeTa id, url: `https://example.com/${id}`, vendorId: overrides.vendorId ?? 1, + providerType: "claude", lastProbedAt: overrides.lastProbedAt ?? null, lastProbeOk: overrides.lastProbeOk ?? null, lastProbeErrorType: overrides.lastProbeErrorType ?? null, + consecutiveProbeFailures: overrides.consecutiveProbeFailures ?? 0, }; } @@ -96,6 +100,7 @@ describe("provider-endpoints: probe scheduler", () => { expect.objectContaining({ enabled: true, timeoutOverrideIntervalMs: 10_000, + failureBackoffMaxMs: 600_000, }) ); expect(loggerWarnMock).not.toHaveBeenCalled(); @@ -425,6 +430,7 @@ describe("provider-endpoints: probe scheduler", () => { lastProbedAt: new Date("2024-01-01T12:00:00Z"), lastProbeOk: false, lastProbeErrorType: "timeout", + consecutiveProbeFailures: 1, }); // Normal endpoint from same vendor probed 15s ago - not due (60s interval) const normalEndpoint = makeEndpoint(2, { @@ -537,6 +543,7 @@ describe("provider-endpoints: probe scheduler", () => { lastProbedAt: new Date("2024-01-01T12:00:00Z"), lastProbeOk: false, lastProbeErrorType: "timeout", + consecutiveProbeFailures: 1, }); findEnabledEndpointsMock = vi.fn(async () => [timeoutSingleVendor]); @@ -577,6 +584,7 @@ describe("provider-endpoints: probe scheduler", () => { lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 15s ago lastProbeOk: true, // recovered! lastProbeErrorType: "timeout", // had timeout before + consecutiveProbeFailures: 0, }); // Multi-vendor so 60s base interval applies const otherEndpoint = makeEndpoint(2, { @@ -601,6 +609,51 @@ describe("provider-endpoints: probe scheduler", () => { stopEndpointProbeScheduler(); }); + test.each([ + { now: "2024-01-01T12:00:39Z", expectedProbeCount: 0 }, + { now: "2024-01-01T12:00:40Z", expectedProbeCount: 1 }, + ])( + "third consecutive failure uses a 40s retry interval at $now", + async ({ now, expectedProbeCount }) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + + vi.resetModules(); + vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000"); + vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0"); + + acquireLeaderLockMock = vi.fn(async () => ({ + key: "locks:endpoint-probe-scheduler", + lockId: "test", + lockType: "memory" as const, + })); + renewLeaderLockMock = vi.fn(async () => true); + releaseLeaderLockMock = vi.fn(async () => {}); + + const failedEndpoint = makeEndpoint(1, { + lastProbedAt: new Date("2024-01-01T12:00:00Z"), + lastProbeOk: false, + lastProbeErrorType: "timeout", + consecutiveProbeFailures: 3, + }); + const healthyPeer = makeEndpoint(2, { + lastProbedAt: new Date("2024-01-01T12:00:30Z"), + lastProbeOk: true, + }); + findEnabledEndpointsMock = vi.fn(async () => [failedEndpoint, healthyPeer]); + probeByEndpointMock = vi.fn(async () => makeOkResult()); + + const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import( + "@/lib/provider-endpoints/probe-scheduler" + ); + startEndpointProbeScheduler(); + await flushMicrotasks(); + + expect(probeByEndpointMock).toHaveBeenCalledTimes(expectedProbeCount); + stopEndpointProbeScheduler(); + } + ); + test("null lastProbedAt is always due for probing", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-01T12:00:00Z")); diff --git a/tests/unit/lib/session-snapshot-store.test.ts b/tests/unit/lib/session-snapshot-store.test.ts new file mode 100644 index 000000000..c53ca81c0 --- /dev/null +++ b/tests/unit/lib/session-snapshot-store.test.ts @@ -0,0 +1,97 @@ +import { lstat, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SystemSettings } from "@/types/system-config"; + +const getCachedSystemSettingsMock = vi.fn(); +const getCachedSystemSettingsOnlyCacheMock = vi.fn(); + +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: () => getCachedSystemSettingsMock(), + getCachedSystemSettingsOnlyCache: () => getCachedSystemSettingsOnlyCacheMock(), +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: () => null, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +const roots: string[] = []; +const originalSnapshotRoot = process.env.SESSION_SNAPSHOT_ROOT; + +async function createRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "cch-session-store-")); + roots.push(root); + return root; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +afterEach(async () => { + if (originalSnapshotRoot === undefined) { + delete process.env.SESSION_SNAPSHOT_ROOT; + } else { + process.env.SESSION_SNAPSHOT_ROOT = originalSnapshotRoot; + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("session snapshot store selection", () => { + it("uses the persisted backend on cold start without starting filesystem storage", async () => { + const base = await createRoot(); + const snapshotRoot = path.join(base, "snapshots"); + process.env.SESSION_SNAPSHOT_ROOT = snapshotRoot; + const settings = { sessionSnapshotStore: "disabled" } as SystemSettings; + getCachedSystemSettingsMock.mockResolvedValue(settings); + getCachedSystemSettingsOnlyCacheMock.mockReturnValue(settings); + + const { startSessionSnapshotStore, stopSessionSnapshotStores } = await import( + "@/lib/session-snapshot/store" + ); + await startSessionSnapshotStore(); + + await expect(lstat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await stopSessionSnapshotStores(); + }); + + it("allows a later backend transition after filesystem startup fails", async () => { + const base = await createRoot(); + const blockedRoot = path.join(base, "not-a-directory"); + await writeFile(blockedRoot, "blocked"); + process.env.SESSION_SNAPSHOT_ROOT = blockedRoot; + getCachedSystemSettingsOnlyCacheMock.mockReturnValue(null); + + const { getSessionSnapshotStore, reconfigureSessionSnapshotStore, stopSessionSnapshotStores } = + await import("@/lib/session-snapshot/store"); + + await expect(reconfigureSessionSnapshotStore("filesystem")).rejects.toBeInstanceOf(Error); + await expect(reconfigureSessionSnapshotStore("disabled")).resolves.toBeUndefined(); + expect( + getSessionSnapshotStore().enqueuePatch( + { + sessionId: "disabled", + sequence: 1, + kind: "request", + phase: "before", + }, + { body: "ignored" }, + 300 + ) + ).toBe(false); + await stopSessionSnapshotStores(); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts index fd17f9fbf..7a68870db 100644 --- a/tests/unit/price-sync/cloud-price-updater.test.ts +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -4,6 +4,12 @@ import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; const asyncTasks: Promise[] = []; let asyncTaskManagerLoaded = false; +const withAdvisoryLockMock = vi.fn( + async (_lockName: string, fn: () => Promise): Promise<{ ran: boolean; result?: T }> => ({ + ran: true, + result: await fn(), + }) +); vi.mock("@/lib/logger", () => ({ logger: { @@ -44,6 +50,10 @@ vi.mock("@/lib/async-task-manager", () => { }; }); +vi.mock("@/lib/migrate", () => ({ + withAdvisoryLock: (...args: unknown[]) => withAdvisoryLockMock(...args), +})); + vi.mock("@/actions/model-prices", () => ({ processPriceTableInternal: vi.fn(async () => ({ ok: true, @@ -114,6 +124,12 @@ describe("syncCloudPriceTableToDatabase", () => { asyncTasks.splice(0, asyncTasks.length); vi.unstubAllGlobals(); asyncTaskManagerLoaded = false; + withAdvisoryLockMock.mockImplementation( + async ( + _lockName: string, + fn: () => Promise + ): Promise<{ ran: boolean; result?: T }> => ({ ran: true, result: await fn() }) + ); }); it("returns ok=false when cloud fetch fails with HTTP error", async () => { @@ -637,6 +653,19 @@ describe("requestCloudPriceTableSync", () => { expect(typeof g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBe("number"); }); + it("skips the cloud fetch when another instance owns the advisory lock", async () => { + withAdvisoryLockMock.mockResolvedValueOnce({ ran: false }); + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await flushAsync(); + await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("logs warn when sync task fails", async () => { const { AsyncTaskManager } = await import("@/lib/async-task-manager"); const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index 3bc0b6c23..58259143f 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -275,6 +275,8 @@ function createSession(clientAbortSignal: AbortSignal | null = null): ProxySessi authState: { success: true, user: null, key: null, apiKey: null }, provider: null, messageContext: null, + ttfbMs: null, + ttftMs: null, sessionId: "sess-hedge", streamingHedgeDisabled: false, sessionBindingAllowed: true, @@ -1557,6 +1559,7 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { await vi.advanceTimersByTimeAsync(45); const response = await responsePromise; + expect(session.ttftMs).toBeNull(); expect(await response.text()).toContain('"provider":"p1"'); expect(controller1.signal.aborted).toBe(false); expect(controller2.signal.aborted).toBe(true); diff --git a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts index 69ecff895..d3ca3038a 100644 --- a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts +++ b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts @@ -211,9 +211,10 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr shouldPersistSessionDebugArtifacts: () => false, shouldTrackSessionObservability: () => false, getResolvedPricingByBillingSource: async () => null, - recordTfft: vi.fn(), - tfftMs: null, - firstByteMs: null, + recordTtfb: vi.fn(), + recordTtft: vi.fn(), + ttfbMs: null, + ttftMs: null, addProviderToChain: vi.fn(), clearResponseTimeout: vi.fn(), releaseAgent: vi.fn(), diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts index 447864632..aa646501f 100644 --- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -296,8 +296,8 @@ function createSession( sessionId: null, specialSettings: [], startTime: Date.now(), - tfftMs: null, - firstByteMs: null, + ttfbMs: null, + ttftMs: null, userAgent: "Go-http-client/1.1", userName: "admin", addProviderToChain(this: ProxySession & { providerChain: unknown[] }, prov: Provider, meta) { @@ -318,7 +318,7 @@ function createSession( getResolvedPricingByBillingSource: async () => null, getSpecialSettings: () => [], isHeaderModified: () => false, - recordTfft: vi.fn(), + recordTtft: vi.fn(), releaseAgent: vi.fn(), setContext1mApplied: vi.fn(), shouldPersistSessionDebugArtifacts: () => false, @@ -1423,7 +1423,7 @@ describe("ProxyResponseHandler stream client abort finalization", () => { await downstream.text(); await drainAsyncTasks(); - expect(session.recordTfft).not.toHaveBeenCalled(); + expect(session.recordTtft).not.toHaveBeenCalled(); expect(session.clearResponseTimeout).toHaveBeenCalledTimes(1); }); diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 169ecfdb9..031eb746b 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -232,9 +232,9 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { getCurrentModel: () => "test-model", getProviderChain: () => session.providerChain, getCachedPriceDataByBillingSource: async () => testPriceData, - recordTfft: () => 100, - tfftMs: null, - firstByteMs: null, + recordTtft: () => 100, + ttfbMs: null, + ttftMs: null, getRequestSequence: () => 1, addProviderToChain: function ( this: ProxySession & { providerChain: Record[] }, diff --git a/tests/unit/proxy/response-handler-exported-finalizers.test.ts b/tests/unit/proxy/response-handler-exported-finalizers.test.ts index 6ce969d2a..3b90ff7c0 100644 --- a/tests/unit/proxy/response-handler-exported-finalizers.test.ts +++ b/tests/unit/proxy/response-handler-exported-finalizers.test.ts @@ -5,12 +5,14 @@ import { finalizeRequestStats, } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { FingerprintBoundary } from "@/app/v1/_lib/proxy/affinity/fingerprint"; import type { Provider } from "@/types/provider"; const mocks = vi.hoisted(() => ({ addLoserCost: vi.fn<(id: number, cost: object, entry: object) => Promise>(), durable: vi.fn<(id: number, details: object) => Promise>(), updateCost: vi.fn<(id: number, cost: object, breakdown: object) => Promise>(), + cacheEffectivenessEnabled: vi.fn(() => true), })); vi.mock("@/lib/logger", () => ({ @@ -31,9 +33,16 @@ vi.mock("@/repository/message", () => ({ updateMessageRequestDuration: vi.fn(), updateMessageRequestWinnerCost: vi.fn(), })); +vi.mock("@/lib/system-settings/proxy-runtime", () => ({ + isCacheEffectivenessEnabled: mocks.cacheEffectivenessEnabled, +})); const CREATED_AT = new Date(0); +function boundary(depth: number, fp: string, prefixBytes: number): FingerprintBoundary { + return { depth, fp, prefixBytes }; +} + function createProvider(): Provider { return { activeTimeEnd: null, @@ -144,6 +153,7 @@ describe("exported response finalizers", () => { mocks.addLoserCost.mockResolvedValue(undefined); mocks.durable.mockResolvedValue(undefined); mocks.updateCost.mockResolvedValue(undefined); + mocks.cacheEffectivenessEnabled.mockReturnValue(true); }); it("skips request finalization without provider and message context", async () => { @@ -157,6 +167,16 @@ describe("exported response finalizers", () => { it("returns parsed usage and durably persists request statistics", async () => { const session = await createSession(createProvider()); + session.affinity = { + scopeTag: "k42", + chain: { + sys: boundary(0, "sysfp", 41), + tail: [boundary(1, "tipfp", 103)], + }, + nominatedProviderId: null, + matchedFp: null, + matchedTier: null, + }; const responseText = JSON.stringify({ usage: { input_tokens: 2, output_tokens: 3 } }); const usage = await finalizeRequestStats(session, responseText, 200, 15); @@ -164,10 +184,57 @@ describe("exported response finalizers", () => { expect(usage).toMatchObject({ input_tokens: 2, output_tokens: 3 }); expect(mocks.durable).toHaveBeenCalledWith( 71, - expect.objectContaining({ inputTokens: 2, outputTokens: 3, statusCode: 200 }) + expect.objectContaining({ + inputTokens: 2, + outputTokens: 3, + statusCode: 200, + cacheCompatibilityKey: "k42:tipfp", + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + theoreticalCacheTokens: 25, + cacheTtlBucket: "5m", + }) ); }); + it("persists non-observable cache score fields when upstream usage is absent", async () => { + const session = await createSession(createProvider()); + session.affinity = { + scopeTag: "k42", + chain: { sys: boundary(0, "sysfp", 41), tail: [] }, + nominatedProviderId: null, + matchedFp: null, + matchedTier: null, + }; + + await finalizeRequestStats(session, "{}", 200, 15); + + expect(mocks.durable).toHaveBeenCalledWith( + 71, + expect.objectContaining({ + cacheCompatibilityKey: "k42:sysfp", + cacheScoreEligible: false, + cacheScoreExcludedReason: "not_observable", + }) + ); + }); + + it("does not write cache score fields when cache effectiveness is disabled", async () => { + mocks.cacheEffectivenessEnabled.mockReturnValue(false); + const session = await createSession(createProvider()); + + await finalizeRequestStats( + session, + JSON.stringify({ usage: { input_tokens: 2, output_tokens: 3 } }), + 200, + 15 + ); + + const details = mocks.durable.mock.calls[0]?.[1] as Record; + expect(details).not.toHaveProperty("cacheCompatibilityKey"); + expect(details).not.toHaveProperty("cacheScoreEligible"); + }); + it("skips incomplete hedge drains that contain no usage", async () => { const provider = createProvider(); const session = await createSession(provider); diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index 9d542e8c4..f734a849b 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -210,9 +210,9 @@ function createSession(opts: { source: "cloud_exact" as const, priceData: testPriceData, }), - recordTfft: () => 100, - tfftMs: null, - firstByteMs: null, + recordTtft: () => 100, + ttfbMs: null, + ttftMs: null, getRequestSequence: () => 1, }); diff --git a/tests/unit/proxy/response-handler-non200.test.ts b/tests/unit/proxy/response-handler-non200.test.ts index d7160ee19..be722c892 100644 --- a/tests/unit/proxy/response-handler-non200.test.ts +++ b/tests/unit/proxy/response-handler-non200.test.ts @@ -194,9 +194,9 @@ function createSession(opts: { getCurrentModel: () => redirectedModel, getProviderChain: () => session.providerChain, getCachedPriceDataByBillingSource: async () => testPriceData, - recordTfft: () => 100, - tfftMs: null, - firstByteMs: null, + recordTtft: () => 100, + ttfbMs: null, + ttftMs: null, getRequestSequence: () => 1, addProviderToChain: function ( prov: Provider, diff --git a/tests/unit/proxy/session-ttfb-tfft.test.ts b/tests/unit/proxy/session-ttfb-tfft.test.ts index 798557ec0..cd62f4961 100644 --- a/tests/unit/proxy/session-ttfb-tfft.test.ts +++ b/tests/unit/proxy/session-ttfb-tfft.test.ts @@ -38,55 +38,52 @@ function createSession(startTime: number): ProxySession { }); } -describe("ProxySession TTFB / TFFT", () => { - it("门控旁路时 recordTfft 同时补齐 TTFB(两者同一时刻)", () => { +describe("ProxySession TTFB / TTFT", () => { + it("分别记录响应头与首个有效内容耗时", () => { const session = createSession(Date.now() - 1_200); - const tfft = session.recordTfft(); + const ttfb = session.recordTtfb(200); + const ttft = session.recordTtft(800); - expect(session.tfftMs).toBe(tfft); - expect(session.firstByteMs).toBe(tfft); + expect(session.ttfbMs).toBe(ttfb); + expect(session.ttftMs).toBe(ttft); }); - it("门控提交时先记 TTFB,recordTfft 不覆盖它", () => { - const startTime = Date.now() - 3_000; - const session = createSession(startTime); + it("TTFT 不覆盖已经记录的 TTFB", () => { + const session = createSession(Date.now() - 3_000); - session.recordFirstByte(startTime + 400); - const tfft = session.recordTfft(); + session.recordTtfb(400); + const ttft = session.recordTtft(1_200); - expect(session.firstByteMs).toBe(400); - expect(session.tfftMs).toBe(tfft); - // TTFB 必须早于 TFFT,否则延迟分解与 TPS 分母都会失真 - expect(session.firstByteMs!).toBeLessThan(session.tfftMs!); + expect(session.ttfbMs).toBe(400); + expect(session.ttftMs).toBe(ttft); + expect(session.ttfbMs!).toBeLessThan(session.ttftMs!); }); - it("recordFirstByte 首写生效:failover 后不会被后续尝试改写", () => { - const startTime = Date.now() - 5_000; - const session = createSession(startTime); + it("recordTtfb 首写生效,后续尝试不会改写 winner timing", () => { + const session = createSession(Date.now() - 5_000); - session.recordFirstByte(startTime + 900); - session.recordFirstByte(startTime + 2_500); + session.recordTtfb(900); + session.recordTtfb(2_500); - expect(session.firstByteMs).toBe(900); + expect(session.ttfbMs).toBe(900); }); - it("recordFirstByte 对早于 startTime 的时刻钳到 0", () => { - const startTime = Date.now(); - const session = createSession(startTime); + it("recordTtfb 将负耗时钳到 0", () => { + const session = createSession(Date.now()); - session.recordFirstByte(startTime - 50); + session.recordTtfb(-50); - expect(session.firstByteMs).toBe(0); + expect(session.ttfbMs).toBe(0); }); - it("recordTfft 幂等:重复调用不改变已记录的值", () => { + it("recordTtft 幂等,重复调用不改变已记录的值", () => { const session = createSession(Date.now() - 800); - const first = session.recordTfft(); - const second = session.recordTfft(); + const first = session.recordTtft(500); + const second = session.recordTtft(700); expect(second).toBe(first); - expect(session.firstByteMs).toBe(first); + expect(session.ttftMs).toBe(first); }); }); diff --git a/tests/unit/proxy/stream-gate-content-gate.test.ts b/tests/unit/proxy/stream-gate-content-gate.test.ts index 10cb9066d..3dcbade9a 100644 --- a/tests/unit/proxy/stream-gate-content-gate.test.ts +++ b/tests/unit/proxy/stream-gate-content-gate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { EmptyResponseError, ProxyError } from "@/app/v1/_lib/proxy/errors"; import { concatChunks, + createContentTimingObserver, runStreamContentGate, StreamPrecommitError, } from "@/app/v1/_lib/proxy/stream-gate/stream-content-gate"; @@ -236,6 +237,76 @@ describe("concatChunks", () => { }); }); +describe("createContentTimingObserver", () => { + it("waits through neutral Anthropic frames and fragmented SSE before recording content once", () => { + let contentCount = 0; + const observer = createContentTimingObserver({ + family: "anthropic", + onContent: () => { + contentCount += 1; + }, + }); + const body = encoder.encode(PING + MESSAGE_START + TEXT_DELTA + TEXT_DELTA); + + observer.observe(body.slice(0, 17)); + observer.observe(body.slice(17, 91)); + expect(contentCount).toBe(0); + observer.observe(body.slice(91)); + observer.observe(encoder.encode(TEXT_DELTA)); + + expect(contentCount).toBe(1); + }); + + it.each([ + { + family: "openai-chat" as const, + neutral: 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n', + content: 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n', + }, + { + family: "gemini" as const, + neutral: 'data: {"usageMetadata":{"totalTokenCount":1}}\n\n', + content: 'data: {"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}\n\n', + }, + ])("records the first protocol content for $family", ({ family, neutral, content }) => { + let contentCount = 0; + const observer = createContentTimingObserver({ + family, + onContent: () => { + contentCount += 1; + }, + }); + + observer.observe(encoder.encode(neutral)); + expect(contentCount).toBe(0); + observer.observe(encoder.encode(content)); + expect(contentCount).toBe(1); + }); + + it("settles without recording when an error or terminal frame arrives first", () => { + let contentCount = 0; + const errorObserver = createContentTimingObserver({ + family: "anthropic", + onContent: () => { + contentCount += 1; + }, + }); + errorObserver.observe(encoder.encode(ERROR_FRAME)); + errorObserver.observe(encoder.encode(TEXT_DELTA)); + + const terminalObserver = createContentTimingObserver({ + family: "anthropic", + onContent: () => { + contentCount += 1; + }, + }); + terminalObserver.observe(encoder.encode(MESSAGE_STOP)); + terminalObserver.observe(encoder.encode(TEXT_DELTA)); + + expect(contentCount).toBe(0); + }); +}); + describe("StreamPrecommitError classification", () => { it("is a ProxyError with 502 so categorizeErrorAsync yields PROVIDER_ERROR semantics", () => { const error = new StreamPrecommitError("gate_error", { diff --git a/tests/unit/public-status/aggregation-core-tps.test.ts b/tests/unit/public-status/aggregation-core-tps.test.ts index b2e2f2f23..2f2d80a88 100644 --- a/tests/unit/public-status/aggregation-core-tps.test.ts +++ b/tests/unit/public-status/aggregation-core-tps.test.ts @@ -2,44 +2,34 @@ import { describe, expect, it } from "vitest"; import { computeTokensPerSecond } from "@/lib/public-status/aggregation-core"; describe("computeTokensPerSecond", () => { - it("以真 TTFB 为生成窗口起点", () => { - expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: 500 })).toBe( - 100 - ); + it("以 TTFT 为生成窗口起点", () => { + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, ttftMs: 500 })).toBe(100); }); - it("firstByteMs 缺失返回 null(门禁上线前的历史行不参与 TPS)", () => { - expect( - computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: null }) - ).toBeNull(); + it("ttftMs 缺失返回 null(旧 timing 语义不参与 TPS)", () => { + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, ttftMs: null })).toBeNull(); expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000 })).toBeNull(); }); - it("TTFB 基准得到的 TPS 低于(被门控放大的)TFFT 基准", () => { - const basedOnTfft = computeTokensPerSecond({ + it("TTFT 越接近总耗时,计算出的生成速率越高", () => { + const lateTtft = computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, - firstByteMs: 900, + ttftMs: 900, }); - const basedOnTtfb = computeTokensPerSecond({ + const earlyTtft = computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, - firstByteMs: 200, + ttftMs: 200, }); - expect(basedOnTfft).toBe(500); - expect(basedOnTtfb).toBe(62.5); + expect(lateTtft).toBe(500); + expect(earlyTtft).toBe(62.5); }); it("生成窗口非正、无 token、无耗时都返回 null", () => { - expect( - computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, firstByteMs: 1000 }) - ).toBeNull(); - expect( - computeTokensPerSecond({ outputTokens: 0, durationMs: 1000, firstByteMs: 100 }) - ).toBeNull(); - expect( - computeTokensPerSecond({ outputTokens: 50, durationMs: null, firstByteMs: 100 }) - ).toBeNull(); + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: 1000, ttftMs: 1000 })).toBeNull(); + expect(computeTokensPerSecond({ outputTokens: 0, durationMs: 1000, ttftMs: 100 })).toBeNull(); + expect(computeTokensPerSecond({ outputTokens: 50, durationMs: null, ttftMs: 100 })).toBeNull(); }); }); diff --git a/tests/unit/public-status/aggregation.test.ts b/tests/unit/public-status/aggregation.test.ts index 7d19ed7e4..e691b1253 100644 --- a/tests/unit/public-status/aggregation.test.ts +++ b/tests/unit/public-status/aggregation.test.ts @@ -34,8 +34,9 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:10:00.000Z", originalModel: "gpt-4.1", durationMs: 1000, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 80, providerChain: [ { @@ -52,8 +53,9 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:40:00.000Z", originalModel: "gpt-4.1", durationMs: 1400, - tfftMs: 300, - firstByteMs: 300, + ttfbMs: 300, + ttftMs: 400, + timingSemanticsVersion: 2, outputTokens: 60, providerChain: [ { @@ -103,8 +105,8 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:25:00.000Z", originalModel: "gpt-4.1", durationMs: 1500, - tfftMs: 500, - firstByteMs: 500, + ttfbMs: 500, + ttftMs: 500, outputTokens: null, providerChain: [ { @@ -229,8 +231,9 @@ describe("public-status aggregation", () => { createdAt: "2026-04-21T10:10:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -257,22 +260,78 @@ describe("public-status aggregation", () => { expect(failedModel?.availabilityPct).toBe(0); expect(failedModel?.latestTtfbMs).toBeNull(); + expect(failedModel?.latestTtftMs).toBeNull(); expect(failedModel?.latestTps).toBeNull(); expect(failedModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ sampleCount: 1, ttfbMs: null, + ttftMs: null, tps: null, }); expect(successfulModel?.availabilityPct).toBe(100); expect(successfulModel?.latestTtfbMs).toBe(200); + expect(successfulModel?.latestTtftMs).toBe(200); expect(successfulModel?.latestTps).toBe(50); expect(successfulModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ sampleCount: 1, ttfbMs: 200, + ttftMs: 200, tps: 50, }); }); + it("excludes legacy timing semantics while preserving availability", () => { + const result = buildPublicStatusPayloadFromRequests({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups: [ + { + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + requests: [ + { + id: 41, + createdAt: "2026-04-21T10:10:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 900, + ttftMs: 1000, + timingSemanticsVersion: null, + outputTokens: 50, + providerChain: [ + { + id: 401, + name: "legacy-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + ], + }); + + const model = result.groups[0]?.models[0]; + expect(model?.availabilityPct).toBe(100); + expect(model?.latestTtfbMs).toBeNull(); + expect(model?.latestTtftMs).toBeNull(); + expect(model?.latestTps).toBeNull(); + }); + it("uses originalModel before redirected model for grouping", () => { const result = buildPublicStatusPayloadFromRequests({ rangeHours: 1, diff --git a/tests/unit/public-status/read-store.test.ts b/tests/unit/public-status/read-store.test.ts index fca5ca0c1..7bfa69de6 100644 --- a/tests/unit/public-status/read-store.test.ts +++ b/tests/unit/public-status/read-store.test.ts @@ -462,6 +462,7 @@ describe("readPublicStatusPayload", () => { latestState: "operational", availabilityPct: 99.5, latestTtfbMs: 120, + latestTtftMs: null, latestTps: 4.2, timeline: [ { @@ -470,6 +471,7 @@ describe("readPublicStatusPayload", () => { state: "operational", availabilityPct: 99.5, ttfbMs: 120, + ttftMs: null, tps: 4.2, sampleCount: 10, }, @@ -686,6 +688,7 @@ describe("readPublicStatusPayload", () => { state: "operational", availabilityPct: 99.5, ttfbMs: 120, + ttftMs: null, tps: 4.2, sampleCount: 10, }, diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts index 466f9207d..11d0a67fb 100644 --- a/tests/unit/public-status/rollup-store.test.ts +++ b/tests/unit/public-status/rollup-store.test.ts @@ -42,8 +42,9 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -63,6 +64,8 @@ describe("public-status rollup store", () => { { groupId: "42", modelKey: "gpt-4.1", metric: "success", value: 1 }, { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttft_sum", value: 200 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttft_count", value: 1 }, { groupId: "42", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, { groupId: "42", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, ]) @@ -74,6 +77,34 @@ describe("public-status rollup store", () => { } }); + it("keeps availability but excludes legacy timing values from latency rollups", () => { + const increments = buildPublicStatusRollupIncrements({ + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 900, + ttftMs: 1000, + timingSemanticsVersion: 1, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "legacy-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(increments).toEqual([ + { groupId: "42", modelKey: "gpt-4.1", metric: "success", value: 1 }, + ]); + }); + it("excludes local/client failures from rollup counts", () => { const increments = buildPublicStatusRollupIncrements({ groups, @@ -150,8 +181,9 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -178,6 +210,8 @@ describe("public-status rollup store", () => { { groupId: "43", modelKey: "gpt-4.1", metric: "success", value: 1 }, { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttft_sum", value: 200 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttft_count", value: 1 }, { groupId: "43", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, { groupId: "43", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, ]) @@ -186,6 +220,8 @@ describe("public-status rollup store", () => { expect.arrayContaining([ expect.objectContaining({ groupId: "42", metric: "ttfb_sum" }), expect.objectContaining({ groupId: "42", metric: "ttfb_count" }), + expect.objectContaining({ groupId: "42", metric: "ttft_sum" }), + expect.objectContaining({ groupId: "42", metric: "ttft_count" }), expect.objectContaining({ groupId: "42", metric: "tps_sum" }), expect.objectContaining({ groupId: "42", metric: "tps_count" }), ]) @@ -206,6 +242,8 @@ describe("public-status rollup store", () => { [null, "1"], [null, "1"], [null, "1"], + [null, "1"], + [null, "1"], [null, "OK"], [null, 1], [null, 1], @@ -227,8 +265,9 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -255,6 +294,8 @@ describe("public-status rollup store", () => { result.key, result.key, result.key, + result.key, + result.key, ]); expect(pipeline.set).toHaveBeenCalledWith( buildPublicStatusRollupCoverageStartKey(), @@ -293,6 +334,8 @@ describe("public-status rollup store", () => { [null, "1"], [null, "1"], [null, "1"], + [null, "1"], + [null, "1"], [null, "OK"], [null, 1], [null, 1], @@ -312,8 +355,9 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -350,8 +394,9 @@ describe("public-status rollup store", () => { createdAt: "2026-04-21T10:02:00.000Z", originalModel: "gpt-4.1", durationMs: 1200, - tfftMs: 200, - firstByteMs: 200, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -538,5 +583,14 @@ describe("public-status rollup store", () => { modelKey: "vendor/model|v1", metric: "failure", }); + expect( + parsePublicStatusRollupField( + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "ttft_sum", + }) + ) + ).toEqual({ groupId: "42", modelKey: "gpt-4.1", metric: "ttft_sum" }); }); }); diff --git a/tests/unit/public-status/system-config-publish.test.ts b/tests/unit/public-status/system-config-publish.test.ts index d0448275e..ebb5573b8 100644 --- a/tests/unit/public-status/system-config-publish.test.ts +++ b/tests/unit/public-status/system-config-publish.test.ts @@ -5,7 +5,7 @@ const mockGetSystemSettings = vi.hoisted(() => vi.fn()); const mockUpdateSystemSettings = vi.hoisted(() => vi.fn()); const mockPublishCurrentPublicStatusConfigProjection = vi.hoisted(() => vi.fn()); const mockSchedulePublicStatusRebuild = vi.hoisted(() => vi.fn()); -const mockInvalidateSystemSettingsCache = vi.hoisted(() => vi.fn()); +const mockPrimeSystemSettingsCache = vi.hoisted(() => vi.fn()); const mockRevalidatePath = vi.hoisted(() => vi.fn()); vi.mock("@/lib/auth", () => ({ @@ -26,7 +26,7 @@ vi.mock("@/lib/public-status/rebuild-hints", () => ({ })); vi.mock("@/lib/config", () => ({ - invalidateSystemSettingsCache: mockInvalidateSystemSettingsCache, + primeSystemSettingsCache: mockPrimeSystemSettingsCache, })); vi.mock("next/cache", () => ({ @@ -89,7 +89,9 @@ describe("system settings public-status republish", () => { rangeHours: 48, reason: "system-settings-updated", }); - expect(mockInvalidateSystemSettingsCache).toHaveBeenCalled(); + expect(mockPrimeSystemSettingsCache).toHaveBeenCalledWith( + expect.objectContaining({ publicStatusWindowHours: 24 }) + ); expect(mockRevalidatePath).toHaveBeenCalled(); }); }); diff --git a/tests/unit/redis/leaderboard-cache.test.ts b/tests/unit/redis/leaderboard-cache.test.ts index f3ce0f6c4..66cb34561 100644 --- a/tests/unit/redis/leaderboard-cache.test.ts +++ b/tests/unit/redis/leaderboard-cache.test.ts @@ -102,7 +102,7 @@ describe("getLeaderboardWithCache", () => { true ); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:v2:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", + "leaderboard:v3:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", 60, JSON.stringify(rows) ); @@ -128,7 +128,7 @@ describe("getLeaderboardWithCache", () => { await getLeaderboardWithCache("daily", "USD", "userCacheHitRate"); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:v2:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", + "leaderboard:v3:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", 60, JSON.stringify(rows) ); diff --git a/tests/unit/repository/leaderboard-cache-coefficient.test.ts b/tests/unit/repository/leaderboard-cache-coefficient.test.ts index 7fe13de34..71cee95ca 100644 --- a/tests/unit/repository/leaderboard-cache-coefficient.test.ts +++ b/tests/unit/repository/leaderboard-cache-coefficient.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getProviderCacheCoefficients, + getProviderModelCacheCoefficients, resolveLeaderboardWindow, } from "@/repository/provider-cache-effectiveness"; @@ -97,6 +98,42 @@ describe("getProviderCacheCoefficients", () => { }); }); +describe("getProviderModelCacheCoefficients", () => { + beforeEach(() => { + dbMocks.groupBy.mockResolvedValue([]); + }); + + const window = { + start: new Date("2026-07-22T00:00:00Z"), + end: new Date("2026-07-22T01:00:00Z"), + }; + + it("recomputes coefficients independently for each provider and model", async () => { + dbMocks.groupBy.mockResolvedValue([ + { ...aggregateRow(7, "200", "150", "100000", "86000"), model: "model-a" }, + { ...aggregateRow(7, "5", "5", "100", "100"), model: "model-b" }, + { ...aggregateRow(8, "10", "10", "100", "50"), model: "model-a" }, + ]); + + const result = await getProviderModelCacheCoefficients(window); + + expect(result.get(7)?.get("model-a")).toEqual({ + providerId: 7, + model: "model-a", + coefficientBp: 6450, + sampleCount: 200, + }); + expect(result.get(7)?.get("model-b")?.coefficientBp).toBe(3000); + expect(result.get(8)?.get("model-a")?.coefficientBp).toBe(1500); + }); + + it("returns an empty map when no model windows fall inside the range", async () => { + const result = await getProviderModelCacheCoefficients(window); + + expect(result.size).toBe(0); + }); +}); + describe("resolveLeaderboardWindow", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index ac85f9ae8..1de6dc2e2 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ resolveSystemTimezone: vi.fn(), getSystemSettings: vi.fn(), getProviderCacheCoefficients: vi.fn(), + getProviderModelCacheCoefficients: vi.fn(), })); vi.mock("@/drizzle/db", () => ({ @@ -53,8 +54,9 @@ vi.mock("@/drizzle/schema", () => ({ successRateOutcome: "successRateOutcome", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -71,8 +73,9 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -96,6 +99,7 @@ vi.mock("@/repository/system-config", () => ({ vi.mock("@/repository/provider-cache-effectiveness", () => ({ getProviderCacheCoefficients: mocks.getProviderCacheCoefficients, + getProviderModelCacheCoefficients: mocks.getProviderModelCacheCoefficients, resolveLeaderboardWindow: () => ({ start: new Date(0), end: new Date() }), })); @@ -104,6 +108,18 @@ function coefficientMap(entries: Array<{ providerId: number; coefficientBp: numb return new Map(entries.map((e) => [e.providerId, { ...e, sampleCount: 100 }] as const)); } +function modelCoefficientMap( + entries: Array<{ providerId: number; model: string; coefficientBp: number }> +) { + const result = new Map>(); + for (const entry of entries) { + const providerModels = result.get(entry.providerId) ?? new Map(); + providerModels.set(entry.model, { ...entry, sampleCount: 100 }); + result.set(entry.providerId, providerModels); + } + return result; +} + describe("Provider Leaderboard Average Cost Metrics", () => { beforeEach(() => { vi.resetModules(); @@ -113,6 +129,7 @@ describe("Provider Leaderboard Average Cost Metrics", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); it("computes avgCostPerRequest = totalCost / totalRequests for valid denominators", async () => { @@ -282,9 +299,16 @@ describe("Provider Leaderboard Model Breakdown", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); it("includes modelStats when includeModelStats=true and excludes empty model names", async () => { + mocks.getProviderModelCacheCoefficients.mockResolvedValue( + modelCoefficientMap([ + { providerId: 1, model: "model-a", coefficientBp: 6400 }, + { providerId: 2, model: "model-c", coefficientBp: 3200 }, + ]) + ); chainMocks = [ createChainMock([ { @@ -362,6 +386,8 @@ describe("Provider Leaderboard Model Breakdown", () => { expect(p1!.modelStats).toBeDefined(); expect(p1!.modelStats).toHaveLength(2); expect(p1!.modelStats![0].model).toBe("model-a"); + expect(p1!.modelStats![0].cacheCoefficientBp).toBe(6400); + expect(p1!.modelStats![1].cacheCoefficientBp).toBeNull(); expect(p1!.modelStats![0].avgCostPerRequest).toBeCloseTo(6.0 / 60); expect(p1!.modelStats![0].avgCostPerMillionTokens).toBeCloseTo((6.0 * 1_000_000) / 600); @@ -370,9 +396,10 @@ describe("Provider Leaderboard Model Breakdown", () => { // Empty model must be excluded expect(p2!.modelStats).toHaveLength(1); expect(p2!.modelStats![0].model).toBe("model-c"); + expect(p2!.modelStats![0].cacheCoefficientBp).toBe(3200); }); - it("marks model-grain successRate as unavailable when billingModelSource is redirected", async () => { + it("keeps model-grain successRate available when billingModelSource is redirected", async () => { chainMocks = [ createChainMock([ { @@ -406,13 +433,13 @@ describe("Provider Leaderboard Model Breakdown", () => { expect(modelStat).toMatchObject({ model: "redirected-model", - successRate: null, + successRate: 0.9, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", }); + expect(modelStat).not.toHaveProperty("successRateUnavailableReason"); }); }); @@ -425,9 +452,13 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); it("includes modelStats field on cache-hit leaderboard entries", async () => { + mocks.getProviderModelCacheCoefficients.mockResolvedValue( + modelCoefficientMap([{ providerId: 1, model: "claude-3-opus", coefficientBp: 5700 }]) + ); chainMocks = [ createChainMock([ { @@ -470,6 +501,8 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { expect(Array.isArray(entry.modelStats)).toBe(true); expect(entry.modelStats).toHaveLength(2); expect(entry.modelStats[0].model).toBe("claude-3-opus"); + expect(entry.modelStats[0].cacheCoefficientBp).toBe(5700); + expect(entry.modelStats[1].cacheCoefficientBp).toBeNull(); }); it("falls back to cacheHitRate descending when no provider has a cache coefficient", async () => { @@ -681,6 +714,7 @@ describe("Provider Leaderboard Cache Coefficient", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); const usageRow = (providerId: number, providerName: string, totalCost: string) => ({ @@ -788,9 +822,10 @@ describe("Model Leaderboard basis handling", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); - it("marks top-level model successRate as unavailable when billingModelSource is redirected", async () => { + it("keeps top-level model successRate available when billingModelSource is redirected", async () => { chainMocks = [ createChainMock([ { @@ -808,12 +843,35 @@ describe("Model Leaderboard basis handling", () => { expect(result[0]).toMatchObject({ model: "redirected-model", - successRate: null, + successRate: 0.8, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", + }); + expect(result[0]).not.toHaveProperty("successRateUnavailableReason"); + }); + + it("keeps null successRate when redirected rows have no countable outcome", async () => { + chainMocks = [ + createChainMock([ + { + model: "redirected-model", + totalRequests: 12, + totalCost: "3.0", + totalTokens: 1200, + successRate: null, + }, + ]), + ]; + + const { findDailyModelLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyModelLeaderboard(); + + expect(result[0]).toMatchObject({ + successRate: null, + successRateBasis: "redirected", + basisDisclosureRequired: true, }); }); }); @@ -827,6 +885,7 @@ describe("Model Leaderboard sort order", () => { mocks.resolveSystemTimezone.mockResolvedValue("UTC"); mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); + mocks.getProviderModelCacheCoefficients.mockResolvedValue(new Map()); }); it("orders by total cost descending with request count as tiebreaker", async () => { diff --git a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts index 7cd5195ec..6c4aaa34c 100644 --- a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts +++ b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts @@ -87,8 +87,9 @@ vi.mock("@/drizzle/schema", () => ({ cacheReadInputTokens: "cacheReadInputTokens", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", statusCode: "statusCode", isSuccess: "isSuccess", @@ -108,8 +109,9 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", statusCode: "statusCode", model: "model", diff --git a/tests/unit/repository/leaderboard-tps-basis.test.ts b/tests/unit/repository/leaderboard-tps-basis.test.ts index 9b2f0167a..044653f48 100644 --- a/tests/unit/repository/leaderboard-tps-basis.test.ts +++ b/tests/unit/repository/leaderboard-tps-basis.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * 排行榜的两个延迟指标口径不同,必须分开: - * - 展示用的 avgTtfbMs 走 usage_ledger.ttfb_ms(该列存的是 TFFT) - * - avgTokensPerSecond 的分母必须是真 TTFB(first_byte_ms),历史行由 IS NOT NULL 排除 + * 排行榜的延迟指标必须分开: + * - avgTtfbMs 使用响应头到达时间 + * - avgTtftMs 使用首个有效内容到达时间 + * - avgTokensPerSecond 的生成窗口从 TTFT 开始 */ const createChainMock = (resolvedData: unknown[]) => ({ @@ -46,8 +47,9 @@ vi.mock("@/drizzle/schema", () => ({ successRateOutcome: "successRateOutcome", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -79,7 +81,7 @@ beforeEach(() => { }); describe("排行榜延迟指标口径", () => { - it("TPS 分母用 first_byte_ms,展示均值仍用 ttfb_ms 列", async () => { + it("TPS 使用 TTFT 生成窗口,并分别聚合 TTFB 与 TTFT", async () => { const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); await findDailyProviderLeaderboard(); @@ -90,11 +92,16 @@ describe("排行榜延迟指标口径", () => { expect(projection).toBeDefined(); const tpsSql = JSON.stringify(projection?.avgTokensPerSecond); - expect(tpsSql).toContain("firstByteMs"); - expect(tpsSql).not.toContain("tfftMs"); + expect(tpsSql).toContain("ttftMs"); + expect(tpsSql).not.toContain("ttfbMs"); + expect(tpsSql).toContain("timingSemanticsVersion"); - const avgLatencySql = JSON.stringify(projection?.avgTtfbMs); - expect(avgLatencySql).toContain("tfftMs"); - expect(avgLatencySql).not.toContain("firstByteMs"); + const avgTtfbSql = JSON.stringify(projection?.avgTtfbMs); + expect(avgTtfbSql).toContain("ttfbMs"); + expect(avgTtfbSql).not.toContain("ttftMs"); + + const avgTtftSql = JSON.stringify(projection?.avgTtftMs); + expect(avgTtftSql).toContain("ttftMs"); + expect(avgTtftSql).not.toContain("ttfbMs"); }); }); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts index 6e75b17e1..03b7a8e2c 100644 --- a/tests/unit/repository/leaderboard-user-model-stats.test.ts +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -83,8 +83,9 @@ vi.mock("@/drizzle/schema", () => ({ successRateOutcome: "successRateOutcome", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", model: "model", originalModel: "originalModel", @@ -101,8 +102,9 @@ vi.mock("@/drizzle/schema", () => ({ errorMessage: "errorMessage", blockedBy: "blockedBy", createdAt: "createdAt", - tfftMs: "tfftMs", - firstByteMs: "firstByteMs", + ttfbMs: "ttfbMs", + ttftMs: "ttftMs", + timingSemanticsVersion: "timingSemanticsVersion", durationMs: "durationMs", model: "model", originalModel: "originalModel", diff --git a/tests/unit/repository/message-public-readback.test.ts b/tests/unit/repository/message-public-readback.test.ts index 47b119bf6..d9e41749f 100644 --- a/tests/unit/repository/message-public-readback.test.ts +++ b/tests/unit/repository/message-public-readback.test.ts @@ -61,7 +61,9 @@ const MESSAGE_ROW = { ...LATEST_ROW, model: "gpt-4.1", originalModel: "gpt-4.1-mini", - tfftMs: 120, + ttfbMs: 80, + ttftMs: 120, + timingSemanticsVersion: 2, costMultiplier: "1.5", sessionId: "public-session", userAgent: "vitest", @@ -105,7 +107,9 @@ const LEDGER_ROW = { context1mApplied: false, swapCacheTtlApplied: true, durationMs: 1_500, - tfftMs: 250, + ttfbMs: 150, + ttftMs: 250, + timingSemanticsVersion: 2, sessionId: "ledger-session", createdAt: CREATED_AT, }; diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts index 70c96b18b..3cb257a90 100644 --- a/tests/unit/repository/message-public-status-rollup.test.ts +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -349,6 +349,9 @@ describe("repository/message public status rollup hook", () => { durationMs: 1_500, statusCode: 200, outputTokens: 10, + ttfbMs: 250, + ttftMs: 400, + timingSemanticsVersion: 2, providerChain: [{ id: 1, name: "provider-a", groupTag: "openai" }], model: "gpt-4.1", } satisfies Readonly; @@ -366,7 +369,12 @@ describe("repository/message public status rollup hook", () => { expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( expect.objectContaining({ - event: expect.objectContaining({ durationMs: 1_500 }), + event: expect.objectContaining({ + durationMs: 1_500, + ttfbMs: 250, + ttftMs: 400, + timingSemanticsVersion: 2, + }), }) ); }); @@ -440,7 +448,9 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - tfftMs: 200, + ttfbMs: 200, + ttftMs: 300, + timingSemanticsVersion: 2, outputTokens: 50, providerChain: [ { @@ -469,7 +479,9 @@ describe("repository/message public status rollup hook", () => { originalModel: "gpt-4.1", model: "gpt-4.1", outputTokens: 50, - tfftMs: 200, + ttfbMs: 200, + ttftMs: 300, + timingSemanticsVersion: 2, }), }) ); @@ -489,7 +501,9 @@ describe("repository/message public status rollup hook", () => { await updateMessageRequestDetails(202, { statusCode: 200, - tfftMs: 250, + ttfbMs: 250, + ttftMs: 400, + timingSemanticsVersion: 2, outputTokens: 75, providerChain: [ { @@ -513,7 +527,9 @@ describe("repository/message public status rollup hook", () => { originalModel: "gpt-4.1", model: "gpt-4.1", outputTokens: 75, - tfftMs: 250, + ttfbMs: 250, + ttftMs: 400, + timingSemanticsVersion: 2, }), }) ); @@ -577,7 +593,9 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - tfftMs: 300, + ttfbMs: 300, + ttftMs: 500, + timingSemanticsVersion: 2, outputTokens: 90, providerChain: [ { @@ -604,7 +622,9 @@ describe("repository/message public status rollup hook", () => { createdAt: new Date("2026-04-21T10:04:00.000Z"), durationMs: 1800, outputTokens: 90, - tfftMs: 300, + ttfbMs: 300, + ttftMs: 500, + timingSemanticsVersion: 2, }), }) ); @@ -661,7 +681,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - tfftMs: 300, + ttfbMs: 300, outputTokens: 90, providerChain: [ { @@ -750,7 +770,7 @@ describe("repository/message public status rollup hook", () => { const finalDetails = { statusCode: 200, - tfftMs: 320, + ttfbMs: 320, outputTokens: 95, providerChain: [ { @@ -776,7 +796,7 @@ describe("repository/message public status rollup hook", () => { createdAt: new Date("2026-04-21T10:06:00.000Z"), durationMs: 1900, outputTokens: 95, - tfftMs: 320, + ttfbMs: 320, }), }) ); diff --git a/tests/unit/repository/message-session-readback.test.ts b/tests/unit/repository/message-session-readback.test.ts index 895a85363..c5c2514d7 100644 --- a/tests/unit/repository/message-session-readback.test.ts +++ b/tests/unit/repository/message-session-readback.test.ts @@ -101,7 +101,9 @@ const LEDGER_ROW = { context1mApplied: true, swapCacheTtlApplied: false, durationMs: 1_200, - tfftMs: 200, + ttfbMs: 100, + ttftMs: 200, + timingSemanticsVersion: 2, sessionId: "ledger-session-readback", createdAt: CREATED_AT, }; diff --git a/tests/unit/repository/message-terminal-public-status-seam.test.ts b/tests/unit/repository/message-terminal-public-status-seam.test.ts index 33748d342..5470be792 100644 --- a/tests/unit/repository/message-terminal-public-status-seam.test.ts +++ b/tests/unit/repository/message-terminal-public-status-seam.test.ts @@ -77,6 +77,9 @@ describe("message terminal public-status public seam", () => { durationMs: 1_200, statusCode: 200, outputTokens: 60, + ttfbMs: 200, + ttftMs: 200, + timingSemanticsVersion: 2, providerChain: [ { id: 1, @@ -288,6 +291,26 @@ describe("message terminal public-status public seam", () => { const losingMetric = ownerOrder === "primary-first" ? "failure" : "success"; expect(rollupFields).toContain(`42|gpt-4.1|${expectedMetric}`); expect(rollupFields).not.toContain(`42|gpt-4.1|${losingMetric}`); + if (ownerOrder === "primary-first") { + expect(rollupFields).toEqual( + expect.arrayContaining([ + "42|gpt-4.1|ttfb_sum", + "42|gpt-4.1|ttfb_count", + "42|gpt-4.1|ttft_sum", + "42|gpt-4.1|ttft_count", + "42|gpt-4.1|tps_sum", + "42|gpt-4.1|tps_count", + ]) + ); + } else { + expect(rollupFields).not.toEqual( + expect.arrayContaining([ + "42|gpt-4.1|ttfb_sum", + "42|gpt-4.1|ttft_sum", + "42|gpt-4.1|tps_sum", + ]) + ); + } await stopMessageRequestWriteBuffer(); } @@ -303,7 +326,7 @@ describe("message terminal public-status public seam", () => { statusCode: 502, inputTokens: 31, outputTokens: 3, - tfftMs: 900, + ttfbMs: 900, providerChain: [ { id: 11, @@ -321,7 +344,7 @@ describe("message terminal public-status public seam", () => { durationMs: 1_500, statusCode: 200, outputTokens: 96, - tfftMs: 300, + ttfbMs: 300, providerChain: [ { id: 22, @@ -337,7 +360,7 @@ describe("message terminal public-status public seam", () => { const row: TerminalRow & { inputTokens: number | null; outputTokens: number | null; - tfftMs: number | null; + ttfbMs: number | null; providerChain: unknown; providerId: number | null; } = { @@ -349,7 +372,7 @@ describe("message terminal public-status public seam", () => { statusCode: null, inputTokens: null, outputTokens: null, - tfftMs: null, + ttfbMs: null, providerChain: null, providerId: null, }; @@ -385,7 +408,7 @@ describe("message terminal public-status public seam", () => { row.statusCode = Number(readCaseValue("status_code")); row.inputTokens = Number(readCaseValue("input_tokens")); row.outputTokens = Number(readCaseValue("output_tokens")); - row.tfftMs = Number(readCaseValue("ttfb_ms")); + row.ttfbMs = Number(readCaseValue("ttfb_ms")); row.providerChain = JSON.parse(String(readCaseValue("provider_chain"))); row.providerId = Number(readCaseValue("provider_id")); return [{ id }]; @@ -525,7 +548,7 @@ describe("message terminal public-status public seam", () => { statusCode: oldFailureDetails.statusCode, inputTokens: oldFailureDetails.inputTokens, outputTokens: oldFailureDetails.outputTokens, - tfftMs: oldFailureDetails.tfftMs, + ttfbMs: oldFailureDetails.ttfbMs, providerChain: oldFailureDetails.providerChain, providerId: oldFailureDetails.providerId, }); diff --git a/tests/unit/repository/message-terminal-write-apis.test.ts b/tests/unit/repository/message-terminal-write-apis.test.ts index 6dfd4bc27..ba7d06717 100644 --- a/tests/unit/repository/message-terminal-write-apis.test.ts +++ b/tests/unit/repository/message-terminal-write-apis.test.ts @@ -245,7 +245,9 @@ describe("message terminal write APIs", () => { const details = { inputTokens: 101, outputTokens: 23, - tfftMs: null, + ttfbMs: null, + ttftMs: null, + timingSemanticsVersion: 2, cacheCreationInputTokens: 7, cacheReadInputTokens: 8, cacheCreation5mInputTokens: 3, diff --git a/tests/unit/repository/message-usage-logs-query.test.ts b/tests/unit/repository/message-usage-logs-query.test.ts index fe945e5a4..02971619b 100644 --- a/tests/unit/repository/message-usage-logs-query.test.ts +++ b/tests/unit/repository/message-usage-logs-query.test.ts @@ -154,7 +154,9 @@ describe("message repository findUsageLogs", () => { context1mApplied: true, swapCacheTtlApplied: false, durationMs: 250, - tfftMs: 40, + ttfbMs: 20, + ttftMs: 40, + timingSemanticsVersion: 2, sessionId: "session-ledger", createdAt, }, diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts index a3c0fefb8..c302e5f27 100644 --- a/tests/unit/repository/message-write-buffer.test.ts +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -154,7 +154,12 @@ describe("message_request 异步批量写入", () => { } = await import("@/repository/message-write-buffer"); enqueueMessageRequestUpdate(42, { durationMs: 100 }); - enqueueMessageRequestUpdate(42, { statusCode: 200, tfftMs: 10 }); + enqueueMessageRequestUpdate(42, { + statusCode: 200, + ttfbMs: 5, + ttftMs: 10, + timingSemanticsVersion: 2, + }); await flushMessageRequestWriteBuffer(); await stopMessageRequestWriteBuffer(); @@ -168,6 +173,8 @@ describe("message_request 异步批量写入", () => { expect(built.sql).toContain("duration_ms"); expect(built.sql).toContain("status_code"); expect(built.sql).toContain("ttfb_ms"); + expect(built.sql).toContain("ttft_ms"); + expect(built.sql).toContain("timing_semantics_version"); expect(built.sql).toContain("updated_at"); expect(built.sql).toContain("deleted_at IS NULL"); expect(built.sql).not.toContain("RETURNING id"); @@ -1439,6 +1446,7 @@ describe("message_request 异步批量写入", () => { statusCode: 200, durationMs: 100, ttfbMs: 50, + ttftMs: 70, attemptsPerRequest: 2, maxActiveAttempts: 2, rounds: 1, diff --git a/tests/unit/repository/provider-endpoints-probe-result.test.ts b/tests/unit/repository/provider-endpoints-probe-result.test.ts index 31c8b3444..e88dc8b22 100644 --- a/tests/unit/repository/provider-endpoints-probe-result.test.ts +++ b/tests/unit/repository/provider-endpoints-probe-result.test.ts @@ -1,5 +1,24 @@ import { describe, expect, test, vi } from "vitest"; +function sqlToString(value: unknown): string { + const seen = new Set(); + const visit = (node: unknown): string => { + if (node == null || seen.has(node)) return ""; + if (typeof node === "string") return node; + if (typeof node !== "object") return String(node); + seen.add(node); + if (Array.isArray(node)) return node.map(visit).join(" "); + + const record = node as Record; + if (typeof record.name === "string") return record.name; + if (Array.isArray(record.value)) return record.value.map(visit).join(" "); + if (record.value != null) return visit(record.value); + if (record.queryChunks != null) return visit(record.queryChunks); + return ""; + }; + return visit(value); +} + describe("provider-endpoints repository - recordProviderEndpointProbeResult", () => { test("endpoint 不存在/已删除时应静默忽略(不写 probe log)", async () => { vi.resetModules(); @@ -38,6 +57,7 @@ describe("provider-endpoints repository - recordProviderEndpointProbeResult", () ).resolves.toBeUndefined(); expect(updateMock).toHaveBeenCalledTimes(1); + expect(setMock).toHaveBeenCalledWith(expect.objectContaining({ consecutiveProbeFailures: 0 })); expect(insertMock).not.toHaveBeenCalled(); expect(valuesMock).not.toHaveBeenCalled(); }); @@ -77,6 +97,11 @@ describe("provider-endpoints repository - recordProviderEndpointProbeResult", () }); expect(updateMock).toHaveBeenCalledTimes(1); + const updatePatch = setMock.mock.calls[0]?.[0] as Record; + expect(sqlToString(updatePatch.consecutiveProbeFailures)).toContain( + "consecutive_probe_failures" + ); + expect(sqlToString(updatePatch.consecutiveProbeFailures)).toContain("+ 1"); expect(insertMock).toHaveBeenCalledTimes(1); expect(valuesMock).toHaveBeenCalledTimes(1); expect(valuesMock).toHaveBeenCalledWith( diff --git a/tests/unit/repository/system-config-degradation-ladder.test.ts b/tests/unit/repository/system-config-degradation-ladder.test.ts index 0996e8b72..c7c436370 100644 --- a/tests/unit/repository/system-config-degradation-ladder.test.ts +++ b/tests/unit/repository/system-config-degradation-ladder.test.ts @@ -7,6 +7,7 @@ import type { UpdateSystemSettingsInput } from "@/types/system-config"; // 近代新增列(最新在前),降级链按引入顺序逐层累计剥离。 const RECENT_COLUMNS = [ + "sessionSnapshotStore", "cacheEffectivenessEnabled", "replayEnabled", "affinityIgnoreClientSessionId", @@ -29,6 +30,7 @@ const RECENT_COLUMNS = [ // 全量字段集(46 列)。 const FULL_COLUMNS = [ + "sessionSnapshotStore", "cacheEffectivenessEnabled", "replayEnabled", "affinityIgnoreClientSessionId", @@ -189,7 +191,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const selectMock = vi.fn((selection: Record) => { selections.push(sortedKeys(selection)); callIndex += 1; - if (callIndex < 20) { + if (callIndex < 21) { return createRejectingSelectQuery({ code: "42703" }); } return createResolvingSelectQuery([ @@ -222,14 +224,14 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const result = await getSystemSettings(); - expect(selectMock).toHaveBeenCalledTimes(20); - // 第 19 次(近代链末层)不含这些新列;第 20 次(passThrough 世代)重新包含旧列。 - expect(selections[18]).not.toContain("enableThinkingEffortConflictRectifier"); - expect(selections[18]).not.toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[18]).toContain("passThroughUpstreamErrorMessage"); - expect(selections[19]).toContain("enableThinkingEffortConflictRectifier"); - expect(selections[19]).toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[19]).not.toContain("passThroughUpstreamErrorMessage"); + expect(selectMock).toHaveBeenCalledTimes(21); + // 第 20 次(近代链末层)不含这些新列;第 21 次(passThrough 世代)重新包含旧列。 + expect(selections[19]).not.toContain("enableThinkingEffortConflictRectifier"); + expect(selections[19]).not.toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[19]).toContain("passThroughUpstreamErrorMessage"); + expect(selections[20]).toContain("enableThinkingEffortConflictRectifier"); + expect(selections[20]).toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[20]).not.toContain("passThroughUpstreamErrorMessage"); // 世代字段集选出的真实值要透传,缺失列由 transformer 落默认值。 expect(result.siteTitle).toBe("Era Row"); @@ -309,7 +311,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "system_settings 表列缺失,请执行数据库迁移以升级数据库结构。" ); - expect(updateMock).toHaveBeenCalledTimes(22); + expect(updateMock).toHaveBeenCalledTimes(23); const expectedReturningSequence = [ [...FULL_COLUMNS], diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index 9b26aed05..17f8be0f6 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -299,10 +299,11 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { vi.setSystemTime(now); // 第一次 select(fullSelection) 因新列缺失而抛 42703; - // 第二次 select(去掉 cacheEffectivenessEnabled)命中——验证新列已加入降级链最外层。 + // 第二次先剥离更新的 sessionSnapshotStore,第三次再剥离 cacheEffectivenessEnabled。 const selectMock = vi .fn() .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) + .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) .mockReturnValueOnce( createThenableQuery([ { @@ -335,23 +336,27 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { const result = await getSystemSettings(); // 降级读取成功(未抛错),缺失列由 transformer 落默认值。 - expect(selectMock).toHaveBeenCalledTimes(2); + expect(selectMock).toHaveBeenCalledTimes(3); expect(result.siteTitle).toBe("CC Hub"); expect(result.enableHttp2).toBe(true); expect(result.affinityIgnoreClientSessionId).toBe(true); expect(result.streamGateMode).toBe("enforce"); - // 关键回归保护:第二次 select 必须恰好剥离了最新列(最外层降级), - // 而非旧行为先剥离更早引入的列。若新列未加入降级链最外层,下面断言会失败。 + // 关键回归保护:降级链必须按 migration 新旧顺序逐层剥离。 const secondSelection = selectMock.mock.calls[1]?.[0] as Record; - expect(secondSelection).not.toHaveProperty("cacheEffectivenessEnabled"); - expect(secondSelection).toHaveProperty("replayEnabled"); - expect(secondSelection).toHaveProperty("affinityIgnoreClientSessionId"); - expect(secondSelection).toHaveProperty("streamGateMode"); - expect(secondSelection).toHaveProperty("stickyTimeoutCooldownMs"); - expect(secondSelection).toHaveProperty("racingTotalTimeoutMs"); - expect(secondSelection).toHaveProperty("enableGeminiFunctionIdRectifier"); - expect(secondSelection).toHaveProperty("enableThinkingEffortConflictRectifier"); + expect(secondSelection).not.toHaveProperty("sessionSnapshotStore"); + expect(secondSelection).toHaveProperty("cacheEffectivenessEnabled"); + + const thirdSelection = selectMock.mock.calls[2]?.[0] as Record; + expect(thirdSelection).not.toHaveProperty("sessionSnapshotStore"); + expect(thirdSelection).not.toHaveProperty("cacheEffectivenessEnabled"); + expect(thirdSelection).toHaveProperty("replayEnabled"); + expect(thirdSelection).toHaveProperty("affinityIgnoreClientSessionId"); + expect(thirdSelection).toHaveProperty("streamGateMode"); + expect(thirdSelection).toHaveProperty("stickyTimeoutCooldownMs"); + expect(thirdSelection).toHaveProperty("racingTotalTimeoutMs"); + expect(thirdSelection).toHaveProperty("enableGeminiFunctionIdRectifier"); + expect(thirdSelection).toHaveProperty("enableThinkingEffortConflictRectifier"); vi.useRealTimers(); }); diff --git a/tests/unit/repository/usage-logs-actual-response-model.test.ts b/tests/unit/repository/usage-logs-actual-response-model.test.ts index b057d226b..b24ebe0e8 100644 --- a/tests/unit/repository/usage-logs-actual-response-model.test.ts +++ b/tests/unit/repository/usage-logs-actual-response-model.test.ts @@ -50,7 +50,9 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { groupCostMultiplier: null, costBreakdown: null, durationMs: 500, - tfftMs: 100, + ttfbMs: 60, + ttftMs: 100, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, @@ -118,7 +120,9 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { costMultiplier: null, groupCostMultiplier: null, durationMs: 400, - tfftMs: 80, + ttfbMs: 50, + ttftMs: 80, + timingSemanticsVersion: 2, clientIp: null, context1mApplied: false, swapCacheTtlApplied: false, @@ -178,7 +182,9 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { costMultiplier: null, groupCostMultiplier: null, durationMs: 0, - tfftMs: 0, + ttfbMs: 0, + ttftMs: 0, + timingSemanticsVersion: 2, clientIp: null, context1mApplied: false, swapCacheTtlApplied: false, diff --git a/tests/unit/repository/usage-logs-sessionid-filter.test.ts b/tests/unit/repository/usage-logs-sessionid-filter.test.ts index 3ace29fc2..aa9218d3d 100644 --- a/tests/unit/repository/usage-logs-sessionid-filter.test.ts +++ b/tests/unit/repository/usage-logs-sessionid-filter.test.ts @@ -137,7 +137,9 @@ describe("Usage logs sessionId filter", () => { costUsd: "0.01", costMultiplier: null, durationMs: 10, - tfftMs: 5, + ttfbMs: 2, + ttftMs: 5, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, @@ -171,7 +173,9 @@ describe("Usage logs sessionId filter", () => { costUsd: "0.01", costMultiplier: null, durationMs: 10, - tfftMs: 5, + ttfbMs: 2, + ttftMs: 5, + timingSemanticsVersion: 2, errorMessage: null, providerChain: null, blockedBy: null, diff --git a/tests/unit/server-shutdown.test.ts b/tests/unit/server-shutdown.test.ts index caa628d97..c0665555c 100644 --- a/tests/unit/server-shutdown.test.ts +++ b/tests/unit/server-shutdown.test.ts @@ -252,6 +252,9 @@ describe.sequential("registerOrchestratedShutdown", () => { process.env.SHUTDOWN_HARD_EXIT_MS = "1000"; vi.doMock("@/lib/cache/session-cache", () => ({ stopCacheCleanup: () => {} })); + vi.doMock("@/lib/session-snapshot/store", () => ({ + stopSessionSnapshotStores: async () => {}, + })); vi.doMock("@/lib/provider-endpoints/probe-scheduler", () => ({ stopEndpointProbeScheduler: () => {}, })); diff --git a/tests/unit/settings/system-settings-form-replay-cache-toggles.test.tsx b/tests/unit/settings/system-settings-form-replay-cache-toggles.test.tsx index 712aad6de..ab0283972 100644 --- a/tests/unit/settings/system-settings-form-replay-cache-toggles.test.tsx +++ b/tests/unit/settings/system-settings-form-replay-cache-toggles.test.tsx @@ -68,6 +68,7 @@ const baseSettings = { // null = 跟随环境变量:本组用例的核心前置 replayEnabled: null, cacheEffectivenessEnabled: null, + sessionSnapshotStore: "filesystem", } satisfies Pick< SystemSettings, | "siteTitle" @@ -101,6 +102,7 @@ const baseSettings = { | "ipExtractionConfig" | "replayEnabled" | "cacheEffectivenessEnabled" + | "sessionSnapshotStore" >; function loadMessages(locale: string) { @@ -163,6 +165,7 @@ describe("SystemSettingsForm replay/cache-effectiveness null 三态", () => { expect.objectContaining({ replayEnabled: null, cacheEffectivenessEnabled: null, + sessionSnapshotStore: "filesystem", }) ); diff --git a/vitest.config.ts b/vitest.config.ts index 520c48fcd..a22b9d349 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -36,11 +36,14 @@ export default defineConfig({ // UI 配置 // Vitest UI/Server 使用的是 test.api(不是 Vite 的 server 配置) // 默认仅允许本机访问,避免浏览器尝试连接 0.0.0.0 导致 UI 显示 Disconnected - api: { - host: process.env.VITEST_API_HOST || "127.0.0.1", - port: Number(process.env.VITEST_API_PORT || 51204), - strictPort: false, - }, + api: + process.env.VITEST_API_DISABLED === "1" + ? false + : { + host: process.env.VITEST_API_HOST || "127.0.0.1", + port: Number(process.env.VITEST_API_PORT || 51204), + strictPort: false, + }, open: false, // 不自动打开浏览器(手动访问 http://localhost:51204/__vitest__/) // ==================== 覆盖率配置 ====================