From 063564c85c82153fd4666ab0ae0aff282335bf10 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:11:42 +0800 Subject: [PATCH 01/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D=E5=88=86?= =?UTF-8?q?=E5=B7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .teamwork/sync/gpt-to-opus.md | 108 ++---------------- .teamwork/sync/status.json | 22 ++-- ...56\351\242\230\344\277\256\345\244\215.md" | 38 ++++++ task.md | 12 +- 4 files changed, 69 insertions(+), 111 deletions(-) create mode 100644 ".teamwork/tasks/2026-07-11_\345\256\241\350\256\241\351\227\256\351\242\230\344\277\256\345\244\215.md" diff --git a/.teamwork/sync/gpt-to-opus.md b/.teamwork/sync/gpt-to-opus.md index 50356bba7..8316323fb 100644 --- a/.teamwork/sync/gpt-to-opus.md +++ b/.teamwork/sync/gpt-to-opus.md @@ -1,101 +1,15 @@ -# 给 Claude/Opus 的执行任务:J 项 usage_snapshots 无变化写入去重 +# 给修复子代理的任务:审计问题并行修复 -发起方:CodeX-GPT -任务时间:2026-06-24T16:31:02+08:00 -工作目录:`C:\code\CodeX\Codex-Manager-CE` -目标分支:`hardening/main` +发起方:【CodeX-GPT】 +任务时间:2026-07-11T15:07:30+08:00 +基线提交:`ba56918d611c910daf1cf9a52d370cc105b6fe17` -## 背景 +## 执行要求 -`task.md` 的 J 项仍真实存在:生产写入路径 `crates/service/src/usage/usage_snapshot_store.rs::store_usage_snapshot()` 每次刷新都会: +- 各子代理必须使用独立 Git worktree 和独立分支。 +- 只修改分配范围,使用中文提交信息,禁止 `git add .`。 +- 每个分支必须写入独立 `.teamwork/progress/` 结果文件,记录修改、测试、commit 和未验证项。 +- 不得把密钥、Token、Cookie 或用户隐私写入协作文件。 +- 主代理将独立审计 diff 和测试,不以子代理声明作为完成依据。 -1. `parse_usage_snapshot()` -2. 构造 `UsageSnapshotRecord` -3. `storage.insert_usage_snapshot(&record)` -4. `prune_usage_snapshots_for_account(account_id, retain)` - -即使服务端返回的用量关键字段完全没变化,也会 INSERT 新行再 prune。大账号池 + 高频刷新时会造成 `usage_snapshots` 和 WAL 写放大。 - -## 目标 - -完成 J 项:用量快照在关键字段未变化时不要 append 新行。 - -## 约束 - -1. 不要改变对外用量状态语义:`apply_status_from_snapshot()` 仍应基于本次解析结果执行。 -2. 不要因为 skipped insert 而丢失“最近刷新时间”的表达能力。推荐方案是更新最新行 `captured_at`,或者通过等价轻量路径维持 latest 语义。 -3. 比较关键字段时应覆盖: - - `used_percent` - - `window_minutes` - - `resets_at` - - `secondary_used_percent` - - `secondary_window_minutes` - - `secondary_resets_at` - - `credits_json` -4. `credits_json` 比较要避免简单字符串格式差异造成误判。如果当前项目没有规范化 JSON helper,可以使用 `serde_json::Value` 语义比较;若决定做字符串比较,必须解释风险并补测试覆盖稳定序列化。 -5. 不要 `git add .`;只暂存本任务相关文件。 -6. 新增注释必须使用简体中文。 -7. 不要把密钥、cookie、token、身份证号、手机号写入协作文件。 - -## 推荐实现方向 - -小步方案: - -1. 在 storage 层新增一个方法,例如: - - `update_latest_usage_snapshot_captured_at_for_account(account_id, captured_at) -> Result` - - 或 `upsert_usage_snapshot_if_changed(&record) -> Result` -2. 在 service 层 `store_usage_snapshot()` 中读取 `latest_usage_snapshot_for_account(account_id)`,比较关键字段。 -3. 如果字段未变: - - 不调用 `insert_usage_snapshot()` - - 更新最新快照 `captured_at` 为本次时间 - - 可跳过 `prune_usage_snapshots_for_account()`,因为没有新增行 - - 仍调用 `apply_status_from_snapshot(storage, &record)` -4. 如果字段变化: - - 保持原 insert + prune 行为 - -更优方案: - -- 把“比较 + insert/update”尽量收口到 storage,service 只处理解析和状态更新。 -- 但不要做大范围表结构迁移,除非你证明必须。 - -## 必须验证 - -至少运行并记录: - -```powershell -cargo test -p codexmanager-core --lib usage_snapshot -cargo test -p codexmanager-service --lib usage_snapshot -cargo check -p codexmanager-service -``` - -如果 filter 不匹配导致 `0 tests`,必须换成真实能跑到新增/修改测试的命令,不能把 `0 tests` 当通过。 - -建议新增测试覆盖: - -- 相同关键字段连续 store 两次,`usage_snapshot_count_for_account(account_id)` 不增加。 -- 相同关键字段第二次 store 后,最新行 `captured_at` 更新为新时间或 latest 语义能体现新刷新。 -- 任一关键字段变化时会新增快照。 -- `credits_json` 语义相同但字段顺序不同不应新增快照(如果采用语义比较)。 - -## 交付要求 - -完成后写入 `.teamwork/sync/opus-to-gpt.md`,至少包含: - -- 修改摘要 -- 产出文件 -- 关键设计取舍 -- 实际运行的验证命令与结果 -- git commit hash -- 未验证项或剩余风险 - -然后将 `.teamwork/sync/status.json` 更新为 `waiting_for_gpt`,`last_actor` 写你的身份。 - -## 审计提醒 - -CodeX-GPT 会独立复核: - -1. 是否真的避免相同快照 append。 -2. 是否保留 latest/captured_at 语义。 -3. 是否仍在字段变化时新增记录。 -4. 是否没有把测试 fixture 的 `insert_usage_snapshot()` 当生产路径误改。 -5. diff 是否只包含 J 项相关文件。 +详细分工见 `.teamwork/tasks/2026-07-11_审计问题修复.md`。 diff --git a/.teamwork/sync/status.json b/.teamwork/sync/status.json index 7235ca6d6..aaeac63b5 100644 --- a/.teamwork/sync/status.json +++ b/.teamwork/sync/status.json @@ -1,17 +1,17 @@ { - "status": "completed", - "task": "usage-snapshot-dedup-j", - "created_at": "2026-06-24T16:31:02+08:00", - "last_update": "2026-06-24T17:28:09+08:00", + "status": "opus_working", + "task": "audit-fix-followups-20260711", + "created_at": "2026-07-11T15:07:30+08:00", + "last_update": "2026-07-11T15:07:30+08:00", "iteration": 1, "max_iterations": 3, "last_actor": "CodeX-GPT", - "current_agent": "CodeX-GPT", - "next_agent": null, - "workflow": "J项usage_snapshots无变化写入去重", - "description": "相同用量快照不再append新行,只更新latest captured_at,降低usage_snapshots/WAL写放大", + "current_agent": "frontend-backend-release-subagents", + "next_agent": "CodeX-GPT", + "workflow": "审计问题三分支并行修复与主代理复核", + "description": "修复 Web RPC、权限缓存、日志脱敏、Docker 镜像与数据库工具安全默认值", "priority": "P0", - "phase": "completed", - "audit_result": "PASS", - "tests_status": "PASS" + "phase": "implementation", + "audit_result": null, + "tests_status": "pending" } diff --git "a/.teamwork/tasks/2026-07-11_\345\256\241\350\256\241\351\227\256\351\242\230\344\277\256\345\244\215.md" "b/.teamwork/tasks/2026-07-11_\345\256\241\350\256\241\351\227\256\351\242\230\344\277\256\345\244\215.md" new file mode 100644 index 000000000..4d71338bc --- /dev/null +++ "b/.teamwork/tasks/2026-07-11_\345\256\241\350\256\241\351\227\256\351\242\230\344\277\256\345\244\215.md" @@ -0,0 +1,38 @@ +# 2026-07-11 审计问题修复任务 + +## 目标 + +修复 CodeX-GPT 独立审计确认的前端/Web、后端安全与缓存、发布/工具治理问题,并由主代理完成二次审计、测试和 PR。 + +## 分工 + +### 前端/Web 子任务 + +- 补齐 5 个 Web command 映射。 +- 删除模型价格错误重复映射并增加完整性测试。 +- 更新 direct-mode 统计测试,恢复 `test:runtime`。 +- 修复 Tauri 因旧 `out/index.html` 跳过构建的问题。 +- 同步 `/platform-mode` 根路由清单。 + +### 后端安全与缓存子任务 + +- 单删、批删账号后立即失效候选缓存。 +- 将候选缓存和 single-flight 按 low quota mode 隔离。 +- 收紧 member 对全局账号池 RPC 的权限。 +- 删除 OAuth 登录成功日志中的敏感标识。 +- 为上述行为补定向测试。 + +### 发布/工具治理子任务 + +- 将 release compose 和多语言部署文档切换到 CE GHCR 镜像。 +- 移除 `db-optimize` 的本机默认路径。 +- 默认只检查,不自动执行 `VACUUM`;破坏性操作必须显式参数授权。 +- 补脚本/文档验证。 + +## 主代理审计要求 + +1. 检查每个子分支的 diff 与提交范围。 +2. 独立运行前端 runtime/build、Rust 定向及 workspace 测试。 +3. 检查权限边界、缓存失效、日志脱敏和部署镜像。 +4. 只将审计通过的提交集成到 `audit/fix-followups`。 +5. 推送后创建 PR,并以【CodeX-GPT】身份提交审计评论。 diff --git a/task.md b/task.md index 21c4cbd0f..3af9e095b 100644 --- a/task.md +++ b/task.md @@ -4,7 +4,13 @@ ## 当前待处理(2026-07-07) -1. P2 上游差异巡检 +1. P0 审计问题修复与独立复核(🔄 进行中) + - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 + - 后端:修复账号删除后的候选缓存失效、成员账号池权限边界、OAuth 日志脱敏及多模式候选缓存隔离。 + - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 + - 主代理负责逐提交审计、独立测试、集成分支和 PR,不直接采纳子代理完成声明。 + +2. P2 上游差异巡检 - 当前上游基准:`upstream/main = a614b559 docs: tidy repository links in readme`。 - 已确认:`09223f6f` / `f3efb3a2` 不能整包移植,只能拆成页面或组件级小项;`a614b559` 为 README 链接整理但包含 AtomGit / Gitee / 官网 / 赞助入口,不按 CE 当前 README 直接移植。 - 已完成拆分小项:模型页搜索框 focus 反馈、Codex CLI 引导弹窗密度压缩、开发态 Web runtime rewrites、Switch 对比度。 @@ -12,11 +18,11 @@ - 禁止项:作者页、赞助、远程 author content、AtomGit 推广、上游整包 README/docs 推广内容。 - 保留项:README 中的 Linux.do 认可社区入口需要保留,不能按作者/赞助推广残留误删。 -2. P2 分支 / PR 治理 +3. P2 分支 / PR 治理 - 当前 fork 与 upstream 分叉较大,对外 PR 应从干净分支 cherry-pick 关键提交。 - 不建议整包提交当前 CE 主线到 upstream;先按主题拆分,确保每个 PR 都能独立审计。 -3. P2 低优先级性能观察 +4. P2 低优先级性能观察 - 候选缓存 stale-while-revalidate 可选评估:single-flight 已完成,SWR 还需确认是否会延长低额度 / 封禁账号的旧快照使用窗口。 - 请求体 JSON parse 深水区继续观察:本地校验、多候选 `prompt_cache_key` 提取、compact transport、非原生 Responses 默认 `stream=true` 后文本长度校验复用、Official Responses 标准化后 Value 复用、request rewrite 输出 Value 旁路已收敛;local count tokens、WebSocket 包装等路径仍需按风险继续拆小项评估。 From a48e8c7eb49b6bd74d989fcfa3c3638c6543ec41 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:30:26 +0800 Subject: [PATCH 02/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20CE=20=E5=8F=91=E5=B8=83=E9=95=9C=E5=83=8F=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...41\350\256\241\344\277\256\345\244\215.md" | 36 ++ Cargo.toml | 6 + crates/db-optimize/src/main.rs | 446 +++++++++++++----- docker/docker-compose.release.yml | 4 +- .../en/report/runtime-and-deployment-guide.md | 2 +- ...4-\352\260\200\354\235\264\353\223\234.md" | 2 +- ...20\262\320\260\320\275\320\270\321\216.md" | 2 +- ...50\347\275\262\346\214\207\345\215\227.md" | 2 +- 8 files changed, 387 insertions(+), 113 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_\345\217\221\345\270\203\345\267\245\345\205\267\345\256\241\350\256\241\344\277\256\345\244\215.md" diff --git "a/.teamwork/progress/2026-07-11_\345\217\221\345\270\203\345\267\245\345\205\267\345\256\241\350\256\241\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-07-11_\345\217\221\345\270\203\345\267\245\345\205\267\345\256\241\350\256\241\344\277\256\345\244\215.md" new file mode 100644 index 000000000..c11216183 --- /dev/null +++ "b/.teamwork/progress/2026-07-11_\345\217\221\345\270\203\345\267\245\345\205\267\345\256\241\350\256\241\344\277\256\345\244\215.md" @@ -0,0 +1,36 @@ +# 发布与数据库工具审计修复进度 + +## 任务状态 + +- 状态:✅ 已完成,等待主代理独立审计 +- 执行方:【CodeX-GPT】发布/工具修复子代理 +- 分支:`audit/release-fixes` + +## 修复范围 + +1. 将 Release Compose 与中、英、韩、俄部署文档中的 GHCR 镜像归属统一为 CE 仓库所有者 `CreatorEdition` 对应的小写命名空间 `creatoredition`。 +2. 移除 `db-optimize` 的本机数据库默认路径,要求显式提供数据库文件。 +3. 将数据库工具默认行为改为只读检查;仅显式传入 `--vacuum` 时才允许 checkpoint 与 VACUUM 锁库操作。 +4. 为缺失路径、未知参数、冲突参数和执行失败提供中文错误信息与非零退出码。 +5. 保留 `db-optimize` 作为 workspace 成员以便定向维护和测试,但从 `default-members` 移除,避免普通 workspace 构建误带一次性运维工具;Release workflow 使用显式 `-p` 构建服务产物,因此不受影响。 + +## 历史治理说明 + +- `.teamwork/sync/` 是当前多 AI 协作协议的一部分,本次不删除、不改写同步状态文件。 +- 本次不修改或删除任何 Git tag。 +- 数据库工具此前存在本机绝对路径和默认执行 VACUUM 的高风险行为;该历史问题仅在本进度文件记录,不扩散到发布产物说明。 + +## 验证结果 + +- ✅ `cargo fmt --all --check` +- ✅ `cargo check -p db-optimize` +- ✅ `cargo test -p db-optimize`:8 项测试全部通过,覆盖默认只读检查、显式 VACUUM、路径不存在不创建空库及参数错误。 +- ✅ 可执行命令验证:无参数退出码为 2,未知参数退出码为 2,`--help` 退出码为 0,均输出中文说明。 +- ✅ `cargo metadata --no-deps --format-version 1`:`db-optimize` 仍是 workspace 成员,但不属于 `workspace_default_members`。 +- ✅ Release workflow 仍通过 `${{ github.repository_owner }}` 转小写后发布镜像;Compose 与四语部署文档已统一到 `ghcr.io/creatoredition/`。 +- ✅ `rg -n 'ghcr\.io/qxcnm/' docker docs` 无匹配。 + +## 未验证项 + +- 未执行完整 `cargo test --workspace`;本次 Rust 改动隔离在 `db-optimize`,已运行定向 check/test。 +- 未实际推送 GHCR 镜像或拉起 Release Compose;需要主代理在集成分支或 Release workflow 中验证远端包权限与镜像可拉取性。 diff --git a/Cargo.toml b/Cargo.toml index 5750969d7..2474faa09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,12 @@ members = [ "crates/web", "crates/db-optimize" ] +default-members = [ + "crates/core", + "crates/service", + "crates/start", + "crates/web" +] exclude = [ "apps/src-tauri" ] diff --git a/crates/db-optimize/src/main.rs b/crates/db-optimize/src/main.rs index 112b73bf4..8e5c6c861 100644 --- a/crates/db-optimize/src/main.rs +++ b/crates/db-optimize/src/main.rs @@ -1,118 +1,350 @@ -use rusqlite::Connection; +use rusqlite::{Connection, OpenFlags}; use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; -fn main() { - let args: Vec = env::args().collect(); - let db_path = args - .get(1) - .map(|s| s.to_string()) - .unwrap_or_else(|| r"D:\Apps\CodexManager\codexmanager.db".to_string()); - - let check_only = args.iter().any(|arg| arg == "--check-only"); - - println!("正在连接数据库: {}", db_path); - - match Connection::open(&db_path) { - Ok(conn) => { - println!("执行 WAL checkpoint (TRUNCATE)..."); - - // PRAGMA wal_checkpoint 返回结果,需要用 query_row - match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { - Ok(( - row.get::<_, i32>(0)?, - row.get::<_, i32>(1)?, - row.get::<_, i32>(2)?, - )) - }) { - Ok((busy, log, checkpointed)) => { - println!("WAL checkpoint 完成"); - println!( - " Busy: {}, Log: {}, Checkpointed: {}", - busy, log, checkpointed - ); - } - Err(e) => { - eprintln!("WAL checkpoint 失败: {}", e); - return; +const VACUUM_RECOMMENDATION_THRESHOLD: i64 = 1000; +const HELP: &str = r#"数据库检查与空间回收工具 + +用法: + db-optimize <数据库路径> [--vacuum] + db-optimize <数据库路径> [--check-only] + db-optimize --help + +说明: + 默认以只读方式检查数据库,不执行 WAL checkpoint 或 VACUUM。 + 只有显式传入 --vacuum 才会以读写方式打开数据库并执行锁库操作。 + --check-only 为兼容旧命令保留,与默认行为相同。 +"#; + +#[derive(Debug, PartialEq, Eq)] +enum OperationMode { + Check, + Vacuum, +} + +#[derive(Debug, PartialEq, Eq)] +struct CliOptions { + db_path: PathBuf, + mode: OperationMode, +} + +#[derive(Debug, PartialEq, Eq)] +enum CliAction { + Run(CliOptions), + Help, +} + +#[derive(Debug, Clone, Copy)] +struct DatabaseStats { + page_count: i64, + page_size: i64, + freelist_count: i64, +} + +/// 解析命令行参数,并拒绝缺失路径、未知参数和相互冲突的模式。 +fn parse_args(args: impl IntoIterator) -> Result { + let args: Vec = args.into_iter().collect(); + if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") { + return Ok(CliAction::Help); + } + if args.is_empty() { + return Err("缺少数据库路径".to_string()); + } + + let mut db_path = None; + let mut vacuum = false; + let mut check_only = false; + + for arg in args { + match arg.as_str() { + "--vacuum" => { + if vacuum { + return Err("参数 --vacuum 不能重复".to_string()); } + vacuum = true; } - - // 检查页面统计 - let page_count: i64 = conn - .query_row("PRAGMA page_count;", [], |row| row.get(0)) - .unwrap_or(0); - - let page_size: i64 = conn - .query_row("PRAGMA page_size;", [], |row| row.get(0)) - .unwrap_or(4096); - - let freelist_count: i64 = conn - .query_row("PRAGMA freelist_count;", [], |row| row.get(0)) - .unwrap_or(0); - - println!("\n数据库统计:"); - println!(" 总页数: {}", page_count); - println!(" 页大小: {} bytes", page_size); - println!(" 空闲页: {}", freelist_count); - println!( - " 总大小: {:.2} MB", - (page_count * page_size) as f64 / 1024.0 / 1024.0 - ); - println!( - " 空闲空间: {:.2} MB", - (freelist_count * page_size) as f64 / 1024.0 / 1024.0 - ); - - if freelist_count > 1000 { + "--check-only" => { if check_only { - println!("\n检测到较多空闲页 ({}), 建议执行 VACUUM", freelist_count); - println!("注意:VACUUM 会锁定数据库,请在应用停止时执行"); - } else { - println!( - "\n检测到较多空闲页 ({}), 开始执行 VACUUM...", - freelist_count - ); - println!("注意:VACUUM 会锁定数据库并需要较长时间"); - - match conn.execute("VACUUM;", []) { - Ok(_) => { - println!("VACUUM 执行成功"); - - // 重新检查统计 - let new_page_count: i64 = conn - .query_row("PRAGMA page_count;", [], |row| row.get(0)) - .unwrap_or(0); - let new_freelist: i64 = conn - .query_row("PRAGMA freelist_count;", [], |row| row.get(0)) - .unwrap_or(0); - - println!("\nVACUUM 后统计:"); - println!(" 总页数: {} → {}", page_count, new_page_count); - println!(" 空闲页: {} → {}", freelist_count, new_freelist); - println!( - " 总大小: {:.2} MB → {:.2} MB", - (page_count * page_size) as f64 / 1024.0 / 1024.0, - (new_page_count * page_size) as f64 / 1024.0 / 1024.0 - ); - println!( - " 回收空间: {:.2} MB", - ((page_count - new_page_count) * page_size) as f64 - / 1024.0 - / 1024.0 - ); - } - Err(e) => { - eprintln!("VACUUM 执行失败: {}", e); - eprintln!("可能原因:数据库正在被其他进程使用"); - } - } + return Err("参数 --check-only 不能重复".to_string()); } - } else { - println!("\n空闲页数量正常 ({}), 无需 VACUUM", freelist_count); + check_only = true; + } + "--help" | "-h" => return Err("帮助参数不能与其他参数同时使用".to_string()), + value if value.starts_with('-') => return Err(format!("未知参数: {value}")), + value => { + if db_path.is_some() { + return Err(format!("只能提供一个数据库路径,多余参数: {value}")); + } + db_path = Some(PathBuf::from(value)); + } + } + } + + if vacuum && check_only { + return Err("--vacuum 与 --check-only 不能同时使用".to_string()); + } + + let db_path = db_path.ok_or_else(|| "缺少数据库路径".to_string())?; + let mode = if vacuum { + OperationMode::Vacuum + } else { + OperationMode::Check + }; + + Ok(CliAction::Run(CliOptions { db_path, mode })) +} + +/// 校验数据库路径,避免 SQLite 因输入错误而创建新的空数据库。 +fn validate_db_path(db_path: &Path) -> Result<(), String> { + let metadata = fs::metadata(db_path) + .map_err(|error| format!("数据库路径不可访问 {}: {error}", db_path.display()))?; + if !metadata.is_file() { + return Err(format!("数据库路径不是文件: {}", db_path.display())); + } + Ok(()) +} + +/// 读取 SQLite 页面统计,任何读取错误都会使命令以非零状态退出。 +fn collect_stats(conn: &Connection) -> Result { + let page_count = conn + .query_row("PRAGMA page_count;", [], |row| row.get(0)) + .map_err(|error| format!("读取 page_count 失败: {error}"))?; + let page_size = conn + .query_row("PRAGMA page_size;", [], |row| row.get(0)) + .map_err(|error| format!("读取 page_size 失败: {error}"))?; + let freelist_count = conn + .query_row("PRAGMA freelist_count;", [], |row| row.get(0)) + .map_err(|error| format!("读取 freelist_count 失败: {error}"))?; + + Ok(DatabaseStats { + page_count, + page_size, + freelist_count, + }) +} + +/// 将 SQLite 页数换算为 MiB,避免整数乘法溢出。 +fn pages_to_mib(pages: i64, page_size: i64) -> f64 { + pages as f64 * page_size as f64 / 1024.0 / 1024.0 +} + +/// 输出数据库页面统计。 +fn print_stats(title: &str, stats: DatabaseStats) { + println!("\n{title}:"); + println!(" 总页数: {}", stats.page_count); + println!(" 页大小: {} bytes", stats.page_size); + println!(" 空闲页: {}", stats.freelist_count); + println!( + " 总大小: {:.2} MiB", + pages_to_mib(stats.page_count, stats.page_size) + ); + println!( + " 空闲空间: {:.2} MiB", + pages_to_mib(stats.freelist_count, stats.page_size) + ); +} + +/// 执行只读检查,不运行可能改变数据库或需要排他锁的操作。 +fn run_check(db_path: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|error| format!("无法以只读方式打开数据库 {}: {error}", db_path.display()))?; + let stats = collect_stats(&conn)?; + print_stats("数据库统计", stats); + + if stats.freelist_count > VACUUM_RECOMMENDATION_THRESHOLD { + println!( + "\n检测到较多空闲页({}),可在停止应用后显式执行 --vacuum", + stats.freelist_count + ); + } else { + println!("\n空闲页数量正常({}),无需 VACUUM", stats.freelist_count); + } + + Ok(()) +} + +/// 在用户显式授权后执行 checkpoint 与 VACUUM,并输出前后统计。 +fn run_vacuum(db_path: &Path) -> Result<(), String> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_WRITE) + .map_err(|error| format!("无法以读写方式打开数据库 {}: {error}", db_path.display()))?; + + println!("警告:即将执行 WAL checkpoint 与 VACUUM,请确认应用已停止"); + let (busy, log, checkpointed) = conn + .query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { + Ok(( + row.get::<_, i32>(0)?, + row.get::<_, i32>(1)?, + row.get::<_, i32>(2)?, + )) + }) + .map_err(|error| format!("WAL checkpoint 失败: {error}"))?; + if busy != 0 { + return Err(format!( + "WAL checkpoint 未完成,数据库可能仍在使用中(busy={busy}, log={log}, checkpointed={checkpointed})" + )); + } + println!("WAL checkpoint 完成(log={log}, checkpointed={checkpointed})"); + + let before = collect_stats(&conn)?; + print_stats("VACUUM 前统计", before); + conn.execute_batch("VACUUM;") + .map_err(|error| format!("VACUUM 执行失败,请确认应用已停止: {error}"))?; + let after = collect_stats(&conn)?; + print_stats("VACUUM 后统计", after); + println!( + " 回收空间: {:.2} MiB", + pages_to_mib( + before.page_count.saturating_sub(after.page_count), + before.page_size + ) + ); + + Ok(()) +} + +/// 根据解析后的模式执行只读检查或显式空间回收。 +fn run(options: CliOptions) -> Result<(), String> { + validate_db_path(&options.db_path)?; + println!("正在连接数据库: {}", options.db_path.display()); + + match options.mode { + OperationMode::Check => run_check(&options.db_path), + OperationMode::Vacuum => run_vacuum(&options.db_path), + } +} + +/// 命令行入口,参数或执行错误统一返回非零退出码。 +fn main() -> ExitCode { + match parse_args(env::args().skip(1)) { + Ok(CliAction::Help) => { + print!("{HELP}"); + ExitCode::SUCCESS + } + Ok(CliAction::Run(options)) => match run(options) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("错误:{error}"); + ExitCode::FAILURE } + }, + Err(error) => { + eprintln!("错误:{error}\n\n{HELP}"); + ExitCode::from(2) } - Err(e) => { - eprintln!("无法打开数据库: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// 将测试参数转换为命令行字符串列表。 + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + /// 创建独立的临时 SQLite 数据库,供读写行为测试使用。 + fn create_temp_db() -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("系统时间应晚于 Unix 纪元") + .as_nanos(); + let path = env::temp_dir().join(format!( + "db-optimize-test-{}-{timestamp}.db", + std::process::id() + )); + let conn = Connection::open(&path).expect("应创建临时 SQLite 数据库"); + conn.execute_batch( + "CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT);\ + INSERT INTO sample(value) VALUES ('测试数据');", + ) + .expect("应写入临时测试数据"); + drop(conn); + path + } + + /// 删除临时数据库及 SQLite 可能生成的旁路文件。 + fn remove_temp_db(path: &Path) { + for candidate in [ + path.to_path_buf(), + PathBuf::from(format!("{}-wal", path.display())), + PathBuf::from(format!("{}-shm", path.display())), + ] { + let _ = fs::remove_file(candidate); } } + + #[test] + fn default_mode_is_read_only_check() { + let action = parse_args(args(&["test.db"])).expect("应成功解析数据库路径"); + assert_eq!( + action, + CliAction::Run(CliOptions { + db_path: PathBuf::from("test.db"), + mode: OperationMode::Check, + }) + ); + } + + #[test] + fn vacuum_requires_explicit_flag() { + let action = parse_args(args(&["--vacuum", "test.db"])).expect("应成功解析 VACUUM 模式"); + assert_eq!( + action, + CliAction::Run(CliOptions { + db_path: PathBuf::from("test.db"), + mode: OperationMode::Vacuum, + }) + ); + } + + #[test] + fn legacy_check_only_flag_remains_read_only() { + let action = parse_args(args(&["test.db", "--check-only"])).expect("应兼容旧检查参数"); + assert_eq!( + action, + CliAction::Run(CliOptions { + db_path: PathBuf::from("test.db"), + mode: OperationMode::Check, + }) + ); + } + + #[test] + fn missing_path_is_rejected() { + let error = parse_args(Vec::::new()).expect_err("缺少路径必须失败"); + assert!(error.contains("缺少数据库路径")); + } + + #[test] + fn unknown_flag_is_rejected() { + let error = parse_args(args(&["test.db", "--force"])).expect_err("未知参数必须失败"); + assert!(error.contains("未知参数")); + } + + #[test] + fn conflicting_modes_are_rejected() { + let error = parse_args(args(&["test.db", "--vacuum", "--check-only"])) + .expect_err("冲突模式必须失败"); + assert!(error.contains("不能同时使用")); + } + + #[test] + fn check_and_explicit_vacuum_handle_existing_database() { + let db_path = create_temp_db(); + run_check(&db_path).expect("默认只读检查应成功"); + run_vacuum(&db_path).expect("显式 VACUUM 应成功"); + remove_temp_db(&db_path); + } + + #[test] + fn invalid_path_does_not_create_database() { + let path = env::temp_dir().join(format!("db-optimize-missing-{}.db", std::process::id())); + remove_temp_db(&path); + validate_db_path(&path).expect_err("不存在的路径必须失败"); + assert!(!path.exists(), "路径校验不得创建空数据库"); + } } diff --git a/docker/docker-compose.release.yml b/docker/docker-compose.release.yml index bd4570a2c..6de607be5 100644 --- a/docker/docker-compose.release.yml +++ b/docker/docker-compose.release.yml @@ -1,6 +1,6 @@ services: codexmanager-service: - image: ghcr.io/qxcnm/codexmanager-service:${CODEXMANAGER_RELEASE_TAG:?set CODEXMANAGER_RELEASE_TAG} + image: ghcr.io/creatoredition/codexmanager-service:${CODEXMANAGER_RELEASE_TAG:?set CODEXMANAGER_RELEASE_TAG} restart: unless-stopped environment: CODEXMANAGER_SERVICE_ADDR: 0.0.0.0:48760 @@ -12,7 +12,7 @@ services: - "48760:48760" codexmanager-web: - image: ghcr.io/qxcnm/codexmanager-web:${CODEXMANAGER_RELEASE_TAG:?set CODEXMANAGER_RELEASE_TAG} + image: ghcr.io/creatoredition/codexmanager-web:${CODEXMANAGER_RELEASE_TAG:?set CODEXMANAGER_RELEASE_TAG} restart: unless-stopped depends_on: codexmanager-service: diff --git a/docs/en/report/runtime-and-deployment-guide.md b/docs/en/report/runtime-and-deployment-guide.md index f0d3ac46c..1b2e7acb7 100644 --- a/docs/en/report/runtime-and-deployment-guide.md +++ b/docs/en/report/runtime-and-deployment-guide.md @@ -92,7 +92,7 @@ You can still set `CODEXMANAGER_WEB_ROOT=/path/to/out` when you intentionally wa ### GitHub Packages / GHCR - After a Release is published, both `codexmanager-service` and `codexmanager-web` images are pushed to GitHub Packages (GHCR). -- Pull the corresponding release tag, for example: `docker pull ghcr.io/qxcnm/codexmanager-service:v0.1.15` +- Pull the corresponding release tag, for example: `docker pull ghcr.io/creatoredition/codexmanager-service:v0.1.15` - [`docker/docker-compose.release.yml`](../../../docker/docker-compose.release.yml) in the repository also points directly to GHCR. Set `CODEXMANAGER_RELEASE_TAG` before use. - Example: `CODEXMANAGER_RELEASE_TAG=v0.1.15 docker compose -f docker/docker-compose.release.yml up -d` diff --git "a/docs/ko/report/\354\213\244\355\226\211-\353\260\217-\353\260\260\355\217\254-\352\260\200\354\235\264\353\223\234.md" "b/docs/ko/report/\354\213\244\355\226\211-\353\260\217-\353\260\260\355\217\254-\352\260\200\354\235\264\353\223\234.md" index 0d686751b..2611b90b9 100644 --- "a/docs/ko/report/\354\213\244\355\226\211-\353\260\217-\353\260\260\355\217\254-\352\260\200\354\235\264\353\223\234.md" +++ "b/docs/ko/report/\354\213\244\355\226\211-\353\260\217-\353\260\260\355\217\254-\352\260\200\354\235\264\353\223\234.md" @@ -72,7 +72,7 @@ wire_api = "responses" ### GitHub 패키지/GHCR - 릴리스가 출시된 후 `codexmanager-service` 및 `codexmanager-web` 이미지가 GitHub 패키지(GHCR)에 동시에 푸시됩니다. -- 해당 릴리스 태그를 당기기만 하면 됩니다. 예: `docker pull ghcr.io/qxcnm/codexmanager-service:v0.1.15` +- 해당 릴리스 태그를 당기기만 하면 됩니다. 예: `docker pull ghcr.io/creatoredition/codexmanager-service:v0.1.15` - 저장소의 [`docker/docker-compose.release.yml`](../../../docker/docker-compose.release.yml) 역시 GHCR을 직접 참조하므로, 사용 전에 `CODEXMANAGER_RELEASE_TAG`을 설정하세요. - 예: `CODEXMANAGER_RELEASE_TAG=v0.1.15 docker compose -f docker/docker-compose.release.yml up -d` diff --git "a/docs/ru/report/\320\240\321\203\320\272\320\276\320\262\320\276\320\264\321\201\321\202\320\262\320\276-\320\277\320\276-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-\320\270-\321\200\320\260\320\267\320\262\320\265\321\200\321\202\321\213\320\262\320\260\320\275\320\270\321\216.md" "b/docs/ru/report/\320\240\321\203\320\272\320\276\320\262\320\276\320\264\321\201\321\202\320\262\320\276-\320\277\320\276-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-\320\270-\321\200\320\260\320\267\320\262\320\265\321\200\321\202\321\213\320\262\320\260\320\275\320\270\321\216.md" index 65defd1fb..93cf5d40a 100644 --- "a/docs/ru/report/\320\240\321\203\320\272\320\276\320\262\320\276\320\264\321\201\321\202\320\262\320\276-\320\277\320\276-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-\320\270-\321\200\320\260\320\267\320\262\320\265\321\200\321\202\321\213\320\262\320\260\320\275\320\270\321\216.md" +++ "b/docs/ru/report/\320\240\321\203\320\272\320\276\320\262\320\276\320\264\321\201\321\202\320\262\320\276-\320\277\320\276-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-\320\270-\321\200\320\260\320\267\320\262\320\265\321\200\321\202\321\213\320\262\320\260\320\275\320\270\321\216.md" @@ -72,7 +72,7 @@ wire_api = "responses" ### GitHub Пакеты / GHCR - После выпуска Release образы `codexmanager-service` и `codexmanager-web` будут одновременно перенесены в пакеты GitHub (GHCR). -- Просто потяните соответствующий тег выпуска, например: `docker pull ghcr.io/qxcnm/codexmanager-service:v0.1.15`. +- Просто потяните соответствующий тег выпуска, например: `docker pull ghcr.io/creatoredition/codexmanager-service:v0.1.15`. - [`docker/docker-compose.release.yml`](../../../docker/docker-compose.release.yml) на складе также напрямую ссылается на GHCR, установите `CODEXMANAGER_RELEASE_TAG` перед использованием. - Пример: `CODEXMANAGER_RELEASE_TAG=v0.1.15 docker compose -f docker/docker-compose.release.yml up -d` diff --git "a/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" "b/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" index bd41277e0..5281a3cbf 100644 --- "a/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" +++ "b/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" @@ -110,7 +110,7 @@ wire_api = "responses" ### GitHub Packages / GHCR - Release 发布后会同时推送 `codexmanager-service` 和 `codexmanager-web` 镜像到 GitHub Packages(GHCR)。 -- 直接拉取对应发布 tag 即可,例如:`docker pull ghcr.io/qxcnm/codexmanager-service:v0.1.15` +- 直接拉取对应发布 tag 即可,例如:`docker pull ghcr.io/creatoredition/codexmanager-service:v0.1.15` - 仓库里的 [`docker/docker-compose.release.yml`](../../../docker/docker-compose.release.yml) 也直接引用 GHCR,使用前先设置 `CODEXMANAGER_RELEASE_TAG`。 - 例如:`CODEXMANAGER_RELEASE_TAG=v0.1.15 docker compose -f docker/docker-compose.release.yml up -d` From e00e80d2649eb01da742565db533d0824143ff59 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:37:53 +0800 Subject: [PATCH 03/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E8=A1=A5=E9=BD=90W?= =?UTF-8?q?eb=E5=91=BD=E4=BB=A4=E6=98=A0=E5=B0=84=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=9E=84=E5=BB=BA=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...41\350\256\241\344\277\256\345\244\215.md" | 43 +++++++++++ apps/src-tauri/scripts/before-build.mjs | 9 --- .../lib/api/transport-web-commands/account.ts | 1 + .../transport-web-commands/aggregate-api.ts | 1 + .../lib/api/transport-web-commands/apikey.ts | 4 +- .../lib/api/transport-web-commands/misc.ts | 1 + .../lib/api/transport-web-commands/quota.ts | 1 + apps/src/lib/routes/root-page-paths.ts | 1 + apps/tests/dashboard-direct-mode.test.mjs | 39 +++------- apps/tests/tauri-command-registry.test.mjs | 9 +++ apps/tests/top-level-routes.test.mjs | 9 +++ apps/tests/transport-web-commands.test.mjs | 75 +++++++++++++++++++ 12 files changed, 154 insertions(+), 39 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_\345\211\215\347\253\257Web\345\256\241\350\256\241\344\277\256\345\244\215.md" diff --git "a/.teamwork/progress/2026-07-11_\345\211\215\347\253\257Web\345\256\241\350\256\241\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-07-11_\345\211\215\347\253\257Web\345\256\241\350\256\241\344\277\256\345\244\215.md" new file mode 100644 index 000000000..d0fd617b9 --- /dev/null +++ "b/.teamwork/progress/2026-07-11_\345\211\215\347\253\257Web\345\256\241\350\256\241\344\277\256\345\244\215.md" @@ -0,0 +1,43 @@ +# 前端 / Web 审计问题修复 + +执行方:【CodeX-GPT】前端修复子代理 + +## 状态 + +✅ 已完成,等待主代理独立审核。 + +## 修改摘要 + +- 补齐账号、平台 Key、聚合 API、模型池来源和请求错误摘要共 5 个 Web command 映射。 +- 删除 API Key command 模块中错误的模型价格重复键,保留服务端真实的 `modelPriceRules/*` RPC 映射。 +- 新增 Web command 静态调用完整性、跨模块重复命令键及关键 RPC 名称回归测试。 +- 按混合路由正确语义更新首页与日志页测试:当前路由模式不应遮挡已经写入 CodexManager 的历史统计。 +- 删除 Tauri 生产构建对既有 `out/index.html` 的跳过逻辑,确保每次打包都重新生成静态产物。 +- 将 `/platform-mode` 同步加入 `ROOT_PAGE_PATHS`,并增加回归覆盖。 + +## 验证结果 + +- `corepack pnpm -C apps exec node --test tests/transport-web-commands.test.mjs tests/dashboard-direct-mode.test.mjs tests/tauri-command-registry.test.mjs tests/top-level-routes.test.mjs`:32 项全部通过。 +- `corepack pnpm -C apps run test:runtime`:113 项全部通过。 +- `corepack pnpm -C apps run build`:未通过;当前 worktree 的 `apps/node_modules` 是指向仓库外部目录的联接,Turbopack 拒绝越出 filesystem root。 +- `corepack pnpm -C apps run build --webpack`:通过,15 个静态页面全部生成,包含 `/platform-mode`。 +- `git diff --check`:通过。 + +## Git 提交 + +- 分支:`audit/frontend-fixes` +- 提交:本文件与修复代码同一提交,最终哈希以该分支 Git 历史为准。 + +## 未验证项 + +- 未执行完整 Tauri 安装包构建;本轮已用脚本回归测试确认生产前置脚本不再因旧 `out/index.html` 提前退出。 +- 未在无目录联接的独立依赖目录下重跑 Turbopack;Webpack 生产构建已通过,可证明 TypeScript、静态导出和页面生成正常。 + +## 自检 + +- 功能完整性:通过。 +- 代码质量与防回归:通过。 +- 文档同步:已写入本进度文件;未新增环境变量或改变用户启动流程,无需修改 README。 +- 依赖检查:未新增依赖。 +- 安全审查:未引入敏感信息或权限放宽。 +- 可测试性:完整 runtime 门禁恢复为全绿。 diff --git a/apps/src-tauri/scripts/before-build.mjs b/apps/src-tauri/scripts/before-build.mjs index 89917c8be..2ab3dc243 100644 --- a/apps/src-tauri/scripts/before-build.mjs +++ b/apps/src-tauri/scripts/before-build.mjs @@ -22,10 +22,6 @@ function hasFrontendPackage(dir) { return existsSync(resolve(dir, "package.json")); } -function hasBuiltFrontendDist(dir) { - return existsSync(resolve(dir, "out", "index.html")); -} - function canConnect(host, port, timeoutMs = 1000) { return new Promise((resolvePromise) => { const socket = new net.Socket(); @@ -289,11 +285,6 @@ if (!frontendDir) { process.exit(1); } -if (task === "build:desktop" && hasBuiltFrontendDist(frontendDir)) { - console.log(`前端产物已存在,跳过重复构建: ${resolve(frontendDir, "out", "index.html")}`); - process.exit(0); -} - if (task === "dev:desktop") { if (await hasReusableDesktopDevServer()) { console.log(`检测到现有前端开发服务,直接复用: http://${desktopDevHost}:${desktopDevPort}`); diff --git a/apps/src/lib/api/transport-web-commands/account.ts b/apps/src/lib/api/transport-web-commands/account.ts index e18082749..c033542b0 100644 --- a/apps/src/lib/api/transport-web-commands/account.ts +++ b/apps/src/lib/api/transport-web-commands/account.ts @@ -9,6 +9,7 @@ import { exportAccountsViaBrowser, pickImportFilesFromBrowser } from "./browser- export function createAccountWebCommands(postWebRpc: WebRpcCaller): Record { return { service_account_list: { rpcMethod: "account/list" }, + service_account_lookup: { rpcMethod: "account/lookup" }, service_account_delete: { rpcMethod: "account/delete" }, service_account_delete_many: { rpcMethod: "account/deleteMany", diff --git a/apps/src/lib/api/transport-web-commands/aggregate-api.ts b/apps/src/lib/api/transport-web-commands/aggregate-api.ts index 955647fa1..148913d53 100644 --- a/apps/src/lib/api/transport-web-commands/aggregate-api.ts +++ b/apps/src/lib/api/transport-web-commands/aggregate-api.ts @@ -8,6 +8,7 @@ import { export function createAggregateApiWebCommands(): Record { return { service_aggregate_api_list: { rpcMethod: "aggregateApi/list" }, + service_aggregate_api_lookup: { rpcMethod: "aggregateApi/lookup" }, service_aggregate_api_create: { rpcMethod: "aggregateApi/create" }, service_aggregate_api_update: { rpcMethod: "aggregateApi/update" }, service_aggregate_api_delete: { rpcMethod: "aggregateApi/delete" }, diff --git a/apps/src/lib/api/transport-web-commands/apikey.ts b/apps/src/lib/api/transport-web-commands/apikey.ts index 9be1a402c..7f67faa6b 100644 --- a/apps/src/lib/api/transport-web-commands/apikey.ts +++ b/apps/src/lib/api/transport-web-commands/apikey.ts @@ -4,6 +4,7 @@ import { asRecord, mapKeyIdToId } from "./shared"; export function createApiKeyWebCommands(): Record { return { service_apikey_list: { rpcMethod: "apikey/list" }, + service_apikey_lookup: { rpcMethod: "apikey/lookup" }, service_apikey_create: { rpcMethod: "apikey/create" }, service_apikey_usage_stats: { rpcMethod: "apikey/usageStats" }, service_apikey_delete: { rpcMethod: "apikey/delete", mapParams: mapKeyIdToId }, @@ -20,9 +21,6 @@ export function createApiKeyWebCommands(): Record service_model_source_model_save: { rpcMethod: "apikey/modelSourceModelSave", mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {} }, service_model_source_mapping_save: { rpcMethod: "apikey/modelSourceMappingSave", mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {} }, service_model_source_mapping_delete: { rpcMethod: "apikey/modelSourceMappingDelete", mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {} }, - service_model_price_rules_list: { rpcMethod: "quota/modelPriceRules/list" }, - service_model_price_rule_read: { rpcMethod: "quota/modelPriceRule/read" }, - service_model_price_rule_upsert: { rpcMethod: "quota/modelPriceRule/upsert", mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {} }, service_apikey_read_secret: { rpcMethod: "apikey/readSecret", mapParams: mapKeyIdToId }, }; } diff --git a/apps/src/lib/api/transport-web-commands/misc.ts b/apps/src/lib/api/transport-web-commands/misc.ts index 9fd4f15c2..0f9634ea5 100644 --- a/apps/src/lib/api/transport-web-commands/misc.ts +++ b/apps/src/lib/api/transport-web-commands/misc.ts @@ -31,6 +31,7 @@ export function createMiscWebCommands(): Record { timeoutMessage: "RPC requestlog/summary 超时:请求日志摘要查询超过 30 秒", }, }, + service_requestlog_error_summary: { rpcMethod: "requestlog/errorSummary" }, service_requestlog_clear: { rpcMethod: "requestlog/clear", requestOptions: noRetryTimeoutOptions( diff --git a/apps/src/lib/api/transport-web-commands/quota.ts b/apps/src/lib/api/transport-web-commands/quota.ts index ed004fe4d..5d534e455 100644 --- a/apps/src/lib/api/transport-web-commands/quota.ts +++ b/apps/src/lib/api/transport-web-commands/quota.ts @@ -23,6 +23,7 @@ export function createQuotaWebCommands(): Record { timeoutMessage: "RPC quota/modelPoolSummary 超时:模型池容量汇总超过 30 秒", }, }, + service_quota_model_pool_sources: { rpcMethod: "quota/modelPoolSources" }, service_quota_system_pool: { rpcMethod: "quota/systemPool" }, service_quota_capacity_config: { rpcMethod: "quota/capacityConfig" }, service_quota_billing_rules: { rpcMethod: "quota/billingRules" }, diff --git a/apps/src/lib/routes/root-page-paths.ts b/apps/src/lib/routes/root-page-paths.ts index 00a6170d8..27450a148 100644 --- a/apps/src/lib/routes/root-page-paths.ts +++ b/apps/src/lib/routes/root-page-paths.ts @@ -3,6 +3,7 @@ export const ROOT_PAGE_PATHS = [ "/accounts", "/account-manager", "/aggregate-api", + "/platform-mode", "/apikeys", "/models", "/model-groups", diff --git a/apps/tests/dashboard-direct-mode.test.mjs b/apps/tests/dashboard-direct-mode.test.mjs index 0180af03a..bac7503b5 100644 --- a/apps/tests/dashboard-direct-mode.test.mjs +++ b/apps/tests/dashboard-direct-mode.test.mjs @@ -13,38 +13,23 @@ async function readSource(relativePath) { return fs.readFile(path.join(appsRoot, relativePath), "utf8"); } -test("账号直连模式下会遮罩依赖网关请求日志的仪表盘区域", async () => { +test("账号直连与混合模式均展示已经写入 CodexManager 的统计数据", async () => { const source = await readDashboardSource(); - assert.match(source, /useCodexProfileModeStatus/); - assert.match(source, /function DirectModeUnavailable/); - assert.match(source, /未经过本地网关的请求不可统计/); - assert.match(source, /请求经过 CodexManager 本地网关后可统计请求日志、Token 和费用/); - assert.match(source, /buildStaticRouteUrl\("\/platform-mode"\)/); - assert.match(source, /当前为账号直连模式/); - assert.match( - source, - /当前 CLI 直连 OpenAI,未经过 CodexManager 的请求不会产生请求日志、Token 和费用统计。/, - ); - assert.match( - source, - /\s*\s*
/s, - ); - assert.match( - source, - /\s*/s, - ); + assert.doesNotMatch(source, /useCodexProfileModeStatus/); + assert.doesNotMatch(source, /DirectModeUnavailable/); + assert.doesNotMatch(source, /未经过本地网关的请求不可统计/); + assert.match(source, /\{t\("当前活跃账号"\)\}<\/CardTitle>/); }); -test("日志页 direct 模式只提示日志口径不遮罩历史日志", async () => { +test("日志页不按当前路由模式隐藏已记录的历史日志", async () => { const source = await readSource("src/app/logs/page.tsx"); const sectionsSource = await readSource("src/app/logs/page-sections.tsx"); - assert.match(source, /useCodexProfileModeStatus/); - assert.match(sectionsSource, /未经过本地网关的请求不会产生新的 CodexManager 请求日志/); - assert.match(sectionsSource, /本地网关或包含本地网关的混合路由才会记录/); + assert.doesNotMatch(source, /useCodexProfileModeStatus/); + assert.match(source, / { ); } }); + +test("Tauri 生产构建始终重新生成前端静态产物", async () => { + const source = await readSource("src-tauri/scripts/before-build.mjs"); + + assert.doesNotMatch(source, /hasBuiltFrontendDist/); + assert.doesNotMatch(source, /前端产物已存在,跳过重复构建/); + assert.match(source, /const packageManager = resolvePnpmCommand\(\)/); + assert.match(source, /spawnSync\(packageManager\.command, packageManager\.args/); +}); diff --git a/apps/tests/top-level-routes.test.mjs b/apps/tests/top-level-routes.test.mjs index a1377da11..50ccda8a6 100644 --- a/apps/tests/top-level-routes.test.mjs +++ b/apps/tests/top-level-routes.test.mjs @@ -42,6 +42,15 @@ async function loadTopLevelRoutesModule() { const routes = await loadTopLevelRoutesModule(); +test("根页面清单包含平台模式页面", async () => { + const source = await fs.readFile( + path.join(appsRoot, "src", "lib", "routes", "root-page-paths.ts"), + "utf8", + ); + + assert.match(source, /"\/platform-mode"/); +}); + test("accounts 模式管理员菜单按任务域分组并保留账号体系入口", () => { const access = { role: "admin", mode: "accounts" }; const sections = routes.getAllowedTopLevelRouteSections(access); diff --git a/apps/tests/transport-web-commands.test.mjs b/apps/tests/transport-web-commands.test.mjs index f551c2138..05253a84f 100644 --- a/apps/tests/transport-web-commands.test.mjs +++ b/apps/tests/transport-web-commands.test.mjs @@ -20,6 +20,34 @@ const modulePaths = [ path.join(appsRoot, "src", "lib", "api", "transport-web-commands", "quota.ts"), path.join(appsRoot, "src", "lib", "api", "transport-web-commands", "shared.ts"), ]; +const commandModulePaths = modulePaths.filter( + (modulePath) => !["browser-direct.ts", "shared.ts"].includes(path.basename(modulePath)), +); +// Web 运行壳不负责桌面生命周期、自更新和本地 Codex 缓存同步命令。 +const webCommandExemptions = new Set([ + "app_close_to_tray_on_close_get", + "app_close_to_tray_on_close_set", + "app_update_apply_portable", + "app_update_check", + "app_update_launch_installer", + "app_update_prepare", + "app_update_status", + "service_start", + "service_stop", + "service_sync_codex_models_cache", +]); + +function extractStaticInvokedCommands(source) { + return Array.from( + source.matchAll(/invoke(?:First)?(?:<[^>]+>)?\(\s*(?:\[\s*)?["']([^"']+)["']/g), + ).map((match) => match[1]); +} + +function extractDeclaredWebCommandKeys(source) { + return Array.from(source.matchAll(/^ {4}([a-z][a-z0-9_]+):\s*\{/gm)).map( + (match) => match[1], + ); +} function rewriteImports(outputText) { return outputText @@ -76,6 +104,53 @@ async function loadTransportWebCommandsModule() { const transportWebCommands = await loadTransportWebCommandsModule(); const commandMap = transportWebCommands.createWebCommandMap(async () => ({})); +test("createWebCommandMap 覆盖前端静态调用的全部 Web 命令", async () => { + const apiFiles = (await fs.readdir(path.join(appsRoot, "src", "lib", "api"))) + .filter((file) => file.endsWith("-client.ts")) + .map((file) => path.join(appsRoot, "src", "lib", "api", file)); + const invokedCommands = new Set( + ( + await Promise.all( + apiFiles.map(async (file) => extractStaticInvokedCommands(await fs.readFile(file, "utf8"))), + ) + ).flat(), + ); + const missingCommands = [...invokedCommands] + .filter((command) => !webCommandExemptions.has(command) && !commandMap[command]) + .sort(); + + assert.deepEqual(missingCommands, []); +}); + +test("Web command 模块不声明重复命令键", async () => { + const declarations = new Map(); + for (const modulePath of commandModulePaths) { + const source = await fs.readFile(modulePath, "utf8"); + for (const command of extractDeclaredWebCommandKeys(source)) { + const owners = declarations.get(command) ?? []; + owners.push(path.basename(modulePath)); + declarations.set(command, owners); + } + } + const duplicates = [...declarations] + .filter(([, owners]) => owners.length > 1) + .map(([command, owners]) => ({ command, owners })) + .sort((left, right) => left.command.localeCompare(right.command)); + + assert.deepEqual(duplicates, []); +}); + +test("关键查询与模型价格命令使用服务端真实 RPC 名称", () => { + assert.equal(commandMap.service_account_lookup.rpcMethod, "account/lookup"); + assert.equal(commandMap.service_apikey_lookup.rpcMethod, "apikey/lookup"); + assert.equal(commandMap.service_aggregate_api_lookup.rpcMethod, "aggregateApi/lookup"); + assert.equal(commandMap.service_quota_model_pool_sources.rpcMethod, "quota/modelPoolSources"); + assert.equal(commandMap.service_requestlog_error_summary.rpcMethod, "requestlog/errorSummary"); + assert.equal(commandMap.service_model_price_rules_list.rpcMethod, "modelPriceRules/list"); + assert.equal(commandMap.service_model_price_rule_read.rpcMethod, "modelPriceRule/read"); + assert.equal(commandMap.service_model_price_rule_upsert.rpcMethod, "modelPriceRule/upsert"); +}); + test("createWebCommandMap 复用 keyId 到 id 的参数映射", () => { const descriptor = commandMap.service_apikey_delete; assert.ok(descriptor.mapParams); From 185e34514b28610e15eade01e67b652feeff9223 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:04:58 +0800 Subject: [PATCH 04/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=B1=A0=E6=9D=83=E9=99=90=E5=B9=B6=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E5=80=99=E9=80=89=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...41\350\256\241\344\277\256\345\244\215.md" | 44 +++ crates/service/src/account/account_cleanup.rs | 3 + crates/service/src/account/account_delete.rs | 2 + .../src/account/account_delete_many.rs | 2 + crates/service/src/auth/auth_tokens.rs | 14 +- .../service/src/gateway/routing/selection.rs | 63 ++-- .../gateway/routing/tests/selection_tests.rs | 284 ++++++++++++++++++ crates/service/src/rpc_dispatch/mod.rs | 10 - crates/service/src/tests/lib_tests.rs | 83 ++++- task.md | 1 + 10 files changed, 453 insertions(+), 53 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_\345\220\216\347\253\257\345\256\211\345\205\250\347\274\223\345\255\230\345\256\241\350\256\241\344\277\256\345\244\215.md" diff --git "a/.teamwork/progress/2026-07-11_\345\220\216\347\253\257\345\256\211\345\205\250\347\274\223\345\255\230\345\256\241\350\256\241\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-07-11_\345\220\216\347\253\257\345\256\211\345\205\250\347\274\223\345\255\230\345\256\241\350\256\241\344\277\256\345\244\215.md" new file mode 100644 index 000000000..f3af43494 --- /dev/null +++ "b/.teamwork/progress/2026-07-11_\345\220\216\347\253\257\345\256\211\345\205\250\347\274\223\345\255\230\345\256\241\350\256\241\344\277\256\345\244\215.md" @@ -0,0 +1,44 @@ +# 后端安全与候选缓存审计修复进度 + +## 执行身份 + +- 【CodeX-GPT】后端修复子代理 +- 分支:`audit/backend-fixes` + +## 已完成 + +1. 所有服务层账号删除路径在数据库删除成功后立即调用 `invalidate_candidate_cache`: + - 单账号删除; + - 指定账号批量删除; + - 按封禁、不可用免费账号及状态批量清理。 +2. 候选快照缓存改为按“数据库路径 + `LowQuotaCandidateMode`”独立保存,避免 `NormalOnly` 与 `AppendFallback` 相互驱逐。 +3. candidate cache single-flight 改为按同一缓存键互斥,不同候选模式可以并行刷新。 +4. accounts 鉴权模式下,从成员 RPC allowlist 移除所有全局账号池读取、更新、用量刷新、Token 刷新与预热方法。 +5. OAuth 成功日志改为无上下文标识的结构化事件 `event=oauth_login_persisted`,不再记录数据库路径、OAuth state、账号、workspace、ChatGPT 标识及回调地址。 + +## 回归测试 + +- 新增单删与批删后候选缓存立即失效测试。 +- 新增两种候选模式交替读取时缓存互不驱逐测试。 +- 新增相同模式 single-flight、不同模式并行刷新测试。 +- 新增成员访问全局账号池均返回 `permission_denied`,且账号字段保持不变测试。 + +## 验证结果 + +- `cargo fmt --all --check`:通过。 +- 候选缓存、删除缓存失效、成员权限定向测试:全部通过。 +- `cargo test -p codexmanager-service`: + - lib:1052/1052 通过; + - app_settings:30/30 通过; + - default_addr:10/10 通过; + - e2e:1/1 通过; + - gateway_logs:26/26 通过; + - gateway_logs 测试进程在输出全部通过后未自动退出,已精确终止本工作树对应进程,因此整条命令退出码为 1; + - 后续单独执行 rpc:44/44 通过,shutdown_flag:1/1 通过;rpc 同样在输出全部通过后未自动退出并被精确终止。 + +## 未处理与交接 + +- DST 固定 86400 秒的日级 rollup 已由主代理拆给独立子代理,本分支未修改 `request_token_stats` 或 maintenance,避免并行冲突。 +- 主代理需独立检查 diff、复跑关键测试并决定是否集成。 + +> **交接提示**:本任务代码已完成,建议由【CodeX-GPT】主审代理执行独立代码审计与集成。 diff --git a/crates/service/src/account/account_cleanup.rs b/crates/service/src/account/account_cleanup.rs index 516a41d3f..de2d3041b 100644 --- a/crates/service/src/account/account_cleanup.rs +++ b/crates/service/src/account/account_cleanup.rs @@ -127,6 +127,7 @@ pub(crate) fn delete_unavailable_free_accounts() -> Result format!("bulk delete unavailable free account: plan={plan}"), @@ -185,6 +186,7 @@ pub(crate) fn delete_banned_accounts() -> Result { storage .delete_account(&account.id) .map_err(|err| err.to_string())?; + crate::gateway::invalidate_candidate_cache(); let _ = storage.insert_event(&Event { account_id: Some(account.id.clone()), event_type: "account_bulk_delete_banned".to_string(), @@ -237,6 +239,7 @@ pub(crate) fn delete_accounts_by_statuses( storage .delete_account(&account.id) .map_err(|err| err.to_string())?; + crate::gateway::invalidate_candidate_cache(); let _ = storage.insert_event(&Event { account_id: Some(account.id.clone()), event_type: "account_bulk_delete_by_status".to_string(), diff --git a/crates/service/src/account/account_delete.rs b/crates/service/src/account/account_delete.rs index f1da925ea..1659b5f37 100644 --- a/crates/service/src/account/account_delete.rs +++ b/crates/service/src/account/account_delete.rs @@ -22,6 +22,8 @@ pub(crate) fn delete_account(account_id: &str) -> Result<(), String> { storage .delete_account(account_id) .map_err(|e| e.to_string())?; + // 删除成功后立即清除网关候选快照,避免旧凭据在缓存 TTL 内继续被选中。 + crate::gateway::invalidate_candidate_cache(); let _ = storage.insert_event(&Event { account_id: Some(account_id.to_string()), event_type: "account_delete".to_string(), diff --git a/crates/service/src/account/account_delete_many.rs b/crates/service/src/account/account_delete_many.rs index 96877d44d..03d1e5d78 100644 --- a/crates/service/src/account/account_delete_many.rs +++ b/crates/service/src/account/account_delete_many.rs @@ -76,6 +76,8 @@ pub(crate) fn delete_accounts(account_ids: Vec) -> Result { + // 每个账号删除成功后立即失效快照,避免大批量操作期间继续选中已删除凭据。 + crate::gateway::invalidate_candidate_cache(); let _ = storage.insert_event(&Event { account_id: Some(account_id.clone()), event_type: "account_delete_many".to_string(), diff --git a/crates/service/src/auth/auth_tokens.rs b/crates/service/src/auth/auth_tokens.rs index a135515a3..082fab3b6 100644 --- a/crates/service/src/auth/auth_tokens.rs +++ b/crates/service/src/auth/auth_tokens.rs @@ -1199,8 +1199,6 @@ pub(crate) fn complete_login_with_redirect( .as_ref() .map(|account| account.created_at) .unwrap_or(now); - let workspace_id_for_log = workspace_id.clone(); - let chatgpt_account_id_for_log = chatgpt_account_id.clone(); let account = Account { id: account_key.clone(), label, @@ -1235,16 +1233,8 @@ pub(crate) fn complete_login_with_redirect( }; storage.insert_token(&token).map_err(|e| e.to_string())?; - let db_path = std::env::var("CODEXMANAGER_DB_PATH").unwrap_or_else(|_| "".to_string()); - log::info!( - "oauth login persisted account: db_path={} login_id={} account_id={} workspace_id={} chatgpt_account_id={} redirect_uri={}", - db_path, - state, - account_key, - workspace_id_for_log.as_deref().unwrap_or("-"), - chatgpt_account_id_for_log.as_deref().unwrap_or("-"), - redirect_uri - ); + // 成功事件仅记录流程结果,不输出数据库路径、OAuth state 或账号标识等敏感上下文。 + log::info!("event=oauth_login_persisted"); storage .update_login_session_status(state, "success", None) diff --git a/crates/service/src/gateway/routing/selection.rs b/crates/service/src/gateway/routing/selection.rs index ba8210ce8..867cdc667 100644 --- a/crates/service/src/gateway/routing/selection.rs +++ b/crates/service/src/gateway/routing/selection.rs @@ -1,14 +1,16 @@ use codexmanager_core::storage::{now_ts, Account, Storage, Token, UsageSnapshotRecord}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, OnceLock, RwLock}; use std::time::{Duration, Instant}; use crate::usage_account_meta::{derive_account_meta, patch_account_meta_in_place}; -static CANDIDATE_SNAPSHOT_CACHE: OnceLock>> = OnceLock::new(); -static CANDIDATE_CACHE_REFRESH: OnceLock<(Mutex>, Condvar)> = +static CANDIDATE_SNAPSHOT_CACHE: OnceLock< + Mutex>, +> = OnceLock::new(); +static CANDIDATE_CACHE_REFRESH: OnceLock<(Mutex>, Condvar)> = OnceLock::new(); static SELECTION_CONFIG_LOADED: OnceLock<()> = OnceLock::new(); static CANDIDATE_CACHE_TTL_MS: AtomicU64 = AtomicU64::new(DEFAULT_CANDIDATE_CACHE_TTL_MS); @@ -79,13 +81,11 @@ impl QuotaGuardConfig { #[derive(Clone)] struct CandidateSnapshotCache { - db_path: String, - low_quota_mode: LowQuotaCandidateMode, expires_at: Instant, candidates: GatewayCandidateSnapshot, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Hash, PartialEq, Eq)] struct CandidateRefreshKey { db_path: String, low_quota_mode: LowQuotaCandidateMode, @@ -103,7 +103,7 @@ impl Drop for CandidateRefreshGuard { pub(crate) type GatewayCandidateSnapshot = Arc>; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub(crate) enum LowQuotaCandidateMode { NormalOnly, AppendFallback, @@ -353,24 +353,24 @@ fn read_candidate_cache(low_quota_mode: LowQuotaCandidateMode) -> Option guard, Err(poisoned) => { log::warn!("candidate snapshot cache lock poisoned; dropping cache and continuing"); let mut guard = poisoned.into_inner(); - *guard = None; + guard.clear(); guard } }; - let cached = guard.as_ref()?; - if cached.db_path != db_path - || cached.low_quota_mode != low_quota_mode - || cached.expires_at <= now - { - *guard = None; + let cached = guard.get(&key)?; + if cached.expires_at <= now { + guard.remove(&key); return None; } Some(Arc::clone(&cached.candidates)) @@ -398,8 +398,12 @@ fn write_candidate_cache( let Some(db_path) = cache_identity() else { return; }; + let key = CandidateRefreshKey { + db_path, + low_quota_mode, + }; let expires_at = Instant::now() + ttl; - let mutex = CANDIDATE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); + let mutex = CANDIDATE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = match mutex.lock() { Ok(guard) => guard, Err(poisoned) => { @@ -407,12 +411,13 @@ fn write_candidate_cache( poisoned.into_inner() } }; - *guard = Some(CandidateSnapshotCache { - db_path, - low_quota_mode, - expires_at, - candidates, - }); + guard.insert( + key, + CandidateSnapshotCache { + expires_at, + candidates, + }, + ); } fn acquire_candidate_cache_refresh( @@ -426,7 +431,7 @@ fn acquire_candidate_cache_refresh( low_quota_mode, }; let (mutex, condvar) = - CANDIDATE_CACHE_REFRESH.get_or_init(|| (Mutex::new(None), Condvar::new())); + CANDIDATE_CACHE_REFRESH.get_or_init(|| (Mutex::new(HashSet::new()), Condvar::new())); let mut guard = match mutex.lock() { Ok(guard) => guard, Err(poisoned) => { @@ -434,7 +439,7 @@ fn acquire_candidate_cache_refresh( poisoned.into_inner() } }; - while guard.is_some() { + while guard.contains(&key) { guard = match condvar.wait(guard) { Ok(guard) => guard, Err(poisoned) => { @@ -443,7 +448,7 @@ fn acquire_candidate_cache_refresh( } }; } - *guard = Some(key.clone()); + guard.insert(key.clone()); Some(CandidateRefreshGuard { key }) } @@ -458,9 +463,7 @@ fn finish_candidate_cache_refresh(key: &CandidateRefreshKey) { poisoned.into_inner() } }; - if guard.as_ref() == Some(key) { - *guard = None; - } + guard.remove(key); condvar.notify_all(); } @@ -723,7 +726,7 @@ fn clear_candidate_cache() { poisoned.into_inner() } }; - *guard = None; + guard.clear(); } } diff --git a/crates/service/src/gateway/routing/tests/selection_tests.rs b/crates/service/src/gateway/routing/tests/selection_tests.rs index 7e9048e4d..bd0f8be59 100644 --- a/crates/service/src/gateway/routing/tests/selection_tests.rs +++ b/crates/service/src/gateway/routing/tests/selection_tests.rs @@ -119,6 +119,154 @@ fn candidate_snapshot_cache_reuses_recent_snapshot() { super::reload_from_env(); } +/// 验证单账号删除成功后立即清除候选缓存。 +#[test] +fn deleting_account_invalidates_cached_candidate_snapshot() { + let _guard = crate::test_env_guard(); + let previous_ttl = std::env::var(CANDIDATE_CACHE_TTL_ENV).ok(); + let previous_db_path = std::env::var("CODEXMANAGER_DB_PATH").ok(); + let db_path = std::env::temp_dir().join(format!( + "selection-delete-cache-{}-{}.sqlite", + std::process::id(), + now_ts() + )); + let _ = std::fs::remove_file(&db_path); + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, "2000"); + std::env::set_var("CODEXMANAGER_DB_PATH", &db_path); + super::reload_from_env(); + clear_candidate_cache_for_tests(); + + let storage = Storage::open(&db_path).expect("open"); + storage.init().expect("init"); + let now = now_ts(); + storage + .insert_account(&Account { + id: "acc-delete-cache".to_string(), + label: "delete cache".to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + storage + .insert_token(&Token { + account_id: "acc-delete-cache".to_string(), + id_token: "id".to_string(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + api_key_access_token: None, + last_refresh: now, + }) + .expect("insert token"); + + assert_eq!( + collect_gateway_candidates(&storage) + .expect("cached candidates") + .len(), + 1 + ); + crate::account_delete::delete_account("acc-delete-cache").expect("delete account"); + assert!( + collect_gateway_candidates(&storage) + .expect("candidates after deletion") + .is_empty(), + "单删成功后不能继续返回缓存中的旧账号凭据" + ); + + clear_candidate_cache_for_tests(); + let _ = std::fs::remove_file(&db_path); + if let Some(value) = previous_ttl { + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, value); + } else { + std::env::remove_var(CANDIDATE_CACHE_TTL_ENV); + } + if let Some(value) = previous_db_path { + std::env::set_var("CODEXMANAGER_DB_PATH", value); + } else { + std::env::remove_var("CODEXMANAGER_DB_PATH"); + } + super::reload_from_env(); +} + +/// 验证批量删除成功后不会继续返回已删除账号的候选快照。 +#[test] +fn deleting_many_accounts_invalidates_cached_candidate_snapshot() { + let _guard = crate::test_env_guard(); + let previous_ttl = std::env::var(CANDIDATE_CACHE_TTL_ENV).ok(); + let previous_db_path = std::env::var("CODEXMANAGER_DB_PATH").ok(); + let db_path = std::env::temp_dir().join(format!( + "selection-delete-many-cache-{}-{}.sqlite", + std::process::id(), + now_ts() + )); + let _ = std::fs::remove_file(&db_path); + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, "2000"); + std::env::set_var("CODEXMANAGER_DB_PATH", &db_path); + super::reload_from_env(); + clear_candidate_cache_for_tests(); + + let storage = Storage::open(&db_path).expect("open"); + storage.init().expect("init"); + let now = now_ts(); + for (id, sort) in [("acc-delete-many-a", 0_i64), ("acc-delete-many-b", 1_i64)] { + storage + .insert_account(&Account { + id: id.to_string(), + label: id.to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + storage + .insert_token(&Token { + account_id: id.to_string(), + id_token: "id".to_string(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + api_key_access_token: None, + last_refresh: now, + }) + .expect("insert token"); + } + + assert_eq!( + collect_gateway_candidates(&storage) + .expect("cached candidates") + .len(), + 2 + ); + crate::account_delete_many::delete_accounts(vec!["acc-delete-many-a".to_string()]) + .expect("delete accounts"); + let remaining = collect_gateway_candidates(&storage).expect("candidates after bulk deletion"); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].0.id, "acc-delete-many-b"); + + clear_candidate_cache_for_tests(); + let _ = std::fs::remove_file(&db_path); + if let Some(value) = previous_ttl { + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, value); + } else { + std::env::remove_var(CANDIDATE_CACHE_TTL_ENV); + } + if let Some(value) = previous_db_path { + std::env::set_var("CODEXMANAGER_DB_PATH", value); + } else { + std::env::remove_var("CODEXMANAGER_DB_PATH"); + } + super::reload_from_env(); +} + #[test] fn candidate_cache_refresh_is_single_flight_per_cache_window() { let _guard = crate::test_env_guard(); @@ -167,6 +315,142 @@ fn candidate_cache_refresh_is_single_flight_per_cache_window() { super::reload_from_env(); } +/// 验证不同低额度候选模式可以并行刷新,不共享全局串行锁。 +#[test] +fn candidate_cache_refresh_allows_different_modes_in_parallel() { + let _guard = crate::test_env_guard(); + let previous_ttl = std::env::var(CANDIDATE_CACHE_TTL_ENV).ok(); + let previous_db_path = std::env::var("CODEXMANAGER_DB_PATH").ok(); + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, "2000"); + std::env::set_var( + "CODEXMANAGER_DB_PATH", + "selection-cache-mode-parallel-refresh", + ); + super::reload_from_env(); + clear_candidate_cache_for_tests(); + + let normal_guard = super::acquire_candidate_cache_refresh(LowQuotaCandidateMode::NormalOnly) + .expect("normal refresh guard"); + let (tx, rx) = mpsc::channel(); + let fallback_worker = thread::spawn(move || { + let _fallback_guard = + super::acquire_candidate_cache_refresh(LowQuotaCandidateMode::AppendFallback) + .expect("fallback refresh guard"); + tx.send(()).expect("send fallback completion"); + }); + + rx.recv_timeout(Duration::from_secs(2)) + .expect("不同候选模式不应共用全局 single-flight 锁"); + drop(normal_guard); + fallback_worker.join().expect("fallback worker"); + + clear_candidate_cache_for_tests(); + if let Some(value) = previous_ttl { + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, value); + } else { + std::env::remove_var(CANDIDATE_CACHE_TTL_ENV); + } + if let Some(value) = previous_db_path { + std::env::set_var("CODEXMANAGER_DB_PATH", value); + } else { + std::env::remove_var("CODEXMANAGER_DB_PATH"); + } + super::reload_from_env(); +} + +/// 验证交替读取两种候选模式时,各自缓存不会相互驱逐。 +#[test] +fn alternating_candidate_modes_keep_independent_cached_snapshots() { + let _guard = crate::test_env_guard(); + let previous_ttl = std::env::var(CANDIDATE_CACHE_TTL_ENV).ok(); + let previous_db_path = std::env::var("CODEXMANAGER_DB_PATH").ok(); + let previous_threshold = std::env::var(LOW_QUOTA_THRESHOLD_ENV).ok(); + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, "2000"); + std::env::set_var("CODEXMANAGER_DB_PATH", "selection-cache-alternating-modes"); + std::env::set_var(LOW_QUOTA_THRESHOLD_ENV, "95"); + super::reload_from_env(); + clear_candidate_cache_for_tests(); + + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + for (id, sort, used_percent) in [ + ("acc-healthy-cache", 0_i64, 10.0), + ("acc-low-cache", 1_i64, 99.0), + ] { + storage + .insert_account(&Account { + id: id.to_string(), + label: id.to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + storage + .insert_token(&Token { + account_id: id.to_string(), + id_token: "id".to_string(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + api_key_access_token: None, + last_refresh: now, + }) + .expect("insert token"); + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: id.to_string(), + used_percent: Some(used_percent), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: None, + secondary_window_minutes: None, + secondary_resets_at: None, + credits_json: None, + captured_at: now, + }) + .expect("insert snapshot"); + } + + let normal_first = collect_gateway_candidates(&storage).expect("normal candidates"); + let fallback = collect_gateway_candidates_with_low_quota_mode( + &storage, + LowQuotaCandidateMode::AppendFallback, + ) + .expect("fallback candidates"); + let normal_second = collect_gateway_candidates(&storage).expect("normal cached candidates"); + + assert_eq!(normal_first.len(), 1); + assert_eq!(fallback.len(), 2); + assert!( + Arc::ptr_eq(&normal_first, &normal_second), + "交替读取不同模式时不应驱逐另一个模式的候选快照" + ); + + clear_candidate_cache_for_tests(); + if let Some(value) = previous_ttl { + std::env::set_var(CANDIDATE_CACHE_TTL_ENV, value); + } else { + std::env::remove_var(CANDIDATE_CACHE_TTL_ENV); + } + if let Some(value) = previous_db_path { + std::env::set_var("CODEXMANAGER_DB_PATH", value); + } else { + std::env::remove_var("CODEXMANAGER_DB_PATH"); + } + if let Some(value) = previous_threshold { + std::env::set_var(LOW_QUOTA_THRESHOLD_ENV, value); + } else { + std::env::remove_var(LOW_QUOTA_THRESHOLD_ENV); + } + super::reload_from_env(); +} + /// 函数 `candidates_follow_account_sort_order` /// /// 作者: gaohongshun diff --git a/crates/service/src/rpc_dispatch/mod.rs b/crates/service/src/rpc_dispatch/mod.rs index f28893a0e..da8bed77c 100644 --- a/crates/service/src/rpc_dispatch/mod.rs +++ b/crates/service/src/rpc_dispatch/mod.rs @@ -182,16 +182,6 @@ fn permission_denied(method: &str) -> String { } const MEMBER_METHOD_ALLOWLIST: &[&str] = &[ - "account/chatgptAuthTokens/refresh", - "account/chatgptAuthTokens/refreshAll", - "account/list", - "account/read", - "account/update", - "account/usage/aggregate", - "account/usage/list", - "account/usage/read", - "account/usage/refresh", - "account/warmup", "accountManager/password/change", "accountManager/profile/update", "accountManager/session/current", diff --git a/crates/service/src/tests/lib_tests.rs b/crates/service/src/tests/lib_tests.rs index b12512583..4585820b1 100644 --- a/crates/service/src/tests/lib_tests.rs +++ b/crates/service/src/tests/lib_tests.rs @@ -1,7 +1,7 @@ use super::*; use codexmanager_core::rpc::types::{JsonRpcMessage, JsonRpcResponse}; use codexmanager_core::storage::{ - ModelCatalogModelRecord, ModelGroupModel, RequestLog, RequestTokenStat, + Account, ModelCatalogModelRecord, ModelGroupModel, RequestLog, RequestTokenStat, Storage, }; /// 函数 `response_result` @@ -137,6 +137,87 @@ fn member_actor_cannot_call_admin_only_rpc() { } } +/// 验证成员无法访问全局账号池,且被拒绝的更新不会改变账号数据。 +#[test] +fn member_actor_cannot_access_global_account_pool_and_data_stays_unchanged() { + let _guard = test_env_guard(); + let db_path = setup_dashboard_test_db("codexmanager-member-account-pool-denied"); + set_web_auth_mode("accounts").expect("enable accounts mode"); + let storage = Storage::open(&db_path).expect("open storage"); + storage + .insert_account(&Account { + id: "acc-member-denied".to_string(), + label: "原始账号".to_string(), + issuer: "chatgpt".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: 1, + updated_at: 1, + }) + .expect("insert account"); + let actor = RpcActor::from_parts(Some(ROLE_MEMBER), Some("member-account-pool")); + + for (method, params) in [ + ("account/list", serde_json::json!({})), + ( + "account/read", + serde_json::json!({ "accountId": "acc-member-denied" }), + ), + ( + "account/update", + serde_json::json!({ + "accountId": "acc-member-denied", + "label": "越权修改", + "status": "disabled" + }), + ), + ("account/usage/aggregate", serde_json::json!({})), + ("account/usage/list", serde_json::json!({})), + ( + "account/usage/read", + serde_json::json!({ "accountId": "acc-member-denied" }), + ), + ( + "account/usage/refresh", + serde_json::json!({ "accountId": "acc-member-denied" }), + ), + ( + "account/warmup", + serde_json::json!({ "accountId": "acc-member-denied" }), + ), + ( + "account/chatgptAuthTokens/refresh", + serde_json::json!({ "accountId": "acc-member-denied" }), + ), + ( + "account/chatgptAuthTokens/refreshAll", + serde_json::json!({}), + ), + ] { + let resp = response_result(handle_request_with_actor( + rpc_request(method, params), + actor.clone(), + )); + assert!( + rpc_error(&resp).contains("permission_denied"), + "{method} 应拒绝成员访问全局账号池: {:?}", + resp.result + ); + } + + let unchanged = storage + .find_account_by_id("acc-member-denied") + .expect("find account") + .expect("account exists"); + assert_eq!(unchanged.label, "原始账号"); + assert_eq!(unchanged.status, "active"); + drop(storage); + let _ = std::fs::remove_file(db_path); +} + #[test] fn password_mode_can_call_admin_and_model_source_rpcs() { let _guard = test_env_guard(); diff --git a/task.md b/task.md index 3af9e095b..731ce3eb3 100644 --- a/task.md +++ b/task.md @@ -7,6 +7,7 @@ 1. P0 审计问题修复与独立复核(🔄 进行中) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 - 后端:修复账号删除后的候选缓存失效、成员账号池权限边界、OAuth 日志脱敏及多模式候选缓存隔离。 + - `audit/backend-fixes` 已完成上述后端子项并通过定向测试,等待主代理独立审计与集成;DST 日级 rollup 由独立子代理处理。 - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 - 主代理负责逐提交审计、独立测试、集成分支和 PR,不直接采纳子代理完成声明。 From da455ed05c6410916098a64fdd49987b174159bd Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:07:33 +0800 Subject: [PATCH 05/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8C=89=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=87=AA=E7=84=B6=E6=97=A5=E5=A4=84=E7=90=86DST?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...41\350\256\241\344\277\256\345\244\215.md" | 33 +++ .../core/src/storage/request_token_stats.rs | 248 +++++++++++++++- .../src/storage/tests/request_logs_tests.rs | 81 ++++++ crates/service/src/dashboard.rs | 273 +++++++----------- .../src/gateway/observability/maintenance.rs | 67 ++++- task.md | 1 + 6 files changed, 512 insertions(+), 191 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" diff --git "a/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" new file mode 100644 index 000000000..b8316f45a --- /dev/null +++ "b/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" @@ -0,0 +1,33 @@ +# DST 日级 Rollup 审计修复进度 + +## 状态 + +✅ 已完成,等待主代理独立审计 + +## 审计结论 + +- 原实现以“当前本地零点 + 固定 86400 秒”反推历史日边界,在 America/New_York 春季切换日会错过 1 小时,秋季切换日会跨入相邻自然日。 +- SQLite 表中的 `day_start` 足以保存真实本地日零点,不需要修改表结构;问题位于边界生成与 mixed 查询切分。 +- 修复采用显式 `(day_start, day_end)` 区间,由服务层使用 `chrono::Local` 生成本地自然日,core 只按传入边界聚合。 + +## 实施项 + +- [x] 增加最早未固化明细查询,维护任务从该明细所在本地日开始按日期顺序 rollup。 +- [x] 增加显式本地日区间的维护入口,保留原固定秒数入口兼容既有调用。 +- [x] 增加显式日区间的 daily / user / source mixed 查询。 +- [x] Dashboard 默认七日范围按本地日期偏移,不再以秒数回退。 +- [x] 增加 America/New_York 春季 23 小时与秋季 25 小时测试,覆盖明细删除前后汇总一致性。 +- [x] 完成格式化、core/service 定向测试与提交。 + +## 验证结果 + +- `cargo fmt --all --check`:通过。 +- `cargo test -p codexmanager-core storage::request_logs::tests::`:17 项通过。 +- `cargo check -p codexmanager-service`:通过,无警告。 +- `cargo test -p codexmanager-service dashboard`:首次运行的 4 项 dashboard 单元测试全部通过;命令随后未正常退出并持续占用测试可执行文件。再次链接时出现 `LNK1104` 文件占用,属于本机残留测试进程锁,不是编译错误。 + +## 风险控制 + +- 未修改 SQLite 表结构与历史 rollup 数据。 +- 无法解析本地午夜时返回错误并停止维护,不使用 UTC 对齐猜测替代本地日边界。 +- 维护仍受原批次上限约束,未完成的日级明细会留待下轮继续处理。 diff --git a/crates/core/src/storage/request_token_stats.rs b/crates/core/src/storage/request_token_stats.rs index d1e95927a..4ef0c59ce 100644 --- a/crates/core/src/storage/request_token_stats.rs +++ b/crates/core/src/storage/request_token_stats.rs @@ -235,6 +235,33 @@ fn split_mixed_token_stats_range( range } +fn live_segments_excluding_closed_ranges( + start_ts: i64, + end_ts: i64, + closed_ranges: &[(i64, i64)], +) -> Vec<(i64, i64)> { + let mut segments = Vec::new(); + let mut cursor = start_ts; + for &(closed_start, closed_end) in closed_ranges { + let closed_start = closed_start.max(start_ts); + let closed_end = closed_end.min(end_ts); + if closed_end <= closed_start || closed_end <= cursor { + continue; + } + if closed_start > cursor { + segments.push((cursor, closed_start)); + } + cursor = cursor.max(closed_end); + if cursor >= end_ts { + break; + } + } + if cursor < end_ts { + segments.push((cursor, end_ts)); + } + segments +} + fn ranked_usage_ids_from_maps( today_map: &HashMap, range_map: &HashMap, @@ -394,17 +421,44 @@ impl Storage { &self, now: i64, daily_rollup_anchor_ts: i64, + ) -> Result<()> { + self.prune_observability_history_with_daily_rollup_ranges( + now, + &[( + daily_rollup_anchor_ts.saturating_sub(86_400), + daily_rollup_anchor_ts, + )], + ) + } + + /// 使用显式本地自然日边界执行观测数据维护。 + /// + /// 每个区间必须按时间升序排列且互不重叠。显式边界允许调用方保留 + /// 夏令时切换日的 23/25 小时时长,而不把本地自然日强制压成 86400 秒。 + pub fn prune_observability_history_with_daily_rollup_ranges( + &self, + now: i64, + daily_rollup_ranges: &[(i64, i64)], ) -> Result<()> { let batch_limit = observability_maintenance_batch_limit(); let mut touched = 0_usize; - let daily_rolled = self.rollup_request_token_stats_daily_before_limited( - daily_rollup_anchor_ts, - daily_rollup_anchor_ts, - 86_400, - batch_limit, - )?; + let mut daily_rolled = 0_usize; + for &(day_start, day_end) in daily_rollup_ranges { + if day_end <= day_start || daily_rolled >= batch_limit { + continue; + } + daily_rolled = + daily_rolled.saturating_add(self.rollup_request_token_stats_daily_before_limited( + day_end, + day_end, + day_end.saturating_sub(day_start), + batch_limit.saturating_sub(daily_rolled), + )?); + } touched = touched.saturating_add(daily_rolled); - let mut defer_request_log_prune = daily_rolled >= batch_limit; + let closed_before_ts = daily_rollup_ranges.last().map(|(_, end)| *end).unwrap_or(0); + let mut defer_request_log_prune = daily_rolled >= batch_limit + || self.has_pending_daily_rollup_before(closed_before_ts)?; if let Some(cutoff) = retention_cutoff(now, request_token_stats_retain_days()) { let rolled = self.rollup_daily_rolled_request_token_stats_before_limited(cutoff, batch_limit)?; @@ -430,6 +484,27 @@ impl Storage { } Ok(()) } + + /// 返回指定边界前最早尚未写入日级汇总的 token 明细时间。 + pub fn oldest_pending_daily_rollup_ts_before(&self, cutoff_ts: i64) -> Result> { + if cutoff_ts <= 0 { + return Ok(None); + } + self.conn.query_row( + "SELECT MIN(created_at) + FROM request_token_stats + WHERE created_at < ?1 + AND daily_rolled_at IS NULL", + [cutoff_ts], + |row| row.get(0), + ) + } + + fn has_pending_daily_rollup_before(&self, cutoff_ts: i64) -> Result { + Ok(self + .oldest_pending_daily_rollup_ts_before(cutoff_ts)? + .is_some()) + } pub fn rollup_request_token_stats_daily_before_limited( &self, cutoff_ts: i64, @@ -1628,6 +1703,165 @@ impl Storage { Ok(items) } + /// 按显式本地日区间汇总 token 使用量,避免 DST 日被固定秒数切错。 + pub fn summarize_request_token_stats_daily_mixed_ranges( + &self, + day_ranges: &[(i64, i64)], + closed_day_ranges: &[(i64, i64)], + ) -> Result> { + let closed_days = closed_day_ranges.iter().copied().collect::>(); + let mut items = Vec::with_capacity(day_ranges.len()); + for &(day_start, day_end) in day_ranges { + if day_end <= day_start { + continue; + } + let mut usage = TokenUsageRollup::default(); + if closed_days.contains(&(day_start, day_end)) { + add_token_usage_rollup( + &mut usage, + &self + .query_request_token_stat_daily_rollup_usage_between(day_start, day_end)?, + ); + if let Some(unrolled_usage) = self + .summarize_request_token_stats_daily_unrolled( + day_start, + day_end, + day_end.saturating_sub(day_start), + )? + .into_iter() + .next() + .map(|item| item.usage) + { + add_token_usage_rollup(&mut usage, &unrolled_usage); + } + } else if let Some(live_usage) = self + .summarize_request_token_stats_daily( + day_start, + day_end, + day_end.saturating_sub(day_start), + )? + .into_iter() + .next() + .map(|item| item.usage) + { + add_token_usage_rollup(&mut usage, &live_usage); + } + items.push(DailyTokenUsageRollup { + day_start_ts: day_start, + day_end_ts: day_end, + usage, + }); + } + Ok(items) + } + + /// 使用显式已关闭本地日边界合并用户维度的日级汇总与存量明细。 + pub fn summarize_request_token_stats_by_user_between_mixed_ranges( + &self, + start_ts: i64, + end_ts: i64, + closed_day_ranges: &[(i64, i64)], + ) -> Result> { + if end_ts <= start_ts { + return Ok(Vec::new()); + } + let mut usage_by_user = HashMap::new(); + for &(day_start, day_end) in closed_day_ranges { + if day_start < start_ts || day_end > end_ts || day_end <= day_start { + continue; + } + add_user_rollups_to_map( + &mut usage_by_user, + self.query_request_token_stat_daily_rollup_users_between(day_start, day_end)?, + ); + add_user_rollups_to_map( + &mut usage_by_user, + self.summarize_request_token_stats_by_user_between_unrolled(day_start, day_end)?, + ); + } + for (segment_start, segment_end) in + live_segments_excluding_closed_ranges(start_ts, end_ts, closed_day_ranges) + { + add_user_rollups_to_map( + &mut usage_by_user, + self.summarize_request_token_stats_by_user_between(segment_start, segment_end)?, + ); + } + let mut items = usage_by_user + .into_iter() + .map(|(user_id, usage)| UserTokenUsageRollup { user_id, usage }) + .collect::>(); + items.sort_by(|a, b| { + b.usage + .total_tokens + .cmp(&a.usage.total_tokens) + .then_with(|| a.user_id.cmp(&b.user_id)) + }); + Ok(items) + } + + /// 使用显式已关闭本地日边界合并来源维度的日级汇总与存量明细。 + pub fn summarize_request_token_stats_by_source_between_mixed_ranges( + &self, + source_kind: &str, + start_ts: i64, + end_ts: i64, + closed_day_ranges: &[(i64, i64)], + ) -> Result> { + if end_ts <= start_ts { + return Ok(Vec::new()); + } + let mut usage_by_source = HashMap::new(); + for &(day_start, day_end) in closed_day_ranges { + if day_start < start_ts || day_end > end_ts || day_end <= day_start { + continue; + } + add_source_rollups_to_map( + &mut usage_by_source, + self.query_request_token_stat_daily_rollup_sources_between( + source_kind, + day_start, + day_end, + )?, + ); + add_source_rollups_to_map( + &mut usage_by_source, + self.summarize_request_token_stats_by_source_between_unrolled( + source_kind, + day_start, + day_end, + )?, + ); + } + for (segment_start, segment_end) in + live_segments_excluding_closed_ranges(start_ts, end_ts, closed_day_ranges) + { + add_source_rollups_to_map( + &mut usage_by_source, + self.summarize_request_token_stats_by_source_between( + source_kind, + segment_start, + segment_end, + )?, + ); + } + let mut items = usage_by_source + .into_iter() + .map(|(source_id, usage)| SourceTokenUsageRollup { + source_kind: source_kind.to_string(), + source_id, + usage, + }) + .collect::>(); + items.sort_by(|a, b| { + b.usage + .total_tokens + .cmp(&a.usage.total_tokens) + .then_with(|| a.source_id.cmp(&b.source_id)) + }); + Ok(items) + } + pub fn summarize_request_token_stats_by_user_between_mixed( &self, start_ts: i64, diff --git a/crates/core/src/storage/tests/request_logs_tests.rs b/crates/core/src/storage/tests/request_logs_tests.rs index 410a597d5..3db4813c1 100644 --- a/crates/core/src/storage/tests/request_logs_tests.rs +++ b/crates/core/src/storage/tests/request_logs_tests.rs @@ -1179,3 +1179,84 @@ fn daily_rollup_mixed_queries_use_unrolled_live_stats_when_rollup_is_not_ready() assert_eq!(source_usage[0].source_id, account_id); assert_eq!(source_usage[0].usage.total_tokens, 30); } + +#[test] +fn america_new_york_dst_days_keep_daily_rollups_before_and_after_detail_prune() { + // America/New_York:2024-03-10 为 23 小时,2024-11-03 为 25 小时。 + for (case_name, day_start, day_end) in [ + ("spring-forward", 1_710_046_800_i64, 1_710_129_600_i64), + ("fall-back", 1_730_606_400_i64, 1_730_696_400_i64), + ] { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let key_id = format!("gk-dst-{case_name}"); + let account_id = format!("acc-dst-{case_name}"); + + for (offset, total_tokens) in [(60_i64, 10_i64), (day_end - day_start - 60, 20)] { + let created_at = day_start + offset; + let log_id = storage + .insert_request_log(&RequestLog { + trace_id: Some(format!("trace-{case_name}-{offset}")), + key_id: Some(key_id.clone()), + account_id: Some(account_id.clone()), + request_path: "/v1/responses".to_string(), + method: "POST".to_string(), + model: Some("gpt-5".to_string()), + actual_source_kind: Some("openai_account".to_string()), + actual_source_id: Some(account_id.clone()), + status_code: Some(200), + created_at, + ..Default::default() + }) + .expect("insert request log"); + storage + .insert_request_token_stat(&RequestTokenStat { + request_log_id: log_id, + key_id: Some(key_id.clone()), + account_id: Some(account_id.clone()), + model: Some("gpt-5".to_string()), + total_tokens: Some(total_tokens), + created_at, + ..Default::default() + }) + .expect("insert token stat"); + } + + assert_eq!( + storage + .rollup_request_token_stats_daily_before_limited( + day_end, + day_end, + day_end - day_start, + 100, + ) + .expect("roll up DST day"), + 2 + ); + let ranges = [(day_start, day_end)]; + let before_prune = storage + .summarize_request_token_stats_daily_mixed_ranges(&ranges, &ranges) + .expect("mixed summary before detail prune"); + assert_eq!(before_prune.len(), 1, "{case_name}"); + assert_eq!(before_prune[0].day_start_ts, day_start, "{case_name}"); + assert_eq!(before_prune[0].day_end_ts, day_end, "{case_name}"); + assert_eq!(before_prune[0].usage.total_tokens, 30, "{case_name}"); + + storage + .rollup_request_token_stats_before(day_end) + .expect("prune rolled detail"); + let remaining_stats: i64 = storage + .conn + .query_row("SELECT COUNT(1) FROM request_token_stats", [], |row| { + row.get(0) + }) + .expect("count remaining stats"); + assert_eq!(remaining_stats, 0, "{case_name}"); + + let after_prune = storage + .summarize_request_token_stats_daily_mixed_ranges(&ranges, &ranges) + .expect("mixed summary after detail prune"); + assert_eq!(after_prune.len(), 1, "{case_name}"); + assert_eq!(after_prune[0].usage.total_tokens, 30, "{case_name}"); + } +} diff --git a/crates/service/src/dashboard.rs b/crates/service/src/dashboard.rs index 228178748..4882dc54c 100644 --- a/crates/service/src/dashboard.rs +++ b/crates/service/src/dashboard.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use chrono::{Duration, Local, LocalResult, TimeZone}; +use chrono::{Duration, Local, LocalResult, NaiveDate, TimeZone}; use codexmanager_core::rpc::types::{ ApiKeySummary, DashboardAdminUsageSummaryResult, DashboardDailyUsagePoint, DashboardSourceUsageSummary, DashboardTokenUsageResult, DashboardUserUsageSummary, @@ -8,10 +8,7 @@ use codexmanager_core::rpc::types::{ MemberDashboardModelUsage, MemberDashboardSummaryResult, MemberDashboardUsagePoint, MemberDashboardUsageToday, MemberDashboardWalletResult, ModelInfo, }; -use codexmanager_core::storage::{ - DailyTokenUsageRollup, SourceTokenUsageRanking, SourceTokenUsageRollup, TokenUsageRollup, - UserTokenUsageRanking, UserTokenUsageRollup, -}; +use codexmanager_core::storage::{SourceTokenUsageRollup, TokenUsageRollup, UserTokenUsageRollup}; use serde_json::json; use crate::{ @@ -44,38 +41,42 @@ pub(crate) fn read_admin_usage_summary( let (today_start, today_end) = local_day_bounds_ts()?; let range_start = start_ts .filter(|value| *value > 0) - .unwrap_or_else(|| today_start.saturating_sub((ADMIN_USAGE_RANGE_DAYS - 1) * DAY_SECONDS)); + .unwrap_or(local_day_start_offset( + today_start, + -(ADMIN_USAGE_RANGE_DAYS - 1), + )?); let range_end = end_ts .filter(|value| *value > range_start) .unwrap_or(today_end); let ranking_limit = normalize_admin_usage_ranking_limit(ranking_limit); + let today_ranges = local_calendar_ranges(today_start, today_end)?; + let range_day_ranges = local_calendar_ranges(range_start, range_end)?; + let closed_day_ranges = closed_local_day_ranges(&range_day_ranges, today_start)?; let today_usage = storage - .summarize_request_token_stats_daily_mixed(today_start, today_end, DAY_SECONDS, today_start) + .summarize_request_token_stats_daily_mixed_ranges(&today_ranges, &[]) .map_err(|err| format!("summarize today usage failed: {err}"))? .into_iter() .next() .map(|item| item.usage) .unwrap_or_default(); - let daily_usage = fill_daily_usage( - range_start, - range_end, - DAY_SECONDS, - storage - .summarize_request_token_stats_daily_mixed( - range_start, - range_end, - DAY_SECONDS, - today_start, - ) - .map_err(|err| format!("summarize daily usage failed: {err}"))?, - ); + let daily_usage = storage + .summarize_request_token_stats_daily_mixed_ranges(&range_day_ranges, &closed_day_ranges) + .map_err(|err| format!("summarize daily usage failed: {err}"))? + .into_iter() + .map(|item| DashboardDailyUsagePoint { + day_start_ts: item.day_start_ts, + day_end_ts: item.day_end_ts, + usage: dashboard_usage(&item.usage), + }) + .collect(); let users = read_dashboard_user_summaries( &storage, today_start, today_end, range_start, range_end, + &closed_day_ranges, ranking_limit, )?; let openai_accounts = read_dashboard_source_summaries( @@ -85,6 +86,7 @@ pub(crate) fn read_admin_usage_summary( today_end, range_start, range_end, + &closed_day_ranges, ranking_limit, )?; let aggregate_apis = read_dashboard_source_summaries( @@ -94,6 +96,7 @@ pub(crate) fn read_admin_usage_summary( today_end, range_start, range_end, + &closed_day_ranges, ranking_limit, )?; @@ -139,6 +142,75 @@ fn local_day_bounds_ts() -> Result<(i64, i64), String> { Ok((start, end.max(start))) } +fn local_midnight_ts(date: NaiveDate, prefer_latest: bool) -> Result { + let naive = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| "build local midnight failed".to_string())?; + match Local.from_local_datetime(&naive) { + LocalResult::Single(value) => Ok(value.timestamp()), + LocalResult::Ambiguous(a, b) if prefer_latest => Ok(a.timestamp().max(b.timestamp())), + LocalResult::Ambiguous(a, b) => Ok(a.timestamp().min(b.timestamp())), + LocalResult::None => Err(format!("resolve local midnight failed: {date}")), + } +} + +fn local_day_start_offset(reference_start_ts: i64, days: i64) -> Result { + let reference = Local + .timestamp_opt(reference_start_ts, 0) + .single() + .ok_or_else(|| "resolve local reference day failed".to_string())?; + local_midnight_ts(reference.date_naive() + Duration::days(days), false) +} + +fn local_calendar_ranges(start_ts: i64, end_ts: i64) -> Result, String> { + if end_ts <= start_ts { + return Ok(Vec::new()); + } + let start_local = Local + .timestamp_opt(start_ts, 0) + .single() + .ok_or_else(|| "resolve local range start failed".to_string())?; + let mut date = start_local.date_naive(); + let mut ranges = Vec::new(); + loop { + let day_start = local_midnight_ts(date, false)?; + let day_end = local_midnight_ts(date + Duration::days(1), true)?; + let range_start = day_start.max(start_ts); + let range_end = day_end.min(end_ts); + if range_end > range_start { + ranges.push((range_start, range_end)); + } + if day_end >= end_ts { + break; + } + date += Duration::days(1); + } + Ok(ranges) +} + +fn closed_local_day_ranges( + day_ranges: &[(i64, i64)], + closed_before_ts: i64, +) -> Result, String> { + let mut closed = Vec::new(); + for &(range_start, range_end) in day_ranges { + if range_end > closed_before_ts { + continue; + } + let local = Local + .timestamp_opt(range_start, 0) + .single() + .ok_or_else(|| "resolve local day range failed".to_string())?; + let date = local.date_naive(); + let day_start = local_midnight_ts(date, false)?; + let day_end = local_midnight_ts(date + Duration::days(1), true)?; + if range_start == day_start && range_end == day_end { + closed.push((day_start, day_end)); + } + } + Ok(closed) +} + fn dashboard_usage(usage: &TokenUsageRollup) -> DashboardTokenUsageResult { DashboardTokenUsageResult { input_tokens: usage.input_tokens.max(0), @@ -187,79 +259,28 @@ fn ranked_usage_ids( ids } -fn fill_daily_usage( - start_ts: i64, - end_ts: i64, - bucket_seconds: i64, - items: Vec, -) -> Vec { - let bucket_seconds = bucket_seconds.max(1); - let mut by_start = items - .into_iter() - .map(|item| (item.day_start_ts, item)) - .collect::>(); - let mut cursor = start_ts; - let mut result = Vec::new(); - while cursor < end_ts { - let next = cursor.saturating_add(bucket_seconds).min(end_ts); - if let Some(item) = by_start.remove(&cursor) { - result.push(DashboardDailyUsagePoint { - day_start_ts: item.day_start_ts, - day_end_ts: item.day_end_ts, - usage: dashboard_usage(&item.usage), - }); - } else { - result.push(DashboardDailyUsagePoint { - day_start_ts: cursor, - day_end_ts: next, - usage: DashboardTokenUsageResult::default(), - }); - } - cursor = next; - } - result -} - fn read_dashboard_user_summaries( storage: &codexmanager_core::storage::Storage, today_start: i64, today_end: i64, range_start: i64, range_end: i64, + closed_day_ranges: &[(i64, i64)], ranking_limit: Option, ) -> Result, String> { - if let Some(limit) = ranking_limit { - return build_dashboard_user_summaries_from_rankings( - storage, - storage - .summarize_request_token_stats_user_ranking_between_mixed( - today_start, - today_end, - range_start, - range_end, - today_start, - limit, - ) - .map_err(|err| format!("summarize ranked user usage failed: {err}"))?, - ); - } build_dashboard_user_summaries( storage, storage - .summarize_request_token_stats_by_user_between_mixed( - today_start, - today_end, - today_start, - ) + .summarize_request_token_stats_by_user_between_mixed_ranges(today_start, today_end, &[]) .map_err(|err| format!("summarize today user usage failed: {err}"))?, storage - .summarize_request_token_stats_by_user_between_mixed( + .summarize_request_token_stats_by_user_between_mixed_ranges( range_start, range_end, - today_start, + closed_day_ranges, ) .map_err(|err| format!("summarize range user usage failed: {err}"))?, - None, + ranking_limit, ) } @@ -270,45 +291,29 @@ fn read_dashboard_source_summaries( today_end: i64, range_start: i64, range_end: i64, + closed_day_ranges: &[(i64, i64)], ranking_limit: Option, ) -> Result, String> { - if let Some(limit) = ranking_limit { - return build_dashboard_source_summaries_from_rankings( - storage, - source_kind, - storage - .summarize_request_token_stats_source_ranking_between_mixed( - source_kind, - today_start, - today_end, - range_start, - range_end, - today_start, - limit, - ) - .map_err(|err| format!("summarize ranked {source_kind} usage failed: {err}"))?, - ); - } build_dashboard_source_summaries( storage, source_kind, storage - .summarize_request_token_stats_by_source_between_mixed( + .summarize_request_token_stats_by_source_between_mixed_ranges( source_kind, today_start, today_end, - today_start, + &[], ) .map_err(|err| format!("summarize today {source_kind} usage failed: {err}"))?, storage - .summarize_request_token_stats_by_source_between_mixed( + .summarize_request_token_stats_by_source_between_mixed_ranges( source_kind, range_start, range_end, - today_start, + closed_day_ranges, ) .map_err(|err| format!("summarize range {source_kind} usage failed: {err}"))?, - None, + ranking_limit, ) } @@ -400,44 +405,6 @@ fn build_dashboard_user_summaries( Ok(results) } -fn build_dashboard_user_summaries_from_rankings( - storage: &codexmanager_core::storage::Storage, - ranking_items: Vec, -) -> Result, String> { - let user_ids = ranking_items - .iter() - .map(|item| item.user_id.clone()) - .collect::>(); - let users = storage - .list_app_users_by_ids(&user_ids) - .map_err(|err| format!("list app users failed: {err}"))?; - let wallets = wallets_for_user_ids(storage, &user_ids)?; - let user_map = users - .into_iter() - .map(|user| (user.id.clone(), user)) - .collect::>(); - - Ok(ranking_items - .into_iter() - .map(|item| { - let user = user_map.get(item.user_id.as_str()); - let wallet_available = wallets - .get(item.user_id.as_str()) - .map(|wallet| wallet.balance_credit_micros - wallet.frozen_credit_micros); - DashboardUserUsageSummary { - user_id: item.user_id, - username: user.map(|value| value.username.clone()), - display_name: user.and_then(|value| value.display_name.clone()), - role: user.map(|value| value.role.clone()), - status: user.map(|value| value.status.clone()), - wallet_available_credit_micros: wallet_available, - today_usage: dashboard_usage(&item.today_usage), - range_usage: dashboard_usage(&item.range_usage), - } - }) - .collect()) -} - fn wallets_for_user_ids( storage: &codexmanager_core::storage::Storage, user_ids: &[String], @@ -579,40 +546,6 @@ fn build_dashboard_source_summaries( Ok(results) } -fn build_dashboard_source_summaries_from_rankings( - storage: &codexmanager_core::storage::Storage, - source_kind: &str, - ranking_items: Vec, -) -> Result, String> { - let source_ids = ranking_items - .iter() - .map(|item| item.source_id.clone()) - .collect::>(); - let metadata = match source_kind { - "openai_account" => account_source_metadata(storage, Some(source_ids.as_slice()))?, - "aggregate_api" => aggregate_source_metadata(storage, Some(source_ids.as_slice()))?, - _ => HashMap::new(), - }; - Ok(ranking_items - .into_iter() - .map(|item| { - let meta = metadata - .get(item.source_id.as_str()) - .cloned() - .unwrap_or_default(); - DashboardSourceUsageSummary { - source_kind: item.source_kind, - source_id: item.source_id, - name: meta.name, - status: meta.status, - provider: meta.provider, - today_usage: dashboard_usage(&item.today_usage), - range_usage: dashboard_usage(&item.range_usage), - } - }) - .collect()) -} - pub(crate) fn read_member_dashboard_summary( actor: &RpcActor, requested_user_id: Option, diff --git a/crates/service/src/gateway/observability/maintenance.rs b/crates/service/src/gateway/observability/maintenance.rs index d43375bc8..2dedc9167 100644 --- a/crates/service/src/gateway/observability/maintenance.rs +++ b/crates/service/src/gateway/observability/maintenance.rs @@ -1,4 +1,4 @@ -use chrono::{Local, LocalResult, TimeZone, Timelike}; +use chrono::{Duration as ChronoDuration, Local, LocalResult, NaiveDate, TimeZone, Timelike}; use codexmanager_core::storage::{DatabasePageStats, Storage}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::thread; @@ -17,7 +17,6 @@ const DB_AUTO_VACUUM_MIN_FREE_MB_ENV: &str = "CODEXMANAGER_DB_AUTO_VACUUM_MIN_FR const DB_AUTO_VACUUM_MIN_FREE_PERCENT_ENV: &str = "CODEXMANAGER_DB_AUTO_VACUUM_MIN_FREE_PERCENT"; const DB_AUTO_VACUUM_WINDOW_START_HOUR_ENV: &str = "CODEXMANAGER_DB_AUTO_VACUUM_WINDOW_START_HOUR"; const DB_AUTO_VACUUM_WINDOW_END_HOUR_ENV: &str = "CODEXMANAGER_DB_AUTO_VACUUM_WINDOW_END_HOUR"; -const DAY_SECONDS: i64 = 86_400; const OBSERVABILITY_MAINTENANCE_INTERVAL_SECS_ENV: &str = "CODEXMANAGER_OBSERVABILITY_MAINTENANCE_INTERVAL_SECS"; @@ -288,18 +287,54 @@ fn should_run_db_auto_vacuum( /// /// # 返回 /// 返回当前本地日期的起始 Unix 时间戳,供日级 rollup 与 dashboard 日边界保持一致。 -fn local_today_start_ts() -> i64 { +fn local_midnight_ts(date: NaiveDate, prefer_latest: bool) -> Result { + let naive = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| "build local midnight failed".to_string())?; + match Local.from_local_datetime(&naive) { + LocalResult::Single(value) => Ok(value.timestamp()), + LocalResult::Ambiguous(a, b) if prefer_latest => Ok(a.timestamp().max(b.timestamp())), + LocalResult::Ambiguous(a, b) => Ok(a.timestamp().min(b.timestamp())), + LocalResult::None => Err(format!("resolve local midnight failed: {date}")), + } +} + +fn local_today_start_ts() -> Result { let now = Local::now(); - let Some(start_naive) = now.date_naive().and_hms_opt(0, 0, 0) else { - return now.timestamp(); + local_midnight_ts(now.date_naive(), false) +} + +fn local_closed_day_ranges( + storage: &Storage, + today_start_ts: i64, +) -> Result, String> { + let Some(oldest_ts) = storage + .oldest_pending_daily_rollup_ts_before(today_start_ts) + .map_err(|err| format!("load oldest pending daily rollup failed: {err}"))? + else { + return Ok(Vec::new()); }; - match Local.from_local_datetime(&start_naive) { - LocalResult::Single(value) => value.timestamp(), - LocalResult::Ambiguous(a, b) => a.timestamp().min(b.timestamp()), - LocalResult::None => now - .timestamp() - .saturating_sub(now.timestamp().rem_euclid(DAY_SECONDS)), + let oldest_local = Local + .timestamp_opt(oldest_ts, 0) + .single() + .ok_or_else(|| "resolve oldest pending local day failed".to_string())?; + let today_local = Local + .timestamp_opt(today_start_ts, 0) + .single() + .ok_or_else(|| "resolve current local day failed".to_string())?; + let mut date = oldest_local.date_naive(); + let today = today_local.date_naive(); + let mut ranges = Vec::new(); + while date < today { + let day_start = local_midnight_ts(date, false)?; + let day_end = local_midnight_ts(date + ChronoDuration::days(1), true)?; + if day_end <= day_start { + return Err(format!("invalid local day range: {day_start}..{day_end}")); + } + ranges.push((day_start, day_end)); + date += ChronoDuration::days(1); } + Ok(ranges) } /// 函数 `schedule_observability_maintenance` /// @@ -382,9 +417,13 @@ fn run_idle_db_maintenance_loop() { fn run_observability_maintenance(now: i64, previous_last: i64) { let succeeded = match crate::storage_helpers::open_storage() { Some(storage) => { - match storage - .prune_observability_history_with_daily_rollup_anchor(now, local_today_start_ts()) - { + let maintenance_result = local_today_start_ts().and_then(|today_start| { + let day_ranges = local_closed_day_ranges(&storage, today_start)?; + storage + .prune_observability_history_with_daily_rollup_ranges(now, &day_ranges) + .map_err(|err| err.to_string()) + }); + match maintenance_result { Ok(()) => { log::debug!("event=gateway_observability_maintenance_completed"); maybe_run_idle_db_auto_vacuum(&storage, now); diff --git a/task.md b/task.md index 731ce3eb3..ac1cb8e78 100644 --- a/task.md +++ b/task.md @@ -9,6 +9,7 @@ - 后端:修复账号删除后的候选缓存失效、成员账号池权限边界、OAuth 日志脱敏及多模式候选缓存隔离。 - `audit/backend-fixes` 已完成上述后端子项并通过定向测试,等待主代理独立审计与集成;DST 日级 rollup 由独立子代理处理。 - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 + - 日级统计:改用显式本地自然日边界执行 rollup 与 mixed 查询,覆盖 DST 23/25 小时切换日。 - 主代理负责逐提交审计、独立测试、集成分支和 PR,不直接采纳子代理完成声明。 2. P2 上游差异巡检 From d345f5609b8fabce3ef7f24a0965730664cf5b8d Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:45:29 +0800 Subject: [PATCH 06/35] =?UTF-8?q?=E6=80=A7=E8=83=BD:=20=E9=81=BF=E5=85=8DD?= =?UTF-8?q?ST=E6=B1=87=E6=80=BB=E6=9E=9A=E4=B8=BE=E7=A8=80=E7=96=8F?= =?UTF-8?q?=E7=A9=BA=E6=97=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...41\350\256\241\344\277\256\345\244\215.md" | 8 +++ crates/core/src/storage/mod.rs | 1 + .../core/src/storage/request_token_stats.rs | 27 ++++++++- .../src/gateway/observability/maintenance.rs | 57 ++++++++++++------- crates/service/src/tests/lib_tests.rs | 52 ++++++++++++++--- task.md | 1 + 6 files changed, 117 insertions(+), 29 deletions(-) diff --git "a/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" index b8316f45a..b3dadc544 100644 --- "a/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" +++ "b/.teamwork/progress/2026-07-11_DST\346\227\245\347\272\247Rollup\345\256\241\350\256\241\344\277\256\345\244\215.md" @@ -9,6 +9,7 @@ - 原实现以“当前本地零点 + 固定 86400 秒”反推历史日边界,在 America/New_York 春季切换日会错过 1 小时,秋季切换日会跨入相邻自然日。 - SQLite 表中的 `day_start` 足以保存真实本地日零点,不需要修改表结构;问题位于边界生成与 mixed 查询切分。 - 修复采用显式 `(day_start, day_end)` 区间,由服务层使用 `chrono::Local` 生成本地自然日,core 只按传入边界聚合。 +- 主审复核发现首版会从最早 pending 日期枚举到今天;稀疏多年数据会产生大量空日事务。修订为只读取受维护批次上限约束的 pending 明细时间戳,并仅生成真实存在数据的本地日期。 ## 实施项 @@ -17,6 +18,7 @@ - [x] 增加显式日区间的 daily / user / source mixed 查询。 - [x] Dashboard 默认七日范围按本地日期偏移,不再以秒数回退。 - [x] 增加 America/New_York 春季 23 小时与秋季 25 小时测试,覆盖明细删除前后汇总一致性。 +- [x] 增加跨 25 年稀疏 pending 数据测试,确认不会枚举中间空日。 - [x] 完成格式化、core/service 定向测试与提交。 ## 验证结果 @@ -31,3 +33,9 @@ - 未修改 SQLite 表结构与历史 rollup 数据。 - 无法解析本地午夜时返回错误并停止维护,不使用 UTC 对齐猜测替代本地日边界。 - 维护仍受原批次上限约束,未完成的日级明细会留待下轮继续处理。 + +## 已知粒度限制 + +- 日级 rollup 只保存完整本地自然日聚合,不保存小时级切片。 +- 历史自定义查询若只覆盖某日首半日或尾半日,而该日原始明细已被 retention 清理,现有表结构无法精确回答该半日用量。 +- mixed 查询只对完整且已关闭的本地自然日读取 rollup;首尾不完整区间继续读取明细。明细不存在时返回可验证的空结果,不把整日 rollup 伪造成半日数据。 diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index 7f1c74b15..cb55d1455 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -22,6 +22,7 @@ mod quota_pools; mod request_log_query; mod request_logs; mod request_token_stats; +pub use request_token_stats::observability_maintenance_batch_limit; mod settings; mod tokens; mod usage; diff --git a/crates/core/src/storage/request_token_stats.rs b/crates/core/src/storage/request_token_stats.rs index 4ef0c59ce..bede3daaa 100644 --- a/crates/core/src/storage/request_token_stats.rs +++ b/crates/core/src/storage/request_token_stats.rs @@ -34,7 +34,7 @@ fn observability_maintenance_interval_secs() -> i64 { .unwrap_or(DEFAULT_OBSERVABILITY_MAINTENANCE_INTERVAL_SECS) } -pub(super) fn observability_maintenance_batch_limit() -> usize { +pub fn observability_maintenance_batch_limit() -> usize { std::env::var(OBSERVABILITY_MAINTENANCE_BATCH_LIMIT_ENV) .ok() .and_then(|raw| raw.trim().parse::().ok()) @@ -500,6 +500,31 @@ impl Storage { ) } + /// 按创建时间读取指定边界前尚未日级固化的明细时间戳。 + /// + /// 返回行数受 limit 限制,供服务层只解析本批次真实存在数据的本地日期, + /// 避免稀疏历史数据按日历跨度枚举大量空日期。 + pub fn pending_daily_rollup_timestamps_before_limited( + &self, + cutoff_ts: i64, + limit: usize, + ) -> Result> { + if cutoff_ts <= 0 || limit == 0 { + return Ok(Vec::new()); + } + let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX); + let mut stmt = self.conn.prepare( + "SELECT created_at + FROM request_token_stats + WHERE created_at < ?1 + AND daily_rolled_at IS NULL + ORDER BY created_at ASC, id ASC + LIMIT ?2", + )?; + let rows = stmt.query_map((cutoff_ts, limit_i64), |row| row.get(0))?; + rows.collect() + } + fn has_pending_daily_rollup_before(&self, cutoff_ts: i64) -> Result { Ok(self .oldest_pending_daily_rollup_ts_before(cutoff_ts)? diff --git a/crates/service/src/gateway/observability/maintenance.rs b/crates/service/src/gateway/observability/maintenance.rs index 2dedc9167..816099f9c 100644 --- a/crates/service/src/gateway/observability/maintenance.rs +++ b/crates/service/src/gateway/observability/maintenance.rs @@ -1,5 +1,8 @@ use chrono::{Duration as ChronoDuration, Local, LocalResult, NaiveDate, TimeZone, Timelike}; -use codexmanager_core::storage::{DatabasePageStats, Storage}; +use codexmanager_core::storage::{ + observability_maintenance_batch_limit, DatabasePageStats, Storage, +}; +use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -308,31 +311,34 @@ fn local_closed_day_ranges( storage: &Storage, today_start_ts: i64, ) -> Result, String> { - let Some(oldest_ts) = storage - .oldest_pending_daily_rollup_ts_before(today_start_ts) - .map_err(|err| format!("load oldest pending daily rollup failed: {err}"))? - else { - return Ok(Vec::new()); - }; - let oldest_local = Local - .timestamp_opt(oldest_ts, 0) - .single() - .ok_or_else(|| "resolve oldest pending local day failed".to_string())?; - let today_local = Local - .timestamp_opt(today_start_ts, 0) - .single() - .ok_or_else(|| "resolve current local day failed".to_string())?; - let mut date = oldest_local.date_naive(); - let today = today_local.date_naive(); + let pending_timestamps = storage + .pending_daily_rollup_timestamps_before_limited( + today_start_ts, + observability_maintenance_batch_limit(), + ) + .map_err(|err| format!("load pending daily rollup timestamps failed: {err}"))?; + local_closed_day_ranges_from_timestamps(&pending_timestamps) +} + +fn local_closed_day_ranges_from_timestamps( + pending_timestamps: &[i64], +) -> Result, String> { + let mut dates = BTreeSet::new(); + for ×tamp in pending_timestamps { + let local = Local + .timestamp_opt(timestamp, 0) + .single() + .ok_or_else(|| format!("resolve pending local day failed: {timestamp}"))?; + dates.insert(local.date_naive()); + } let mut ranges = Vec::new(); - while date < today { + for date in dates { let day_start = local_midnight_ts(date, false)?; let day_end = local_midnight_ts(date + ChronoDuration::days(1), true)?; if day_end <= day_start { return Err(format!("invalid local day range: {day_start}..{day_end}")); } ranges.push((day_start, day_end)); - date += ChronoDuration::days(1); } Ok(ranges) } @@ -539,13 +545,24 @@ fn run_idle_db_auto_vacuum(storage: &Storage, before: DatabasePageStats) -> Resu mod tests { use super::{ db_compaction_busy_response, finish_observability_maintenance_slot_with_state, - hour_inside_window, should_run_db_auto_vacuum, + hour_inside_window, local_closed_day_ranges_from_timestamps, should_run_db_auto_vacuum, try_reserve_observability_maintenance_slot_with_state, DbAutoVacuumConfig, DbAutoVacuumDecision, }; use codexmanager_core::storage::DatabasePageStats; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; + #[test] + fn sparse_pending_history_only_builds_ranges_for_dates_with_rows() { + // 两批明细相隔 25 年,中间没有数据时只应生成两个本地日区间。 + let ranges = + local_closed_day_ranges_from_timestamps(&[946_728_000, 946_731_600, 1_735_732_800]) + .expect("build sparse local day ranges"); + + assert_eq!(ranges.len(), 2); + assert!(ranges.iter().all(|(start, end)| end > start)); + } + #[test] fn reserves_first_maintenance_slot() { let running = AtomicBool::new(false); diff --git a/crates/service/src/tests/lib_tests.rs b/crates/service/src/tests/lib_tests.rs index 4585820b1..79e51dd43 100644 --- a/crates/service/src/tests/lib_tests.rs +++ b/crates/service/src/tests/lib_tests.rs @@ -1,4 +1,5 @@ use super::*; +use chrono::{Duration, Local, LocalResult, TimeZone}; use codexmanager_core::rpc::types::{JsonRpcMessage, JsonRpcResponse}; use codexmanager_core::storage::{ Account, ModelCatalogModelRecord, ModelGroupModel, RequestLog, RequestTokenStat, Storage, @@ -24,6 +25,24 @@ fn response_result(resp: JsonRpcMessage) -> JsonRpcResponse { } } +fn test_local_day_boundaries(reference_ts: i64, day_count: i64) -> Vec { + let reference = Local + .timestamp_opt(reference_ts, 0) + .single() + .expect("resolve test local reference"); + (0..=day_count) + .map(|offset| { + let date = reference.date_naive() + Duration::days(offset); + let naive = date.and_hms_opt(0, 0, 0).expect("build test midnight"); + match Local.from_local_datetime(&naive) { + LocalResult::Single(value) => value.timestamp(), + LocalResult::Ambiguous(a, b) => a.timestamp().min(b.timestamp()), + LocalResult::None => panic!("resolve test local midnight: {date}"), + } + }) + .collect() +} + /// 函数 `login_complete_requires_params` /// /// 作者: gaohongshun @@ -594,8 +613,9 @@ fn wallet_charge_uses_model_group_billing_model_override() { fn member_dashboard_filters_to_current_user_keys() { let _guard = test_env_guard(); let db_path = setup_dashboard_test_db("codexmanager-member-dashboard-filter"); - let day_start = 1_700_000_000; - let day_end = day_start + 86_400; + let day_boundaries = test_local_day_boundaries(1_700_000_000, 1); + let day_start = day_boundaries[0]; + let day_end = day_boundaries[1]; let user_one = create_app_user(AppUserCreateInput { username: "member-one".to_string(), password: "password-one".to_string(), @@ -878,8 +898,9 @@ fn admin_member_dashboard_can_query_requested_user() { fn admin_usage_summary_requires_admin_and_returns_range_rollups() { let _guard = test_env_guard(); let db_path = setup_dashboard_test_db("codexmanager-admin-usage-summary"); - let day_start = 1_700_000_000; - let day_end = day_start + 86_400; + let day_boundaries = test_local_day_boundaries(1_700_000_000, 1); + let day_start = day_boundaries[0]; + let day_end = day_boundaries[1]; let user = create_test_member("admin-usage-member", Some(2_000_000)); let key_id = create_owned_test_api_key(&user.id, "admin usage key", "gpt-5-mini"); @@ -929,6 +950,8 @@ fn admin_usage_summary_requires_admin_and_returns_range_rollups() { assert_eq!(admin_resp.result["rangeStartTs"], day_start); assert_eq!(admin_resp.result["rangeEndTs"], day_end); assert_eq!(admin_resp.result["dailyUsage"].as_array().unwrap().len(), 1); + assert_eq!(admin_resp.result["dailyUsage"][0]["dayStartTs"], day_start); + assert_eq!(admin_resp.result["dailyUsage"][0]["dayEndTs"], day_end); assert_eq!( admin_resp.result["dailyUsage"][0]["usage"]["totalTokens"], 30 @@ -961,8 +984,9 @@ fn admin_usage_summary_requires_admin_and_returns_range_rollups() { fn admin_usage_summary_daily_trend_includes_token_stats_without_request_logs() { let _guard = test_env_guard(); let db_path = setup_dashboard_test_db("codexmanager-admin-usage-orphan-stats"); - let day_start = 1_700_000_000; - let day_end = day_start + 3 * 86_400; + let day_boundaries = test_local_day_boundaries(1_700_000_000, 3); + let day_start = day_boundaries[0]; + let day_end = day_boundaries[3]; let user = create_test_member("admin-usage-orphan-member", Some(2_000_000)); let key_id = create_owned_test_api_key(&user.id, "admin orphan key", "gpt-5-mini"); let storage = storage_helpers::open_storage().expect("open storage"); @@ -993,7 +1017,7 @@ fn admin_usage_summary_daily_trend_includes_token_stats_without_request_logs() { output_tokens: Some(100), total_tokens: Some(400), estimated_cost_usd: Some(0.4), - created_at: day_start + 86_400 + 180, + created_at: day_boundaries[1] + 180, ..RequestTokenStat::default() }) .expect("insert orphan day two stat"); @@ -1007,7 +1031,7 @@ fn admin_usage_summary_daily_trend_includes_token_stats_without_request_logs() { 5, 10, 0.03, - day_start + 2 * 86_400 + 240, + day_boundaries[2] + 240, ); let admin_resp = response_result(handle_request_with_actor( @@ -1025,7 +1049,19 @@ fn admin_usage_summary_daily_trend_includes_token_stats_without_request_logs() { "{:?}", admin_resp.result ); + assert_eq!(admin_resp.result["rangeStartTs"], day_start); + assert_eq!(admin_resp.result["rangeEndTs"], day_end); assert_eq!(admin_resp.result["dailyUsage"].as_array().unwrap().len(), 3); + for index in 0..3 { + assert_eq!( + admin_resp.result["dailyUsage"][index]["dayStartTs"], + day_boundaries[index] + ); + assert_eq!( + admin_resp.result["dailyUsage"][index]["dayEndTs"], + day_boundaries[index + 1] + ); + } assert_eq!( admin_resp.result["dailyUsage"][0]["usage"]["totalTokens"], 500 diff --git a/task.md b/task.md index ac1cb8e78..21259ab01 100644 --- a/task.md +++ b/task.md @@ -10,6 +10,7 @@ - `audit/backend-fixes` 已完成上述后端子项并通过定向测试,等待主代理独立审计与集成;DST 日级 rollup 由独立子代理处理。 - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 - 日级统计:改用显式本地自然日边界执行 rollup 与 mixed 查询,覆盖 DST 23/25 小时切换日。 + - 已知粒度限制:历史自定义范围若只覆盖某自然日首尾半日,且该日明细已按 retention 清理,只能使用整日 rollup,无法精确还原半日数据;接口不得把整日汇总伪装成该半日结果。 - 主代理负责逐提交审计、独立测试、集成分支和 PR,不直接采纳子代理完成声明。 2. P2 上游差异巡检 From d35506a854dcc1eaf6fc23d5f9b645eaf0295ea6 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:02:26 +0800 Subject: [PATCH 07/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=E9=AA=8C=E6=94=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .teamwork/sync/opus-to-gpt.md | 66 +++++++++++++---------------------- .teamwork/sync/status.json | 16 ++++----- docs/zh-CN/CHANGELOG.md | 5 +++ task.md | 6 ++-- 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/.teamwork/sync/opus-to-gpt.md b/.teamwork/sync/opus-to-gpt.md index 382cbdf01..a49fd5bc2 100644 --- a/.teamwork/sync/opus-to-gpt.md +++ b/.teamwork/sync/opus-to-gpt.md @@ -1,52 +1,34 @@ -# J 项 usage_snapshots 无变化写入去重结果 +# 审计问题修复子代理交付汇总 -执行身份:CodeX-GPT -接手时间:2026-06-24T17:28:09+08:00 -任务:`usage-snapshot-dedup-j` +执行身份:CodeX-GPT 子代理组 +交付时间:2026-07-11T16:51:35+08:00 +任务:`audit-fix-followups-20260711` -## 接手原因 +## 子代理产出 -Claude/Opus 执行方完成了核心半成品,但未完成协议交付、未确认 service 测试、未运行 `cargo check`、未写协作结果、未更新状态、未提交,并带入了多处 unrelated rustfmt/行尾噪声。CodeX-GPT 已关闭执行方、清理无关 diff 并完成审计收口。 +- 发布与数据库工具:统一 CE GHCR 镜像归属;数据库优化工具改为显式路径、默认只读、仅 `--vacuum` 执行破坏性维护。 +- 前端与 Web RPC:补齐命令映射及完整性测试;修正生产构建跳过条件和 `/platform-mode` 页面验证。 +- 后端安全与缓存:账号删除后失效候选缓存;缓存及 single-flight 按数据库和候选模式隔离;收紧成员全局账号池权限;OAuth 成功日志脱敏。 +- DST 日级统计:按真实本地自然日边界执行 rollup 和 mixed 查询;覆盖 23/25 小时日;修复稀疏多年历史枚举空日的性能问题。 -## 修改摘要 +## 主代理初审 -- storage 层新增 `update_latest_usage_snapshot_captured_at_for_account(account_id, captured_at)`,用于在相同快照去重时维护最新刷新时间语义。 -- service 层 `store_usage_snapshot()` 先读取账号最新快照,比较关键字段: - - `used_percent` - - `window_minutes` - - `resets_at` - - `secondary_used_percent` - - `secondary_window_minutes` - - `secondary_resets_at` - - `credits_json` -- 如果关键字段未变化:不再 `insert_usage_snapshot()`,只更新最新行 `captured_at`,并继续基于本次解析结果执行 `apply_status_from_snapshot()`。 -- 如果关键字段变化:保持原 insert + prune 行为。 -- `credits_json` 使用 `serde_json::Value` 语义比较,避免对象字段顺序不同导致误判为变化。 +- 四组提交均已检查完整 diff、提交范围和定向测试。 +- 主审退回 DST 首版的空日线性枚举问题,修订后仅处理受 batch limit 约束且真实存在 pending 数据的日期。 +- 历史首尾半日范围在原始明细清理后无法由日级 rollup 精确还原,已作为存储粒度限制记录;实现不会用整日数据伪造半日结果。 -## 产出文件 +## 最终验证 -- `crates/core/src/storage/usage.rs` -- `crates/service/src/usage/usage_snapshot_store.rs` -- `task.md` -- `.teamwork/sync/opus-to-gpt.md` -- `.teamwork/sync/status.json` - -## 验证结果 - -- `cargo test -p codexmanager-core --lib usage_snapshot -- --nocapture` -> 6 passed。 -- `cargo test -p codexmanager-service --lib usage_snapshot -- --nocapture` -> 5 passed。 -- `cargo check -p codexmanager-service` -> Finished(仅既有 warning)。 -- `git diff --check` -> 通过。 -- `rustfmt --edition 2021 crates/core/src/storage/usage.rs crates/service/src/usage/usage_snapshot_store.rs` -> 通过。 +- `git diff ba56918d..HEAD --check`:通过。 +- `cargo fmt --all --check`:通过。 +- `cargo test --workspace`:core 95/95、service lib 1053/1053 已通过;Windows 测试进程在打印成功结果后未退出,后续包改为独立复跑。 +- `cargo test -p db-optimize`:8/8 通过。 +- `cargo test -p codexmanager-web`:18/18 通过。 +- `cargo test -p codexmanager-start`:2/2 通过。 +- `corepack pnpm -C apps run test:runtime`:113/113 通过。 +- `corepack pnpm -C apps run build`:通过,15 个静态页面生成。 +- CE GHCR 残留与 OAuth 敏感成功日志扫描:无命中。 ## 审计结论 -- 相同关键字段连续写入不会增加 `usage_snapshots` 行数。 -- 相同关键字段第二次写入会更新 latest `captured_at`。 -- 任一关键字段变化仍新增快照。 -- `credits_json` 语义相同但字段顺序不同不会新增快照。 -- 执行方误带入的 unrelated rustfmt/行尾噪声已清理,未纳入提交。 - -## 剩余风险 - -- latest 快照的比较与更新时间是两步操作,极端同账号并发刷新下仍可能存在竞态窗口;当前 token/usage 刷新路径已有批次调度和账号级刷新语义,风险可接受。若后续观测到同账号并发写入,可进一步把 compare/update/insert 收口到 storage transaction。 +修复范围与审计问题一致,未发现需阻止提交 PR 的剩余代码问题。历史半日查询精度限制已记录为存储粒度约束。 diff --git a/.teamwork/sync/status.json b/.teamwork/sync/status.json index aaeac63b5..c266a94d2 100644 --- a/.teamwork/sync/status.json +++ b/.teamwork/sync/status.json @@ -1,17 +1,17 @@ { - "status": "opus_working", + "status": "completed", "task": "audit-fix-followups-20260711", "created_at": "2026-07-11T15:07:30+08:00", - "last_update": "2026-07-11T15:07:30+08:00", - "iteration": 1, + "last_update": "2026-07-11T17:00:58+08:00", + "iteration": 2, "max_iterations": 3, "last_actor": "CodeX-GPT", - "current_agent": "frontend-backend-release-subagents", - "next_agent": "CodeX-GPT", + "current_agent": "CodeX-GPT", + "next_agent": null, "workflow": "审计问题三分支并行修复与主代理复核", "description": "修复 Web RPC、权限缓存、日志脱敏、Docker 镜像与数据库工具安全默认值", "priority": "P0", - "phase": "implementation", - "audit_result": null, - "tests_status": "pending" + "phase": "completed", + "audit_result": "PASS", + "tests_status": "PASS" } diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 93acd64c1..7af60d8bb 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -6,6 +6,8 @@ ## [Unreleased] ### Changed +- Docker Compose 与多语言部署文档统一使用 `ghcr.io/creatoredition` 镜像;`db-optimize` 改为必须显式指定数据库路径、默认只读检查,并仅在传入 `--vacuum` 时执行 checkpoint/VACUUM。 +- Dashboard 日级趋势、用户排行和来源统计改用真实本地自然日边界,夏令时切换日按 23/25 小时聚合;后台维护只处理本批真实存在待汇总明细的日期,避免稀疏多年历史产生大量空事务。 - Dashboard 与请求日志页不再按“账号直连模式”遮挡统计、追加“仅网关流量”标签或引导切换模式;CodexManager 的账号与聚合 API 可混合路由,页面统一展示服务实际记录的数据。 - 官方价格种子新增 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 的标准与长上下文价格,并记录标准上下文 Cache writes 单价;已有数据库会通过新版种子自动补齐规则。 - README 恢复保留 Linux.do 认可社区入口,清理策略只排除作者赞助、远程 author content 与发行推广内容,不再误删社区来源说明。 @@ -25,6 +27,9 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- 补齐 Web 运行壳遗漏的命令映射及完整性门禁,移除错误的模型价格重复 RPC 映射;Tauri 生产构建不再因旧静态产物存在而跳过重新生成,并把 `/platform-mode` 纳入根页面校验。 +- 账号单删、批删和状态清理成功后立即失效网关候选缓存;候选快照与 single-flight 按数据库和低额度模式隔离,避免交替模式互相驱逐或删除后继续选中旧凭据。 +- accounts 鉴权模式下成员不再访问全局账号池读取、更新、用量刷新和 Token 刷新 RPC;OAuth 登录成功日志不再输出数据库路径、state、workspace 或账号标识。 - 账号页状态筛选新增“不可用”,可直接筛出 Refresh Token 撤销/过期、401/403 等 `unavailable` 账号;`unknown` 或空状态不再被“可用”查询误收录。 - 账号额度卡片的重置绝对时间与相对倒计时改为上下两行,避免窄列中日期和“后刷新”文本互相叠加。 - Windows 默认登录回调端口 `1455` 被系统 TCP 排除区间或安全策略拒绝时,会自动回退到系统分配的 loopback 动态端口;显式配置 `CODEXMANAGER_LOGIN_ADDR` 时仍严格使用配置值。 diff --git a/task.md b/task.md index 21259ab01..aa2582ef8 100644 --- a/task.md +++ b/task.md @@ -4,14 +4,14 @@ ## 当前待处理(2026-07-07) -1. P0 审计问题修复与独立复核(🔄 进行中) +1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 - 后端:修复账号删除后的候选缓存失效、成员账号池权限边界、OAuth 日志脱敏及多模式候选缓存隔离。 - - `audit/backend-fixes` 已完成上述后端子项并通过定向测试,等待主代理独立审计与集成;DST 日级 rollup 由独立子代理处理。 + - 后端子项已经主代理独立审计并集成,权限、缓存与日志定向测试通过。 - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 - 日级统计:改用显式本地自然日边界执行 rollup 与 mixed 查询,覆盖 DST 23/25 小时切换日。 - 已知粒度限制:历史自定义范围若只覆盖某自然日首尾半日,且该日明细已按 retention 清理,只能使用整日 rollup,无法精确还原半日数据;接口不得把整日汇总伪装成该半日结果。 - - 主代理负责逐提交审计、独立测试、集成分支和 PR,不直接采纳子代理完成声明。 + - 主代理已逐提交审计、退回并修复 DST 稀疏空日性能问题,完成前后端构建与集成测试;待 PR 合并后从当前看板移除。 2. P2 上游差异巡检 - 当前上游基准:`upstream/main = a614b559 docs: tidy repository links in readme`。 From a6ec3fc6d2b682eb3521ddf9f428841219f1578d Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:40:10 +0800 Subject: [PATCH 08/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E4=B8=8E=E5=88=B7=E6=96=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BF=AE=E5=A4=8D=E5=88=86=E5=B7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .teamwork/sync/gpt-to-opus.md | 25 +++++++++++-------- .teamwork/sync/status.json | 24 +++++++++--------- ...66\346\200\201\344\277\256\345\244\215.md" | 19 ++++++++++++++ task.md | 7 ++++++ 4 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 ".teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" diff --git a/.teamwork/sync/gpt-to-opus.md b/.teamwork/sync/gpt-to-opus.md index 8316323fb..ea8471f52 100644 --- a/.teamwork/sync/gpt-to-opus.md +++ b/.teamwork/sync/gpt-to-opus.md @@ -1,15 +1,18 @@ -# 给修复子代理的任务:审计问题并行修复 +# 定价分层与刷新状态补充修复任务 -发起方:【CodeX-GPT】 -任务时间:2026-07-11T15:07:30+08:00 -基线提交:`ba56918d611c910daf1cf9a52d370cc105b6fe17` +发起身份:【CodeX-GPT】 +时间:2026-07-11 -## 执行要求 +## 任务 -- 各子代理必须使用独立 Git worktree 和独立分支。 -- 只修改分配范围,使用中文提交信息,禁止 `git add .`。 -- 每个分支必须写入独立 `.teamwork/progress/` 结果文件,记录修改、测试、commit 和未验证项。 -- 不得把密钥、Token、Cookie 或用户隐私写入协作文件。 -- 主代理将独立审计 diff 和测试,不以子代理声明作为完成依据。 +1. 按 `effective_service_tier` 修正 Standard/Priority 计费并补官方价格种子。 +2. 修复 Refresh Token 永久错误过滤与 Token 成功后的状态恢复链路。 +3. 增加出口 IP/地区诊断,参考 Clash Verge 多服务降级,但不以 IP 查询直接裁决账号状态。 +4. 刷新完成后同步前端账号实体状态。 -详细分工见 `.teamwork/tasks/2026-07-11_审计问题修复.md`。 +## 审计要求 + +- 每个主题独立提交。 +- 必须包含定向测试。 +- 禁止修改无关模块。 +- 主代理不会直接采纳完成声明,必须复核 diff 与测试。 diff --git a/.teamwork/sync/status.json b/.teamwork/sync/status.json index c266a94d2..2da9c37b8 100644 --- a/.teamwork/sync/status.json +++ b/.teamwork/sync/status.json @@ -1,17 +1,17 @@ { - "status": "completed", - "task": "audit-fix-followups-20260711", - "created_at": "2026-07-11T15:07:30+08:00", - "last_update": "2026-07-11T17:00:58+08:00", - "iteration": 2, + "status": "opus_working", + "task": "pricing-refresh-followups-20260711", + "created_at": "2026-07-11T17:20:00+08:00", + "last_update": "2026-07-11T17:20:00+08:00", + "iteration": 1, "max_iterations": 3, "last_actor": "CodeX-GPT", - "current_agent": "CodeX-GPT", - "next_agent": null, - "workflow": "审计问题三分支并行修复与主代理复核", - "description": "修复 Web RPC、权限缓存、日志脱敏、Docker 镜像与数据库工具安全默认值", + "current_agent": "pricing-refresh-ip-subagents", + "next_agent": "CodeX-GPT", + "workflow": "定价分层、刷新恢复与区域诊断并行修复", + "description": "修复 Priority 计费、刷新状态恢复、区域诊断与前端状态同步", "priority": "P0", - "phase": "completed", - "audit_result": "PASS", - "tests_status": "PASS" + "phase": "implementation", + "audit_result": null, + "tests_status": "pending" } diff --git "a/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" "b/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" new file mode 100644 index 000000000..19ea800cb --- /dev/null +++ "b/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" @@ -0,0 +1,19 @@ +# 定价分层与刷新状态修复 + +## 状态 + +🔄 进行中 + +## 分工 + +- 定价子代理:Priority/Standard 规则匹配、官方种子、费用估算测试。 +- 刷新子代理:永久错误过滤、Token 成功后的用量验证和状态恢复。 +- 区域与前端子代理:出口 IP 诊断、事件触发策略、账号状态缓存同步和 UI。 +- 主代理:独立审计、冲突处理、最终门禁和 PR 更新。 + +## 约束 + +- IP 信息仅用于诊断,不直接把账号恢复为 active。 +- 账号恢复必须以 Token 与用量接口的真实结果为依据。 +- Priority 价格必须逐模型录入,禁止统一倍率推导。 +- 不自动重算历史账单,除非用户另行授权。 diff --git a/task.md b/task.md index aa2582ef8..eca71b9c0 100644 --- a/task.md +++ b/task.md @@ -4,6 +4,13 @@ ## 当前待处理(2026-07-07) +0. P0 定价分层与刷新状态补充修复(🔄 进行中) + - 定价:按请求最终 `effective_service_tier` 区分 Standard / Priority,补齐官方 Priority 种子与回归测试;历史费用重算不在本轮自动执行。 + - 刷新:修复 `refresh_token_expired` 永久过滤遗漏;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;明确 disabled/banned 的后台刷新边界。 + - 区域诊断:参考 Clash Verge 多 IP 服务映射与失败切换,但仅用于出口诊断;启动检查、区域阻断事件触发检查和低频兜底均不得直接替代上游接口判定。 + - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 + - 主代理负责对子代理补丁独立审计、集成测试与 PR 更新。 + 1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 - 后端:修复账号删除后的候选缓存失效、成员账号池权限边界、OAuth 日志脱敏及多模式候选缓存隔离。 From e826b540a35561cb76a10fe86e2a4a6cd79cd51b Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:44:14 +0800 Subject: [PATCH 09/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0=E5=90=8E=E7=9A=84=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=81=A2=E5=A4=8D=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...66\346\200\201\344\277\256\345\244\215.md" | 7 + crates/core/src/storage/accounts.rs | 31 +++- crates/core/src/storage/tokens.rs | 66 +++++++- crates/core/tests/storage.rs | 13 ++ crates/service/src/usage/refresh/mod.rs | 78 ++++++++- .../src/usage/tests/usage_http_tests.rs | 16 +- .../src/usage/tests/usage_refresh_tests.rs | 148 +++++++++++++++++- crates/service/src/usage/usage_http.rs | 24 +-- ...63\350\277\207\350\257\264\346\230\216.md" | 13 +- ...15\347\275\256\350\257\264\346\230\216.md" | 5 +- task.md | 3 +- 11 files changed, 364 insertions(+), 40 deletions(-) diff --git "a/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" "b/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" index 19ea800cb..03b47183f 100644 --- "a/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" +++ "b/.teamwork/tasks/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\270\216\345\210\267\346\226\260\347\212\266\346\200\201\344\277\256\345\244\215.md" @@ -11,6 +11,13 @@ - 区域与前端子代理:出口 IP 诊断、事件触发策略、账号状态缓存同步和 UI。 - 主代理:独立审计、冲突处理、最终门禁和 PR 更新。 +## 刷新子项进度 + +- ✅ `refresh_token_expired` 已改为长退避后的低频复检;仅 reused / invalidated / invalid_grant / app_session_terminated 永久过滤。 +- ✅ 后台 Token 轮询明确跳过 `disabled` / `banned`;手动单账号刷新保留为显式恢复通道。 +- ✅ Token 成功后不直接激活账号,仅对可恢复故障异步排队真实用量验证,并绕过陈旧用量失败冷却。 +- 🔄 等待主代理独立审计与集成门禁。 + ## 约束 - IP 信息仅用于诊断,不直接把账号恢复为 active。 diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index 93fab6563..6759f83f5 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -1416,8 +1416,10 @@ mod tests { region_blocked.sort = 7; let mut invalid_refresh = sample_account("acc-invalid-refresh", "active", now); invalid_refresh.sort = 8; + let mut expired_refresh = sample_account("acc-expired-refresh", "unavailable", now); + expired_refresh.sort = 9; let mut restored = sample_account("acc-restored", "active", now); - restored.sort = 9; + restored.sort = 10; for account in [ &active_a, @@ -1428,6 +1430,7 @@ mod tests { &deactivated, ®ion_blocked, &invalid_refresh, + &expired_refresh, &restored, ] { storage.insert_account(account).expect("insert account"); @@ -1441,6 +1444,7 @@ mod tests { &deactivated, ®ion_blocked, &invalid_refresh, + &expired_refresh, &restored, ] { storage @@ -1479,6 +1483,15 @@ mod tests { created_at: now + 10, }) .expect("insert invalid refresh status event"); + storage + .insert_event(&Event { + account_id: Some(expired_refresh.id.clone()), + event_type: "account_status_update".to_string(), + message: "status=unavailable reason=refresh_token_invalid:refresh_token_expired" + .to_string(), + created_at: now + 10, + }) + .expect("insert expired refresh status event"); storage .insert_event(&Event { account_id: Some(restored.id.clone()), @@ -1500,7 +1513,7 @@ mod tests { storage .usage_refresh_candidate_count(None) .expect("candidate count"), - 4 + 5 ); let first_page = storage @@ -1523,7 +1536,19 @@ mod tests { .iter() .map(|(account, _)| account.id.as_str()) .collect::>(); - assert_eq!(second_page_ids, vec!["acc-region-blocked", "acc-restored"]); + assert_eq!( + second_page_ids, + vec!["acc-region-blocked", "acc-expired-refresh"] + ); + + let third_page = storage + .list_usage_refresh_candidates_paginated(4, 2, None) + .expect("third page"); + let third_page_ids = third_page + .iter() + .map(|(account, _)| account.id.as_str()) + .collect::>(); + assert_eq!(third_page_ids, vec!["acc-restored"]); } #[test] diff --git a/crates/core/src/storage/tokens.rs b/crates/core/src/storage/tokens.rs index abd1bd912..57bed77e3 100644 --- a/crates/core/src/storage/tokens.rs +++ b/crates/core/src/storage/tokens.rs @@ -76,7 +76,10 @@ impl Storage { LIMIT 1 ) AS latest_status_message FROM tokens - WHERE TRIM(COALESCE(refresh_token, '')) <> '' + JOIN accounts + ON accounts.id = tokens.account_id + WHERE LOWER(TRIM(COALESCE(accounts.status, ''))) NOT IN ('disabled', 'banned') + AND TRIM(COALESCE(tokens.refresh_token, '')) <> '' AND ( next_refresh_at <= ?1 OR ( @@ -613,6 +616,67 @@ mod tests { ); } + /// 后台 Token 轮询跳过禁用/封禁账号,但过期错误在长退避后仍允许复检。 + #[test] + fn list_tokens_due_for_refresh_skips_disabled_banned_but_rechecks_expired_accounts() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + + for (account_id, status) in [ + ("acc-active", "active"), + ("acc-disabled", "disabled"), + ("acc-banned", "banned"), + ("acc-expired", "unavailable"), + ] { + let mut account = sample_account(account_id, now); + account.status = status.to_string(); + storage.insert_account(&account).expect("insert account"); + storage + .insert_token(&sample_token(account_id, now)) + .expect("insert token"); + storage + .update_token_refresh_schedule( + account_id, + None, + Some(if account_id == "acc-expired" { + now + 21_600 + } else { + now - 1 + }), + ) + .expect("schedule token"); + } + + storage + .insert_event(&Event { + account_id: Some("acc-expired".to_string()), + event_type: "account_status_update".to_string(), + message: "status=unavailable reason=refresh_token_invalid:refresh_token_expired" + .to_string(), + created_at: now + 10, + }) + .expect("insert expired status"); + + let initial_ids = storage + .list_tokens_due_for_refresh(now, now, 10) + .expect("list due tokens") + .into_iter() + .map(|token| token.account_id) + .collect::>(); + assert_eq!(initial_ids, vec!["acc-active".to_string()]); + + let recheck_ids = storage + .list_tokens_due_for_refresh(now + 21_600, now + 21_600, 10) + .expect("list due tokens after expired cooldown") + .into_iter() + .map(|token| token.account_id) + .collect::>(); + assert!(recheck_ids.contains(&"acc-expired".to_string())); + assert!(!recheck_ids.contains(&"acc-disabled".to_string())); + assert!(!recheck_ids.contains(&"acc-banned".to_string())); + } + #[test] fn update_token_next_refresh_at_preserves_access_exp() { let storage = Storage::open_in_memory().expect("open"); diff --git a/crates/core/tests/storage.rs b/crates/core/tests/storage.rs index bde5ef115..d4b4bcf29 100644 --- a/crates/core/tests/storage.rs +++ b/crates/core/tests/storage.rs @@ -542,6 +542,9 @@ fn tokens_due_for_refresh_include_other_unavailable_accounts_but_skip_deactivate ("acc-region-blocked-refresh", "unavailable"), ("acc-unavailable-refresh", "unavailable"), ("acc-deactivated-refresh", "banned"), + ("acc-disabled-refresh", "disabled"), + ("acc-banned-refresh", "banned"), + ("acc-expired-refresh", "unavailable"), ] { storage .insert_account(&Account { @@ -587,6 +590,15 @@ fn tokens_due_for_refresh_include_other_unavailable_accounts_but_skip_deactivate created_at: now + 1, }) .expect("insert region blocked event"); + storage + .insert_event(&Event { + account_id: Some("acc-expired-refresh".to_string()), + event_type: "account_status_update".to_string(), + message: "status=unavailable reason=refresh_token_invalid:refresh_token_expired" + .to_string(), + created_at: now + 1, + }) + .expect("insert expired refresh event"); let due = storage .list_tokens_due_for_refresh(4_102_444_300, 4_102_444_900, 10) @@ -599,6 +611,7 @@ fn tokens_due_for_refresh_include_other_unavailable_accounts_but_skip_deactivate account_ids, vec![ "acc-active-refresh".to_string(), + "acc-expired-refresh".to_string(), "acc-region-blocked-refresh".to_string(), "acc-unavailable-refresh".to_string() ] diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index 2a093a683..8c6d3a2e0 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -8,7 +8,9 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; -use crate::account_status::mark_account_unavailable_for_auth_error; +use crate::account_status::{ + mark_account_unavailable_for_auth_error, REFRESH_TOKEN_REGION_BLOCKED_REASON, +}; use crate::storage_helpers::open_storage; use crate::usage_account_meta::{ build_workspace_map_from_accounts, clean_header_value, derive_account_meta, patch_account_meta, @@ -399,6 +401,60 @@ pub(crate) fn enqueue_usage_refresh_for_account(account_id: &str) -> bool { }) } +/// 判断 Token 刷新成功后是否需要立即验证单账号用量。 +/// +/// 这里只识别“认证或用量鉴权曾失败,但新 Token 可能已经解除”的状态原因。 +/// 手动禁用与确认封禁账号即使在刷新执行期间发生状态变化,也不会被后台任务恢复。 +fn should_validate_usage_after_token_refresh(storage: &Storage, account_id: &str) -> bool { + let account = match storage.find_account_by_id(account_id) { + Ok(Some(account)) => account, + _ => return false, + }; + if matches!( + account.status.trim().to_ascii_lowercase().as_str(), + "disabled" | "banned" + ) { + return false; + } + + storage + .latest_account_status_reasons(&[account_id.to_string()]) + .ok() + .and_then(|reasons| reasons.get(account_id).cloned()) + .map(|reason| { + let normalized = reason.trim().to_ascii_lowercase(); + normalized.starts_with("refresh_token_invalid:") + || matches!( + normalized.as_str(), + REFRESH_TOKEN_REGION_BLOCKED_REASON | "usage_http_401" | "usage_http_403" + ) + }) + .unwrap_or(false) +} + +/// Token 刷新成功后异步排队一次真实用量验证。 +/// +/// 该路径直接进入单账号刷新队列,不经过后台轮询候选 SQL,因此不会被陈旧的 +/// `usage_refresh_failed` 冷却事件继续阻塞;最终状态只由用量快照状态机更新。 +fn enqueue_usage_validation_after_token_refresh_with( + storage: &Storage, + account_id: &str, + enqueue: F, +) -> bool +where + F: FnOnce(&str) -> bool, +{ + should_validate_usage_after_token_refresh(storage, account_id) && enqueue(account_id) +} + +fn enqueue_usage_validation_after_token_refresh(storage: &Storage, account_id: &str) -> bool { + enqueue_usage_validation_after_token_refresh_with( + storage, + account_id, + enqueue_usage_refresh_for_account, + ) +} + pub(crate) fn enqueue_usage_refresh_after_account_add(account_id: &str) -> bool { if !auto_refresh_after_account_add_enabled() { return false; @@ -891,9 +947,9 @@ fn token_refresh_transient_backoff_secs(failure_count: i64) -> u64 { /// 函数 `schedule_token_refresh_failure_retry` /// /// 中文注释:按失败性质分流计算 token 刷新的下次重试时间(per-account,写 tokens.next_refresh_at)。 -/// - 永久无效(reused/invalidated/expired/invalid_grant/app_session_terminated): +/// - 明确永久无效(reused/invalidated/invalid_grant/app_session_terminated)及疑似过期: /// 施加长冷却(`token_refresh_failure_cooldown_secs`,默认 6 小时),不依赖失败计数; -/// 这类账号已被 `mark_account_unavailable_for_auth_error` 置不可用,长冷却避免反复进轮询。 +/// expired 不永久过滤,冷却结束后仍会低频复检。 /// - 临时失败(Unknown401,以及分类器返回 None 的网络/5xx/超时):连续失败计数 +1, /// 按指数退避 `base * 2^(n-1)`(封顶 max)作为下次重试间隔,让服务端抖动快速恢复。 /// @@ -906,12 +962,12 @@ fn token_refresh_transient_backoff_secs(failure_count: i64) -> u64 { /// # 返回 /// 无 fn schedule_token_refresh_failure_retry(storage: &Storage, account_id: &str, now: i64, err: &str) { - let is_permanent = refresh_token_auth_error_reason_from_message(err) - .map(|reason| reason.is_permanent()) + let uses_long_cooldown = refresh_token_auth_error_reason_from_message(err) + .map(|reason| reason.uses_long_retry_cooldown()) .unwrap_or(false); - if is_permanent { - // 中文注释:永久无效走长冷却;同时清零失败计数,避免后续若被人工恢复时 + if uses_long_cooldown { + // 中文注释:明确永久失效或疑似过期走长冷却;同时清零失败计数,避免恢复时 // 仍残留历史临时计数导致退避错乱。 let _ = storage.reset_token_consecutive_failure_count(account_id); let cooldown = i64::try_from(token_refresh_failure_cooldown_secs()).unwrap_or(i64::MAX); @@ -1078,6 +1134,14 @@ fn run_token_refresh_task( Ok(_) => { // 中文注释:刷新成功,清零连续失败计数,使下次失败重新从最短退避起步。 let _ = storage.reset_token_consecutive_failure_count(&token.account_id); + // 中文注释:成功换取 Token 仅证明认证可继续,不能直接把账号设为 active。 + // 对认证/用量鉴权故障账号异步触发真实用量验证,由快照状态机恢复状态。 + if enqueue_usage_validation_after_token_refresh(storage, &token.account_id) { + log::info!( + "queued usage validation after token refresh: account_id={}", + token.account_id + ); + } true } Err(err) => { diff --git a/crates/service/src/usage/tests/usage_http_tests.rs b/crates/service/src/usage/tests/usage_http_tests.rs index 61aa1a7af..f1290c56e 100644 --- a/crates/service/src/usage/tests/usage_http_tests.rs +++ b/crates/service/src/usage/tests/usage_http_tests.rs @@ -453,27 +453,23 @@ fn refresh_token_auth_error_reason_from_message_tracks_canonical_messages() { /// 函数 `refresh_token_auth_error_reason_is_permanent_classifies_correctly` /// -/// 中文注释:验证 `is_permanent` 分类——永久无效类(reused/invalidated/expired/ -/// invalid_grant/app_session_terminated)返回 true;Unknown401 属临时类返回 false。 +/// 中文注释:验证明确永久分类与长冷却分类。Expired 不永久判死,但使用长冷却复检。 /// 这是 O 项失败分流冷却的核心判定。 #[test] fn refresh_token_auth_error_reason_is_permanent_classifies_correctly() { use super::RefreshTokenAuthErrorReason::*; // 永久无效:全部应判定为 true。 - for reason in [ - Expired, - Reused, - Invalidated, - InvalidGrant, - AppSessionTerminated, - ] { + for reason in [Reused, Invalidated, InvalidGrant, AppSessionTerminated] { assert!(reason.is_permanent(), "{reason:?} 应被判定为永久无效"); + assert!(reason.uses_long_retry_cooldown()); } - // 临时类:Unknown401 应判定为 false(非永久)。 + assert!(!Expired.is_permanent(), "Expired 应保留低频恢复机会"); + assert!(Expired.uses_long_retry_cooldown()); assert!( !Unknown401.is_permanent(), "Unknown401 属临时失败,不应被判定为永久" ); + assert!(!Unknown401.uses_long_retry_cooldown()); } /// 函数 `usage_http_default_headers_follow_gateway_runtime_profile` diff --git a/crates/service/src/usage/tests/usage_refresh_tests.rs b/crates/service/src/usage/tests/usage_refresh_tests.rs index 3fba512b5..ade2db5b9 100644 --- a/crates/service/src/usage/tests/usage_refresh_tests.rs +++ b/crates/service/src/usage/tests/usage_refresh_tests.rs @@ -1,6 +1,7 @@ use super::{ clear_pending_usage_refresh_tasks_for_tests, enqueue_usage_refresh_with_worker, - next_usage_poll_cursor, notify_usage_refresh_completed, reset_usage_poll_cursor_for_tests, + enqueue_usage_validation_after_token_refresh_with, next_usage_poll_cursor, + notify_usage_refresh_completed, reset_usage_poll_cursor_for_tests, resolve_token_refresh_issuer, run_token_refresh_task, schedule_token_refresh_failure_retry, set_usage_refresh_completed_handler, should_retry_usage_refresh_with_token, sleep_startup_stagger_with, subscribe_usage_refresh_completed, token_refresh_access_exp_cutoff, @@ -10,7 +11,7 @@ use super::{ USAGE_POLLING_STARTUP_STAGGER_SECS, WARMUP_CRON_STARTUP_STAGGER_SECS, }; use crate::usage_scheduler::DEFAULT_USAGE_POLL_INTERVAL_SECS; -use codexmanager_core::storage::{now_ts, Account, Storage, Token}; +use codexmanager_core::storage::{now_ts, Account, Event, Storage, Token}; use std::collections::HashSet; use std::sync::mpsc; use std::time::Duration; @@ -182,6 +183,119 @@ fn enqueue_usage_refresh_for_different_accounts_keeps_queue_progress() { clear_pending_usage_refresh_tasks_for_tests(); } +/// Token 刷新成功后的恢复验证直接进入单账号队列,并绕过轮询冷却;同账号并发仍去重。 +#[test] +fn token_refresh_success_validation_bypasses_stale_cooldown_and_deduplicates() { + let _guard = crate::test_env_guard(); + clear_pending_usage_refresh_tasks_for_tests(); + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init"); + let now = now_ts(); + let account_id = "acc-token-recovery-queue"; + insert_account_and_token(&storage, account_id, now); + storage + .update_account_status_if_changed(account_id, "unavailable") + .expect("mark unavailable"); + storage + .insert_event(&Event { + account_id: Some(account_id.to_string()), + event_type: "account_status_update".to_string(), + message: "status=unavailable reason=usage_http_401".to_string(), + created_at: now, + }) + .expect("insert status reason"); + storage + .insert_event(&Event { + account_id: Some(account_id.to_string()), + event_type: "usage_refresh_failed".to_string(), + message: "usage endpoint failed: status=401".to_string(), + created_at: now, + }) + .expect("insert stale cooldown event"); + + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let first = + enqueue_usage_validation_after_token_refresh_with(&storage, account_id, move |id| { + enqueue_usage_refresh_with_worker(id, move |_| { + let _ = started_tx.send(()); + let _ = release_rx.recv_timeout(Duration::from_secs(1)); + }) + }); + assert!( + first, + "陈旧 usage_refresh_failed 冷却不得阻塞 Token 成功后的验证" + ); + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("validation worker started"); + + let duplicate = enqueue_usage_validation_after_token_refresh_with(&storage, account_id, |id| { + enqueue_usage_refresh_with_worker(id, |_| {}) + }); + assert!( + !duplicate, + "同账号恢复验证必须复用队列去重,避免递归并发刷新" + ); + + let _ = release_tx.send(()); + std::thread::sleep(Duration::from_millis(20)); + clear_pending_usage_refresh_tasks_for_tests(); +} + +/// 后台恢复验证只处理认证/用量鉴权类原因,且不会覆盖并发发生的禁用或封禁。 +#[test] +fn token_refresh_success_validation_respects_reason_and_current_status() { + for (account_id, status, reason, expected) in [ + ( + "acc-refresh-invalid", + "unavailable", + "refresh_token_invalid:refresh_token_unknown_401", + true, + ), + ( + "acc-region-blocked", + "unavailable", + "refresh_token_region_blocked", + true, + ), + ("acc-usage-403", "unavailable", "usage_http_403", true), + ("acc-active", "active", "usage_ok", false), + ( + "acc-disabled-race", + "disabled", + "refresh_token_invalid:refresh_token_unknown_401", + false, + ), + ("acc-banned-race", "banned", "usage_http_401", false), + ] { + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init"); + let now = now_ts(); + insert_account_and_token(&storage, account_id, now); + storage + .update_account_status_if_changed(account_id, status) + .expect("update status"); + storage + .insert_event(&Event { + account_id: Some(account_id.to_string()), + event_type: "account_status_update".to_string(), + message: format!("status={status} reason={reason}"), + created_at: now, + }) + .expect("insert status reason"); + + let called = std::cell::Cell::new(false); + let queued = + enqueue_usage_validation_after_token_refresh_with(&storage, account_id, |_| { + called.set(true); + true + }); + assert_eq!(queued, expected, "account_id={account_id}"); + assert_eq!(called.get(), expected, "account_id={account_id}"); + } +} + /// 函数 `schedule_prefers_exp_minus_ahead` /// /// 作者: gaohongshun @@ -509,6 +623,36 @@ fn schedule_failure_retry_permanent_uses_long_cooldown() { ); } +/// refresh_token_expired 可能误判:使用长冷却抑制请求,但不应永久退出候选。 +#[test] +fn schedule_failure_retry_expired_uses_long_recoverable_cooldown() { + let _guard = crate::test_env_guard(); + std::env::remove_var("CODEXMANAGER_TOKEN_REFRESH_FAILURE_COOLDOWN_SECS"); + let storage = Storage::open_in_memory().expect("open in memory"); + storage.init().expect("init"); + let now = now_ts(); + let account_id = "acc-expired-recheck"; + insert_account_and_token(&storage, account_id, now); + + schedule_token_refresh_failure_retry( + &storage, + account_id, + now, + "refresh token failed with status 401 Unauthorized: Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.", + ); + + assert_eq!( + read_next_refresh_at(&storage, account_id), + Some(now + token_refresh_failure_cooldown_secs() as i64) + ); + assert_eq!( + storage + .token_consecutive_failure_count(account_id) + .expect("read count"), + 0 + ); +} + /// 函数 `insert_account_and_token` /// /// 中文注释:测试辅助——插入账号与对应 token,便于失败退避用例复用。 diff --git a/crates/service/src/usage/usage_http.rs b/crates/service/src/usage/usage_http.rs index c6676f43a..f6c9bb612 100644 --- a/crates/service/src/usage/usage_http.rs +++ b/crates/service/src/usage/usage_http.rs @@ -95,12 +95,11 @@ impl RefreshTokenAuthErrorReason { /// 函数 `is_permanent` /// - /// 中文注释:判定该 refresh-token 鉴权失败原因是否为“永久无效”。 - /// 永久无效(Reused/Invalidated/Expired/InvalidGrant/AppSessionTerminated)意味着 + /// 中文注释:判定该 refresh-token 鉴权失败原因是否为“明确永久无效”。 + /// 永久无效(Reused/Invalidated/InvalidGrant/AppSessionTerminated)意味着 /// 当前 refresh token 已不可能再换出有效 access token,账号需重新登录; - /// 这类失败应施加长冷却并退出服务池,避免反复进入轮询。 - /// 而 `Unknown401` 归为非永久(临时),因为它可能源于服务端抖动或身份服务 - /// 临时异常,应走短退避 + 指数增长重试,多次确认后才升级。 + /// 这类失败应退出后台候选。`Expired` 可能来自并发轮换或上游误判,因此不永久 + /// 判死,但仍使用长冷却低频复检;`Unknown401` 使用短指数退避。 /// /// # 参数 /// - self: 失败原因枚举值 @@ -109,14 +108,17 @@ impl RefreshTokenAuthErrorReason { /// 永久无效返回 true,临时失败返回 false pub(crate) fn is_permanent(self) -> bool { match self { - Self::Expired - | Self::Reused - | Self::Invalidated - | Self::InvalidGrant - | Self::AppSessionTerminated => true, - Self::Unknown401 => false, + Self::Reused | Self::Invalidated | Self::InvalidGrant | Self::AppSessionTerminated => { + true + } + Self::Expired | Self::Unknown401 => false, } } + + /// 判断是否使用长冷却;疑似过期与明确永久失效都避免高频请求。 + pub(crate) fn uses_long_retry_cooldown(self) -> bool { + self.is_permanent() || matches!(self, Self::Expired) + } } #[derive(serde::Deserialize)] pub(crate) struct RefreshTokenResponse { diff --git "a/docs/zh-CN/report/\345\220\216\345\217\260\344\273\273\345\212\241\350\264\246\345\217\267\350\267\263\350\277\207\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\345\220\216\345\217\260\344\273\273\345\212\241\350\264\246\345\217\267\350\267\263\350\277\207\350\257\264\346\230\216.md" index 7141e2912..5743cc2c6 100644 --- "a/docs/zh-CN/report/\345\220\216\345\217\260\344\273\273\345\212\241\350\264\246\345\217\267\350\267\263\350\277\207\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/report/\345\220\216\345\217\260\344\273\273\345\212\241\350\264\246\345\217\267\350\267\263\350\277\207\350\257\264\346\230\216.md" @@ -4,9 +4,9 @@ | 任务 | 过滤位置 | 过滤条件 | 被跳过的状态/结果 | 说明 | | --- | --- | --- | --- | --- | -| 用量轮询线程 | [crates/service/src/usage/refresh/batch.rs](../../../crates/service/src/usage/refresh/batch.rs#L91) | `status = disabled`,以及最新事件命中封禁或刷新受阻原因 | `disabled`,`account_deactivated`,`workspace_deactivated`,`refresh_token_region_blocked`,`refresh_token_invalid:*` | 先把这些账号放进 `skipped_ids`,再从 token 列表里过滤掉 | +| 用量轮询线程 | [crates/core/src/storage/accounts.rs](../../../crates/core/src/storage/accounts.rs#L1088) | 账号不能是 `disabled` / `banned`,且最新状态原因不能是确认停用或明确永久 Refresh Token 失效 | `disabled`、`banned`、停用类原因,以及 `refresh_token_reused`、`refresh_token_invalidated`、`invalid_grant`、`app_session_terminated` | `refresh_token_expired` 可能误判,因此不会永久过滤;区域阻断和未知 401 也保留恢复机会。普通后台轮询仍会跳过近期存在 `usage_refresh_failed` 的账号 | | 网关保活线程 | [crates/core/src/storage/accounts.rs](../../../crates/core/src/storage/accounts.rs#L117) | `status = active`,且满足网关可用条件 | 非 `active` 的账号直接不进入候选集 | 不是遍历所有账号,而是先筛候选账号,再做保活 | -| 令牌刷新轮询 | [crates/core/src/storage/tokens.rs](../../../crates/core/src/storage/tokens.rs#L28) | `refresh_token` 不能为空;最新状态不能是停用类原因、`refresh_token_region_blocked` 或 `refresh_token_invalid:*` | 没有 `refresh_token`,`account_deactivated`,`workspace_deactivated`,`refresh_token_region_blocked`,`refresh_token_invalid:*` | 只处理能刷新且未被暂停的 token;修正代理或重新导入有效 token 后手动恢复账号会重新进入轮询 | +| 令牌刷新轮询 | [crates/core/src/storage/tokens.rs](../../../crates/core/src/storage/tokens.rs#L40) | 账号不能是 `disabled` / `banned`,`refresh_token` 不能为空,且最新状态原因不能是确认停用或明确永久 Refresh Token 失效 | `disabled`、`banned`、没有 `refresh_token`、停用类原因,以及上述四类明确永久失效原因 | `refresh_token_expired` 使用长退避后低频复检;区域阻断和未知 401 使用指数退避。Token 成功后只排队真实用量验证,不会直接设为 `active` | ## 补充 @@ -15,7 +15,14 @@ | 看起来“上一秒能用,下一秒过期” | 服务端在下一次刷新时才返回 `refresh_token_expired` | | 网关保活没有动作 | 候选账号为空,或者没有 `active` 可用账号 | | 用量轮询没动静 | 账号都被过滤掉了,或者当前批次没有可处理账号 | +| Token 已刷新但状态仍未恢复 | Token 成功只代表认证可继续;系统会异步调用真实用量接口,再由快照结果恢复为 `active` / `limited`,或在停用响应下保持/转为 `banned` | + +## 手动恢复边界 + +- 单账号手动刷新是显式恢复通道,不经过后台轮询的 `usage_refresh_failed` 冷却。 +- `disabled` 是人工开关:即使手动刷新拿到有效快照,也保留 `disabled`,需要先人工启用。 +- `banned` 不参与后台 Token / 用量轮询;用户显式手动刷新后,如果上游重新返回有效用量快照,可由状态机恢复账号。 ## 最短结论 -`用量轮询` 和 `令牌刷新` 都会按状态过滤账号;`网关保活` 只认 `active` 的候选账号。 +`用量轮询` 和 `令牌刷新` 都跳过 `disabled`、`banned` 与永久失效账号;区域阻断和未知 401 保留退避探测。Token 成功后必须再通过真实用量验证,`网关保活` 仍只认 `active` 候选账号。 diff --git "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" index 1336e35d7..a1bb0b9f8 100644 --- "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -134,7 +134,7 @@ - `CODEXMANAGER_GATEWAY_KEEPALIVE_INTERVAL_SECS` - `CODEXMANAGER_TOKEN_REFRESH_POLLING_ENABLED` - `CODEXMANAGER_TOKEN_REFRESH_POLL_INTERVAL_SECS` -- `CODEXMANAGER_TOKEN_REFRESH_FAILURE_COOLDOWN_SECS`:令牌刷新后台轮询失败后的冷却时间,默认 `21600` 秒,最低按令牌刷新轮询最小间隔生效;已被标记为 `refresh_token_invalid:*` 的账号会直接跳过轮询。 +- `CODEXMANAGER_TOKEN_REFRESH_FAILURE_COOLDOWN_SECS`:疑似或明确 Refresh Token 失效后的长冷却时间,默认 `21600` 秒;`refresh_token_reused`、`refresh_token_invalidated`、`invalid_grant`、`app_session_terminated` 会被后台候选永久过滤。`refresh_token_expired` 可能存在误判,长冷却到期后仍会低频复检;未知 401、网络错误和区域阻断使用按账号指数退避。 - `CODEXMANAGER_WARMUP_CRON_ENABLED` - `CODEXMANAGER_WARMUP_CRON_EXPRESSION` - `CODEXMANAGER_USAGE_REFRESH_WORKERS` @@ -287,7 +287,8 @@ codexmanager-service-bundle/ 安全提示: - `auth.openai.com` / `oauth/token` 等登录态刷新链路必须走 OpenAI 支持的地区出口;不要把香港等受限节点纳入这条链路的自动轮换。 -- 如果刷新请求返回 `unsupported_country_region_territory`,Service 会将账号标记为 `refresh_token_region_blocked` 并暂停后续自动刷新;修正代理后需要手动恢复账号。 +- 如果刷新请求返回 `unsupported_country_region_territory`,Service 会将账号标记为 `refresh_token_region_blocked`,并按账号指数退避继续低频探测。出口 IP 只能作为诊断信息;账号是否恢复仍以 Token 与真实用量接口响应为准。 +- 后台 Token 轮询跳过 `disabled` 和 `banned`。单账号手动刷新仍是显式恢复通道:`disabled` 会保留人工禁用状态,`banned` 只有在手动刷新获得有效快照后才可能由状态机恢复。 默认不接管的是: diff --git a/task.md b/task.md index eca71b9c0..c7d84ef90 100644 --- a/task.md +++ b/task.md @@ -6,10 +6,11 @@ 0. P0 定价分层与刷新状态补充修复(🔄 进行中) - 定价:按请求最终 `effective_service_tier` 区分 Standard / Priority,补齐官方 Priority 种子与回归测试;历史费用重算不在本轮自动执行。 - - 刷新:修复 `refresh_token_expired` 永久过滤遗漏;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;明确 disabled/banned 的后台刷新边界。 + - 刷新:将可能误判的 `refresh_token_expired` 调整为长退避低频复检;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;明确 disabled/banned 的后台刷新边界。 - 区域诊断:参考 Clash Verge 多 IP 服务映射与失败切换,但仅用于出口诊断;启动检查、区域阻断事件触发检查和低频兜底均不得直接替代上游接口判定。 - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 - 主代理负责对子代理补丁独立审计、集成测试与 PR 更新。 + - 刷新子项:✅ 已完成独立实现与定向回归,等待主代理审计集成。 1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 From 4160cdf3b690e0a67e4892f0e341780c11159b9d Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:45:37 +0800 Subject: [PATCH 10/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8C=89=E6=9C=80?= =?UTF-8?q?=E7=BB=88=E6=9C=8D=E5=8A=A1=E7=AD=89=E7=BA=A7=E5=88=86=E5=B1=82?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E6=A8=A1=E5=9E=8B=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\244\215\350\277\233\345\272\246.md" | 21 + .../components/modals/model-catalog-modal.tsx | 1 + apps/src/lib/api/account-client.ts | 6 +- .../lib/api/transport-web-commands/account.ts | 1 + crates/core/src/storage/model_price_rules.rs | 45 ++ crates/service/src/auth/app_manager.rs | 6 + crates/service/src/dashboard.rs | 2 +- .../src/gateway/observability/request_log.rs | 19 +- .../observability/tests/request_log_tests.rs | 70 +++ crates/service/src/quota/model_pricing.rs | 573 +++++++++++++++++- crates/service/src/quota/read.rs | 90 ++- crates/service/src/rpc_dispatch/quota.rs | 6 +- docs/zh-CN/CHANGELOG.md | 1 + task.md | 6 +- 14 files changed, 782 insertions(+), 65 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\277\256\345\244\215\350\277\233\345\272\246.md" diff --git "a/.teamwork/progress/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\277\256\345\244\215\350\277\233\345\272\246.md" "b/.teamwork/progress/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\277\256\345\244\215\350\277\233\345\272\246.md" new file mode 100644 index 000000000..f6983d9ad --- /dev/null +++ "b/.teamwork/progress/2026-07-11_\345\256\232\344\273\267\345\210\206\345\261\202\344\277\256\345\244\215\350\277\233\345\272\246.md" @@ -0,0 +1,21 @@ +# 定价分层修复进度 + +执行身份:【CodeX-GPT】 + +## ✅ 已完成 + +- 请求日志费用估算改为显式采用最终 `effective_service_tier`,同时覆盖 HTTP 与 Responses WebSocket 路径。 +- `fast` 规范为 `priority`;空值、`auto`、`default` 和未知 tier 保守回退 `standard`。 +- `model_price_rules.billing_mode` 已参与实际匹配,同一模型的 Standard / Priority 规则可共存。 +- 按官方附件逐模型补齐 18 条 Priority 种子,并补齐 `gpt-4.1-mini`、`gpt-4.1-nano`、`gpt-4o-mini`、`gpt-4o-2024-05-13` 的 Standard 特异规则。 +- 种子版本升级为 `2026-07-11-tiered-v2`,新版优先级高于旧版种子,已有数据库会自动补齐并立即采用新规则。 +- 价格规则 API 与前端类型已暴露 `billingMode`;当前模型编辑弹窗仍维持单价格编辑交互,后续如需同时编辑两档可单独扩展 UI。 +- 历史请求费用不自动重算。 + +## 验证 + +- `cargo check -p codexmanager-service`:通过。 +- `cargo test -p codexmanager-service quota::model_pricing::tests --lib`:14 通过。 +- `cargo test -p codexmanager-service request_log_uses_final_effective_tier_for_http_and_ws_costs --lib`:1 通过。 +- `cargo fmt --all --check`:通过。 +- `apps/node_modules/.bin/tsc.cmd --noEmit -p apps/tsconfig.json`:通过。 diff --git a/apps/src/components/modals/model-catalog-modal.tsx b/apps/src/components/modals/model-catalog-modal.tsx index 01f7a0e19..02a60a3d4 100644 --- a/apps/src/components/modals/model-catalog-modal.tsx +++ b/apps/src/components/modals/model-catalog-modal.tsx @@ -405,6 +405,7 @@ export function ModelCatalogModal({ id: priceRule?.id, provider: priceRule?.provider ?? undefined, modelPattern: slug, + billingMode: priceRule?.billingMode ?? "standard", inputPricePer1m: isClearingExistingOverride ? 0 : Number(ip), cachedInputPricePer1m: isClearingExistingOverride ? null diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 742de1dfc..9f5af4f4c 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -181,6 +181,7 @@ export interface ModelPriceRuleEntry { provider: string; modelPattern: string; matchType: string; + billingMode: "standard" | "priority" | string; inputPricePer1m: number | null; cachedInputPricePer1m: number | null; outputPricePer1m: number | null; @@ -196,6 +197,7 @@ export interface ModelPriceRuleUpsertPayload { provider?: string | null; modelPattern: string; matchType?: string | null; + billingMode?: "standard" | "priority" | string | null; inputPricePer1m?: number | null; cachedInputPricePer1m?: number | null; outputPricePer1m?: number | null; @@ -995,10 +997,10 @@ export const accountClient = { ); return result.items; }, - readModelPriceRule: async (modelPattern: string) => { + readModelPriceRule: async (modelPattern: string, billingMode?: string | null) => { const result = await invoke( "service_model_price_rule_read", - withAddr({ modelPattern }), + withAddr({ modelPattern, billingMode: billingMode || null }), ); return result; }, diff --git a/apps/src/lib/api/transport-web-commands/account.ts b/apps/src/lib/api/transport-web-commands/account.ts index c033542b0..5ce72cee8 100644 --- a/apps/src/lib/api/transport-web-commands/account.ts +++ b/apps/src/lib/api/transport-web-commands/account.ts @@ -115,6 +115,7 @@ export function createAccountWebCommands(postWebRpc: WebRpcCaller): Record Result> { + let mut stmt = self.conn.prepare( + "SELECT + id, provider, model_pattern, match_type, billing_mode, + currency, unit, input_price_per_1m, cached_input_price_per_1m, + output_price_per_1m, reasoning_output_price_per_1m, + cache_write_5m_price_per_1m, cache_write_1h_price_per_1m, + cache_hit_price_per_1m, long_context_threshold_tokens, + long_context_input_price_per_1m, + long_context_cached_input_price_per_1m, + long_context_output_price_per_1m, source, source_url, + seed_version, enabled, priority, created_at, updated_at + FROM model_price_rules + WHERE id = ?1 + LIMIT 1", + )?; + let mut rows = stmt.query([id])?; + rows.next()?.map(model_price_rule_from_row).transpose() + } + + pub fn find_model_price_rule_by_model_pattern_and_billing_mode( + &self, + model_pattern: &str, + billing_mode: &str, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT + id, provider, model_pattern, match_type, billing_mode, + currency, unit, input_price_per_1m, cached_input_price_per_1m, + output_price_per_1m, reasoning_output_price_per_1m, + cache_write_5m_price_per_1m, cache_write_1h_price_per_1m, + cache_hit_price_per_1m, long_context_threshold_tokens, + long_context_input_price_per_1m, + long_context_cached_input_price_per_1m, + long_context_output_price_per_1m, source, source_url, + seed_version, enabled, priority, created_at, updated_at + FROM model_price_rules + WHERE model_pattern = ?1 AND lower(billing_mode) = lower(?2) + ORDER BY enabled DESC, priority DESC, updated_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query((model_pattern, billing_mode))?; + rows.next()?.map(model_price_rule_from_row).transpose() + } + pub fn list_enabled_model_price_rules(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT diff --git a/crates/service/src/auth/app_manager.rs b/crates/service/src/auth/app_manager.rs index 87e12733f..6e2d00e27 100644 --- a/crates/service/src/auth/app_manager.rs +++ b/crates/service/src/auth/app_manager.rs @@ -1063,6 +1063,12 @@ fn estimate_billing_model_cost_usd( input_tokens, cached_input_tokens, output_tokens, + usage + .get("effectiveServiceTier") + .or_else(|| usage.get("effective_service_tier")) + .or_else(|| usage.get("serviceTier")) + .or_else(|| usage.get("service_tier")) + .and_then(Value::as_str), ); if cost > 0.0 { Some(cost) diff --git a/crates/service/src/dashboard.rs b/crates/service/src/dashboard.rs index 4882dc54c..c9c0cc675 100644 --- a/crates/service/src/dashboard.rs +++ b/crates/service/src/dashboard.rs @@ -702,7 +702,7 @@ fn read_available_models_with_price_summary() -> Result, String> .filter(|model| model.supported_in_api && model.visibility.as_deref() != Some("hide")) .map(|mut model| { if let Some(price) = - model_pricing::resolve_model_price_from_rules(&price_rules, &model.slug, 0) + model_pricing::resolve_model_price_from_rules(&price_rules, &model.slug, 0, None) .or_else(|| model_pricing::resolve_model_price(&model.slug, 0)) { model.extra.insert( diff --git a/crates/service/src/gateway/observability/request_log.rs b/crates/service/src/gateway/observability/request_log.rs index 8e136f59c..f129a79ab 100644 --- a/crates/service/src/gateway/observability/request_log.rs +++ b/crates/service/src/gateway/observability/request_log.rs @@ -522,13 +522,6 @@ pub(crate) fn write_request_log_with_attempts( } let first_response_ms = usage.first_response_ms.map(|value| value.max(0)); let created_at = now_ts(); - let estimated_cost_usd = crate::quota::model_pricing::estimate_cost_usd_for_log( - storage, - model, - input_tokens, - cached_input_tokens, - output_tokens, - ); let request_type = trace_context .request_type .map(str::trim) @@ -542,6 +535,15 @@ pub(crate) fn write_request_log_with_attempts( .effective_service_tier .map(str::trim) .filter(|value| !value.is_empty()); + let billing_service_tier = effective_service_tier.or(service_tier); + let estimated_cost_usd = crate::quota::model_pricing::estimate_cost_usd_for_log( + storage, + model, + input_tokens, + cached_input_tokens, + output_tokens, + billing_service_tier, + ); let service_tier_source = resolve_service_tier_source( service_tier, effective_service_tier, @@ -706,6 +708,7 @@ pub(crate) fn write_request_log_with_attempts( "totalTokens": total_tokens, "reasoningOutputTokens": reasoning_output_tokens, "estimatedCostUsd": estimated_cost_usd, + "effectiveServiceTier": billing_service_tier, })) .ok(); if let Err(err) = crate::wallet_charge_for_request( @@ -714,7 +717,7 @@ pub(crate) fn write_request_log_with_attempts( request_log_id, estimated_cost_usd, model, - effective_service_tier.or(service_tier), + billing_service_tier, raw_usage_json, ) { log::warn!( diff --git a/crates/service/src/gateway/observability/tests/request_log_tests.rs b/crates/service/src/gateway/observability/tests/request_log_tests.rs index c31e50d71..94f934047 100644 --- a/crates/service/src/gateway/observability/tests/request_log_tests.rs +++ b/crates/service/src/gateway/observability/tests/request_log_tests.rs @@ -472,6 +472,76 @@ fn write_request_log_redacts_query_secret_urls() { ); } +#[test] +fn request_log_uses_final_effective_tier_for_http_and_ws_costs() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + let usage = RequestLogUsage { + input_tokens: Some(1_000_000), + cached_input_tokens: Some(0), + output_tokens: Some(0), + total_tokens: Some(1_000_000), + ..Default::default() + }; + + write_request_log( + &storage, + RequestLogTraceContext { + trace_id: Some("trc_http_standard"), + request_type: Some("http"), + service_tier: Some("priority"), + effective_service_tier: Some("default"), + ..Default::default() + }, + None, + None, + "/v1/responses", + "POST", + Some("gpt-5-mini"), + None, + None, + Some(200), + usage, + None, + Some(10), + ); + write_request_log( + &storage, + RequestLogTraceContext { + trace_id: Some("trc_ws_priority"), + request_type: Some("ws"), + service_tier: Some("standard"), + effective_service_tier: Some("fast"), + ..Default::default() + }, + None, + None, + "/v1/responses", + "GET", + Some("gpt-5-mini"), + None, + None, + Some(200), + usage, + None, + Some(10), + ); + + let logs = storage.list_request_logs(None, 10).expect("list logs"); + let http = logs + .iter() + .find(|item| item.trace_id.as_deref() == Some("trc_http_standard")) + .expect("http log"); + let ws = logs + .iter() + .find(|item| item.trace_id.as_deref() == Some("trc_ws_priority")) + .expect("ws log"); + assert_eq!(http.request_type.as_deref(), Some("http")); + assert_eq!(ws.request_type.as_deref(), Some("ws")); + assert_close(http.estimated_cost_usd.expect("http cost"), 0.25); + assert_close(ws.estimated_cost_usd.expect("ws cost"), 0.45); +} + #[test] fn keeps_failed_or_non_model_list_request_logs() { let _guard = crate::test_env_guard(); diff --git a/crates/service/src/quota/model_pricing.rs b/crates/service/src/quota/model_pricing.rs index 5d2a1003f..b4731a649 100644 --- a/crates/service/src/quota/model_pricing.rs +++ b/crates/service/src/quota/model_pricing.rs @@ -1,6 +1,9 @@ use codexmanager_core::storage::{now_ts, ModelPriceRule, Storage}; -pub(crate) const PRICE_SEED_VERSION: &str = "2026-07-11"; +pub(crate) const PRICE_SEED_VERSION: &str = "2026-07-11-tiered-v2"; + +const STANDARD_BILLING_MODE: &str = "standard"; +const PRIORITY_BILLING_MODE: &str = "priority"; #[derive(Debug, Clone, Copy)] struct PriceSeed { @@ -240,6 +243,30 @@ const PRICE_SEEDS: &[PriceSeed] = &[ long_context_output_price_per_1m: None, source_url: OPENAI_PRICE_SOURCE, }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4.1-mini", + input_price_per_1m: 0.4, + cached_input_price_per_1m: Some(0.1), + output_price_per_1m: 1.6, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4.1-nano", + input_price_per_1m: 0.1, + cached_input_price_per_1m: Some(0.025), + output_price_per_1m: 0.4, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, PriceSeed { provider: "openai", model_pattern: "gpt-4.1", @@ -252,6 +279,30 @@ const PRICE_SEEDS: &[PriceSeed] = &[ long_context_output_price_per_1m: None, source_url: OPENAI_PRICE_SOURCE, }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4o-2024-05-13", + input_price_per_1m: 5.0, + cached_input_price_per_1m: None, + output_price_per_1m: 15.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4o-mini", + input_price_per_1m: 0.15, + cached_input_price_per_1m: Some(0.075), + output_price_per_1m: 0.6, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, PriceSeed { provider: "openai", model_pattern: "gpt-4o", @@ -398,33 +449,275 @@ const PRICE_SEEDS: &[PriceSeed] = &[ }, ]; +// Priority 价格逐项来自官方表,不能按 Standard 统一乘倍率推导。 +const PRIORITY_PRICE_SEEDS: &[PriceSeed] = &[ + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.6-sol", + input_price_per_1m: 10.0, + cached_input_price_per_1m: Some(1.0), + output_price_per_1m: 60.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.6-terra", + input_price_per_1m: 5.0, + cached_input_price_per_1m: Some(0.5), + output_price_per_1m: 30.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.6-luna", + input_price_per_1m: 2.0, + cached_input_price_per_1m: Some(0.2), + output_price_per_1m: 12.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.5", + input_price_per_1m: 12.5, + cached_input_price_per_1m: Some(1.25), + output_price_per_1m: 75.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.4-mini", + input_price_per_1m: 1.5, + cached_input_price_per_1m: Some(0.15), + output_price_per_1m: 9.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.4", + input_price_per_1m: 5.0, + cached_input_price_per_1m: Some(0.5), + output_price_per_1m: 30.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.2", + input_price_per_1m: 3.5, + cached_input_price_per_1m: Some(0.35), + output_price_per_1m: 28.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5.1", + input_price_per_1m: 2.5, + cached_input_price_per_1m: Some(0.25), + output_price_per_1m: 20.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5-mini", + input_price_per_1m: 0.45, + cached_input_price_per_1m: Some(0.045), + output_price_per_1m: 3.6, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-5", + input_price_per_1m: 2.5, + cached_input_price_per_1m: Some(0.25), + output_price_per_1m: 20.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4.1-mini", + input_price_per_1m: 0.7, + cached_input_price_per_1m: Some(0.175), + output_price_per_1m: 2.8, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4.1-nano", + input_price_per_1m: 0.2, + cached_input_price_per_1m: Some(0.05), + output_price_per_1m: 0.8, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4.1", + input_price_per_1m: 3.5, + cached_input_price_per_1m: Some(0.875), + output_price_per_1m: 14.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4o-2024-05-13", + input_price_per_1m: 8.75, + cached_input_price_per_1m: None, + output_price_per_1m: 26.25, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4o-mini", + input_price_per_1m: 0.25, + cached_input_price_per_1m: Some(0.125), + output_price_per_1m: 1.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "gpt-4o", + input_price_per_1m: 4.25, + cached_input_price_per_1m: Some(2.125), + output_price_per_1m: 17.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "o4-mini", + input_price_per_1m: 2.0, + cached_input_price_per_1m: Some(0.5), + output_price_per_1m: 8.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, + PriceSeed { + provider: "openai", + model_pattern: "o3", + input_price_per_1m: 3.5, + cached_input_price_per_1m: Some(0.875), + output_price_per_1m: 14.0, + long_context_threshold_tokens: None, + long_context_input_price_per_1m: None, + long_context_cached_input_price_per_1m: None, + long_context_output_price_per_1m: None, + source_url: OPENAI_PRICE_SOURCE, + }, +]; + pub(crate) fn ensure_official_price_seed(storage: &Storage) -> Result<(), String> { let count = storage .count_model_price_rules_for_seed(PRICE_SEED_VERSION) .map_err(|err| format!("count model price seeds failed: {err}"))?; - if count as usize >= PRICE_SEEDS.len() { + let expected_count = PRICE_SEEDS.len() + PRIORITY_PRICE_SEEDS.len(); + if count as usize >= expected_count { return Ok(()); } let now = now_ts(); - for (index, seed) in PRICE_SEEDS.iter().enumerate() { + for (index, (billing_mode, seed)) in PRICE_SEEDS + .iter() + .map(|seed| (STANDARD_BILLING_MODE, seed)) + .chain( + PRIORITY_PRICE_SEEDS + .iter() + .map(|seed| (PRIORITY_BILLING_MODE, seed)), + ) + .enumerate() + { storage .upsert_model_price_rule(&ModelPriceRule { - id: format!("official-{PRICE_SEED_VERSION}-{}", seed.model_pattern), + id: format!( + "official-{PRICE_SEED_VERSION}-{billing_mode}-{}", + seed.model_pattern + ), provider: seed.provider.to_string(), model_pattern: seed.model_pattern.to_string(), - match_type: "prefix".to_string(), - billing_mode: "standard".to_string(), + // Priority 官方表只覆盖列出的明确模型,不能让家族前缀误命中未报价的 Pro/Nano 变体。 + match_type: if billing_mode == PRIORITY_BILLING_MODE { + "exact" + } else { + "prefix" + } + .to_string(), + billing_mode: billing_mode.to_string(), currency: "USD".to_string(), unit: "per_1m_tokens".to_string(), input_price_per_1m: Some(seed.input_price_per_1m), cached_input_price_per_1m: seed.cached_input_price_per_1m, output_price_per_1m: Some(seed.output_price_per_1m), reasoning_output_price_per_1m: None, - cache_write_5m_price_per_1m: match seed.model_pattern { - "gpt-5.6-sol" => Some(6.25), - "gpt-5.6-terra" => Some(3.125), - "gpt-5.6-luna" => Some(1.25), + cache_write_5m_price_per_1m: match (billing_mode, seed.model_pattern) { + (STANDARD_BILLING_MODE, "gpt-5.6-sol") => Some(6.25), + (STANDARD_BILLING_MODE, "gpt-5.6-terra") => Some(3.125), + (STANDARD_BILLING_MODE, "gpt-5.6-luna") => Some(1.25), + (PRIORITY_BILLING_MODE, "gpt-5.6-sol") => Some(12.5), + (PRIORITY_BILLING_MODE, "gpt-5.6-terra") => Some(6.25), + (PRIORITY_BILLING_MODE, "gpt-5.6-luna") => Some(2.5), _ => None, }, cache_write_1h_price_per_1m: None, @@ -437,7 +730,8 @@ pub(crate) fn ensure_official_price_seed(storage: &Storage) -> Result<(), String source_url: Some(seed.source_url.to_string()), seed_version: Some(PRICE_SEED_VERSION.to_string()), enabled: true, - priority: 10_000 - index as i64, + // 新版官方种子必须高于旧版 10_000 档,确保已有数据库升级后立即采用新分层规则。 + priority: 20_000 - index as i64, created_at: now, updated_at: now, }) @@ -495,6 +789,30 @@ fn rule_matches(rule: &ModelPriceRule, normalized_model: &str) -> bool { } } +/// 将最终服务等级映射到价格规则的计费模式。 +/// +/// 未知值保守回退到 Standard,避免仅因客户端拼写错误就收取 Priority 溢价。 +pub(crate) fn normalize_service_tier_for_billing(service_tier: Option<&str>) -> &'static str { + match service_tier + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .as_deref() + { + Some("priority" | "fast") => PRIORITY_BILLING_MODE, + Some("standard" | "default" | "auto") | None => STANDARD_BILLING_MODE, + Some(_) => STANDARD_BILLING_MODE, + } +} + +fn normalize_rule_billing_mode(billing_mode: &str) -> Option<&'static str> { + match billing_mode.trim().to_ascii_lowercase().as_str() { + "standard" | "default" | "auto" | "tokens" | "" => Some(STANDARD_BILLING_MODE), + "priority" | "fast" => Some(PRIORITY_BILLING_MODE), + _ => None, + } +} + fn price_from_rule(rule: &ModelPriceRule, input_tokens: i64) -> Option { if !rule.enabled || !rule.currency.eq_ignore_ascii_case("USD") @@ -531,29 +849,54 @@ pub(crate) fn resolve_model_price_from_rules( rules: &[ModelPriceRule], model: &str, input_tokens: i64, + service_tier: Option<&str>, ) -> Option { let normalized = model.trim().to_ascii_lowercase(); if normalized.is_empty() || normalized == "unknown" { return None; } + let billing_mode = normalize_service_tier_for_billing(service_tier); let matched = rules .iter() - .filter(|rule| rule_matches(rule, &normalized)) + .filter(|rule| { + normalize_rule_billing_mode(&rule.billing_mode) == Some(billing_mode) + && rule_matches(rule, &normalized) + }) .max_by_key(|rule| (rule.priority, rule.model_pattern.len() as i64))?; price_from_rule(matched, input_tokens) } pub(crate) fn resolve_model_price(model: &str, input_tokens: i64) -> Option { + resolve_model_price_for_service_tier(model, input_tokens, None) +} + +pub(crate) fn resolve_model_price_for_service_tier( + model: &str, + input_tokens: i64, + service_tier: Option<&str>, +) -> Option { let normalized = model.trim().to_ascii_lowercase(); if normalized.is_empty() || normalized == "unknown" { return None; } - let matched = PRICE_SEEDS + let priority_mode = normalize_service_tier_for_billing(service_tier) == PRIORITY_BILLING_MODE; + let seeds = if priority_mode { + PRIORITY_PRICE_SEEDS + } else { + PRICE_SEEDS + }; + let matched = seeds .iter() - .filter(|seed| normalized.starts_with(seed.model_pattern)) + .filter(|seed| { + if priority_mode { + normalized == seed.model_pattern + } else { + normalized.starts_with(seed.model_pattern) + } + }) .max_by_key(|seed| seed.model_pattern.len())?; let mut input = matched.input_price_per_1m; @@ -611,6 +954,22 @@ pub(crate) fn estimate_cost( input_tokens: i64, cached_input_tokens: i64, output_tokens: i64, +) -> CostEstimate { + estimate_cost_for_service_tier( + model, + input_tokens, + cached_input_tokens, + output_tokens, + None, + ) +} + +pub(crate) fn estimate_cost_for_service_tier( + model: Option<&str>, + input_tokens: i64, + cached_input_tokens: i64, + output_tokens: i64, + service_tier: Option<&str>, ) -> CostEstimate { let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) else { return CostEstimate { @@ -619,7 +978,9 @@ pub(crate) fn estimate_cost( price_status: "missing", }; }; - let Some(price) = resolve_model_price(model, input_tokens.max(0)) else { + let Some(price) = + resolve_model_price_for_service_tier(model, input_tokens.max(0), service_tier) + else { return CostEstimate { provider: None, cost_usd: None, @@ -636,6 +997,7 @@ pub(crate) fn estimate_cost_with_rules( input_tokens: i64, cached_input_tokens: i64, output_tokens: i64, + service_tier: Option<&str>, ) -> CostEstimate { let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) else { return CostEstimate { @@ -645,8 +1007,10 @@ pub(crate) fn estimate_cost_with_rules( }; }; - let Some(price) = resolve_model_price_from_rules(rules, model, input_tokens.max(0)) - .or_else(|| resolve_model_price(model, input_tokens.max(0))) + let Some(price) = + resolve_model_price_from_rules(rules, model, input_tokens.max(0), service_tier).or_else( + || resolve_model_price_for_service_tier(model, input_tokens.max(0), service_tier), + ) else { return CostEstimate { provider: None, @@ -666,7 +1030,7 @@ pub(crate) fn estimate_remaining_tokens_from_usd_with_rules( if !balance_usd.is_finite() || balance_usd < 0.0 { return None; } - let price = resolve_model_price_from_rules(rules, model, 0) + let price = resolve_model_price_from_rules(rules, model, 0, None) .or_else(|| resolve_model_price(model, 0))?; if balance_usd == 0.0 { return Some(0); @@ -684,16 +1048,20 @@ pub(crate) fn estimate_cost_usd_for_log( input_tokens: Option, cached_input_tokens: Option, output_tokens: Option, + service_tier: Option<&str>, ) -> f64 { let input = input_tokens.unwrap_or(0); let cached = cached_input_tokens.unwrap_or(0); let output = output_tokens.unwrap_or(0); + let _ = ensure_official_price_seed(storage); let cost = storage .list_enabled_model_price_rules() .ok() .filter(|rules| !rules.is_empty()) - .map(|rules| estimate_cost_with_rules(&rules, model, input, cached, output)) - .unwrap_or_else(|| estimate_cost(model, input, cached, output)); + .map(|rules| estimate_cost_with_rules(&rules, model, input, cached, output, service_tier)) + .unwrap_or_else(|| { + estimate_cost_for_service_tier(model, input, cached, output, service_tier) + }); cost.cost_usd.unwrap_or(0.0) } @@ -762,18 +1130,60 @@ mod tests { 4.0, ), ]; - let exact = - resolve_model_price_from_rules(&rules, "vendor-model-mini", 0).expect("exact rule"); + let exact = resolve_model_price_from_rules(&rules, "vendor-model-mini", 0, None) + .expect("exact rule"); assert_close(exact.input_price_per_1m, 3.0); assert_close(exact.cached_input_price_per_1m, 0.3); assert_close(exact.output_price_per_1m, 4.0); - let wildcard = - resolve_model_price_from_rules(&rules, "vendor-other-mini", 0).expect("wildcard rule"); + let wildcard = resolve_model_price_from_rules(&rules, "vendor-other-mini", 0, None) + .expect("wildcard rule"); assert_close(wildcard.input_price_per_1m, 1.0); assert_close(wildcard.output_price_per_1m, 2.0); } + #[test] + fn selects_database_rule_by_billing_mode_for_same_model() { + let standard = test_rule("standard", "gpt-tiered", "exact", 100, 1.0, Some(0.1), 2.0); + let mut priority = standard.clone(); + priority.id = "priority".to_string(); + priority.billing_mode = "priority".to_string(); + priority.input_price_per_1m = Some(3.0); + priority.cached_input_price_per_1m = Some(0.3); + priority.output_price_per_1m = Some(4.0); + let rules = vec![standard, priority]; + + let standard_price = + resolve_model_price_from_rules(&rules, "gpt-tiered", 0, Some("standard")) + .expect("standard rule"); + let priority_price = resolve_model_price_from_rules(&rules, "gpt-tiered", 0, Some("fast")) + .expect("priority rule"); + + assert_close(standard_price.input_price_per_1m, 1.0); + assert_close(priority_price.input_price_per_1m, 3.0); + assert_close(priority_price.output_price_per_1m, 4.0); + } + + #[test] + fn normalizes_service_tier_with_safe_standard_fallback() { + assert_eq!( + normalize_service_tier_for_billing(Some("priority")), + "priority" + ); + assert_eq!(normalize_service_tier_for_billing(Some("FAST")), "priority"); + assert_eq!(normalize_service_tier_for_billing(None), "standard"); + assert_eq!(normalize_service_tier_for_billing(Some("")), "standard"); + assert_eq!(normalize_service_tier_for_billing(Some("auto")), "standard"); + assert_eq!( + normalize_service_tier_for_billing(Some("default")), + "standard" + ); + assert_eq!( + normalize_service_tier_for_billing(Some("future-premium-tier")), + "standard" + ); + } + #[test] fn resolves_exact_and_snapshot_models() { let exact = resolve_model_price("gpt-5.4-mini", 0).expect("exact price"); @@ -787,6 +1197,121 @@ mod tests { assert_close(snapshot.output_price_per_1m, 4.5); } + #[test] + fn resolves_specific_gpt_4_standard_prices_before_family_prefixes() { + let cases = [ + ("gpt-4.1-mini", 0.4, 0.1, 1.6), + ("gpt-4.1-nano", 0.1, 0.025, 0.4), + ("gpt-4o-mini", 0.15, 0.075, 0.6), + ("gpt-4o-2024-05-13", 5.0, 5.0, 15.0), + ]; + for (model, input, cached, output) in cases { + let price = resolve_model_price(model, 0).expect("standard price"); + assert_close(price.input_price_per_1m, input); + assert_close(price.cached_input_price_per_1m, cached); + assert_close(price.output_price_per_1m, output); + } + } + + #[test] + fn resolves_official_priority_prices_without_uniform_multiplier() { + let cases = [ + ("gpt-5.6-sol", 10.0, 1.0, 60.0), + ("gpt-5.6-terra", 5.0, 0.5, 30.0), + ("gpt-5.6-luna", 2.0, 0.2, 12.0), + ("gpt-5.5", 12.5, 1.25, 75.0), + ("gpt-5.4", 5.0, 0.5, 30.0), + ("gpt-5.4-mini", 1.5, 0.15, 9.0), + ("gpt-5.2", 3.5, 0.35, 28.0), + ("gpt-5.1", 2.5, 0.25, 20.0), + ("gpt-5", 2.5, 0.25, 20.0), + ("gpt-5-mini", 0.45, 0.045, 3.6), + ("gpt-4.1", 3.5, 0.875, 14.0), + ("gpt-4.1-mini", 0.7, 0.175, 2.8), + ("gpt-4.1-nano", 0.2, 0.05, 0.8), + ("gpt-4o", 4.25, 2.125, 17.0), + ("gpt-4o-2024-05-13", 8.75, 8.75, 26.25), + ("gpt-4o-mini", 0.25, 0.125, 1.0), + ("o3", 3.5, 0.875, 14.0), + ("o4-mini", 2.0, 0.5, 8.0), + ]; + for (model, input, cached, output) in cases { + let price = resolve_model_price_for_service_tier(model, 0, Some("priority")) + .expect("priority price"); + assert_close(price.input_price_per_1m, input); + assert_close(price.cached_input_price_per_1m, cached); + assert_close(price.output_price_per_1m, output); + } + + let mini_standard = resolve_model_price("gpt-5-mini", 0).expect("standard mini price"); + let mini_priority = resolve_model_price_for_service_tier("gpt-5-mini", 0, Some("priority")) + .expect("priority mini price"); + assert_close( + mini_priority.input_price_per_1m / mini_standard.input_price_per_1m, + 1.8, + ); + for unlisted_variant in [ + "gpt-5.5-pro", + "gpt-5.4-pro", + "gpt-5.4-nano", + "gpt-5.2-pro", + "gpt-5-nano", + "gpt-5-pro", + ] { + assert!( + resolve_model_price_for_service_tier(unlisted_variant, 0, Some("priority")) + .is_none(), + "unlisted Priority variant must not inherit a family price: {unlisted_variant}" + ); + } + } + + #[test] + fn upgraded_seed_inserts_standard_and_priority_rules() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + let mut old_family_rule = test_rule( + "official-2026-07-11-gpt-4.1", + "gpt-4.1", + "prefix", + 9_982, + 2.0, + Some(0.5), + 8.0, + ); + old_family_rule.provider = "openai".to_string(); + old_family_rule.source = "official_seed".to_string(); + old_family_rule.seed_version = Some("2026-07-11".to_string()); + storage + .upsert_model_price_rule(&old_family_rule) + .expect("insert old seed"); + + ensure_official_price_seed(&storage).expect("seed tiered prices"); + + assert_eq!( + storage + .count_model_price_rules_for_seed(PRICE_SEED_VERSION) + .expect("count seeds") as usize, + PRICE_SEEDS.len() + PRIORITY_PRICE_SEEDS.len() + ); + let standard = storage + .find_model_price_rule_by_model_pattern_and_billing_mode("gpt-5-mini", "standard") + .expect("read standard seed") + .expect("standard seed"); + let priority = storage + .find_model_price_rule_by_model_pattern_and_billing_mode("gpt-5-mini", "priority") + .expect("read priority seed") + .expect("priority seed"); + assert_close(standard.input_price_per_1m.expect("standard input"), 0.25); + assert_close(priority.input_price_per_1m.expect("priority input"), 0.45); + let all_rules = storage + .list_enabled_model_price_rules() + .expect("list upgraded rules"); + let nano = resolve_model_price_from_rules(&all_rules, "gpt-4.1-nano", 0, None) + .expect("new specific seed wins over old family seed"); + assert_close(nano.input_price_per_1m, 0.1); + } + #[test] fn resolves_gpt_5_6_standard_and_long_context_prices() { let sol = resolve_model_price("gpt-5.6-sol", 0).expect("sol standard price"); diff --git a/crates/service/src/quota/read.rs b/crates/service/src/quota/read.rs index 8c1a51a76..46eb3ef83 100644 --- a/crates/service/src/quota/read.rs +++ b/crates/service/src/quota/read.rs @@ -53,6 +53,7 @@ pub(crate) struct ModelPriceRuleUpsertInput { pub(crate) provider: Option, pub(crate) model_pattern: String, pub(crate) match_type: Option, + pub(crate) billing_mode: Option, pub(crate) input_price_per_1m: Option, pub(crate) cached_input_price_per_1m: Option, pub(crate) output_price_per_1m: Option, @@ -575,6 +576,7 @@ fn model_price_rule_value(rule: ModelPriceRule) -> Value { "provider": rule.provider, "modelPattern": rule.model_pattern, "matchType": rule.match_type, + "billingMode": rule.billing_mode, "inputPricePer1m": rule.input_price_per_1m, "cachedInputPricePer1m": rule.cached_input_price_per_1m, "outputPricePer1m": rule.output_price_per_1m, @@ -597,17 +599,28 @@ pub(crate) fn read_model_price_rules() -> Result { Ok(serde_json::json!({ "items": items })) } -pub(crate) fn read_model_price_rule(model_pattern: &str) -> Result { +pub(crate) fn read_model_price_rule( + model_pattern: &str, + billing_mode: Option<&str>, +) -> Result { let model_pattern = model_pattern.trim(); if model_pattern.is_empty() { return Ok(Value::Null); } let storage = open_storage().ok_or_else(|| "storage unavailable".to_string())?; - let value = storage - .find_model_price_rule_by_model_pattern(model_pattern) - .map_err(|err| format!("read model price rule failed: {err}"))? - .map(model_price_rule_value) - .unwrap_or(Value::Null); + let value = match billing_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(billing_mode) => storage.find_model_price_rule_by_model_pattern_and_billing_mode( + model_pattern, + model_pricing::normalize_service_tier_for_billing(Some(billing_mode)), + ), + None => storage.find_model_price_rule_by_model_pattern(model_pattern), + } + .map_err(|err| format!("read model price rule failed: {err}"))? + .map(model_price_rule_value) + .unwrap_or(Value::Null); Ok(value) } @@ -617,9 +630,29 @@ pub(crate) fn upsert_model_price_rule(input: ModelPriceRuleUpsertInput) -> Resul return Err("modelPattern required".to_string()); } let storage = open_storage().ok_or_else(|| "storage unavailable".to_string())?; - let existing = storage - .find_model_price_rule_by_model_pattern(model_pattern) - .map_err(|err| format!("read existing model price rule failed: {err}"))?; + let requested_billing_mode = input + .billing_mode + .as_deref() + .map(|value| model_pricing::normalize_service_tier_for_billing(Some(value))); + let lookup_billing_mode = requested_billing_mode.unwrap_or("standard"); + let existing = match input + .id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(id) => storage.find_model_price_rule_by_id(id), + None => storage.find_model_price_rule_by_model_pattern_and_billing_mode( + model_pattern, + lookup_billing_mode, + ), + } + .map_err(|err| format!("read existing model price rule failed: {err}"))?; + let billing_mode = requested_billing_mode.unwrap_or_else(|| { + model_pricing::normalize_service_tier_for_billing( + existing.as_ref().map(|rule| rule.billing_mode.as_str()), + ) + }); let now = now_ts(); let id = input .id @@ -628,7 +661,7 @@ pub(crate) fn upsert_model_price_rule(input: ModelPriceRuleUpsertInput) -> Resul .filter(|value| !value.is_empty()) .map(ToString::to_string) .or_else(|| existing.as_ref().map(|rule| rule.id.clone())) - .unwrap_or_else(|| format!("custom:{model_pattern}")); + .unwrap_or_else(|| format!("custom:{billing_mode}:{model_pattern}")); let provider = input .provider .as_deref() @@ -650,10 +683,7 @@ pub(crate) fn upsert_model_price_rule(input: ModelPriceRuleUpsertInput) -> Resul provider, model_pattern: model_pattern.to_string(), match_type, - billing_mode: existing - .as_ref() - .map(|rule| rule.billing_mode.clone()) - .unwrap_or_else(|| "tokens".to_string()), + billing_mode: billing_mode.to_string(), currency: existing .as_ref() .map(|rule| rule.currency.clone()) @@ -661,7 +691,7 @@ pub(crate) fn upsert_model_price_rule(input: ModelPriceRuleUpsertInput) -> Resul unit: existing .as_ref() .map(|rule| rule.unit.clone()) - .unwrap_or_else(|| "1m_tokens".to_string()), + .unwrap_or_else(|| "per_1m_tokens".to_string()), input_price_per_1m: input .input_price_per_1m .or_else(|| existing.as_ref().and_then(|rule| rule.input_price_per_1m)), @@ -783,6 +813,7 @@ pub(crate) fn read_quota_model_usage( item.input_tokens, item.cached_input_tokens, item.output_tokens, + None, ); let aggregate_estimated_remaining_tokens = aggregate_balance_usd.and_then(|balance| { @@ -1311,9 +1342,13 @@ pub(crate) fn read_quota_system_pool( price_status: "missing".to_string(), ..PoolAccumulator::default() }; - if let Some(price) = - model_pricing::resolve_model_price_from_rules(&price_rules, reference_model.as_str(), 0) - .or_else(|| model_pricing::resolve_model_price(reference_model.as_str(), 0)) + if let Some(price) = model_pricing::resolve_model_price_from_rules( + &price_rules, + reference_model.as_str(), + 0, + None, + ) + .or_else(|| model_pricing::resolve_model_price(reference_model.as_str(), 0)) { pool.provider = Some(price.provider); pool.price_status = "ok".to_string(); @@ -1401,7 +1436,7 @@ fn seed_model_pools( api_models: &[String], ) { for model in api_models { - let price = model_pricing::resolve_model_price_from_rules(price_rules, model, 0) + let price = model_pricing::resolve_model_price_from_rules(price_rules, model, 0, None) .or_else(|| model_pricing::resolve_model_price(model, 0)); let entry = pools .entry(model.clone()) @@ -1455,9 +1490,10 @@ fn add_aggregate_api_pools( if model.is_empty() { continue; } - let provider = model_pricing::resolve_model_price_from_rules(price_rules, &model, 0) - .or_else(|| model_pricing::resolve_model_price(&model, 0)) - .map(|price| price.provider); + let provider = + model_pricing::resolve_model_price_from_rules(price_rules, &model, 0, None) + .or_else(|| model_pricing::resolve_model_price(&model, 0)) + .map(|price| price.provider); let remaining_tokens = balance.remaining.and_then(|remaining| { if is_usd_unit(balance_unit.as_str()) { model_pricing::estimate_remaining_tokens_from_usd_with_rules( @@ -1612,10 +1648,11 @@ fn add_account_pools( if model.is_empty() { continue; } - let provider = model_pricing::resolve_model_price_from_rules(price_rules, &model, 0) - .or_else(|| model_pricing::resolve_model_price(&model, 0)) - .map(|price| price.provider) - .or_else(|| Some("openai".to_string())); + let provider = + model_pricing::resolve_model_price_from_rules(price_rules, &model, 0, None) + .or_else(|| model_pricing::resolve_model_price(&model, 0)) + .map(|price| price.provider) + .or_else(|| Some("openai".to_string())); let entry = pools .entry(model.clone()) .or_insert_with(|| PoolAccumulator { @@ -1980,6 +2017,7 @@ pub(crate) fn read_quota_api_key_usage( item.input_tokens, item.cached_input_tokens, item.output_tokens, + None, ); models_by_key .entry(item.key_id) diff --git a/crates/service/src/rpc_dispatch/quota.rs b/crates/service/src/rpc_dispatch/quota.rs index c728db12e..b9d8ac259 100644 --- a/crates/service/src/rpc_dispatch/quota.rs +++ b/crates/service/src/rpc_dispatch/quota.rs @@ -146,7 +146,10 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { "modelPriceRules/list" => super::value_or_error(read::read_model_price_rules()), "modelPriceRule/read" => { let model_pattern = super::str_param(req, "modelPattern").unwrap_or(""); - super::value_or_error(read::read_model_price_rule(model_pattern)) + super::value_or_error(read::read_model_price_rule( + model_pattern, + super::str_param(req, "billingMode"), + )) } "modelPriceRule/upsert" => { super::value_or_error(read::upsert_model_price_rule(ModelPriceRuleUpsertInput { @@ -154,6 +157,7 @@ pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { provider: nested_string_param(req, "provider"), model_pattern: nested_string_param(req, "modelPattern").unwrap_or_default(), match_type: nested_string_param(req, "matchType"), + billing_mode: nested_string_param(req, "billingMode"), input_price_per_1m: nested_f64_param(req, "inputPricePer1m"), cached_input_price_per_1m: nested_f64_param(req, "cachedInputPricePer1m"), output_price_per_1m: nested_f64_param(req, "outputPricePer1m"), diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 7af60d8bb..070e6b03d 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -27,6 +27,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- 请求费用估算改为在解析最终 `effective_service_tier` 后选择 Standard / Priority 价格,HTTP 与 Responses WebSocket 共用同一计费口径;`fast` 按 Priority,空值、`auto`、`default` 和未知 tier 保守按 Standard。模型价格规则现会实际匹配 `billingMode`,同一模型可同时维护不同服务等级价格;新版种子逐模型补齐官方 Priority 价格及 GPT-4.1/4o 特异 Standard 规则,不对历史账单自动重算。 - 补齐 Web 运行壳遗漏的命令映射及完整性门禁,移除错误的模型价格重复 RPC 映射;Tauri 生产构建不再因旧静态产物存在而跳过重新生成,并把 `/platform-mode` 纳入根页面校验。 - 账号单删、批删和状态清理成功后立即失效网关候选缓存;候选快照与 single-flight 按数据库和低额度模式隔离,避免交替模式互相驱逐或删除后继续选中旧凭据。 - accounts 鉴权模式下成员不再访问全局账号池读取、更新、用量刷新和 Token 刷新 RPC;OAuth 登录成功日志不再输出数据库路径、state、workspace 或账号标识。 diff --git a/task.md b/task.md index c7d84ef90..a8a1f4835 100644 --- a/task.md +++ b/task.md @@ -5,12 +5,12 @@ ## 当前待处理(2026-07-07) 0. P0 定价分层与刷新状态补充修复(🔄 进行中) - - 定价:按请求最终 `effective_service_tier` 区分 Standard / Priority,补齐官方 Priority 种子与回归测试;历史费用重算不在本轮自动执行。 - - 刷新:将可能误判的 `refresh_token_expired` 调整为长退避低频复检;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;明确 disabled/banned 的后台刷新边界。 + - 定价(✅ 子项已完成):按请求最终 `effective_service_tier` 区分 Standard / Priority,`fast` 归入 Priority,空值/`auto`/`default` 与未知值保守回退 Standard;补齐官方逐模型 Priority 种子、`billing_mode` 匹配和 HTTP/WS 回归测试。历史费用不重算。 + - 刷新(✅ 子项已完成):将可能误判的 `refresh_token_expired` 调整为长退避低频复检;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;后台轮询跳过 disabled/banned。 - 区域诊断:参考 Clash Verge 多 IP 服务映射与失败切换,但仅用于出口诊断;启动检查、区域阻断事件触发检查和低频兜底均不得直接替代上游接口判定。 - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 - 主代理负责对子代理补丁独立审计、集成测试与 PR 更新。 - - 刷新子项:✅ 已完成独立实现与定向回归,等待主代理审计集成。 + - 定价与刷新子项已完成主代理审计集成,等待区域诊断与前端同步收口。 1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 From 5010f4212492bb4932b6f2c83ed07fe12c06bcee Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:11:21 +0800 Subject: [PATCH 11/35] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E8=AF=8A=E6=96=AD=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E8=B4=A6=E5=8F=B7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4\346\255\245_\350\277\233\345\272\246.md" | 24 + README.md | 2 +- apps/src-tauri/src/commands/mod.rs | 1 + .../src/commands/network_diagnostics.rs | 17 + apps/src-tauri/src/commands/registry.rs | 3 + .../components/network-diagnostics-card.tsx | 126 ++++ apps/src/app/settings/page.tsx | 8 +- apps/src/hooks/useAccounts.ts | 25 +- apps/src/lib/api/network-diagnostics.ts | 59 ++ .../lib/api/transport-web-commands/account.ts | 4 + apps/tests/account-list-cache.test.mjs | 15 +- apps/tests/transport-web-commands.test.mjs | 11 + crates/service/src/account/account_status.rs | 12 +- crates/service/src/lib.rs | 2 + crates/service/src/lifecycle/startup.rs | 1 + crates/service/src/network_diagnostics.rs | 670 ++++++++++++++++++ crates/service/src/rpc_dispatch/mod.rs | 4 + .../src/rpc_dispatch/network_diagnostics.rs | 11 + crates/service/src/tests/lib_tests.rs | 2 + crates/service/src/usage/refresh/errors.rs | 3 + ...15\347\275\256\350\257\264\346\230\216.md" | 11 + task.md | 1 + 22 files changed, 991 insertions(+), 21 deletions(-) create mode 100644 ".teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" create mode 100644 apps/src-tauri/src/commands/network_diagnostics.rs create mode 100644 apps/src/app/settings/components/network-diagnostics-card.tsx create mode 100644 apps/src/lib/api/network-diagnostics.ts create mode 100644 crates/service/src/network_diagnostics.rs create mode 100644 crates/service/src/rpc_dispatch/network_diagnostics.rs diff --git "a/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" "b/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" new file mode 100644 index 000000000..5375eafb4 --- /dev/null +++ "b/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" @@ -0,0 +1,24 @@ +# 出口诊断与前端状态同步进度 + +执行身份:【CodeX-GPT】 + +## 已完成 + +- 新增固定白名单多源出口诊断,使用确定性轮换起点、单源独立超时、总超时和单飞缓存。 +- 网络错误与 `5xx` 最多同源重试一次;`4xx`、无效 JSON 或字段错误直接切源。 +- 启动异步首检、区域阻断节流复检、存在阻断账号时低频兜底均不阻塞启动或请求热路径。 +- 诊断只展示给管理员,不根据 IP 结果修改账号状态;普通日志不记录完整 IP 或经纬度。 +- 补齐 Service RPC、Tauri command、Web command、设置页最小展示和手动刷新。 +- 用量刷新完成事件及手动刷新收口后会同步失效 `accounts/list`,避免账号状态展示滞后。 + +## 已执行验证 + +- `cargo fmt --all -- --check` +- `cargo test -p codexmanager-service network_diagnostics --lib -j1`:5 项通过。 +- `cargo test -p codexmanager-service member_actor_cannot_call_admin_only_rpc --lib -j1`:1 项通过。 +- `node --test tests/account-list-cache.test.mjs tests/transport-web-commands.test.mjs tests/tauri-command-registry.test.mjs`:23 项通过。 +- `apps/node_modules/.bin/tsc.CMD --noEmit`:通过(临时复用主工作树依赖,未复制依赖目录)。 + +## 待主审 + +- 主代理复核完整 diff,并在集成分支执行更大范围 workspace / 前端构建门禁。 diff --git a/README.md b/README.md index 94b468fd7..5b4e6989b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - 模型管理:维护结构化模型目录、远端并入、自定义模型、空目录自动远端拉取开关、`visibility` / `supportedInApi` 管理,以及桌面端 Codex 缓存同步 / Web 端缓存导出 - 聚合 API:管理第三方最小转发上游,支持创建、编辑、测试连通性、供应商名称、顺序优先级,以及按 Codex / Claude 分类展示 - 插件中心:路由为 `/plugins/`,支持内置精选、企业私有、自定义源三种市场模式,并提供插件清单、任务、日志与 Rhai 对接接口 -- 设置页:支持“系统推导”按钮、单账号并发上限、上游代理、请求总超时、流式空闲超时、SSE 保活间隔,以及更保守的高并发退化策略;实验性上游 WebSocket 可通过 `CODEXMANAGER_USE_WEBSOCKET_UPSTREAM=1` 开启,默认关闭 +- 设置页:支持“系统推导”按钮、单账号并发上限、上游代理、请求总超时、流式空闲超时、SSE 保活间隔,以及更保守的高并发退化策略;通用页可查看和手动刷新出口 IP / 国家 / ASN 诊断,该结果仅用于排障,不会直接改变账号状态;实验性上游 WebSocket 可通过 `CODEXMANAGER_USE_WEBSOCKET_UPSTREAM=1` 开启,默认关闭 - 系统内部接口总表:列出当前桌面端与服务端所有可对接命令、RPC 方法、以及插件内建函数 - 本地服务:自动拉起、可自定义端口与监听地址 - 本地网关:为 Codex CLI、Gemini CLI、Claude Code 和第三方工具提供统一 OpenAI 兼容入口;Gemini 请求可转发到 `/v1/responses`,并兼容 SSE、tools、MCP、skill、请求总超时与流式空闲超时等调用链路 diff --git a/apps/src-tauri/src/commands/mod.rs b/apps/src-tauri/src/commands/mod.rs index f7ead3b40..05b03d19e 100644 --- a/apps/src-tauri/src/commands/mod.rs +++ b/apps/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod apikey; pub mod codex_profile; pub mod dashboard; pub mod login; +pub mod network_diagnostics; pub mod plugin; pub mod quota; mod registry; diff --git a/apps/src-tauri/src/commands/network_diagnostics.rs b/apps/src-tauri/src/commands/network_diagnostics.rs new file mode 100644 index 000000000..066adc4c8 --- /dev/null +++ b/apps/src-tauri/src/commands/network_diagnostics.rs @@ -0,0 +1,17 @@ +use crate::commands::shared::rpc_call_in_background; + +/// 读取服务端缓存的出口网络诊断。 +#[tauri::command] +pub async fn service_network_diagnostics_get( + addr: Option, +) -> Result { + rpc_call_in_background("networkDiagnostics/get", addr, None).await +} + +/// 异步请求服务端刷新出口网络诊断。 +#[tauri::command] +pub async fn service_network_diagnostics_refresh( + addr: Option, +) -> Result { + rpc_call_in_background("networkDiagnostics/refresh", addr, None).await +} diff --git a/apps/src-tauri/src/commands/registry.rs b/apps/src-tauri/src/commands/registry.rs index a0d201c79..98510693a 100644 --- a/apps/src-tauri/src/commands/registry.rs +++ b/apps/src-tauri/src/commands/registry.rs @@ -58,6 +58,9 @@ macro_rules! invoke_handler { crate::commands::usage::service_usage_list, crate::commands::usage::service_usage_aggregate, crate::commands::usage::service_usage_refresh, + // network diagnostics + crate::commands::network_diagnostics::service_network_diagnostics_get, + crate::commands::network_diagnostics::service_network_diagnostics_refresh, // quota crate::commands::quota::service_quota_overview, crate::commands::quota::service_quota_model_usage, diff --git a/apps/src/app/settings/components/network-diagnostics-card.tsx b/apps/src/app/settings/components/network-diagnostics-card.tsx new file mode 100644 index 000000000..f7b5072fc --- /dev/null +++ b/apps/src/app/settings/components/network-diagnostics-card.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Globe2, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + networkDiagnosticsClient, + type NetworkDiagnosticsSnapshot, +} from "@/lib/api/network-diagnostics"; +import { getAppErrorMessage } from "@/lib/api/transport"; + +const QUERY_KEY = ["network-diagnostics"] as const; + +function formatCheckedAt(timestamp: number | null): string { + if (!timestamp) return "--"; + return new Date(timestamp * 1000).toLocaleString(); +} + +export function NetworkDiagnosticsCard({ + t, + enabled, +}: { + t: (value: string) => string; + enabled: boolean; +}) { + const queryClient = useQueryClient(); + const diagnostics = useQuery({ + queryKey: QUERY_KEY, + queryFn: () => networkDiagnosticsClient.get(), + enabled, + staleTime: 30_000, + refetchInterval: (query) => + query.state.data?.refreshing ? 1_000 : false, + }); + const refresh = useMutation({ + mutationFn: () => networkDiagnosticsClient.refresh(), + onSuccess: (snapshot) => { + queryClient.setQueryData(QUERY_KEY, snapshot); + toast.success( + snapshot.refreshScheduled + ? t("已开始刷新出口诊断") + : t("已使用最近的出口诊断结果"), + ); + }, + onError: (error: unknown) => { + toast.error(`${t("出口诊断刷新失败")}: ${getAppErrorMessage(error)}`); + }, + }); + const snapshot = diagnostics.data; + const isRefreshing = refresh.isPending || snapshot?.refreshing; + const regionLabel = [snapshot?.country, snapshot?.countryCode] + .filter(Boolean) + .join(" / ") || "--"; + + return ( + + +
+ + {t("出口网络诊断")} + {snapshot?.enabled === false ? ( + {t("已关闭")} + ) : null} +
+ + {t("用于核对当前服务出口,不会根据 IP 结果自动改变账号状态。")} + +
+ +
+
+
{t("出口 IP")}
+ {snapshot?.ip || "--"} +
+
+
{t("国家或地区")}
+
{regionLabel}
+
+
+
ASN
+
{snapshot?.asn ? `AS${snapshot.asn}` : "--"}
+
+
+
{t("网络组织")}
+
{snapshot?.organization || "--"}
+
+
+
{t("检测时间")}
+
{formatCheckedAt(snapshot?.checkedAt || null)}
+
+
+
{t("检测来源")}
+
{snapshot?.source || "--"}
+
+
+ {snapshot?.error ? ( +

{snapshot.error}

+ ) : null} +
+

+ {t("查询沿用当前 OpenAI 上游代理;外部诊断服务失败不会影响账号刷新或网关请求。")} +

+ +
+
+
+ ); +} diff --git a/apps/src/app/settings/page.tsx b/apps/src/app/settings/page.tsx index 6230a09f3..f98df14b1 100644 --- a/apps/src/app/settings/page.tsx +++ b/apps/src/app/settings/page.tsx @@ -72,6 +72,7 @@ import { ServiceListenCard, } from "@/app/settings/components/general-tab-cards"; import { GeneralBasicsCard } from "@/app/settings/components/general-basics-card"; +import { NetworkDiagnosticsCard } from "@/app/settings/components/network-diagnostics-card"; import { TasksTabContent } from "@/app/settings/components/tasks-tab-content"; import { CUSTOM_WORKER_MODE_VALUE, @@ -1406,12 +1407,17 @@ function AdminSettingsPage() { canCloseToTray={canCloseToTray} updateSettings={updateSettings} /> - + + void) | null = null; const refreshVisibleUsageData = () => { void Promise.all([ + queryClient.refetchQueries({ queryKey: ["accounts", "list"], type: "active" }), queryClient.refetchQueries({ queryKey: ["usage", "list"], type: "active" }), queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), queryClient.invalidateQueries({ queryKey: ["today-summary"] }), @@ -368,8 +369,20 @@ export function useAccounts(params?: AccountListParams) { ]); }; - void listenUsageRefreshCompleted(() => { + void listenUsageRefreshCompleted((payload) => { refreshVisibleUsageData(); + if (payload.source === "single" || payload.source === "manual_all") { + const processed = Number(payload.processed || 0); + const total = Number(payload.total || 0); + toast.success( + total > 0 + ? t("账号用量刷新完成:已处理{processed}/{total}", { + processed, + total, + }) + : t("账号用量已刷新"), + ); + } }).then((cleanup) => { if (disposed) { cleanup(); @@ -519,27 +532,21 @@ export function useAccounts(params?: AccountListParams) { const refreshAccountMutation = useMutation({ mutationFn: (accountId: string) => accountClient.refreshUsage(accountId), - onSuccess: () => { - toast.success(t("账号用量已刷新")); - }, onError: (error: unknown) => { toast.error(`${t("刷新失败")}: ${formatUsageRefreshErrorMessage(error, t)}`); }, onSettled: async () => { - await invalidateUsageData(); + await invalidateAccountData(); }, }); const refreshAllMutation = useMutation({ mutationFn: () => accountClient.refreshUsage(), - onSuccess: () => { - toast.success(t("账号用量已刷新")); - }, onError: (error: unknown) => { toast.error(`${t("刷新失败")}: ${formatUsageRefreshErrorMessage(error, t)}`); }, onSettled: async () => { - await invalidateUsageData(); + await invalidateAccountData(); }, }); diff --git a/apps/src/lib/api/network-diagnostics.ts b/apps/src/lib/api/network-diagnostics.ts new file mode 100644 index 000000000..f4a1f6236 --- /dev/null +++ b/apps/src/lib/api/network-diagnostics.ts @@ -0,0 +1,59 @@ +import { invoke, withAddr } from "./transport"; + +export interface NetworkDiagnosticsSnapshot { + enabled: boolean; + refreshing: boolean; + refreshScheduled: boolean; + ip: string | null; + countryCode: string | null; + country: string | null; + asn: number | null; + organization: string | null; + checkedAt: number | null; + lastAttemptAt: number | null; + source: string | null; + error: string | null; +} + +function normalizeSnapshot(value: unknown): NetworkDiagnosticsSnapshot { + const record = value && typeof value === "object" + ? (value as Record) + : {}; + const optionalString = (key: string) => { + const candidate = record[key]; + return typeof candidate === "string" && candidate.trim() + ? candidate.trim() + : null; + }; + const optionalNumber = (key: string) => { + const candidate = Number(record[key]); + return Number.isFinite(candidate) && candidate > 0 ? candidate : null; + }; + return { + enabled: record.enabled !== false, + refreshing: record.refreshing === true, + refreshScheduled: record.refreshScheduled === true, + ip: optionalString("ip"), + countryCode: optionalString("countryCode"), + country: optionalString("country"), + asn: optionalNumber("asn"), + organization: optionalString("organization"), + checkedAt: optionalNumber("checkedAt"), + lastAttemptAt: optionalNumber("lastAttemptAt"), + source: optionalString("source"), + error: optionalString("error"), + }; +} + +export const networkDiagnosticsClient = { + async get(): Promise { + return normalizeSnapshot( + await invoke("service_network_diagnostics_get", withAddr()), + ); + }, + async refresh(): Promise { + return normalizeSnapshot( + await invoke("service_network_diagnostics_refresh", withAddr()), + ); + }, +}; diff --git a/apps/src/lib/api/transport-web-commands/account.ts b/apps/src/lib/api/transport-web-commands/account.ts index 5ce72cee8..fee63ea27 100644 --- a/apps/src/lib/api/transport-web-commands/account.ts +++ b/apps/src/lib/api/transport-web-commands/account.ts @@ -176,5 +176,9 @@ export function createAccountWebCommands(postWebRpc: WebRpcCaller): Record { +test("用量刷新完成后会同步账号实体状态且保留独立用量缓存", async () => { const source = await readSource("src/hooks/useAccounts.ts"); const invalidateUsageBody = readConstFunctionBody(source, "invalidateUsageData"); + const refreshAccountStart = source.indexOf("const refreshAccountMutation"); + const refreshAllStart = source.indexOf("const refreshAllMutation"); + const tokenRefreshStart = source.indexOf("const refreshTokensMutation"); + const refreshAccountBody = source.slice(refreshAccountStart, refreshAllStart); + const refreshAllBody = source.slice(refreshAllStart, tokenRefreshStart); assert.match( source, @@ -42,12 +47,10 @@ test("账号实体列表不会被用量刷新路径自动打空", async () => { assert.doesNotMatch(invalidateUsageBody, /queryKey:\s*\[\s*"accounts"/); assert.match( source, - /const refreshAccountMutation = useMutation\(\{[\s\S]*onSettled:\s*async \(\) => \{[\s\S]*await invalidateUsageData\(\);/, - ); - assert.match( - source, - /const refreshAllMutation = useMutation\(\{[\s\S]*onSettled:\s*async \(\) => \{[\s\S]*await invalidateUsageData\(\);/, + /const refreshVisibleUsageData = \(\) => \{[\s\S]*refetchQueries\(\{ queryKey: \["accounts", "list"\], type: "active" \}\)/, ); + assert.match(refreshAccountBody, /await invalidateAccountData\(\);/); + assert.match(refreshAllBody, /await invalidateAccountData\(\);/); }); test("账号页用启动快照作为账号实体列表的非空初始来源", async () => { diff --git a/apps/tests/transport-web-commands.test.mjs b/apps/tests/transport-web-commands.test.mjs index 05253a84f..fb015e788 100644 --- a/apps/tests/transport-web-commands.test.mjs +++ b/apps/tests/transport-web-commands.test.mjs @@ -436,6 +436,17 @@ test("createWebCommandMap 为长耗时 Web RPC 配置独立超时且不重试", }); }); +test("createWebCommandMap 映射出口网络诊断命令", () => { + assert.equal( + commandMap.service_network_diagnostics_get.rpcMethod, + "networkDiagnostics/get", + ); + assert.equal( + commandMap.service_network_diagnostics_refresh.rpcMethod, + "networkDiagnostics/refresh", + ); +}); + test("createWebCommandMap 为维护类 Web RPC 配置独立超时且不重试", () => { assert.deepEqual(commandMap.service_requestlog_clear.requestOptions, { timeoutMs: 60000, diff --git a/crates/service/src/account/account_status.rs b/crates/service/src/account/account_status.rs index e33dcbd9c..a311728d0 100644 --- a/crates/service/src/account/account_status.rs +++ b/crates/service/src/account/account_status.rs @@ -401,11 +401,13 @@ pub(crate) fn mark_account_unavailable_for_auth_error( }; match signal { AccountAvailabilitySignal::RefreshTokenRegionBlocked => { - set_account_unavailable_with_reason( + let changed = set_account_unavailable_with_reason( storage, account_id, REFRESH_TOKEN_REGION_BLOCKED_REASON, - ) + ); + crate::network_diagnostics::notify_region_blocked(); + changed } AccountAvailabilitySignal::RefreshToken(reason) => { let status_reason = format!("refresh_token_invalid:{}", reason.as_code()); @@ -436,11 +438,13 @@ pub(crate) fn mark_account_unavailable_for_refresh_token_error( ) -> bool { match classify_account_availability_signal(err) { Some(AccountAvailabilitySignal::RefreshTokenRegionBlocked) => { - set_account_unavailable_with_reason( + let changed = set_account_unavailable_with_reason( storage, account_id, REFRESH_TOKEN_REGION_BLOCKED_REASON, - ) + ); + crate::network_diagnostics::notify_region_blocked(); + changed } Some(AccountAvailabilitySignal::RefreshToken(reason)) => { let status_reason = format!("refresh_token_invalid:{}", reason.as_code()); diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index d0f790e22..454bd58e3 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -15,6 +15,7 @@ mod lifecycle; mod log_redaction; mod logging; mod model_groups; +mod network_diagnostics; mod plugin; mod quota; mod requestlog; @@ -145,6 +146,7 @@ pub use lifecycle::bootstrap::{initialize_storage_if_needed, portable}; pub use lifecycle::shutdown::{clear_shutdown_flag, request_shutdown, shutdown_requested}; pub use lifecycle::startup::{start_one_shot_server, start_server, ServerHandle}; pub use logging::init_logging; +pub use network_diagnostics::{network_diagnostics_get, network_diagnostics_refresh}; pub use rpc_actor::{RpcActor, ROLE_ADMIN, ROLE_MEMBER, ROLE_SYSTEM_ADMIN}; pub use usage_refresh::{set_usage_refresh_completed_handler, UsageRefreshCompletedEvent}; diff --git a/crates/service/src/lifecycle/startup.rs b/crates/service/src/lifecycle/startup.rs index a436b015c..c2527d615 100644 --- a/crates/service/src/lifecycle/startup.rs +++ b/crates/service/src/lifecycle/startup.rs @@ -78,6 +78,7 @@ pub fn start_server(addr: &str) -> std::io::Result<()> { // 版本信息已从持久化存储恢复(sync_runtime_settings_from_storage), // 网络拉取完全交给后台线程异步首刷,避免阻塞启动主路径。 crate::app_settings::ensure_codex_latest_version_sync(); + crate::network_diagnostics::ensure_network_diagnostics(); crate::usage_refresh::ensure_usage_polling(); crate::usage_refresh::ensure_gateway_keepalive(); crate::usage_refresh::ensure_token_refresh_polling(); diff --git a/crates/service/src/network_diagnostics.rs b/crates/service/src/network_diagnostics.rs new file mode 100644 index 000000000..f18c27e40 --- /dev/null +++ b/crates/service/src/network_diagnostics.rs @@ -0,0 +1,670 @@ +use codexmanager_core::storage::now_ts; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::Value; +use std::net::IpAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const ENV_ENABLED: &str = "CODEXMANAGER_IP_DIAGNOSTICS_ENABLED"; +const ENV_CACHE_TTL_SECS: &str = "CODEXMANAGER_IP_DIAGNOSTICS_CACHE_TTL_SECS"; +const ENV_REGION_THROTTLE_SECS: &str = "CODEXMANAGER_IP_DIAGNOSTICS_REGION_THROTTLE_SECS"; +const ENV_BLOCKED_INTERVAL_SECS: &str = "CODEXMANAGER_IP_DIAGNOSTICS_BLOCKED_INTERVAL_SECS"; +const ENV_SOURCE_TIMEOUT_MS: &str = "CODEXMANAGER_IP_DIAGNOSTICS_SOURCE_TIMEOUT_MS"; +const ENV_TOTAL_TIMEOUT_MS: &str = "CODEXMANAGER_IP_DIAGNOSTICS_TOTAL_TIMEOUT_MS"; + +const DEFAULT_CACHE_TTL_SECS: u64 = 1_800; +const DEFAULT_REGION_THROTTLE_SECS: u64 = 300; +const DEFAULT_BLOCKED_INTERVAL_SECS: u64 = 1_800; +const DEFAULT_SOURCE_TIMEOUT_MS: u64 = 5_000; +const DEFAULT_TOTAL_TIMEOUT_MS: u64 = 12_000; +const MANUAL_REFRESH_MIN_INTERVAL_SECS: i64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IpServiceKind { + IpSb, + IpApiCo, + IpWhoIs, + GeoJs, +} + +#[derive(Debug, Clone, Copy)] +struct IpService { + name: &'static str, + url: &'static str, + kind: IpServiceKind, +} + +const IP_SERVICES: &[IpService] = &[ + IpService { + name: "ip_sb", + url: "https://api.ip.sb/geoip", + kind: IpServiceKind::IpSb, + }, + IpService { + name: "ipapi_co", + url: "https://ipapi.co/json", + kind: IpServiceKind::IpApiCo, + }, + IpService { + name: "ipwho_is", + url: "https://ipwho.is/", + kind: IpServiceKind::IpWhoIs, + }, + IpService { + name: "geojs", + url: "https://get.geojs.io/v1/ip/geo.json", + kind: IpServiceKind::GeoJs, + }, +]; + +#[derive(Debug, Clone)] +struct NetworkDiagnosticRecord { + ip: String, + country_code: Option, + country: Option, + asn: Option, + organization: Option, + checked_at: i64, + source: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkDiagnosticsView { + enabled: bool, + refreshing: bool, + refresh_scheduled: bool, + ip: Option, + country_code: Option, + country: Option, + asn: Option, + organization: Option, + checked_at: Option, + last_attempt_at: Option, + source: Option, + error: Option, +} + +#[derive(Debug, Default)] +struct DiagnosticsInner { + last_success: Option, + last_error: Option, + last_attempt_at: Option, + last_region_trigger_at: Option, + next_source_index: usize, + refreshing: bool, +} + +#[derive(Debug, Default)] +struct DiagnosticsState { + inner: Mutex, +} + +#[derive(Debug, Clone, Copy)] +enum RefreshReason { + Startup, + CacheStale, + RegionBlocked, + BlockedFallback, + Manual, +} + +impl RefreshReason { + fn as_str(self) -> &'static str { + match self { + Self::Startup => "startup", + Self::CacheStale => "cache_stale", + Self::RegionBlocked => "region_blocked", + Self::BlockedFallback => "blocked_fallback", + Self::Manual => "manual", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct DiagnosticsConfig { + enabled: bool, + cache_ttl_secs: u64, + region_throttle_secs: u64, + blocked_interval_secs: u64, + source_timeout_ms: u64, + total_timeout_ms: u64, +} + +#[derive(Debug)] +struct AttemptError { + message: String, + retryable: bool, +} + +static DIAGNOSTICS_STATE: OnceLock> = OnceLock::new(); +static FALLBACK_SCHEDULER_STARTED: AtomicBool = AtomicBool::new(false); + +fn diagnostics_state() -> Arc { + DIAGNOSTICS_STATE + .get_or_init(|| Arc::new(DiagnosticsState::default())) + .clone() +} + +/// 启动出口网络诊断后台任务。 +/// +/// 启动检查和阻断兜底都在独立线程执行,不阻塞服务监听与网关请求路径。 +pub(crate) fn ensure_network_diagnostics() { + let config = diagnostics_config(); + if !config.enabled { + return; + } + let _ = request_refresh(RefreshReason::Startup); + if FALLBACK_SCHEDULER_STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(err) = std::thread::Builder::new() + .name("ip-diagnostics-fallback".to_string()) + .spawn(move || loop { + let interval = diagnostics_config().blocked_interval_secs; + std::thread::sleep(Duration::from_secs(interval)); + if crate::shutdown_requested() { + break; + } + if has_region_blocked_accounts() { + let _ = request_refresh(RefreshReason::BlockedFallback); + } + }) + { + FALLBACK_SCHEDULER_STARTED.store(false, Ordering::SeqCst); + log::warn!("event=ip_diagnostics_scheduler_start_failed err={err}"); + } +} + +/// 在上游明确返回区域阻断后,节流触发一次出口诊断。 +/// +/// 本函数只收集诊断信息,绝不修改账号状态。 +pub(crate) fn notify_region_blocked() { + let _ = request_refresh(RefreshReason::RegionBlocked); +} + +/// 读取当前出口诊断快照,并在缓存过期时异步补刷。 +pub fn network_diagnostics_get() -> NetworkDiagnosticsView { + let scheduled = request_refresh(RefreshReason::CacheStale); + build_view(scheduled) +} + +/// 请求手动刷新出口诊断,立即返回当前快照和调度状态。 +pub fn network_diagnostics_refresh() -> NetworkDiagnosticsView { + let scheduled = request_refresh(RefreshReason::Manual); + build_view(scheduled) +} + +fn request_refresh(reason: RefreshReason) -> bool { + let config = diagnostics_config(); + if !config.enabled { + return false; + } + let state = diagnostics_state(); + let now = now_ts(); + let start_index = { + let mut inner = crate::lock_utils::lock_recover(&state.inner, "ip_diagnostics_state"); + if inner.refreshing || !refresh_due(&inner, reason, now, config) { + return false; + } + inner.refreshing = true; + inner.last_attempt_at = Some(now); + if matches!(reason, RefreshReason::RegionBlocked) { + inner.last_region_trigger_at = Some(now); + } + let start_index = inner.next_source_index % IP_SERVICES.len(); + inner.next_source_index = (start_index + 1) % IP_SERVICES.len(); + start_index + }; + + let state_for_worker = Arc::clone(&state); + let spawn_result = std::thread::Builder::new() + .name("ip-diagnostics-query".to_string()) + .spawn(move || { + let result = query_network_diagnostics(start_index, config); + let mut inner = + crate::lock_utils::lock_recover(&state_for_worker.inner, "ip_diagnostics_state"); + inner.refreshing = false; + match result { + Ok(record) => { + log::info!( + "event=ip_diagnostics_succeeded reason={} source={} country_code={} asn={}", + reason.as_str(), + record.source, + record.country_code.as_deref().unwrap_or("unknown"), + record.asn.unwrap_or_default() + ); + inner.last_success = Some(record); + inner.last_error = None; + } + Err(err) => { + log::warn!( + "event=ip_diagnostics_failed reason={} err={}", + reason.as_str(), + err + ); + inner.last_error = Some(err); + } + } + }); + + if let Err(err) = spawn_result { + let mut inner = crate::lock_utils::lock_recover(&state.inner, "ip_diagnostics_state"); + inner.refreshing = false; + inner.last_error = Some("无法启动出口诊断后台任务".to_string()); + log::warn!("event=ip_diagnostics_worker_start_failed err={err}"); + return false; + } + true +} + +fn refresh_due( + inner: &DiagnosticsInner, + reason: RefreshReason, + now: i64, + config: DiagnosticsConfig, +) -> bool { + let elapsed_since_attempt = inner + .last_attempt_at + .map(|last| now.saturating_sub(last)) + .unwrap_or(i64::MAX); + match reason { + RefreshReason::Startup => inner.last_attempt_at.is_none(), + RefreshReason::Manual => elapsed_since_attempt >= MANUAL_REFRESH_MIN_INTERVAL_SECS, + RefreshReason::RegionBlocked => inner + .last_region_trigger_at + .map(|last| now.saturating_sub(last) >= config.region_throttle_secs as i64) + .unwrap_or(true), + RefreshReason::CacheStale => elapsed_since_attempt >= config.cache_ttl_secs as i64, + RefreshReason::BlockedFallback => { + elapsed_since_attempt >= config.blocked_interval_secs as i64 + } + } +} + +fn build_view(refresh_scheduled: bool) -> NetworkDiagnosticsView { + let config = diagnostics_config(); + let state = diagnostics_state(); + let inner = crate::lock_utils::lock_recover(&state.inner, "ip_diagnostics_state"); + let record = inner.last_success.as_ref(); + NetworkDiagnosticsView { + enabled: config.enabled, + refreshing: inner.refreshing, + refresh_scheduled, + ip: record.map(|value| value.ip.clone()), + country_code: record.and_then(|value| value.country_code.clone()), + country: record.and_then(|value| value.country.clone()), + asn: record.and_then(|value| value.asn), + organization: record.and_then(|value| value.organization.clone()), + checked_at: record.map(|value| value.checked_at), + last_attempt_at: inner.last_attempt_at, + source: record.map(|value| value.source.clone()), + error: inner.last_error.clone(), + } +} + +fn query_network_diagnostics( + start_index: usize, + config: DiagnosticsConfig, +) -> Result { + let client = crate::gateway::fresh_upstream_client(); + let started_at = Instant::now(); + let total_timeout = Duration::from_millis(config.total_timeout_ms); + let mut last_error = "没有可用的出口诊断服务".to_string(); + + for offset in 0..IP_SERVICES.len() { + let service = IP_SERVICES[(start_index + offset) % IP_SERVICES.len()]; + for attempt in 0..=1 { + let Some(remaining) = total_timeout.checked_sub(started_at.elapsed()) else { + return Err("出口诊断超过总超时".to_string()); + }; + if remaining < Duration::from_millis(100) { + return Err("出口诊断超过总超时".to_string()); + } + let request_timeout = remaining.min(Duration::from_millis(config.source_timeout_ms)); + match query_service(&client, service, request_timeout) { + Ok(record) => return Ok(record), + Err(err) => { + last_error = format!("{}: {}", service.name, err.message); + if !err.retryable || attempt > 0 { + break; + } + } + } + } + } + Err(format!("所有出口诊断服务均失败({last_error})")) +} + +fn query_service( + client: &Client, + service: IpService, + timeout: Duration, +) -> Result { + let response = client + .get(service.url) + .header("accept", "application/json") + .header("user-agent", crate::gateway::current_codex_user_agent()) + .timeout(timeout) + .send() + .map_err(|err| AttemptError { + message: if err.is_timeout() { + "请求超时".to_string() + } else if err.is_connect() { + "连接失败".to_string() + } else { + "网络错误".to_string() + }, + retryable: true, + })?; + let status = response.status(); + if !status.is_success() { + return Err(AttemptError { + message: format!("HTTP {}", status.as_u16()), + retryable: status.is_server_error(), + }); + } + let payload = response.json::().map_err(|_| AttemptError { + message: "响应不是有效 JSON".to_string(), + retryable: false, + })?; + map_service_response(service, &payload).map_err(|message| AttemptError { + message, + retryable: false, + }) +} + +fn map_service_response( + service: IpService, + payload: &Value, +) -> Result { + let (ip, country_code, country, asn, organization) = match service.kind { + IpServiceKind::IpSb => ( + text_at(payload, &["ip"]), + text_at(payload, &["country_code"]), + text_at(payload, &["country"]), + asn_at(payload, &["asn"]), + first_text( + payload, + &[&["asn_organization"], &["organization"], &["isp"]], + ), + ), + IpServiceKind::IpApiCo => ( + text_at(payload, &["ip"]), + text_at(payload, &["country_code"]), + text_at(payload, &["country_name"]), + asn_at(payload, &["asn"]), + text_at(payload, &["org"]), + ), + IpServiceKind::IpWhoIs => ( + text_at(payload, &["ip"]), + text_at(payload, &["country_code"]), + text_at(payload, &["country"]), + asn_at(payload, &["connection", "asn"]), + first_text(payload, &[&["connection", "org"], &["connection", "isp"]]), + ), + IpServiceKind::GeoJs => ( + text_at(payload, &["ip"]), + text_at(payload, &["country_code"]), + text_at(payload, &["country"]), + asn_at(payload, &["asn"]), + text_at(payload, &["organization_name"]), + ), + }; + let ip = ip.ok_or_else(|| "响应缺少 IP".to_string())?; + if ip.parse::().is_err() { + return Err("响应包含无效 IP".to_string()); + } + Ok(NetworkDiagnosticRecord { + ip, + country_code: normalize_country_code(country_code), + country: normalize_text(country, 80), + asn, + organization: normalize_text(organization, 160), + checked_at: now_ts(), + source: service.name.to_string(), + }) +} + +fn text_at(payload: &Value, path: &[&str]) -> Option { + let mut current = payload; + for segment in path { + current = current.get(*segment)?; + } + match current { + Value::String(value) => normalize_text(Some(value.clone()), 256), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn first_text(payload: &Value, paths: &[&[&str]]) -> Option { + paths.iter().find_map(|path| text_at(payload, path)) +} + +fn asn_at(payload: &Value, path: &[&str]) -> Option { + let value = text_at(payload, path)?; + let normalized = value.trim().trim_start_matches(|character: char| { + character == 'A' || character == 'a' || character == 'S' || character == 's' + }); + normalized.parse::().ok().filter(|asn| *asn > 0) +} + +fn normalize_country_code(value: Option) -> Option { + let normalized = value?.trim().to_ascii_uppercase(); + if normalized.len() == 2 && normalized.bytes().all(|byte| byte.is_ascii_alphabetic()) { + Some(normalized) + } else { + None + } +} + +fn normalize_text(value: Option, max_chars: usize) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(max_chars).collect()) +} + +fn has_region_blocked_accounts() -> bool { + let Some(storage) = crate::storage_helpers::open_storage() else { + return false; + }; + let Ok(accounts) = storage.list_accounts() else { + return false; + }; + let account_ids = accounts + .into_iter() + .filter(|account| account.status.trim().eq_ignore_ascii_case("unavailable")) + .map(|account| account.id) + .collect::>(); + if account_ids.is_empty() { + return false; + } + storage + .latest_account_status_reasons(&account_ids) + .map(|reasons| { + reasons.values().any(|reason| { + reason.trim() == crate::account_status::REFRESH_TOKEN_REGION_BLOCKED_REASON + }) + }) + .unwrap_or(false) +} + +fn diagnostics_config() -> DiagnosticsConfig { + DiagnosticsConfig { + enabled: env_bool(ENV_ENABLED, !cfg!(test)), + cache_ttl_secs: env_u64(ENV_CACHE_TTL_SECS, DEFAULT_CACHE_TTL_SECS, 60, 86_400), + region_throttle_secs: env_u64( + ENV_REGION_THROTTLE_SECS, + DEFAULT_REGION_THROTTLE_SECS, + 30, + 3_600, + ), + blocked_interval_secs: env_u64( + ENV_BLOCKED_INTERVAL_SECS, + DEFAULT_BLOCKED_INTERVAL_SECS, + 300, + 21_600, + ), + source_timeout_ms: env_u64( + ENV_SOURCE_TIMEOUT_MS, + DEFAULT_SOURCE_TIMEOUT_MS, + 1_000, + 10_000, + ), + total_timeout_ms: env_u64( + ENV_TOTAL_TIMEOUT_MS, + DEFAULT_TOTAL_TIMEOUT_MS, + 2_000, + 30_000, + ), + } +} + +fn env_bool(key: &str, fallback: bool) -> bool { + std::env::var(key) + .ok() + .map(|value| match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => fallback, + }) + .unwrap_or(fallback) +} + +fn env_u64(key: &str, fallback: u64, min: u64, max: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(fallback) + .clamp(min, max) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn maps_service_specific_fields_without_location_details() { + let cases = [ + ( + IP_SERVICES[0], + json!({"ip":"203.0.113.1","country_code":"us","country":"United States","asn":64500,"asn_organization":"Example ASN","latitude":1.2,"longitude":3.4}), + ), + ( + IP_SERVICES[1], + json!({"ip":"2001:db8::1","country_code":"jp","country_name":"Japan","asn":"AS64501","org":"Example Org"}), + ), + ( + IP_SERVICES[2], + json!({"ip":"198.51.100.2","country_code":"de","country":"Germany","connection":{"asn":64502,"org":"Example ISP"}}), + ), + ( + IP_SERVICES[3], + json!({"ip":"192.0.2.3","country_code":"fr","country":"France","asn":"64503","organization_name":"Example Network"}), + ), + ]; + for (service, payload) in cases { + let record = map_service_response(service, &payload).expect("map response"); + assert_eq!(record.country_code.as_deref().map(str::len), Some(2)); + assert!(record.asn.unwrap_or_default() >= 64_500); + assert!(record.organization.is_some()); + } + } + + #[test] + fn rejects_invalid_ip_and_country_code() { + let invalid_ip = map_service_response( + IP_SERVICES[0], + &json!({"ip":"not-an-ip","country_code":"USA"}), + ); + assert_eq!(invalid_ip.unwrap_err(), "响应包含无效 IP"); + let record = map_service_response( + IP_SERVICES[0], + &json!({"ip":"203.0.113.9","country_code":"USA"}), + ) + .expect("valid record"); + assert_eq!(record.country_code, None); + } + + #[test] + fn refresh_due_applies_cache_manual_and_region_throttles() { + let config = DiagnosticsConfig { + enabled: true, + cache_ttl_secs: 1_000, + region_throttle_secs: 30, + blocked_interval_secs: 300, + source_timeout_ms: 5_000, + total_timeout_ms: 12_000, + }; + let inner = DiagnosticsInner { + last_attempt_at: Some(1_000), + last_region_trigger_at: Some(990), + ..DiagnosticsInner::default() + }; + assert!(!refresh_due( + &inner, + RefreshReason::CacheStale, + 1_050, + config + )); + assert!(refresh_due( + &inner, + RefreshReason::CacheStale, + 2_000, + config + )); + assert!(!refresh_due(&inner, RefreshReason::Manual, 1_004, config)); + assert!(refresh_due(&inner, RefreshReason::Manual, 1_005, config)); + assert!(!refresh_due( + &inner, + RefreshReason::RegionBlocked, + 1_019, + config + )); + assert!(refresh_due( + &inner, + RefreshReason::RegionBlocked, + 1_020, + config + )); + assert!(!refresh_due( + &inner, + RefreshReason::BlockedFallback, + 1_299, + config + )); + assert!(refresh_due( + &inner, + RefreshReason::BlockedFallback, + 1_300, + config + )); + } + + #[test] + fn service_rotation_uses_each_source_once_per_cycle() { + for start_index in 0..IP_SERVICES.len() { + let ordered = (0..IP_SERVICES.len()) + .map(|offset| IP_SERVICES[(start_index + offset) % IP_SERVICES.len()].name) + .collect::>(); + let mut unique = ordered.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), IP_SERVICES.len()); + assert_eq!(ordered[0], IP_SERVICES[start_index].name); + } + } + + #[test] + fn http_retry_policy_only_retries_server_errors() { + assert!(reqwest::StatusCode::BAD_GATEWAY.is_server_error()); + assert!(!reqwest::StatusCode::TOO_MANY_REQUESTS.is_server_error()); + assert!(!reqwest::StatusCode::FORBIDDEN.is_server_error()); + } +} diff --git a/crates/service/src/rpc_dispatch/mod.rs b/crates/service/src/rpc_dispatch/mod.rs index da8bed77c..24ea56527 100644 --- a/crates/service/src/rpc_dispatch/mod.rs +++ b/crates/service/src/rpc_dispatch/mod.rs @@ -18,6 +18,7 @@ mod codex_profile; mod dashboard; mod gateway; mod model_groups; +mod network_diagnostics; mod quota; mod requestlog; mod service_config; @@ -282,6 +283,9 @@ pub(crate) fn handle_request_with_actor(req: JsonRpcRequest, actor: RpcActor) -> if let Some(resp) = dashboard::try_handle(&req, &actor) { return JsonRpcMessage::Response(resp); } + if let Some(resp) = network_diagnostics::try_handle(&req) { + return JsonRpcMessage::Response(resp); + } if let Some(resp) = usage::try_handle(&req) { return JsonRpcMessage::Response(resp); } diff --git a/crates/service/src/rpc_dispatch/network_diagnostics.rs b/crates/service/src/rpc_dispatch/network_diagnostics.rs new file mode 100644 index 000000000..d91a58e80 --- /dev/null +++ b/crates/service/src/rpc_dispatch/network_diagnostics.rs @@ -0,0 +1,11 @@ +use codexmanager_core::rpc::types::{JsonRpcRequest, JsonRpcResponse}; + +/// 处理出口网络诊断 RPC。 +pub(super) fn try_handle(req: &JsonRpcRequest) -> Option { + let result = match req.method.as_str() { + "networkDiagnostics/get" => super::as_json(crate::network_diagnostics_get()), + "networkDiagnostics/refresh" => super::as_json(crate::network_diagnostics_refresh()), + _ => return None, + }; + Some(super::response(req, result)) +} diff --git a/crates/service/src/tests/lib_tests.rs b/crates/service/src/tests/lib_tests.rs index 79e51dd43..a5dd4dc27 100644 --- a/crates/service/src/tests/lib_tests.rs +++ b/crates/service/src/tests/lib_tests.rs @@ -135,6 +135,8 @@ fn member_actor_cannot_call_admin_only_rpc() { "accountManager/users/list", "codexProfile/repairHistory", "codexProfile/pruneHistoryBackups", + "networkDiagnostics/get", + "networkDiagnostics/refresh", ] { let req = JsonRpcRequest { id: 21.into(), diff --git a/crates/service/src/usage/refresh/errors.rs b/crates/service/src/usage/refresh/errors.rs index 683107075..0ab8b3eea 100644 --- a/crates/service/src/usage/refresh/errors.rs +++ b/crates/service/src/usage/refresh/errors.rs @@ -69,6 +69,9 @@ pub(super) fn record_usage_refresh_failure(storage: &Storage, account_id: &str, /// # 返回 /// 无 pub(super) fn mark_usage_unreachable_if_needed(storage: &Storage, account_id: &str, err: &str) { + if crate::usage_http::is_region_blocked_error_message(err) { + crate::network_diagnostics::notify_region_blocked(); + } if mark_account_unavailable_for_refresh_token_error(storage, account_id, err) { return; } diff --git "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" index a1bb0b9f8..08fc8ac22 100644 --- "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -148,6 +148,17 @@ - `CODEXMANAGER_HTTP_STREAM_WORKER_MIN` - `CODEXMANAGER_FRONT_PROXY_MAX_BLOCKING_THREADS`:前端代理 runtime 的 blocking 线程上限,默认跟随存储连接池上限且不超过 `32`。 +### 出口网络诊断 + +- `CODEXMANAGER_IP_DIAGNOSTICS_ENABLED`:是否启用出口网络诊断,默认开启。诊断会沿用当前 OpenAI 上游代理,但只记录排障快照,不参与账号 `active` / `unavailable` 裁决。 +- `CODEXMANAGER_IP_DIAGNOSTICS_CACHE_TTL_SECS`:成功或失败后再次自动查询的最短缓存时间,默认 `1800` 秒,取值按 `60` 到 `86400` 秒收敛。 +- `CODEXMANAGER_IP_DIAGNOSTICS_REGION_THROTTLE_SECS`:收到明确区域阻断信号后再次触发诊断的节流时间,默认 `300` 秒,取值按 `30` 到 `3600` 秒收敛。 +- `CODEXMANAGER_IP_DIAGNOSTICS_BLOCKED_INTERVAL_SECS`:存在区域阻断账号时的低频兜底检查间隔,默认 `1800` 秒,取值按 `300` 到 `21600` 秒收敛。 +- `CODEXMANAGER_IP_DIAGNOSTICS_SOURCE_TIMEOUT_MS`:单个诊断服务单次请求超时,默认 `5000` 毫秒,取值按 `1000` 到 `10000` 毫秒收敛。 +- `CODEXMANAGER_IP_DIAGNOSTICS_TOTAL_TIMEOUT_MS`:一次多服务降级查询的总预算,默认 `12000` 毫秒,取值按 `2000` 到 `30000` 毫秒收敛。 + +诊断服务使用固定白名单和轮换起点,避免由自定义 URL 引入 SSRF;网络错误或 `5xx` 最多同源重试一次,`4xx` 与无效 JSON 直接切换下一个服务。普通日志不会写入完整 IP 或经纬度,管理员设置页才会显示必要的 IP、国家、ASN、检测时间、来源与错误。所有查询均在后台单飞执行,外部服务失败不会阻塞启动、账号刷新或网关请求。 + ### 存储与鉴权 - `CODEXMANAGER_DB_PATH` diff --git a/task.md b/task.md index a8a1f4835..59118460f 100644 --- a/task.md +++ b/task.md @@ -9,6 +9,7 @@ - 刷新(✅ 子项已完成):将可能误判的 `refresh_token_expired` 调整为长退避低频复检;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;后台轮询跳过 disabled/banned。 - 区域诊断:参考 Clash Verge 多 IP 服务映射与失败切换,但仅用于出口诊断;启动检查、区域阻断事件触发检查和低频兜底均不得直接替代上游接口判定。 - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 + - 子任务进度:出口诊断缓存/RPC/设置页展示与 `accounts/list` 刷新同步已实现,正在执行主代理独立审计与集成门禁。 - 主代理负责对子代理补丁独立审计、集成测试与 PR 更新。 - 定价与刷新子项已完成主代理审计集成,等待区域诊断与前端同步收口。 From 2c5079096e5c31d61b3a19532c736c63211aa0d8 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:37:19 +0800 Subject: [PATCH 12/35] =?UTF-8?q?=E5=9B=BD=E9=99=85=E5=8C=96:=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=87=BA=E5=8F=A3=E8=AF=8A=E6=96=AD=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\214\346\255\245_\350\277\233\345\272\246.md" | 1 + apps/src/lib/i18n/messages/en.ts | 16 ++++++++++++++++ apps/src/lib/i18n/messages/ko.ts | 16 ++++++++++++++++ apps/src/lib/i18n/messages/ru.ts | 16 ++++++++++++++++ .../lib/i18n/messages/sections/en-accounts.ts | 2 ++ .../lib/i18n/messages/sections/ko-accounts.ts | 2 ++ .../lib/i18n/messages/sections/ru-accounts.ts | 2 ++ 7 files changed, 55 insertions(+) diff --git "a/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" "b/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" index 5375eafb4..c9cb03230 100644 --- "a/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" +++ "b/.teamwork/progress/2026-07-11_\345\207\272\345\217\243\350\257\212\346\226\255\344\270\216\345\211\215\347\253\257\345\220\214\346\255\245_\350\277\233\345\272\246.md" @@ -18,6 +18,7 @@ - `cargo test -p codexmanager-service member_actor_cannot_call_admin_only_rpc --lib -j1`:1 项通过。 - `node --test tests/account-list-cache.test.mjs tests/transport-web-commands.test.mjs tests/tauri-command-registry.test.mjs`:23 项通过。 - `apps/node_modules/.bin/tsc.CMD --noEmit`:通过(临时复用主工作树依赖,未复制依赖目录)。 +- `node --test tests/i18n-page-coverage.test.mjs`:3 项通过,出口诊断与刷新完成提示的英/韩/俄文案已补齐。 ## 待主审 diff --git a/apps/src/lib/i18n/messages/en.ts b/apps/src/lib/i18n/messages/en.ts index 80779a996..06b7c764e 100644 --- a/apps/src/lib/i18n/messages/en.ts +++ b/apps/src/lib/i18n/messages/en.ts @@ -31,6 +31,22 @@ export const EN_MESSAGES: MessageCatalog = { ...EN_MODELS_MESSAGES, ...EN_PLATFORM_MODE_MESSAGES, ...EN_RUNTIME_UI_MESSAGES, + "出口 IP": "Egress IP", + 出口网络诊断: "Egress network diagnostics", + 出口诊断刷新失败: "Failed to refresh egress diagnostics", + 国家或地区: "Country or region", + 已使用最近的出口诊断结果: "Using the latest cached egress diagnostics", + 已关闭: "Disabled", + 已开始刷新出口诊断: "Egress diagnostics refresh started", + 手动刷新: "Refresh manually", + "查询沿用当前 OpenAI 上游代理;外部诊断服务失败不会影响账号刷新或网关请求。": + "The query uses the current OpenAI upstream proxy. External diagnostic service failures do not affect account refreshes or gateway requests.", + "检测中...": "Checking...", + 检测时间: "Checked at", + 检测来源: "Source", + "用于核对当前服务出口,不会根据 IP 结果自动改变账号状态。": + "Used to verify the current service egress. The IP result never changes account status automatically.", + 网络组织: "Network organization", 模型与路由: "Models & Routing", 成员管理: "Member Management", 运行观测: "Observability", diff --git a/apps/src/lib/i18n/messages/ko.ts b/apps/src/lib/i18n/messages/ko.ts index 7bca0b66c..141057f51 100644 --- a/apps/src/lib/i18n/messages/ko.ts +++ b/apps/src/lib/i18n/messages/ko.ts @@ -31,6 +31,22 @@ export const KO_MESSAGES: MessageCatalog = { ...KO_MODELS_MESSAGES, ...KO_PLATFORM_MODE_MESSAGES, ...KO_RUNTIME_UI_MESSAGES, + "出口 IP": "출구 IP", + 出口网络诊断: "출구 네트워크 진단", + 出口诊断刷新失败: "출구 진단 새로고침 실패", + 国家或地区: "국가 또는 지역", + 已使用最近的出口诊断结果: "최근 출구 진단 결과를 사용했습니다", + 已关闭: "비활성화됨", + 已开始刷新出口诊断: "출구 진단 새로고침을 시작했습니다", + 手动刷新: "수동 새로고침", + "查询沿用当前 OpenAI 上游代理;外部诊断服务失败不会影响账号刷新或网关请求。": + "조회는 현재 OpenAI 업스트림 프록시를 사용합니다. 외부 진단 서비스 실패는 계정 새로고침이나 게이트웨이 요청에 영향을 주지 않습니다.", + "检测中...": "진단 중...", + 检测时间: "진단 시간", + 检测来源: "진단 소스", + "用于核对当前服务出口,不会根据 IP 结果自动改变账号状态。": + "현재 서비스의 출구를 확인하는 용도이며, IP 결과에 따라 계정 상태를 자동으로 변경하지 않습니다.", + 网络组织: "네트워크 조직", 模型与路由: "모델 및 라우팅", 成员管理: "멤버 관리", 运行观测: "운영 관측", diff --git a/apps/src/lib/i18n/messages/ru.ts b/apps/src/lib/i18n/messages/ru.ts index 2e462a975..b8ea3e5f9 100644 --- a/apps/src/lib/i18n/messages/ru.ts +++ b/apps/src/lib/i18n/messages/ru.ts @@ -31,6 +31,22 @@ export const RU_MESSAGES: MessageCatalog = { ...RU_MODELS_MESSAGES, ...RU_PLATFORM_MODE_MESSAGES, ...RU_RUNTIME_UI_MESSAGES, + "出口 IP": "Исходящий IP", + 出口网络诊断: "Диагностика исходящей сети", + 出口诊断刷新失败: "Не удалось обновить диагностику исходящей сети", + 国家或地区: "Страна или регион", + 已使用最近的出口诊断结果: "Использован последний результат диагностики исходящей сети", + 已关闭: "Отключено", + 已开始刷新出口诊断: "Обновление диагностики исходящей сети запущено", + 手动刷新: "Обновить вручную", + "查询沿用当前 OpenAI 上游代理;外部诊断服务失败不会影响账号刷新或网关请求。": + "Запрос использует текущий upstream-прокси OpenAI. Сбои внешних сервисов диагностики не влияют на обновление аккаунтов и запросы gateway.", + "检测中...": "Диагностика...", + 检测时间: "Время проверки", + 检测来源: "Источник проверки", + "用于核对当前服务出口,不会根据 IP 结果自动改变账号状态。": + "Используется для проверки текущего исходящего соединения сервиса. Результат IP не меняет статус аккаунта автоматически.", + 网络组织: "Сетевая организация", 模型与路由: "Модели и маршрутизация", 成员管理: "Участники", 运行观测: "Наблюдаемость", diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index 0797d49b5..6290954e5 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -14,6 +14,8 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "AT/RT refresh completed: {success} succeeded, {skipped} skipped", "AT/RT 过期、用量接口 401/403 等不可用账号": "Unavailable accounts such as expired AT/RT or usage API 401/403", + "账号用量刷新完成:已处理{processed}/{total}": + "Account usage refresh completed: processed {processed}/{total}", "Refresh Token 失效,需要重新登录": "Refresh token is invalid. Log in again.", "Refresh Token 已被撤销,需要重新登录": "Refresh token was revoked. Log in again.", "Refresh Token 已被重复使用,需要重新登录": diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index a11e1fb30..2ca8c7d39 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -14,6 +14,8 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "AT/RT 새로고침 완료: 성공 {success}개, 건너뜀 {skipped}개", "AT/RT 过期、用量接口 401/403 等不可用账号": "AT/RT 만료, 사용량 API 401/403 등으로 사용할 수 없는 계정", + "账号用量刷新完成:已处理{processed}/{total}": + "계정 사용량 새로고침 완료: {processed}/{total}개 처리됨", "Refresh Token 失效,需要重新登录": "Refresh Token이 유효하지 않습니다. 다시 로그인하세요.", "Refresh Token 已被撤销,需要重新登录": "Refresh Token이 취소되었습니다. 다시 로그인하세요.", "Refresh Token 已被重复使用,需要重新登录": diff --git a/apps/src/lib/i18n/messages/sections/ru-accounts.ts b/apps/src/lib/i18n/messages/sections/ru-accounts.ts index e39fe981c..d1844831f 100644 --- a/apps/src/lib/i18n/messages/sections/ru-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ru-accounts.ts @@ -14,6 +14,8 @@ export const RU_ACCOUNTS_MESSAGES: MessageCatalog = { "Обновление AT/RT завершено: успешно {success}, пропущено {skipped}", "AT/RT 过期、用量接口 401/403 等不可用账号": "Недоступные аккаунты: истекшие AT/RT, usage API 401/403 и подобные случаи", + "账号用量刷新完成:已处理{processed}/{total}": + "Обновление usage аккаунтов завершено: обработано {processed}/{total}", "Refresh Token 失效,需要重新登录": "Refresh Token недействителен. Войдите снова.", "Refresh Token 已被撤销,需要重新登录": "Refresh Token отозван. Войдите снова.", "Refresh Token 已被重复使用,需要重新登录": From 846413d7be4803fcfbb884f6b19232aeb0cc6cd1 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:52:42 +0800 Subject: [PATCH 13/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E4=B8=8E=E5=88=B7=E6=96=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .teamwork/sync/opus-to-gpt.md | 19 +++++++++++++++++++ .teamwork/sync/status.json | 14 +++++++------- crates/service/src/quota/model_pricing.rs | 1 + docs/zh-CN/CHANGELOG.md | 2 ++ task.md | 8 ++++---- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.teamwork/sync/opus-to-gpt.md b/.teamwork/sync/opus-to-gpt.md index a49fd5bc2..83fcfd7c9 100644 --- a/.teamwork/sync/opus-to-gpt.md +++ b/.teamwork/sync/opus-to-gpt.md @@ -32,3 +32,22 @@ ## 审计结论 修复范围与审计问题一致,未发现需阻止提交 PR 的剩余代码问题。历史半日查询精度限制已记录为存储粒度约束。 + +## 定价、刷新与出口诊断补充交付 + +执行身份:【CodeX-GPT】子代理组;主审身份:【CodeX-GPT】主代理。 + +- 定价:按最终 `effective_service_tier` 区分 Standard / Priority,逐模型录入 Priority 价格,HTTP 与 WebSocket 共用最终 tier 计费。 +- 刷新:`refresh_token_expired` 改为长冷却低频复检;Token 刷新成功后立即排队真实用量验证,验证成功后由快照状态机恢复账号状态。 +- 区域诊断:实现多来源出口 IP 查询、失败切换、启动首检、区域阻断事件触发与低频兜底;诊断结果不直接修改账号状态。 +- 前端:刷新事件及手动刷新完成后重新拉取账号列表,管理员设置页展示出口诊断,并补齐多语言文案。 + +主代理独立验证: + +- `cargo fmt --all --check`:通过。 +- `cargo check -p codexmanager-service`:通过,无 dead_code 警告。 +- `git diff --check`:通过。 +- 定价、最终 tier、出口诊断、Token 恢复和 Refresh expired 定向测试均通过。 +- 前端 runtime 114/114,Next.js Turbopack 构建通过并生成 15 个静态页面。 + +最终审计结论:PASS,可以提交并更新 PR。 diff --git a/.teamwork/sync/status.json b/.teamwork/sync/status.json index 2da9c37b8..5cd7940d7 100644 --- a/.teamwork/sync/status.json +++ b/.teamwork/sync/status.json @@ -1,17 +1,17 @@ { - "status": "opus_working", + "status": "completed", "task": "pricing-refresh-followups-20260711", "created_at": "2026-07-11T17:20:00+08:00", - "last_update": "2026-07-11T17:20:00+08:00", + "last_update": "2026-07-11T18:10:00+08:00", "iteration": 1, "max_iterations": 3, "last_actor": "CodeX-GPT", - "current_agent": "pricing-refresh-ip-subagents", - "next_agent": "CodeX-GPT", + "current_agent": "CodeX-GPT", + "next_agent": null, "workflow": "定价分层、刷新恢复与区域诊断并行修复", "description": "修复 Priority 计费、刷新状态恢复、区域诊断与前端状态同步", "priority": "P0", - "phase": "implementation", - "audit_result": null, - "tests_status": "pending" + "phase": "completed", + "audit_result": "PASS", + "tests_status": "PASS" } diff --git a/crates/service/src/quota/model_pricing.rs b/crates/service/src/quota/model_pricing.rs index b4731a649..2bb134c6b 100644 --- a/crates/service/src/quota/model_pricing.rs +++ b/crates/service/src/quota/model_pricing.rs @@ -949,6 +949,7 @@ fn estimate_cost_from_price( } } +#[cfg(test)] pub(crate) fn estimate_cost( model: Option<&str>, input_tokens: i64, diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 070e6b03d..90985b7f5 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### Changed +- 新增出口网络诊断:启动时异步首检,区域阻断事件触发节流检查,并在存在区域阻断账号时低频兜底;诊断按固定白名单 IP 服务失败切换,沿用上游代理,仅向管理员展示缓存后的 IP、国家与 ASN 信息,不直接改变账号状态。 - Docker Compose 与多语言部署文档统一使用 `ghcr.io/creatoredition` 镜像;`db-optimize` 改为必须显式指定数据库路径、默认只读检查,并仅在传入 `--vacuum` 时执行 checkpoint/VACUUM。 - Dashboard 日级趋势、用户排行和来源统计改用真实本地自然日边界,夏令时切换日按 23/25 小时聚合;后台维护只处理本批真实存在待汇总明细的日期,避免稀疏多年历史产生大量空事务。 - Dashboard 与请求日志页不再按“账号直连模式”遮挡统计、追加“仅网关流量”标签或引导切换模式;CodexManager 的账号与聚合 API 可混合路由,页面统一展示服务实际记录的数据。 @@ -27,6 +28,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- `refresh_token_expired` 不再被永久判定为不可恢复,而是在长冷却后低频复检;Token 刷新成功后会立即排队真实用量验证,并由用量快照恢复账号状态。账号页在用量刷新事件及手动刷新完成后重新拉取账号列表,避免状态列长期停留在旧值。 - 请求费用估算改为在解析最终 `effective_service_tier` 后选择 Standard / Priority 价格,HTTP 与 Responses WebSocket 共用同一计费口径;`fast` 按 Priority,空值、`auto`、`default` 和未知 tier 保守按 Standard。模型价格规则现会实际匹配 `billingMode`,同一模型可同时维护不同服务等级价格;新版种子逐模型补齐官方 Priority 价格及 GPT-4.1/4o 特异 Standard 规则,不对历史账单自动重算。 - 补齐 Web 运行壳遗漏的命令映射及完整性门禁,移除错误的模型价格重复 RPC 映射;Tauri 生产构建不再因旧静态产物存在而跳过重新生成,并把 `/platform-mode` 纳入根页面校验。 - 账号单删、批删和状态清理成功后立即失效网关候选缓存;候选快照与 single-flight 按数据库和低额度模式隔离,避免交替模式互相驱逐或删除后继续选中旧凭据。 diff --git a/task.md b/task.md index 59118460f..0bef7467b 100644 --- a/task.md +++ b/task.md @@ -4,14 +4,14 @@ ## 当前待处理(2026-07-07) -0. P0 定价分层与刷新状态补充修复(🔄 进行中) +0. P0 定价分层与刷新状态补充修复(✅ 已完成) - 定价(✅ 子项已完成):按请求最终 `effective_service_tier` 区分 Standard / Priority,`fast` 归入 Priority,空值/`auto`/`default` 与未知值保守回退 Standard;补齐官方逐模型 Priority 种子、`billing_mode` 匹配和 HTTP/WS 回归测试。历史费用不重算。 - 刷新(✅ 子项已完成):将可能误判的 `refresh_token_expired` 调整为长退避低频复检;Token 刷新成功后立即触发用量验证并缩短状态恢复延迟;后台轮询跳过 disabled/banned。 - 区域诊断:参考 Clash Verge 多 IP 服务映射与失败切换,但仅用于出口诊断;启动检查、区域阻断事件触发检查和低频兜底均不得直接替代上游接口判定。 - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 - - 子任务进度:出口诊断缓存/RPC/设置页展示与 `accounts/list` 刷新同步已实现,正在执行主代理独立审计与集成门禁。 - - 主代理负责对子代理补丁独立审计、集成测试与 PR 更新。 - - 定价与刷新子项已完成主代理审计集成,等待区域诊断与前端同步收口。 + - 子任务进度:出口诊断缓存/RPC/设置页展示与 `accounts/list` 刷新同步已实现并通过主代理独立审计。 + - 主代理已完成子代理补丁独立审计、定向测试、前端构建与集成门禁。 + - 定价、刷新、区域诊断与前端同步均已收口,等待 PR 合并后从当前看板移除。 1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 From ede7e5606a6b332c4cecdd823e7d3fc1c83ccde8 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:49:05 +0800 Subject: [PATCH 14/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8v0.5.0=E5=B7=AE=E5=BC=82=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E4=B8=8E=E7=A7=BB=E6=A4=8D=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\274\202\345\267\241\346\243\200.md" | 68 +++++++++++++++++++ task.md | 16 ++++- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 ".teamwork/discussions/2026-07-25_\344\270\212\346\270\270v0.5.0\345\267\256\345\274\202\345\267\241\346\243\200.md" diff --git "a/.teamwork/discussions/2026-07-25_\344\270\212\346\270\270v0.5.0\345\267\256\345\274\202\345\267\241\346\243\200.md" "b/.teamwork/discussions/2026-07-25_\344\270\212\346\270\270v0.5.0\345\267\256\345\274\202\345\267\241\346\243\200.md" new file mode 100644 index 000000000..78b4e2d39 --- /dev/null +++ "b/.teamwork/discussions/2026-07-25_\344\270\212\346\270\270v0.5.0\345\267\256\345\274\202\345\267\241\346\243\200.md" @@ -0,0 +1,68 @@ +# 2026-07-25 上游 v0.5.0 差异巡检结论 + +## 巡检背景 + +- 旧基准:`upstream/main = a614b559`(记录于 2026-07-07 差异结论)。 +- 新基准:`upstream/main = e630b349 修改nginx的配置`(对应 tag v0.5.0,2026-07-23)。 +- 增量规模:`a614b559..e630b349` 新增 **223 个提交**(含 merge);总分叉 upstream 领先 CE 420、CE 领先 upstream 284。 +- CE 定位不变:本地桌面端 + service/web 的 Codex 账号池管理器 + 网关转发;剥离作者推广、赞助导流、远程 author content、AtomGit 推广、发行渠道宣传。 +- 合并策略不变:只做语义移植,不直接 merge upstream,逐提交按"业务能力 / UI 样式 / 推广内容 / 发行渠道"拆分。 + +## CE 现状核对(已读代码确认) + +| 能力 | CE 现状 | 上游现状 | +| --- | --- | --- | +| 模型目录 | 自研 `model_catalog_models`(迁移 047)+ `model_price_rules` / `model_sources` / `model_groups` / `model_options` | `model_catalog_v2` 事务存储 + 不可变整数价格快照 | +| 账号级代理 | 无 proxy 模块 | `account_proxy` + `proxy_testing/jobs` + ipwhois 地理 | +| Codex Skills | `cm-skills/cm-imagegen` 仅图片生成 | skills marketplace / 仓库安装 / skills.sh | +| device_code 登录 | 已有(20 处,auth_login/auth_tokens/rpc) | 有增量 | +| sub2api 导入 | 已有(account_import + add-account-modal) | 新 JSON 格式 + agent_identities 加固 | +| 网关 aggregate/proxy 语义 | 已有(protocol/aggregate_api、proxy.rs、candidates) | hybrid rotation 尊重纯聚合路由 | +| Lite 模式图片工具冲突 | `codex_headers.rs` 未处理 | 已修 | +| reset credit / hourly usage / automatic update / codex_projects / AboutCodexManagerCard | 均无 | 均有 | + +## 分类清单 + +### 🟢 可复用(低风险语义移植,本轮执行) + +| 上游提交 | 说明 | 置信度 | +| --- | --- | --- | +| `22a47d89` | avoid Responses Lite conflict with auto-injected image tool,纯网关 header 修复(42 行),CE 缺失 | 高 | +| `a127e27a` | honor aggregate-only routes in hybrid rotation,CE 有 aggregate/proxy 语义,修复混合轮换绕过纯聚合路由 | 中 | +| `2c61d38e` / `afe86293` | 桌面 dev server 清理 / NVM 环境保留,启动器健壮性小项 | 中 | + +### 🟡 需移植(有对应架构但命名/结构不同,需语义改写) + +| 上游提交 | 说明 | 置信度 | +| --- | --- | --- | +| `2c1fa090` + `715eb90a` | GPT-5.6 官方定价对齐 + GPT Image 2,CE 走自研 `model_price_rules`,需按 CE 结构补种子,不照搬 v2 迁移 | 高 | +| `692ab34e` + `482f7ffa` | 新 sub2api JSON 格式 / 加固导入,CE 有 sub2api 导入,但上游依赖 `agent_identities` 表(迁移 122),需评估是否连带引入 | 中 | +| `7796f900` | keep image generation connections alive,依赖上游 `transport-settings.ts`(CE 无),需落到 CE 网关设置 | 中 | +| Device Code 增量 | CE 已有 `device_code`,需 diff 上游是否有超出部分 | 低(待核对) | + +### 🔴 需重构(架构分叉大,禁止整包,逐能力/页面级评估) + +- **model_catalog_v2 全族**:`f0b5deb3` / `3bec379f` / `a6e44682` / `c9f59d18` / `90635813` / `18fbb6ab` 等。CE 走自研 catalog,v2 事务存储 + 不可变整数价格快照与 CE 冲突,需逐能力语义比对。 +- **账号级代理 + ipwhois 地理**:`56c7c418` / `585a8f8a` / `4e8f4542` / `13889ff` 等约 15 提交。CE 完全无 proxy 模块,属大特性,需独立设计评审。 +- **Codex Skills 市场/仓库/skills.sh**:`22256fe1` / `cc1a0e5c` / `d733b884` / `3edb03de`。CE `cm-skills` 仅 imagegen。 +- **reset credit 额度重置**:`1197113d` / `b38691ca` / `ca93803d`。CE 无该服务流与 UI。 +- **hourly/bucketed 用量分析 + 交互曲线**:`07cc39a4` / `76d7090f` / `6a372450`。CE 无分桶用量分析数据模型。 +- **automatic update checker**:`04deb7a` / `27300e9f` 族。清理版是否需要自动更新需先定性。 +- **Codex 项目启动器 + 账号组路由**:`0c957303`(`codex_projects.rs` 1459 行)。CE 无,大特性。 +- **keep window UI mounted**:`f2131ca3` / `f2887c19`。性能特性,需结合 CE 渲染优化重估。 + +### ⚫ 不移植(推广/发行内容,符合 CE 剥离原则) + +- `a555d900` / `a670ec09` AboutCodexManagerCard(作者信息卡 + i18n)。 +- `e630b349` nginx 配置、`65b935e4` / `3d72ffe5` release、`f571de28` sponsor copy 精简。 +- README 链接整理类。 + +## 本轮执行计划 + +按风险从低到高执行 🟢 项,逐项独立 commit + 定向测试: + +1. `22a47d89` Lite/图片工具冲突(纯 header,风险最低)。 +2. `2c1fa090` GPT-5.6 定价(按 CE `model_price_rules` 结构)。 +3. `a127e27a` 聚合路由混合轮换。 + +🟡 中风险项与 🔴 重构项在本轮后按需另立子任务评估。 diff --git a/task.md b/task.md index 0bef7467b..37c750821 100644 --- a/task.md +++ b/task.md @@ -23,12 +23,24 @@ - 主代理已逐提交审计、退回并修复 DST 稀疏空日性能问题,完成前后端构建与集成测试;待 PR 合并后从当前看板移除。 2. P2 上游差异巡检 - - 当前上游基准:`upstream/main = a614b559 docs: tidy repository links in readme`。 + - 当前上游基准:`upstream/main = e630b349 修改nginx的配置`(v0.5.0,2026-07-25 巡检刷新,旧基准 `a614b559` 之后新增 223 提交)。 - 已确认:`09223f6f` / `f3efb3a2` 不能整包移植,只能拆成页面或组件级小项;`a614b559` 为 README 链接整理但包含 AtomGit / Gitee / 官网 / 赞助入口,不按 CE 当前 README 直接移植。 - 已完成拆分小项:模型页搜索框 focus 反馈、Codex CLI 引导弹窗密度压缩、开发态 Web runtime rewrites、Switch 对比度。 - - 当前无明确可直接移植的小项;后续新增上游提交继续先拆分评估。 - 禁止项:作者页、赞助、远程 author content、AtomGit 推广、上游整包 README/docs 推广内容。 - 保留项:README 中的 Linux.do 认可社区入口需要保留,不能按作者/赞助推广残留误删。 + - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`): + - 🟢 可复用(低风险语义移植,本轮执行): + - `22a47d89` Responses Lite 与自动注入图片工具冲突修复(🔄 进行中):CE `codex_headers.rs` 缺失该处理,纯 header 逻辑。 + - `a127e27a` 混合轮换尊重纯聚合路由:CE 有 aggregate/proxy 语义,需对照移植路由判定。 + - `2c61d38e` / `afe86293` 桌面 dev server 清理与 NVM 环境保留:启动器健壮性小项。 + - 🟡 需移植(有对应架构但命名/结构不同,需语义改写): + - `2c1fa090` GPT-5.6 官方定价对齐 + `715eb90a` GPT Image 2:CE 走自研 `model_price_rules`,需按 CE 结构补种子,不照搬 v2 迁移。 + - `692ab34e` + `482f7ffa` 新 sub2api JSON 格式 / 加固导入:CE 有 sub2api 导入,但上游依赖 `agent_identities` 表(迁移 122),需评估是否连带引入。 + - `7796f900` 图片生成保持连接:依赖上游 `transport-settings.ts`(CE 无),需落到 CE 网关设置。 + - Device Code 增量:CE 已有 `device_code`,需 diff 上游是否有超出部分。 + - 🔴 需重构(架构分叉大,禁止整包,逐能力/页面级评估): + - model_catalog_v2 全族(CE 自研 catalog 冲突)、账号级代理 + ipwhois 地理(CE 无 proxy 模块)、Codex Skills 市场/仓库/skills.sh(CE 仅 imagegen)、reset credit 额度重置、hourly/bucketed 用量分析 + 交互曲线、automatic update checker、Codex 项目启动器 + 账号组路由、keep window UI mounted。 + - ⚫ 不移植(推广/发行内容):AboutCodexManagerCard 作者卡、nginx 配置、release/sponsor 文案、README 链接整理。 3. P2 分支 / PR 治理 - 当前 fork 与 upstream 分叉较大,对外 PR 应从干净分支 cherry-pick 关键提交。 From 5245acbf4e0b3ad04ded5958c952ebc3c7d40903 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:47:28 +0800 Subject: [PATCH 15/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E4=BC=9A=E8=AF=9D=E9=94=9A=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E7=BC=93=E5=AD=98=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/gateway/local_validation/request.rs | 9 ++- crates/service/src/gateway/mod.rs | 1 + .../src/gateway/request/thread_anchor.rs | 73 ++++++++++++++++++- .../service/src/http/responses_websocket.rs | 8 +- .../src/http/tests/proxy_runtime_tests.rs | 2 +- 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/service/src/gateway/local_validation/request.rs b/crates/service/src/gateway/local_validation/request.rs index ed14d0e7e..ddb799a1c 100644 --- a/crates/service/src/gateway/local_validation/request.rs +++ b/crates/service/src/gateway/local_validation/request.rs @@ -2114,7 +2114,7 @@ pub(super) fn build_local_validation_result( allow_codex_compat_rewrite, ) }; - let (rewritten_body, rewritten_body_value) = if should_normalize_compat_service_tier { + let (mut rewritten_body, rewritten_body_value) = if should_normalize_compat_service_tier { ( normalize_compat_service_tier_for_codex_backend(rewritten.body), None, @@ -2122,6 +2122,13 @@ pub(super) fn build_local_validation_result( } else { (rewritten.body, rewritten.value) }; + rewritten_body = super::super::align_existing_prompt_cache_key_with_native_anchor( + rewritten_body, + &incoming_headers, + ); + // 对齐会话锚点可能改变 prompt_cache_key,不能继续使用改写前的解析值。 + let rewritten_body_value = + super::super::parse_request_json_value(&rewritten_body).or(rewritten_body_value); response_adapter = maybe_wrap_compact_response_adapter(path.as_str(), response_adapter); let normalized_transport_path = transport_request_path(path.as_str()); let normalized = super::super::normalize_official_responses_http_body_with_parsed_value( diff --git a/crates/service/src/gateway/mod.rs b/crates/service/src/gateway/mod.rs index f9b136813..d78b963bb 100644 --- a/crates/service/src/gateway/mod.rs +++ b/crates/service/src/gateway/mod.rs @@ -153,6 +153,7 @@ use request_rewrite::{ apply_request_overrides_with_service_tier_and_prompt_cache_key_scope_with_value, compute_upstream_url, }; +pub(crate) use thread_anchor::align_existing_prompt_cache_key_with_native_anchor; pub(super) use thread_anchor::{ resolve_fallback_thread_anchor, resolve_local_conversation_id_with_sticky_fallback, }; diff --git a/crates/service/src/gateway/request/thread_anchor.rs b/crates/service/src/gateway/request/thread_anchor.rs index 0a640d445..6266e26c3 100644 --- a/crates/service/src/gateway/request/thread_anchor.rs +++ b/crates/service/src/gateway/request/thread_anchor.rs @@ -37,11 +37,41 @@ pub(crate) fn resolve_fallback_thread_anchor( super::conversation_binding::effective_thread_anchor(local_conversation_id, binding) } +pub(crate) fn align_existing_prompt_cache_key_with_native_anchor( + body: Vec, + headers: &IncomingHeaderSnapshot, +) -> Vec { + let Ok(mut payload) = serde_json::from_slice::(&body) else { + return body; + }; + let Some(object) = payload.as_object_mut() else { + return body; + }; + if !object.contains_key("prompt_cache_key") { + return body; + } + + if let Some(conversation_id) = normalize_anchor(headers.conversation_id()) { + object.insert( + "prompt_cache_key".to_string(), + serde_json::Value::String(conversation_id), + ); + } else if normalize_anchor(headers.session_id()).is_some() + && normalize_anchor(headers.turn_state()).is_some() + { + object.remove("prompt_cache_key"); + } else { + return body; + } + + serde_json::to_vec(&payload).unwrap_or(body) +} + #[cfg(test)] mod tests { use super::{ - has_native_thread_anchor, resolve_fallback_thread_anchor, - resolve_local_conversation_id_with_sticky_fallback, + align_existing_prompt_cache_key_with_native_anchor, has_native_thread_anchor, + resolve_fallback_thread_anchor, resolve_local_conversation_id_with_sticky_fallback, }; use axum::http::{HeaderMap, HeaderValue}; use codexmanager_core::storage::ConversationBinding; @@ -117,4 +147,43 @@ mod tests { assert_eq!(actual, None); } + + #[test] + fn native_conversation_replaces_conflicting_prompt_cache_key() { + let headers = sample_headers(Some("conversation-1"), None, Some("pk_test")); + let body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.4", + "prompt_cache_key": "client-thread" + })) + .expect("serialize body"); + + let actual = align_existing_prompt_cache_key_with_native_anchor(body, &headers); + let payload: serde_json::Value = serde_json::from_slice(&actual).expect("parse body"); + + assert_eq!(payload["prompt_cache_key"], "conversation-1"); + } + + #[test] + fn complete_session_turn_anchor_removes_conflicting_prompt_cache_key() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "session_id", + axum::http::HeaderValue::from_static("session-1"), + ); + headers.insert( + "x-codex-turn-state", + axum::http::HeaderValue::from_static("turn-state-1"), + ); + let headers = crate::gateway::IncomingHeaderSnapshot::from_http_headers(&headers); + let body = serde_json::to_vec(&serde_json::json!({ + "model": "gpt-5.4", + "prompt_cache_key": "client-thread" + })) + .expect("serialize body"); + + let actual = align_existing_prompt_cache_key_with_native_anchor(body, &headers); + let payload: serde_json::Value = serde_json::from_slice(&actual).expect("parse body"); + + assert!(payload.get("prompt_cache_key").is_none()); + } } diff --git a/crates/service/src/http/responses_websocket.rs b/crates/service/src/http/responses_websocket.rs index 24985803a..00630725d 100644 --- a/crates/service/src/http/responses_websocket.rs +++ b/crates/service/src/http/responses_websocket.rs @@ -670,6 +670,10 @@ fn rewrite_client_frame( &context.api_key, context.prompt_cache_key.as_deref(), ); + let rewritten_body = crate::gateway::align_existing_prompt_cache_key_with_native_anchor( + rewritten_body, + &context.incoming_headers, + ); let mut rewritten_value = serde_json::from_slice::(&rewritten_body).map_err(|err| { WsSessionError::bad_gateway_bilingual( "重写 WebSocket 请求失败", @@ -2195,7 +2199,7 @@ mod tests { } #[test] - fn websocket_frame_preserves_prompt_cache_key_when_native_conversation_anchor_exists() { + fn websocket_frame_aligns_prompt_cache_key_with_native_conversation_anchor() { let _guard = crate::test_env_guard(); let context = WsRequestContext { api_key: sample_api_key(), @@ -2216,7 +2220,7 @@ mod tests { value .get("prompt_cache_key") .and_then(serde_json::Value::as_str), - Some("client-thread") + Some("conversation-1") ); } diff --git a/crates/service/src/http/tests/proxy_runtime_tests.rs b/crates/service/src/http/tests/proxy_runtime_tests.rs index f979b231b..af0c746c9 100644 --- a/crates/service/src/http/tests/proxy_runtime_tests.rs +++ b/crates/service/src/http/tests/proxy_runtime_tests.rs @@ -960,7 +960,7 @@ async fn official_responses_websocket_proxies_frames_and_headers() { } #[tokio::test] -async fn official_responses_websocket_preserves_explicit_prompt_cache_key_when_session_anchor_exists( +async fn official_responses_websocket_preserves_explicit_prompt_cache_key_when_session_anchor_is_incomplete( ) { let _guard = crate::test_env_guard(); let db_path = new_test_db_path("codexmanager-proxy-runtime-ws-explicit-prompt-cache-key"); From 133e68abcfec035a7cc2ebda74232ed52fdd7837 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:19:47 +0800 Subject: [PATCH 16/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=AF=B7=E6=B1=82=E7=9A=84=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E4=B8=8E=E8=B4=B9=E7=94=A8=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...50\351\207\217\351\232\224\347\246\273.md" | 21 +++ ...7_request_token_stats_successful_usage.sql | 39 +++++ .../core/src/storage/api_key_quota_limits.rs | 2 + crates/core/src/storage/mod.rs | 5 + crates/core/src/storage/request_logs.rs | 65 ++++--- .../core/src/storage/request_token_stats.rs | 161 ++++++++++++++---- .../src/storage/tests/request_logs_tests.rs | 147 +++++++++++++++- crates/core/tests/storage.rs | 12 +- crates/service/tests/rpc.rs | 2 +- docs/zh-CN/CHANGELOG.md | 2 + 10 files changed, 386 insertions(+), 70 deletions(-) create mode 100644 ".teamwork/progress/2026-08-06_\345\244\261\350\264\245\350\257\267\346\261\202\347\224\250\351\207\217\351\232\224\347\246\273.md" create mode 100644 crates/core/migrations/077_request_token_stats_successful_usage.sql diff --git "a/.teamwork/progress/2026-08-06_\345\244\261\350\264\245\350\257\267\346\261\202\347\224\250\351\207\217\351\232\224\347\246\273.md" "b/.teamwork/progress/2026-08-06_\345\244\261\350\264\245\350\257\267\346\261\202\347\224\250\351\207\217\351\232\224\347\246\273.md" new file mode 100644 index 000000000..3cdb6ddb7 --- /dev/null +++ "b/.teamwork/progress/2026-08-06_\345\244\261\350\264\245\350\257\267\346\261\202\347\224\250\351\207\217\351\232\224\347\246\273.md" @@ -0,0 +1,21 @@ +# 失败请求用量隔离进度 + +执行身份:【CodeX-GPT】 + +## 完成内容 + +- 语义移植上游 `3cd72771`,新增 `request_token_stats.usage_included` 与成功明细局部索引。 +- 非 2xx 请求继续保留日志和 Token 明细,但 Dashboard、日志汇总、模型/Key 汇总、费用与平台 Key 配额只统计 2xx 请求。 +- 日级与长期压缩写入时对失败请求的 Token/费用归零,同时保留请求数、成功数和错误数;日级 marker 仍覆盖成功与失败明细,避免失败记录反复汇总。 +- 迁移会按关联请求状态回填现存明细,并清零 `success_count = 0` 的历史日级汇总桶。 + +## 验证 + +- `cargo test -p codexmanager-core --lib`:97 passed,1 ignored。 +- `cargo test -p codexmanager-core --test storage request_token_stats_rollups_use_owner_and_actual_source_precedence`:通过。 +- `cargo test -p codexmanager-service --test rpc rpc_requestlog_list_and_summary_support_pagination`:通过。 +- 新增 200 / 499 / 502 回归,验证明细标记、日志汇总、按 Key/模型汇总、配额及长期压缩前后口径一致。 + +## 已知边界 + +- 已经进入旧长期汇总且原始明细已被 retention 删除的数据没有状态维度,无法精确反推历史失败 Token;本次保证仍保留明细、可识别的日级桶与后续写入均使用新口径。 diff --git a/crates/core/migrations/077_request_token_stats_successful_usage.sql b/crates/core/migrations/077_request_token_stats_successful_usage.sql new file mode 100644 index 000000000..6d6d7c9d8 --- /dev/null +++ b/crates/core/migrations/077_request_token_stats_successful_usage.sql @@ -0,0 +1,39 @@ +ALTER TABLE request_token_stats + ADD COLUMN usage_included INTEGER NOT NULL DEFAULT 1 + CHECK (usage_included IN (0, 1)); + +UPDATE request_token_stats +SET usage_included = 0 +WHERE usage_included <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM request_logs + WHERE request_logs.id = request_token_stats.request_log_id + AND request_logs.status_code >= 200 + AND request_logs.status_code <= 299 + ); + +UPDATE request_token_stats +SET usage_included = 1 +WHERE usage_included <> 1 + AND EXISTS ( + SELECT 1 + FROM request_logs + WHERE request_logs.id = request_token_stats.request_log_id + AND request_logs.status_code >= 200 + AND request_logs.status_code <= 299 + ); + +UPDATE request_token_stat_daily_rollups +SET + input_tokens = 0, + cached_input_tokens = 0, + output_tokens = 0, + total_tokens = 0, + reasoning_output_tokens = 0, + estimated_cost = 0.0 +WHERE success_count = 0; + +CREATE INDEX IF NOT EXISTS idx_request_token_stats_success_key_model_created_at + ON request_token_stats(key_id, model, created_at DESC) + WHERE usage_included = 1; diff --git a/crates/core/src/storage/api_key_quota_limits.rs b/crates/core/src/storage/api_key_quota_limits.rs index 5ccad0881..378e89619 100644 --- a/crates/core/src/storage/api_key_quota_limits.rs +++ b/crates/core/src/storage/api_key_quota_limits.rs @@ -114,6 +114,7 @@ impl Storage { estimated_cost_usd FROM request_token_stats WHERE key_id IS NOT NULL AND TRIM(key_id) <> '' + AND usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, @@ -181,6 +182,7 @@ impl Storage { output_tokens, total_tokens FROM request_token_stats + WHERE usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index cb55d1455..d12af687d 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -1272,6 +1272,11 @@ impl Storage { include_str!("../../migrations/076_request_token_stats_daily_rollup_marker.sql"), |s| s.ensure_request_token_stats_daily_rollup_marker(), )?; + self.apply_sql_or_compat_migration( + "077_request_token_stats_successful_usage", + include_str!("../../migrations/077_request_token_stats_successful_usage.sql"), + |s| s.ensure_request_token_stats_usage_included_column(), + )?; self.ensure_api_key_rotation_columns()?; self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; diff --git a/crates/core/src/storage/request_logs.rs b/crates/core/src/storage/request_logs.rs index a5209cf74..4786affd4 100644 --- a/crates/core/src/storage/request_logs.rs +++ b/crates/core/src/storage/request_logs.rs @@ -217,8 +217,8 @@ impl Storage { "INSERT INTO request_token_stats ( request_log_id, key_id, account_id, model, input_tokens, cached_input_tokens, output_tokens, total_tokens, reasoning_output_tokens, - estimated_cost_usd, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + estimated_cost_usd, usage_included, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", ( request_log_id, &stat.key_id, @@ -230,6 +230,10 @@ impl Storage { stat.total_tokens, stat.reasoning_output_tokens, stat.estimated_cost_usd, + i64::from( + log.status_code + .is_some_and(|status| (200..=299).contains(&status)), + ), stat.created_at, ), ) @@ -498,19 +502,21 @@ impl Storage { COUNT(1), IFNULL(SUM(CASE WHEN r.status_code >= 200 AND r.status_code <= 299 THEN 1 ELSE 0 END), 0), IFNULL(SUM(CASE WHEN IFNULL(r.status_code, 0) >= 400 OR TRIM(IFNULL(r.error, '')) <> '' THEN 1 ELSE 0 END), 0), - IFNULL(SUM( - CASE - WHEN t.total_tokens IS NOT NULL THEN - CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END - ELSE - CASE - WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 - THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) - ELSE 0 - END - END - ), 0), - IFNULL(SUM(IFNULL(t.estimated_cost_usd, 0.0)), 0.0) + IFNULL(SUM( + CASE WHEN t.usage_included = 1 THEN + CASE + WHEN t.total_tokens IS NOT NULL THEN + CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END + ELSE + CASE + WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 + THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) + ELSE 0 + END + END + ELSE 0 END + ), 0), + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.estimated_cost_usd, 0.0) ELSE 0.0 END), 0.0) FROM request_logs r {account_join} LEFT JOIN request_token_stats t ON t.request_log_id = r.id @@ -639,19 +645,21 @@ impl Storage { COUNT(1), IFNULL(SUM(CASE WHEN r.status_code >= 200 AND r.status_code <= 299 THEN 1 ELSE 0 END), 0), IFNULL(SUM(CASE WHEN IFNULL(r.status_code, 0) >= 400 OR TRIM(IFNULL(r.error, '')) <> '' THEN 1 ELSE 0 END), 0), - IFNULL(SUM( - CASE - WHEN t.total_tokens IS NOT NULL THEN - CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END - ELSE - CASE - WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 - THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) - ELSE 0 - END - END - ), 0), - IFNULL(SUM(IFNULL(t.estimated_cost_usd, 0.0)), 0.0) + IFNULL(SUM( + CASE WHEN t.usage_included = 1 THEN + CASE + WHEN t.total_tokens IS NOT NULL THEN + CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END + ELSE + CASE + WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 + THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) + ELSE 0 + END + END + ELSE 0 END + ), 0), + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.estimated_cost_usd, 0.0) ELSE 0.0 END), 0.0) FROM request_logs r {account_join} LEFT JOIN request_token_stats t ON t.request_log_id = r.id @@ -777,6 +785,7 @@ impl Storage { FROM request_token_stats WHERE created_at >= ? AND created_at < ? + AND usage_included = 1 AND IFNULL(key_id, '') IN ({placeholders})" ); let mut params = Vec::with_capacity(key_ids.len() + 2); diff --git a/crates/core/src/storage/request_token_stats.rs b/crates/core/src/storage/request_token_stats.rs index bede3daaa..74aa784ed 100644 --- a/crates/core/src/storage/request_token_stats.rs +++ b/crates/core/src/storage/request_token_stats.rs @@ -60,26 +60,28 @@ fn token_total_sql_expr() -> &'static str { } const TOKEN_ROLLUP_COLUMNS: &str = " - IFNULL(SUM(IFNULL(t.input_tokens, 0)), 0) AS input_tokens, - IFNULL(SUM(IFNULL(t.cached_input_tokens, 0)), 0) AS cached_input_tokens, - IFNULL(SUM(IFNULL(t.output_tokens, 0)), 0) AS output_tokens, - IFNULL(SUM(IFNULL(t.reasoning_output_tokens, 0)), 0) AS reasoning_output_tokens, + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.input_tokens, 0) ELSE 0 END), 0) AS input_tokens, + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.cached_input_tokens, 0) ELSE 0 END), 0) AS cached_input_tokens, + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.output_tokens, 0) ELSE 0 END), 0) AS output_tokens, + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.reasoning_output_tokens, 0) ELSE 0 END), 0) AS reasoning_output_tokens, IFNULL( SUM( - CASE - WHEN t.total_tokens IS NOT NULL THEN - CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END - ELSE - CASE - WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 - THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) - ELSE 0 - END - END + CASE WHEN t.usage_included = 1 THEN + CASE + WHEN t.total_tokens IS NOT NULL THEN + CASE WHEN t.total_tokens > 0 THEN t.total_tokens ELSE 0 END + ELSE + CASE + WHEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) > 0 + THEN IFNULL(t.input_tokens, 0) - IFNULL(t.cached_input_tokens, 0) + IFNULL(t.output_tokens, 0) + ELSE 0 + END + END + ELSE 0 END ), 0 ) AS total_tokens, - IFNULL(SUM(IFNULL(t.estimated_cost_usd, 0.0)), 0.0) AS estimated_cost_usd, + IFNULL(SUM(CASE WHEN t.usage_included = 1 THEN IFNULL(t.estimated_cost_usd, 0.0) ELSE 0.0 END), 0.0) AS estimated_cost_usd, COUNT(DISTINCT r.id) AS request_count, COUNT(DISTINCT CASE WHEN r.status_code >= 200 AND r.status_code <= 299 THEN r.id END) AS success_count, COUNT(DISTINCT CASE WHEN IFNULL(r.status_code, 0) >= 400 OR TRIM(IFNULL(r.error, '')) <> '' THEN r.id END) AS error_count"; @@ -374,8 +376,22 @@ impl Storage { "INSERT INTO request_token_stats ( request_log_id, key_id, account_id, model, input_tokens, cached_input_tokens, output_tokens, total_tokens, reasoning_output_tokens, - estimated_cost_usd, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + estimated_cost_usd, usage_included, created_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + COALESCE( + ( + SELECT CASE + WHEN status_code >= 200 AND status_code <= 299 THEN 1 + ELSE 0 + END + FROM request_logs + WHERE id = ?1 + ), + 1 + ), + ?11 + )", ( stat.request_log_id, &stat.key_id, @@ -593,12 +609,12 @@ impl Storage { COALESCE({USER_OWNER_EXPR}, '') AS user_id, COALESCE(NULLIF(TRIM(t.model), ''), '') AS model, {status_bucket_expr} AS status_bucket, - CASE WHEN t.input_tokens > 0 THEN t.input_tokens ELSE 0 END AS input_tokens, - CASE WHEN t.cached_input_tokens > 0 THEN t.cached_input_tokens ELSE 0 END AS cached_input_tokens, - CASE WHEN t.output_tokens > 0 THEN t.output_tokens ELSE 0 END AS output_tokens, - CASE WHEN t.reasoning_output_tokens > 0 THEN t.reasoning_output_tokens ELSE 0 END AS reasoning_output_tokens, - {token_total} AS total_tokens, - CASE WHEN t.estimated_cost_usd > 0 THEN t.estimated_cost_usd ELSE 0.0 END AS estimated_cost, + CASE WHEN t.usage_included = 1 AND t.input_tokens > 0 THEN t.input_tokens ELSE 0 END AS input_tokens, + CASE WHEN t.usage_included = 1 AND t.cached_input_tokens > 0 THEN t.cached_input_tokens ELSE 0 END AS cached_input_tokens, + CASE WHEN t.usage_included = 1 AND t.output_tokens > 0 THEN t.output_tokens ELSE 0 END AS output_tokens, + CASE WHEN t.usage_included = 1 AND t.reasoning_output_tokens > 0 THEN t.reasoning_output_tokens ELSE 0 END AS reasoning_output_tokens, + CASE WHEN t.usage_included = 1 THEN {token_total} ELSE 0 END AS total_tokens, + CASE WHEN t.usage_included = 1 AND t.estimated_cost_usd > 0 THEN t.estimated_cost_usd ELSE 0.0 END AS estimated_cost, r.status_code AS status_code, r.error AS error FROM request_token_stats t @@ -734,12 +750,12 @@ impl Storage { COALESCE(NULLIF(TRIM(key_id), ''), ''), COALESCE(NULLIF(TRIM(account_id), ''), ''), COALESCE(NULLIF(TRIM(model), ''), ''), - IFNULL(SUM(CASE WHEN input_tokens > 0 THEN input_tokens ELSE 0 END), 0), - IFNULL(SUM(CASE WHEN cached_input_tokens > 0 THEN cached_input_tokens ELSE 0 END), 0), - IFNULL(SUM(CASE WHEN output_tokens > 0 THEN output_tokens ELSE 0 END), 0), - IFNULL(SUM({token_total}), 0), - IFNULL(SUM(CASE WHEN reasoning_output_tokens > 0 THEN reasoning_output_tokens ELSE 0 END), 0), - IFNULL(SUM(CASE WHEN estimated_cost_usd > 0 THEN estimated_cost_usd ELSE 0 END), 0.0), + IFNULL(SUM(CASE WHEN usage_included = 1 AND input_tokens > 0 THEN input_tokens ELSE 0 END), 0), + IFNULL(SUM(CASE WHEN usage_included = 1 AND cached_input_tokens > 0 THEN cached_input_tokens ELSE 0 END), 0), + IFNULL(SUM(CASE WHEN usage_included = 1 AND output_tokens > 0 THEN output_tokens ELSE 0 END), 0), + IFNULL(SUM(CASE WHEN usage_included = 1 THEN {token_total} ELSE 0 END), 0), + IFNULL(SUM(CASE WHEN usage_included = 1 AND reasoning_output_tokens > 0 THEN reasoning_output_tokens ELSE 0 END), 0), + IFNULL(SUM(CASE WHEN usage_included = 1 AND estimated_cost_usd > 0 THEN estimated_cost_usd ELSE 0 END), 0.0), COUNT(1), ?2 FROM request_token_stats t @@ -789,7 +805,8 @@ impl Storage { IFNULL(SUM(reasoning_output_tokens), 0), IFNULL(SUM(estimated_cost_usd), 0.0) FROM request_token_stats - WHERE created_at >= ?1 AND created_at < ?2", + WHERE created_at >= ?1 AND created_at < ?2 + AND usage_included = 1", )?; let mut rows = stmt.query((start_ts, end_ts))?; if let Some(row) = rows.next()? { @@ -821,6 +838,7 @@ impl Storage { total_tokens, estimated_cost_usd FROM request_token_stats + WHERE usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, @@ -873,6 +891,7 @@ impl Storage { estimated_cost_usd FROM request_token_stats WHERE key_id IN ({placeholders}) + AND usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, @@ -924,6 +943,7 @@ impl Storage { total_tokens, estimated_cost_usd FROM request_token_stats + WHERE usage_included = 1 UNION ALL SELECT NULLIF(model, '') AS model, @@ -959,7 +979,8 @@ impl Storage { IFNULL(SUM({token_total}), 0) AS total_tokens, IFNULL(SUM(estimated_cost_usd), 0.0) AS estimated_cost_usd FROM request_token_stats - WHERE (?1 IS NULL OR created_at >= ?1) + WHERE usage_included = 1 + AND (?1 IS NULL OR created_at >= ?1) AND (?2 IS NULL OR created_at < ?2) GROUP BY normalized_model ORDER BY total_tokens DESC, normalized_model ASC", @@ -1006,6 +1027,7 @@ impl Storage { total_tokens, estimated_cost_usd FROM request_token_stats + WHERE usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, @@ -1046,6 +1068,7 @@ impl Storage { IFNULL(SUM(estimated_cost_usd), 0.0) AS estimated_cost_usd FROM request_token_stats WHERE key_id IS NOT NULL AND TRIM(key_id) <> '' + AND usage_included = 1 AND (?1 IS NULL OR created_at >= ?1) AND (?2 IS NULL OR created_at < ?2) GROUP BY key_id, normalized_model @@ -1100,6 +1123,7 @@ impl Storage { estimated_cost_usd FROM request_token_stats WHERE key_id IN ({placeholders}) + AND usage_included = 1 UNION ALL SELECT NULLIF(key_id, '') AS key_id, @@ -1141,6 +1165,7 @@ impl Storage { IFNULL(SUM(estimated_cost_usd), 0.0) AS estimated_cost_usd FROM request_token_stats WHERE key_id IN ({placeholders}) + AND usage_included = 1 AND (? IS NULL OR created_at >= ?) AND (? IS NULL OR created_at < ?) GROUP BY key_id, normalized_model @@ -2082,6 +2107,61 @@ impl Storage { )?; Ok(()) } + + /// 确保失败请求的 Token 明细不会进入用量、费用和配额汇总。 + pub(super) fn ensure_request_token_stats_usage_included_column(&self) -> Result<()> { + self.ensure_column( + "request_token_stats", + "usage_included", + "INTEGER NOT NULL DEFAULT 1 CHECK (usage_included IN (0, 1))", + )?; + self.conn.execute( + "UPDATE request_token_stats + SET usage_included = 0 + WHERE usage_included <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM request_logs + WHERE request_logs.id = request_token_stats.request_log_id + AND request_logs.status_code >= 200 + AND request_logs.status_code <= 299 + )", + [], + )?; + self.conn.execute( + "UPDATE request_token_stats + SET usage_included = 1 + WHERE usage_included <> 1 + AND EXISTS ( + SELECT 1 + FROM request_logs + WHERE request_logs.id = request_token_stats.request_log_id + AND request_logs.status_code >= 200 + AND request_logs.status_code <= 299 + )", + [], + )?; + self.conn.execute( + "UPDATE request_token_stat_daily_rollups + SET + input_tokens = 0, + cached_input_tokens = 0, + output_tokens = 0, + total_tokens = 0, + reasoning_output_tokens = 0, + estimated_cost = 0.0 + WHERE success_count = 0", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_token_stats_success_key_model_created_at + ON request_token_stats(key_id, model, created_at DESC) + WHERE usage_included = 1", + [], + )?; + Ok(()) + } + pub(super) fn ensure_request_token_stats_table(&self) -> Result<()> { self.conn.execute( "CREATE TABLE IF NOT EXISTS request_token_stats ( @@ -2096,6 +2176,8 @@ impl Storage { total_tokens INTEGER, reasoning_output_tokens INTEGER, estimated_cost_usd REAL, + usage_included INTEGER NOT NULL DEFAULT 1 + CHECK (usage_included IN (0, 1)), created_at INTEGER NOT NULL )", [], @@ -2148,6 +2230,17 @@ impl Storage { [], )?; self.ensure_column("request_token_stats", "total_tokens", "INTEGER")?; + self.ensure_column( + "request_token_stats", + "usage_included", + "INTEGER NOT NULL DEFAULT 1 CHECK (usage_included IN (0, 1))", + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_token_stats_success_key_model_created_at + ON request_token_stats(key_id, model, created_at DESC) + WHERE usage_included = 1", + [], + )?; self.ensure_request_token_stats_daily_rollup_marker()?; if self.has_column("request_logs", "input_tokens")? { @@ -2155,12 +2248,14 @@ impl Storage { "INSERT OR IGNORE INTO request_token_stats ( request_log_id, key_id, account_id, model, input_tokens, cached_input_tokens, output_tokens, total_tokens, reasoning_output_tokens, - estimated_cost_usd, created_at + estimated_cost_usd, usage_included, created_at ) SELECT id, key_id, account_id, model, input_tokens, cached_input_tokens, output_tokens, NULL, reasoning_output_tokens, - estimated_cost_usd, created_at + estimated_cost_usd, + CASE WHEN status_code >= 200 AND status_code <= 299 THEN 1 ELSE 0 END, + created_at FROM request_logs WHERE input_tokens IS NOT NULL OR cached_input_tokens IS NOT NULL diff --git a/crates/core/src/storage/tests/request_logs_tests.rs b/crates/core/src/storage/tests/request_logs_tests.rs index 3db4813c1..368c7762c 100644 --- a/crates/core/src/storage/tests/request_logs_tests.rs +++ b/crates/core/src/storage/tests/request_logs_tests.rs @@ -540,8 +540,151 @@ fn request_logs_filtered_summary_aggregates_counts_and_tokens() { assert_eq!(summary.count, 3); assert_eq!(summary.success_count, 2); assert_eq!(summary.error_count, 1); - assert_eq!(summary.total_tokens, 150); - assert_eq!(summary.estimated_cost_usd, 0.03); + assert_eq!(summary.total_tokens, 80); + assert_eq!(summary.estimated_cost_usd, 0.02); +} + +#[test] +fn failed_request_usage_is_retained_but_excluded_before_and_after_rollup() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + storage + .insert_api_key(&test_api_key("key-success-only", 900)) + .expect("insert api key"); + storage + .upsert_api_key_quota_limit("key-success-only", Some(100)) + .expect("set api key quota"); + + for (status_code, input_tokens, output_tokens, total_tokens, estimated_cost_usd) in [ + (200, 20, 10, 30, 0.30), + (499, 30, 10, 40, 0.40), + (502, 40, 10, 50, 0.50), + ] { + let created_at = 1_000 + status_code; + storage + .insert_request_log_with_token_stat( + &RequestLog { + key_id: Some("key-success-only".to_string()), + account_id: Some("account-success-only".to_string()), + request_path: "/v1/responses".to_string(), + method: "POST".to_string(), + model: Some("gpt-5".to_string()), + status_code: Some(status_code), + error: (status_code != 200).then(|| format!("http {status_code}")), + created_at, + ..Default::default() + }, + &RequestTokenStat { + key_id: Some("key-success-only".to_string()), + account_id: Some("account-success-only".to_string()), + model: Some("gpt-5".to_string()), + input_tokens: Some(input_tokens), + cached_input_tokens: Some(5), + output_tokens: Some(output_tokens), + total_tokens: Some(total_tokens), + reasoning_output_tokens: Some(2), + estimated_cost_usd: Some(estimated_cost_usd), + created_at, + ..Default::default() + }, + ) + .expect("insert request usage"); + } + + let inclusion_flags = storage + .conn + .prepare( + "SELECT r.status_code, t.usage_included + FROM request_logs r + JOIN request_token_stats t ON t.request_log_id = r.id + ORDER BY r.status_code", + ) + .expect("prepare inclusion flags") + .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))) + .expect("query inclusion flags") + .collect::>>() + .expect("collect inclusion flags"); + assert_eq!(inclusion_flags, vec![(200, 1), (499, 0), (502, 0)]); + + let summary = storage + .summarize_request_logs_filtered(None, Some("all"), Some(0), Some(3_600)) + .expect("summarize request logs"); + assert_eq!(summary.count, 3); + assert_eq!(summary.success_count, 1); + assert_eq!(summary.error_count, 2); + assert_eq!(summary.total_tokens, 30); + assert!((summary.estimated_cost_usd - 0.30).abs() < 1e-9); + + let between = storage + .summarize_request_logs_between_for_keys(0, 3_600, &["key-success-only".to_string()]) + .expect("summarize request usage for key"); + assert_eq!(between.input_tokens, 20); + assert_eq!(between.cached_input_tokens, 5); + assert_eq!(between.output_tokens, 10); + assert_eq!(between.reasoning_output_tokens, 2); + assert!((between.estimated_cost_usd - 0.30).abs() < 1e-9); + + let by_key = storage + .summarize_request_token_stats_by_key_ids(&["key-success-only".to_string()]) + .expect("summarize key usage"); + assert_eq!(by_key.len(), 1); + assert_eq!(by_key[0].total_tokens, 30); + assert!((by_key[0].estimated_cost_usd - 0.30).abs() < 1e-9); + let by_model = storage + .summarize_request_token_stats_by_model(Some(0), Some(3_600)) + .expect("summarize model usage"); + assert_eq!(by_model.len(), 1); + assert_eq!(by_model[0].total_tokens, 30); + let by_key_model = storage + .summarize_request_token_stats_by_key_and_model(Some(0), Some(3_600)) + .expect("summarize key model usage"); + assert_eq!(by_key_model.len(), 1); + assert_eq!(by_key_model[0].total_tokens, 30); + assert_eq!( + storage + .api_key_total_token_usage("key-success-only") + .expect("summarize quota usage"), + 30 + ); + let quota = storage + .quota_api_key_overview_summary() + .expect("summarize quota overview"); + assert_eq!(quota.total_used_tokens, 30); + assert_eq!(quota.total_remaining_tokens, 70); + assert!((quota.estimated_cost_usd - 0.30).abs() < 1e-9); + + storage + .rollup_request_token_stats_before(3_600) + .expect("roll up request usage"); + + let compacted = storage + .summarize_request_token_stats_by_key_ids(&["key-success-only".to_string()]) + .expect("summarize compacted key usage"); + assert_eq!(compacted.len(), 1); + assert_eq!(compacted[0].total_tokens, 30); + assert!((compacted[0].estimated_cost_usd - 0.30).abs() < 1e-9); + let compacted_by_model = storage + .summarize_request_token_stats_by_model(None, None) + .expect("summarize compacted model usage"); + assert_eq!(compacted_by_model.len(), 1); + assert_eq!(compacted_by_model[0].total_tokens, 30); + let compacted_by_key_model = storage + .summarize_request_token_stats_by_key_and_model(None, None) + .expect("summarize compacted key model usage"); + assert_eq!(compacted_by_key_model.len(), 1); + assert_eq!(compacted_by_key_model[0].total_tokens, 30); + assert_eq!( + storage + .api_key_total_token_usage("key-success-only") + .expect("summarize compacted quota usage"), + 30 + ); + let compacted_quota = storage + .quota_api_key_overview_summary() + .expect("summarize compacted quota overview"); + assert_eq!(compacted_quota.total_used_tokens, 30); + assert_eq!(compacted_quota.total_remaining_tokens, 70); + assert!((compacted_quota.estimated_cost_usd - 0.30).abs() < 1e-9); } #[test] diff --git a/crates/core/tests/storage.rs b/crates/core/tests/storage.rs index d4b4bcf29..6bf386ef3 100644 --- a/crates/core/tests/storage.rs +++ b/crates/core/tests/storage.rs @@ -1858,10 +1858,10 @@ fn request_token_stats_rollups_use_owner_and_actual_source_precedence() { .summarize_request_token_stats_daily(base, base + 2 * 86_400, 86_400) .expect("daily rollup"); assert_eq!(daily.len(), 2); - assert_eq!(daily[0].usage.total_tokens, 170); - assert_eq!(daily[0].usage.input_tokens, 180); - assert_eq!(daily[0].usage.cached_input_tokens, 50); - assert_eq!(daily[0].usage.output_tokens, 70); + assert_eq!(daily[0].usage.total_tokens, 100); + assert_eq!(daily[0].usage.input_tokens, 100); + assert_eq!(daily[0].usage.cached_input_tokens, 20); + assert_eq!(daily[0].usage.output_tokens, 50); assert_eq!(daily[0].usage.request_count, 2); assert_eq!(daily[0].usage.success_count, 1); assert_eq!(daily[0].usage.error_count, 1); @@ -1879,7 +1879,7 @@ fn request_token_stats_rollups_use_owner_and_actual_source_precedence() { .find(|item| item.user_id == "current-user") .expect("current owner fallback rollup"); assert_eq!(ledger_user.usage.total_tokens, 100); - assert_eq!(current_user.usage.total_tokens, 70); + assert_eq!(current_user.usage.total_tokens, 0); let ledger_direct = storage .summarize_request_token_stats_for_user_between("ledger-user", base, base + 86_400) @@ -1918,7 +1918,7 @@ fn request_token_stats_rollups_use_owner_and_actual_source_precedence() { .expect("legacy account") .usage .total_tokens, - 70 + 0 ); let ranked_openai_sources = storage .summarize_request_token_stats_source_ranking_between( diff --git a/crates/service/tests/rpc.rs b/crates/service/tests/rpc.rs index f25563b7a..893fc1976 100644 --- a/crates/service/tests/rpc.rs +++ b/crates/service/tests/rpc.rs @@ -4122,7 +4122,7 @@ fn rpc_requestlog_list_and_summary_support_pagination() { summary_result .get("totalTokens") .and_then(|value| value.as_i64()), - Some(45) + Some(0) ); } diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 90985b7f5..c2c76058e 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -28,6 +28,8 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- 非 2xx 请求仍保留请求日志与 Token 明细用于诊断,但不再进入 Token、费用或平台 Key 配额汇总;迁移会重标仍保留的明细并清零纯失败的日级汇总桶,后续长期压缩也保持相同口径。 +- 原生 `conversation_id` 与完整 `session_id` / `x-codex-turn-state` 会覆盖冲突的 `prompt_cache_key`,HTTP 与 Responses WebSocket 使用一致的会话锚点规则,避免重试或恢复原生会话时继续绑定到错误缓存键。 - `refresh_token_expired` 不再被永久判定为不可恢复,而是在长冷却后低频复检;Token 刷新成功后会立即排队真实用量验证,并由用量快照恢复账号状态。账号页在用量刷新事件及手动刷新完成后重新拉取账号列表,避免状态列长期停留在旧值。 - 请求费用估算改为在解析最终 `effective_service_tier` 后选择 Standard / Priority 价格,HTTP 与 Responses WebSocket 共用同一计费口径;`fast` 按 Priority,空值、`auto`、`default` 和未知 tier 保守按 Standard。模型价格规则现会实际匹配 `billingMode`,同一模型可同时维护不同服务等级价格;新版种子逐模型补齐官方 Priority 价格及 GPT-4.1/4o 特异 Standard 规则,不对历史账单自动重算。 - 补齐 Web 运行壳遗漏的命令映射及完整性门禁,移除错误的模型价格重复 RPC 映射;Tauri 生产构建不再因旧静态产物存在而跳过重新生成,并把 `/platform-mode` 纳入根页面校验。 From e5c239f8f3075fd411b7f246ff7dfb6bd8f48899 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:20:24 +0800 Subject: [PATCH 17/35] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8v0.5.2=E5=B7=AE=E5=BC=82=E5=B7=A1=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\274\202\345\267\241\346\243\200.md" | 41 +++++++++++++++++++ ...73\345\212\241\345\256\241\350\256\241.md" | 25 +++++++++++ task.md | 32 ++++++--------- 3 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 ".teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" create mode 100644 ".teamwork/tasks/2026-08-06_\345\256\214\346\210\220\344\270\216\346\234\252\345\256\214\346\210\220\344\273\273\345\212\241\345\256\241\350\256\241.md" diff --git "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" new file mode 100644 index 000000000..929ac3b19 --- /dev/null +++ "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" @@ -0,0 +1,41 @@ +# 上游 v0.5.2 后续差异巡检 + +审计身份:【CodeX-GPT】 + +## 基准证据 + +- 2026-08-06 已执行 `git fetch upstream --prune`,最新 `upstream/main` 为 `cdf0c5f2 docs: adjust sponsor listing`,描述为 `v0.5.2-19-gcdf0c5f2`。 +- `e630b349..upstream/main` 共 95 个图提交,其中 76 个非合并提交、19 个合并提交;旧看板记录的 37 不准确。 +- 当前分支与上游 merge-base 为 `2c43779c`;以当前 HEAD 计算,上游独有约 515 个提交,CE 独有约 299 个提交,不能直接 merge 或整包 cherry-pick。 + +## 已完成 + +1. `c98047eb`:已按 CE 的 HTTP 与 Responses WebSocket 请求链路语义移植。原生 `conversation_id` 或完整 turn state 会移除冲突的 `prompt_cache_key`。 +2. `3cd72771`:已按 CE 的原始明细、日级 rollup、长期 rollup、日志汇总和平台 Key 配额架构改写。失败请求保留诊断明细,但 Token 与费用只统计 2xx。 + +## 下一批优先级 + +| 优先级 | 上游提交 | CE 结论 | +| --- | --- | --- | +| P0 | `6d72131e` | 必须语义移植。CE 当前按 primary/secondary 字段位置套用 5 小时/周阈值,长短窗口互换或仅长窗口账号会误判;SQL 预筛与内存候选判断必须同时按 `window_minutes` 选择阈值。 | +| P1 | `2aa9bec3` | CE 的 `login_sessions` 已有 `group_name` 列,登录流程也传入该值,但存取 SQL 忽略它,属于已确认的数据丢失 bug,无需新增迁移即可修复。 | +| P1 | `7716828f` | CE failover 目前只递归删除 `encrypted_content` 字段,会留下缺字段的 reasoning/compaction 壳对象;应按 item 类型删除整个账号绑定历史项并补索引位置回归。 | +| P1 | `85cf8c73` | CE WebSocket 仅使用应用内上游代理,未读取 `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`;应为 HTTP 与 WS 对齐环境代理语义。 | +| P1 | `0f4f79bd` | CE 发布工作流目前只推版本 tag;正式版可补 `stable/latest`,预发布不得更新稳定标签。CE 已改成 workflow 内联发布,需要重写而非复制上游脚本。 | +| P2 | `552e3f4d` | 日志页分页跳转是独立低风险 UI 能力,可在后端分页稳定后直接按 CE 组件样式实现。 | + +## 条件评估 + +- `1617272a`:上游把 GPT-5.6 Terra/Luna 价格改成另一组数值,与用户此前明确提供的 CE 规则冲突。没有官方价格页面或用户裁决前不覆盖。 +- `a127e27a`:修复“混合模式 + 纯聚合模型路由”,方向正确,但实现依赖上游 `model_catalog_v2`。CE 必须基于现有 model source mapping 和 aggregate candidate 语义重写。 +- `84802156`:桌面启动诊断和有界日志有价值,但改动约 900 行并耦合上游 updater/app storage,先拆成日志上限、诊断 RPC、设置页展示三个子项。 +- `02c17c45`:包含流预检、失败切换、Skills 市场和 WebSocket 大重构,单提交超过 4,000 行,禁止整包移植;仅按可复现故障逐项抽取。 +- `331fb0d0`、`e44bfe35`、账号 move-to-top/批量状态、窄屏表格与 header 改造:属于产品/UI 能力,需结合 CE 已有轻量关闭和响应式修复逐页验收。 +- `2c61d38e`、`afe86293`、`1dbe17da`:桌面 dev server 清理、NVM 环境保留和预期断连降噪可合并为启动脚本健壮性主题处理。 + +## 不直接移植 + +- `15a90056` / `ee62ccf4`:针对上游入站 zstd 解压与 compaction 设置。CE 当前只有出站压缩,没有对应入站解压路径;未来引入该路径时再采用同类上限。 +- `9c6ab64d`:上游删除图片工具自动注入,和 CE 当前已确认的图片生成兼容行为冲突。 +- 上游 model catalog 所有权/导出重构、Codex Skills 市场、项目启动器、作者/赞助卡、AtomGit 徽章、release/sponsor 文案及整包 README/docs 推广。 +- README 的 Linux.do 认可社区入口继续保留。 diff --git "a/.teamwork/tasks/2026-08-06_\345\256\214\346\210\220\344\270\216\346\234\252\345\256\214\346\210\220\344\273\273\345\212\241\345\256\241\350\256\241.md" "b/.teamwork/tasks/2026-08-06_\345\256\214\346\210\220\344\270\216\346\234\252\345\256\214\346\210\220\344\273\273\345\212\241\345\256\241\350\256\241.md" new file mode 100644 index 000000000..ce331acc8 --- /dev/null +++ "b/.teamwork/tasks/2026-08-06_\345\256\214\346\210\220\344\270\216\346\234\252\345\256\214\346\210\220\344\273\273\345\212\241\345\256\241\350\256\241.md" @@ -0,0 +1,25 @@ +# 完成与未完成任务审计 + +状态:已完成;主代理已独立复核提交历史、PR 状态、测试输出与上游引用。 + +## 目标 + +并行盘点当前 PR、上游差异与发布门禁,产出可由主代理独立复核的证据清单。 + +## 子任务 + +1. 审计 `task.md`、CHANGELOG、progress 与提交历史,区分已完成、待合并、真实未完成。 +2. 对比当前 `upstream/main` 与本分支,复核 `task.md` 中上游迁移分类是否仍准确,列出下一批最小可实施项。 +3. 审计测试、CI、发布状态与代码中的 TODO/FIXME,列出发布阻塞项和可立即处理项。 + +## 约束 + +- 子代理只读审计,不修改文件。 +- 每项结论必须给出文件、提交、PR、测试或命令证据。 +- 主代理必须重新检查证据后才能采纳。 + +## 结果 + +- 上游基准、提交计数与下一批移植优先级已写入 `.teamwork/discussions/2026-08-06_上游v0.5.2后续差异巡检.md`。 +- 已完成 `c98047eb` 与 `3cd72771` 的 CE 语义移植;剩余工作已收敛到 `task.md`。 +- PR #1 仍为 Draft,正式发布前仍需完成 PR 合并、完整门禁和版本化 CHANGELOG 小节。 diff --git a/task.md b/task.md index 37c750821..c6d82903d 100644 --- a/task.md +++ b/task.md @@ -1,8 +1,8 @@ # Codex-Manager CE 当前任务看板 -> 本文件只保留仍在进行或尚未收口的工作。已完成变更进入 `docs/zh-CN/CHANGELOG.md`,上游差异结论进入 `.teamwork/discussions/2026-07-07_上游差异与CE清理结论.md`。 +> 本文件只保留仍在进行或尚未收口的工作。已完成变更进入 `docs/zh-CN/CHANGELOG.md`,最新上游结论进入 `.teamwork/discussions/2026-08-06_上游v0.5.2后续差异巡检.md`。 -## 当前待处理(2026-07-07) +## 当前待处理(2026-08-06) 0. P0 定价分层与刷新状态补充修复(✅ 已完成) - 定价(✅ 子项已完成):按请求最终 `effective_service_tier` 区分 Standard / Priority,`fast` 归入 Priority,空值/`auto`/`default` 与未知值保守回退 Standard;补齐官方逐模型 Priority 种子、`billing_mode` 匹配和 HTTP/WS 回归测试。历史费用不重算。 @@ -11,7 +11,7 @@ - 前端:刷新完成后同步账号实体状态,并展示可验证的出口诊断信息与刷新结果。 - 子任务进度:出口诊断缓存/RPC/设置页展示与 `accounts/list` 刷新同步已实现并通过主代理独立审计。 - 主代理已完成子代理补丁独立审计、定向测试、前端构建与集成门禁。 - - 定价、刷新、区域诊断与前端同步均已收口,等待 PR 合并后从当前看板移除。 + - 定价、刷新、区域诊断与前端同步均已收口;PR #1(`audit/fix-followups` -> `main`)已创建,合并后从当前看板移除。 1. P0 审计问题修复与独立复核(✅ 已完成) - 前端/Web:补齐 Web command 映射、移除错误重复 RPC、恢复 direct-mode 门禁、修正桌面构建陈旧产物判断。 @@ -20,27 +20,21 @@ - 发布/工具:修正 CE GHCR 镜像归属,移除数据库工具的本机硬编码与默认破坏性行为。 - 日级统计:改用显式本地自然日边界执行 rollup 与 mixed 查询,覆盖 DST 23/25 小时切换日。 - 已知粒度限制:历史自定义范围若只覆盖某自然日首尾半日,且该日明细已按 retention 清理,只能使用整日 rollup,无法精确还原半日数据;接口不得把整日汇总伪装成该半日结果。 - - 主代理已逐提交审计、退回并修复 DST 稀疏空日性能问题,完成前后端构建与集成测试;待 PR 合并后从当前看板移除。 + - 主代理已逐提交审计、退回并修复 DST 稀疏空日性能问题;PR #1 已包含这些提交,合并后从当前看板移除。 -2. P2 上游差异巡检 - - 当前上游基准:`upstream/main = e630b349 修改nginx的配置`(v0.5.0,2026-07-25 巡检刷新,旧基准 `a614b559` 之后新增 223 提交)。 +2. P1 上游差异巡检与最小语义移植 + - 当前上游基准:`upstream/main = cdf0c5f2 docs: adjust sponsor listing`(`v0.5.2-19-gcdf0c5f2`,2026-08-06 刷新;相对旧基准 `e630b349` 新增 95 个图提交,其中 76 个非合并提交、19 个合并提交)。 - 已确认:`09223f6f` / `f3efb3a2` 不能整包移植,只能拆成页面或组件级小项;`a614b559` 为 README 链接整理但包含 AtomGit / Gitee / 官网 / 赞助入口,不按 CE 当前 README 直接移植。 - 已完成拆分小项:模型页搜索框 focus 反馈、Codex CLI 引导弹窗密度压缩、开发态 Web runtime rewrites、Switch 对比度。 - 禁止项:作者页、赞助、远程 author content、AtomGit 推广、上游整包 README/docs 推广内容。 - 保留项:README 中的 Linux.do 认可社区入口需要保留,不能按作者/赞助推广残留误删。 - - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`): - - 🟢 可复用(低风险语义移植,本轮执行): - - `22a47d89` Responses Lite 与自动注入图片工具冲突修复(🔄 进行中):CE `codex_headers.rs` 缺失该处理,纯 header 逻辑。 - - `a127e27a` 混合轮换尊重纯聚合路由:CE 有 aggregate/proxy 语义,需对照移植路由判定。 - - `2c61d38e` / `afe86293` 桌面 dev server 清理与 NVM 环境保留:启动器健壮性小项。 - - 🟡 需移植(有对应架构但命名/结构不同,需语义改写): - - `2c1fa090` GPT-5.6 官方定价对齐 + `715eb90a` GPT Image 2:CE 走自研 `model_price_rules`,需按 CE 结构补种子,不照搬 v2 迁移。 - - `692ab34e` + `482f7ffa` 新 sub2api JSON 格式 / 加固导入:CE 有 sub2api 导入,但上游依赖 `agent_identities` 表(迁移 122),需评估是否连带引入。 - - `7796f900` 图片生成保持连接:依赖上游 `transport-settings.ts`(CE 无),需落到 CE 网关设置。 - - Device Code 增量:CE 已有 `device_code`,需 diff 上游是否有超出部分。 - - 🔴 需重构(架构分叉大,禁止整包,逐能力/页面级评估): - - model_catalog_v2 全族(CE 自研 catalog 冲突)、账号级代理 + ipwhois 地理(CE 无 proxy 模块)、Codex Skills 市场/仓库/skills.sh(CE 仅 imagegen)、reset credit 额度重置、hourly/bucketed 用量分析 + 交互曲线、automatic update checker、Codex 项目启动器 + 账号组路由、keep window UI mounted。 - - ⚫ 不移植(推广/发行内容):AboutCodexManagerCard 作者卡、nginx 配置、release/sponsor 文案、README 链接整理。 + - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`)仍有效,但 `22a47d89` 不再作为待移植项:CE 当前 Codex wire header 透传策略会丢弃该 header,已天然避免冲突。 + - 2026-08-06 增量复核(详见最新巡检文档): + - 已完成语义移植:`c98047eb` 原生会话锚点与 prompt cache key 对齐;`3cd72771` 失败请求明细保留但不计 Token、费用和配额。 + - P0 下一项:`6d72131e` 按真实窗口时长选择 5 小时/周额度阈值;CE 当前 SQL 与内存候选判断仍按 primary/secondary 字段位置套阈值,窗口互换账号会误判。 + - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量、`552e3f4d` 日志分页跳转、`0f4f79bd` 正式容器补 `stable/latest` 标签。 + - 条件评估:`1617272a` GPT-5.6 价格与用户已确认的 CE 价格规则冲突,核对官方来源前不得覆盖;`a127e27a` 依赖上游 model_catalog_v2,只能按 CE 路由模型重写;`84802156` 桌面诊断、账号批量动作和 UI 响应式改造按页面拆分。 + - 当前不适用:`15a90056` 针对入站 zstd 解压,CE 当前没有该解压路径;`9c6ab64d` 删除图片工具自动注入与 CE 已确认行为冲突;作者、赞助、AtomGit 与整包 README/docs 推广继续不移植。 3. P2 分支 / PR 治理 - 当前 fork 与 upstream 分叉较大,对外 PR 应从干净分支 cherry-pick 关键提交。 From fbabe7b2be327b34e2758c0e63a814bb64526239 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:41:23 +0800 Subject: [PATCH 18/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8C=89=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E7=AA=97=E5=8F=A3=E6=97=B6=E9=95=BF=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\274\202\345\267\241\346\243\200.md" | 4 +- ...76\347\275\256\344\277\256\345\244\215.md" | 22 +++++++ .../env_overrides/catalog/items.rs | 5 +- crates/service/src/gateway/README.md | 1 + .../src/gateway/core/runtime_config.rs | 1 - .../service/src/gateway/routing/selection.rs | 39 ++++++++++-- .../gateway/routing/tests/selection_tests.rs | 61 ++++++++++++++++++- crates/service/tests/app_settings.rs | 35 +++++++++++ docs/zh-CN/CHANGELOG.md | 1 + task.md | 3 +- 10 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 ".teamwork/progress/2026-08-06_\351\242\235\345\272\246\347\252\227\345\217\243\344\270\216\345\271\266\345\217\221\350\256\276\347\275\256\344\277\256\345\244\215.md" diff --git "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" index 929ac3b19..3825d1b18 100644 --- "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" +++ "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" @@ -12,18 +12,20 @@ 1. `c98047eb`:已按 CE 的 HTTP 与 Responses WebSocket 请求链路语义移植。原生 `conversation_id` 或完整 turn state 会移除冲突的 `prompt_cache_key`。 2. `3cd72771`:已按 CE 的原始明细、日级 rollup、长期 rollup、日志汇总和平台 Key 配额架构改写。失败请求保留诊断明细,但 Token 与费用只统计 2xx。 +3. `6d72131e`:已按 CE 的内存候选池结构语义移植。配额保护按真实窗口时长选择短周期/长周期阈值,并保留旧快照缺失时长时 primary=短周期、secondary=长周期的兼容约定;旧 `envOverrides` 也不再覆盖专用并发设置。 ## 下一批优先级 | 优先级 | 上游提交 | CE 结论 | | --- | --- | --- | -| P0 | `6d72131e` | 必须语义移植。CE 当前按 primary/secondary 字段位置套用 5 小时/周阈值,长短窗口互换或仅长窗口账号会误判;SQL 预筛与内存候选判断必须同时按 `window_minutes` 选择阈值。 | | P1 | `2aa9bec3` | CE 的 `login_sessions` 已有 `group_name` 列,登录流程也传入该值,但存取 SQL 忽略它,属于已确认的数据丢失 bug,无需新增迁移即可修复。 | | P1 | `7716828f` | CE failover 目前只递归删除 `encrypted_content` 字段,会留下缺字段的 reasoning/compaction 壳对象;应按 item 类型删除整个账号绑定历史项并补索引位置回归。 | | P1 | `85cf8c73` | CE WebSocket 仅使用应用内上游代理,未读取 `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`;应为 HTTP 与 WS 对齐环境代理语义。 | | P1 | `0f4f79bd` | CE 发布工作流目前只推版本 tag;正式版可补 `stable/latest`,预发布不得更新稳定标签。CE 已改成 workflow 内联发布,需要重写而非复制上游脚本。 | | P2 | `552e3f4d` | 日志页分页跳转是独立低风险 UI 能力,可在后端分页稳定后直接按 CE 组件样式实现。 | +`6d72131e` 验证:`gateway::selection::tests` 18 项通过,`app_settings` 31 项单线程通过,`cargo fmt --all --check` 通过。 + ## 条件评估 - `1617272a`:上游把 GPT-5.6 Terra/Luna 价格改成另一组数值,与用户此前明确提供的 CE 规则冲突。没有官方价格页面或用户裁决前不覆盖。 diff --git "a/.teamwork/progress/2026-08-06_\351\242\235\345\272\246\347\252\227\345\217\243\344\270\216\345\271\266\345\217\221\350\256\276\347\275\256\344\277\256\345\244\215.md" "b/.teamwork/progress/2026-08-06_\351\242\235\345\272\246\347\252\227\345\217\243\344\270\216\345\271\266\345\217\221\350\256\276\347\275\256\344\277\256\345\244\215.md" new file mode 100644 index 000000000..7c16612ea --- /dev/null +++ "b/.teamwork/progress/2026-08-06_\351\242\235\345\272\246\347\252\227\345\217\243\344\270\216\345\271\266\345\217\221\350\256\276\347\275\256\344\277\256\345\244\215.md" @@ -0,0 +1,22 @@ +# 额度窗口与并发设置修复进度 + +执行与审计身份:【CodeX-GPT】 + +## 完成内容 + +- 语义移植上游 `6d72131e` 中适用于 CE 的配额保护修复,不引入 CE 已删除的 SQL 候选预筛路径。 +- 候选配额保护改为按 `window_minutes` 判断短周期与长周期,正确覆盖 300 分钟、10080 分钟、窗口字段互换和仅长窗口账号。 +- 旧快照缺失时长时继续使用 primary=短周期、secondary=长周期的兼容约定。 +- `CODEXMANAGER_ACCOUNT_MAX_INFLIGHT` 从通用 `envOverrides` 保留键中剔除,专用设置不再写入进程环境变量;真实启动环境变量仍保持最高优先级。 + +## 主代理审计 + +- 已核对 CE 候选池仅在内存快照中判断低额度,上游 `crates/core` 的 SQL 预筛实现不适用于当前 CE 架构,因此没有直接复制。 +- 已补充旧快照缺失窗口时长回归,确认兼容分支与长短窗口互换分支均由行为测试覆盖。 +- 已复核专用持久化设置、旧 `envOverrides` 与真实进程环境变量三者的优先级。 + +## 验证 + +- `cargo fmt --all --check`:通过。 +- `cargo test -p codexmanager-service gateway::selection::tests --lib`:18 passed。 +- `cargo test -p codexmanager-service --test app_settings -- --test-threads=1`:31 passed。 diff --git a/crates/service/src/app_settings/env_overrides/catalog/items.rs b/crates/service/src/app_settings/env_overrides/catalog/items.rs index 21da5485b..d47cc7da9 100644 --- a/crates/service/src/app_settings/env_overrides/catalog/items.rs +++ b/crates/service/src/app_settings/env_overrides/catalog/items.rs @@ -16,8 +16,9 @@ pub(crate) const APP_SETTINGS_ENV_UNSUPPORTED_KEYS: &[&str] = &[ "CODEXMANAGER_RPC_TOKEN_FILE", ]; -pub(crate) const APP_SETTINGS_ENV_RESERVED_KEYS: &[&str] = &[ - "CODEXMANAGER_SERVICE_ADDR", +pub(crate) const APP_SETTINGS_ENV_RESERVED_KEYS: &[&str] = &[ + "CODEXMANAGER_ACCOUNT_MAX_INFLIGHT", + "CODEXMANAGER_SERVICE_ADDR", "CODEXMANAGER_WEB_ADDR", "CODEXMANAGER_ROUTE_STRATEGY", "CODEXMANAGER_ENABLE_REQUEST_COMPRESSION", diff --git a/crates/service/src/gateway/README.md b/crates/service/src/gateway/README.md index 1feb49864..040e95461 100644 --- a/crates/service/src/gateway/README.md +++ b/crates/service/src/gateway/README.md @@ -185,6 +185,7 @@ 行为: - 默认值是 `1` +- 启动进程显式提供的 `CODEXMANAGER_ACCOUNT_MAX_INFLIGHT` 优先于持久化设置;设置页只更新专用运行时配置,不会伪造或覆盖进程环境变量 - 含义是同一账号默认只承载一个正在进行中的 gateway 上游请求 - 当并发 Codex 会话较多时,候选预检会优先跳过已满载账号,避免多个长连接同时压到同一账号上 - 如果你明确需要更高吞吐,可以显式调大;设置为 `0` 表示关闭该保护 diff --git a/crates/service/src/gateway/core/runtime_config.rs b/crates/service/src/gateway/core/runtime_config.rs index 986b44e3a..ceba6dcb0 100644 --- a/crates/service/src/gateway/core/runtime_config.rs +++ b/crates/service/src/gateway/core/runtime_config.rs @@ -526,7 +526,6 @@ pub(crate) fn account_max_inflight_limit() -> usize { pub(crate) fn set_account_max_inflight_limit(limit: usize) -> usize { ensure_runtime_config_loaded(); ACCOUNT_MAX_INFLIGHT.store(limit, Ordering::Relaxed); - std::env::set_var(ENV_ACCOUNT_MAX_INFLIGHT, limit.to_string()); limit } diff --git a/crates/service/src/gateway/routing/selection.rs b/crates/service/src/gateway/routing/selection.rs index 867cdc667..680c7bd4e 100644 --- a/crates/service/src/gateway/routing/selection.rs +++ b/crates/service/src/gateway/routing/selection.rs @@ -25,6 +25,8 @@ static CURRENT_DB_PATH: OnceLock> = OnceLock::new(); const DEFAULT_CANDIDATE_CACHE_TTL_MS: u64 = 5_000; const USAGE_SNAPSHOT_CANDIDATE_BATCH_SIZE: usize = 500; const NO_CANDIDATE_LOG_SAMPLE_LIMIT: i64 = 12; +const MINUTES_PER_DAY: i64 = 24 * 60; +const WINDOW_ROUNDING_BIAS_MINUTES: i64 = 3; const CANDIDATE_CACHE_TTL_ENV: &str = "CODEXMANAGER_CANDIDATE_CACHE_TTL_MS"; // OpenAI 在 used_percent 未到 100 时就会触发 usage limit(常见于 ChatGPT Plus OAuth // 账号的 5 小时窗口)。将快要耗尽的账号移出正常候选,必要时按兜底开关使用低额度账号。 @@ -306,17 +308,46 @@ pub(crate) fn is_low_quota_snapshot(snap: &UsageSnapshotRecord) -> bool { } fn is_low_quota_snapshot_at(snap: &UsageSnapshotRecord, config: QuotaGuardConfig) -> bool { - let primary_low = config.primary_min_remaining_percent > 0.0 + let primary_threshold = quota_guard_threshold_for_window( + snap.window_minutes, + config.primary_min_remaining_percent, + config.secondary_min_remaining_percent, + false, + ); + let secondary_threshold = quota_guard_threshold_for_window( + snap.secondary_window_minutes, + config.primary_min_remaining_percent, + config.secondary_min_remaining_percent, + true, + ); + let primary_low = primary_threshold > 0.0 && snap .used_percent - .is_some_and(|pct| remaining_percent(pct) <= config.primary_min_remaining_percent); - let secondary_low = config.secondary_min_remaining_percent > 0.0 + .is_some_and(|pct| remaining_percent(pct) <= primary_threshold); + let secondary_low = secondary_threshold > 0.0 && snap .secondary_used_percent - .is_some_and(|pct| remaining_percent(pct) <= config.secondary_min_remaining_percent); + .is_some_and(|pct| remaining_percent(pct) <= secondary_threshold); primary_low || secondary_low } +/// 按额度窗口时长选择 5 小时或周额度阈值,并兼容旧快照缺少窗口时长的字段约定。 +fn quota_guard_threshold_for_window( + window_minutes: Option, + short_window_threshold: f64, + long_window_threshold: f64, + secondary_field: bool, +) -> f64 { + match window_minutes { + Some(minutes) if minutes > MINUTES_PER_DAY + WINDOW_ROUNDING_BIAS_MINUTES => { + long_window_threshold + } + Some(_) => short_window_threshold, + None if secondary_field => long_window_threshold, + None => short_window_threshold, + } +} + fn remaining_percent(used_percent: f64) -> f64 { (100.0 - used_percent).clamp(0.0, 100.0) } diff --git a/crates/service/src/gateway/routing/tests/selection_tests.rs b/crates/service/src/gateway/routing/tests/selection_tests.rs index bd0f8be59..e715c3cef 100644 --- a/crates/service/src/gateway/routing/tests/selection_tests.rs +++ b/crates/service/src/gateway/routing/tests/selection_tests.rs @@ -1,8 +1,8 @@ use super::{ clear_candidate_cache_for_tests, collect_gateway_candidates, - collect_gateway_candidates_with_low_quota_mode, load_usage_snapshots_for_candidates, - LowQuotaCandidateMode, CANDIDATE_CACHE_TTL_ENV, LOW_QUOTA_THRESHOLD_ENV, - QUOTA_GUARD_ALLOW_ALL_LOW_FALLBACK_ENV, + collect_gateway_candidates_with_low_quota_mode, is_low_quota_snapshot_at, + load_usage_snapshots_for_candidates, LowQuotaCandidateMode, QuotaGuardConfig, + CANDIDATE_CACHE_TTL_ENV, LOW_QUOTA_THRESHOLD_ENV, QUOTA_GUARD_ALLOW_ALL_LOW_FALLBACK_ENV, }; use crate::account_status::mark_account_unavailable_for_gateway_error; use codexmanager_core::storage::{now_ts, Account, Storage, Token, UsageSnapshotRecord}; @@ -10,6 +10,61 @@ use std::sync::{mpsc, Arc}; use std::thread; use std::time::Duration; +/// 配额保护应按真实窗口时长匹配 5 小时与周阈值,而不是依赖 primary/secondary 字段位置。 +#[test] +fn quota_guard_matches_thresholds_by_window_duration() { + let config = QuotaGuardConfig { + enabled: true, + primary_min_remaining_percent: 15.0, + secondary_min_remaining_percent: 5.0, + allow_all_low_quota_fallback: false, + }; + let swapped_windows = UsageSnapshotRecord { + account_id: "acc-swapped".to_string(), + used_percent: Some(90.0), + window_minutes: Some(10_080), + resets_at: None, + secondary_used_percent: Some(90.0), + secondary_window_minutes: Some(300), + secondary_resets_at: None, + credits_json: None, + captured_at: now_ts(), + }; + let long_window_only = UsageSnapshotRecord { + account_id: "acc-long-only".to_string(), + secondary_used_percent: None, + secondary_window_minutes: None, + ..swapped_windows.clone() + }; + let legacy_primary_without_duration = UsageSnapshotRecord { + account_id: "acc-legacy-primary".to_string(), + used_percent: Some(90.0), + window_minutes: None, + secondary_used_percent: None, + secondary_window_minutes: None, + ..swapped_windows.clone() + }; + let legacy_secondary_without_duration = UsageSnapshotRecord { + account_id: "acc-legacy-secondary".to_string(), + used_percent: None, + window_minutes: None, + secondary_used_percent: Some(90.0), + secondary_window_minutes: None, + ..swapped_windows.clone() + }; + + assert!(is_low_quota_snapshot_at(&swapped_windows, config)); + assert!(!is_low_quota_snapshot_at(&long_window_only, config)); + assert!(is_low_quota_snapshot_at( + &legacy_primary_without_duration, + config + )); + assert!(!is_low_quota_snapshot_at( + &legacy_secondary_without_duration, + config + )); +} + /// 默认候选缓存 TTL 不能是亚秒级,否则几千账号场景下会频繁重建候选池。 #[test] fn default_candidate_cache_ttl_avoids_subsecond_rebuilds() { diff --git a/crates/service/tests/app_settings.rs b/crates/service/tests/app_settings.rs index 8369a5cb6..7c3c621ad 100644 --- a/crates/service/tests/app_settings.rs +++ b/crates/service/tests/app_settings.rs @@ -15,6 +15,7 @@ const LEGACY_COMPACT_MODEL_FORWARD_RULES_SETTING_KEY: &str = "gateway.compact_mo const ISOLATED_RUNTIME_ENV_KEYS: &[&str] = &[ CODEX_IMAGE_AUTO_INJECT_TOOL_ENV, + "CODEXMANAGER_ACCOUNT_MAX_INFLIGHT", "CODEXMANAGER_SERVICE_ADDR", "CODEXMANAGER_WEB_ADDR", "CODEXMANAGER_ROUTE_STRATEGY", @@ -590,6 +591,40 @@ fn sync_runtime_settings_from_storage_preserves_process_env_when_override_not_pe }); } +/// 旧 env overrides 中的单账号并发值不得覆盖专用网关设置。 +#[test] +fn sync_runtime_settings_ignores_legacy_max_inflight_env_override() { + with_temp_db(|db_path| { + let storage = Storage::open(db_path).expect("open storage"); + storage + .set_app_setting( + codexmanager_service::APP_SETTING_GATEWAY_ACCOUNT_MAX_INFLIGHT_KEY, + "1", + now_ts(), + ) + .expect("save account max inflight"); + storage + .set_app_setting( + codexmanager_service::APP_SETTING_ENV_OVERRIDES_KEY, + &serde_json::to_string(&json!({ + "CODEXMANAGER_ACCOUNT_MAX_INFLIGHT": "0" + })) + .expect("serialize legacy env override"), + now_ts(), + ) + .expect("save legacy env override"); + drop(storage); + + codexmanager_service::sync_runtime_settings_from_storage(); + + assert_eq!( + codexmanager_service::current_gateway_account_max_inflight(), + 1 + ); + assert!(std::env::var_os("CODEXMANAGER_ACCOUNT_MAX_INFLIGHT").is_none()); + }); +} + /// 函数 `sync_runtime_settings_from_storage_preserves_explicit_process_env_over_persisted_override` /// /// 作者: gaohongshun diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index c2c76058e..bebc7854e 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -28,6 +28,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- 配额保护不再把 primary/secondary 字段位置固定解释为 5 小时/周额度,而是按服务端返回的 `window_minutes` 选择对应阈值;长短窗口互换、仅长窗口及旧快照缺失时长的账号均保持正确候选语义。单账号并发上限的专用持久化设置也不再被旧 `envOverrides` 残留覆盖或伪装成进程环境变量。 - 非 2xx 请求仍保留请求日志与 Token 明细用于诊断,但不再进入 Token、费用或平台 Key 配额汇总;迁移会重标仍保留的明细并清零纯失败的日级汇总桶,后续长期压缩也保持相同口径。 - 原生 `conversation_id` 与完整 `session_id` / `x-codex-turn-state` 会覆盖冲突的 `prompt_cache_key`,HTTP 与 Responses WebSocket 使用一致的会话锚点规则,避免重试或恢复原生会话时继续绑定到错误缓存键。 - `refresh_token_expired` 不再被永久判定为不可恢复,而是在长冷却后低频复检;Token 刷新成功后会立即排队真实用量验证,并由用量快照恢复账号状态。账号页在用量刷新事件及手动刷新完成后重新拉取账号列表,避免状态列长期停留在旧值。 diff --git a/task.md b/task.md index c6d82903d..2b3f5edd1 100644 --- a/task.md +++ b/task.md @@ -30,8 +30,7 @@ - 保留项:README 中的 Linux.do 认可社区入口需要保留,不能按作者/赞助推广残留误删。 - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`)仍有效,但 `22a47d89` 不再作为待移植项:CE 当前 Codex wire header 透传策略会丢弃该 header,已天然避免冲突。 - 2026-08-06 增量复核(详见最新巡检文档): - - 已完成语义移植:`c98047eb` 原生会话锚点与 prompt cache key 对齐;`3cd72771` 失败请求明细保留但不计 Token、费用和配额。 - - P0 下一项:`6d72131e` 按真实窗口时长选择 5 小时/周额度阈值;CE 当前 SQL 与内存候选判断仍按 primary/secondary 字段位置套阈值,窗口互换账号会误判。 + - 已完成语义移植:`c98047eb` 原生会话锚点与 prompt cache key 对齐;`3cd72771` 失败请求明细保留但不计 Token、费用和配额;`6d72131e` 按真实窗口时长应用短周期/长周期配额阈值并隔离旧并发环境覆盖。 - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量、`552e3f4d` 日志分页跳转、`0f4f79bd` 正式容器补 `stable/latest` 标签。 - 条件评估:`1617272a` GPT-5.6 价格与用户已确认的 CE 价格规则冲突,核对官方来源前不得覆盖;`a127e27a` 依赖上游 model_catalog_v2,只能按 CE 路由模型重写;`84802156` 桌面诊断、账号批量动作和 UI 响应式改造按页面拆分。 - 当前不适用:`15a90056` 针对入站 zstd 解压,CE 当前没有该解压路径;`9c6ab64d` 删除图片工具自动注入与 CE 已确认行为冲突;作者、赞助、AtomGit 与整包 README/docs 推广继续不移植。 From ddfac6d8886fb947bfd73e6ab87179697bdbc937 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:47:07 +0800 Subject: [PATCH 19/35] =?UTF-8?q?=E5=8F=91=E5=B8=83:=20=E4=B8=BA=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E5=AE=B9=E5=99=A8=E5=90=8C=E6=AD=A5=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-all.yml | 36 ++++++++++++++++--- ...56\345\274\202\345\267\241\346\243\200.md" | 4 ++- ...07\347\255\276\345\217\221\345\270\203.md" | 19 ++++++++++ docs/zh-CN/CHANGELOG.md | 1 + ...50\347\275\262\346\214\207\345\215\227.md" | 5 +-- task.md | 2 +- 6 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 ".teamwork/progress/2026-08-06_GHCR\347\250\263\345\256\232\346\240\207\347\255\276\345\217\221\345\270\203.md" diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml index 666d4b318..601c3bbbf 100644 --- a/.github/workflows/release-all.yml +++ b/.github/workflows/release-all.yml @@ -545,12 +545,40 @@ jobs: set -euo pipefail owner="$(printf '%s' "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" tag="${{ steps.release-version.outputs.tag }}" + prerelease_input="${{ github.event.inputs.prerelease || 'auto' }}" + + case "$prerelease_input" in + true) + prerelease=true + ;; + false) + prerelease=false + ;; + auto) + if [[ "$tag" == *-* ]]; then + prerelease=true + else + prerelease=false + fi + ;; + *) + echo "invalid prerelease input: $prerelease_input" >&2 + exit 1 + ;; + esac + + publish_tags=("$tag") + if [[ "$prerelease" == "false" ]]; then + publish_tags+=(stable latest) + fi - docker tag codexmanager-service:release "ghcr.io/${owner}/codexmanager-service:${tag}" - docker tag codexmanager-web:release "ghcr.io/${owner}/codexmanager-web:${tag}" + for publish_tag in "${publish_tags[@]}"; do + docker tag codexmanager-service:release "ghcr.io/${owner}/codexmanager-service:${publish_tag}" + docker tag codexmanager-web:release "ghcr.io/${owner}/codexmanager-web:${publish_tag}" - docker push "ghcr.io/${owner}/codexmanager-service:${tag}" - docker push "ghcr.io/${owner}/codexmanager-web:${tag}" + docker push "ghcr.io/${owner}/codexmanager-service:${publish_tag}" + docker push "ghcr.io/${owner}/codexmanager-web:${publish_tag}" + done - name: List release assets run: find release-assets -type f | sort diff --git "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" index 3825d1b18..f93652102 100644 --- "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" +++ "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" @@ -13,6 +13,7 @@ 1. `c98047eb`:已按 CE 的 HTTP 与 Responses WebSocket 请求链路语义移植。原生 `conversation_id` 或完整 turn state 会移除冲突的 `prompt_cache_key`。 2. `3cd72771`:已按 CE 的原始明细、日级 rollup、长期 rollup、日志汇总和平台 Key 配额架构改写。失败请求保留诊断明细,但 Token 与费用只统计 2xx。 3. `6d72131e`:已按 CE 的内存候选池结构语义移植。配额保护按真实窗口时长选择短周期/长周期阈值,并保留旧快照缺失时长时 primary=短周期、secondary=长周期的兼容约定;旧 `envOverrides` 也不再覆盖专用并发设置。 +4. `0f4f79bd`:已按 CE 的内联发布 workflow 重写。正式版推送版本号、`stable`、`latest`,预发布只推送版本号;未引入上游独立发布脚本或上游镜像地址。 ## 下一批优先级 @@ -21,11 +22,12 @@ | P1 | `2aa9bec3` | CE 的 `login_sessions` 已有 `group_name` 列,登录流程也传入该值,但存取 SQL 忽略它,属于已确认的数据丢失 bug,无需新增迁移即可修复。 | | P1 | `7716828f` | CE failover 目前只递归删除 `encrypted_content` 字段,会留下缺字段的 reasoning/compaction 壳对象;应按 item 类型删除整个账号绑定历史项并补索引位置回归。 | | P1 | `85cf8c73` | CE WebSocket 仅使用应用内上游代理,未读取 `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`;应为 HTTP 与 WS 对齐环境代理语义。 | -| P1 | `0f4f79bd` | CE 发布工作流目前只推版本 tag;正式版可补 `stable/latest`,预发布不得更新稳定标签。CE 已改成 workflow 内联发布,需要重写而非复制上游脚本。 | | P2 | `552e3f4d` | 日志页分页跳转是独立低风险 UI 能力,可在后端分页稳定后直接按 CE 组件样式实现。 | `6d72131e` 验证:`gateway::selection::tests` 18 项通过,`app_settings` 31 项单线程通过,`cargo fmt --all --check` 通过。 +`0f4f79bd` 验证:`release-all.yml` 经 `js-yaml` 解析通过;内联 Bash 对稳定版、自动预发布、显式正式版和显式预发布四种输入的推送数量断言通过。 + ## 条件评估 - `1617272a`:上游把 GPT-5.6 Terra/Luna 价格改成另一组数值,与用户此前明确提供的 CE 规则冲突。没有官方价格页面或用户裁决前不覆盖。 diff --git "a/.teamwork/progress/2026-08-06_GHCR\347\250\263\345\256\232\346\240\207\347\255\276\345\217\221\345\270\203.md" "b/.teamwork/progress/2026-08-06_GHCR\347\250\263\345\256\232\346\240\207\347\255\276\345\217\221\345\270\203.md" new file mode 100644 index 000000000..a75b478b7 --- /dev/null +++ "b/.teamwork/progress/2026-08-06_GHCR\347\250\263\345\256\232\346\240\207\347\255\276\345\217\221\345\270\203.md" @@ -0,0 +1,19 @@ +# GHCR 稳定标签发布进度 + +执行与审计身份:【CodeX-GPT】 + +## 完成内容 + +- CE 正式版容器发布除版本号外同步推送 `stable` 与 `latest`。 +- 预发布在自动识别或显式 `prerelease=true` 时只推送版本号,不覆盖稳定浮动标签。 +- 显式 `prerelease=false` 仍按正式版处理,和 GitHub Release 现有输入语义一致。 +- 保留 CE 的 `ghcr.io/creatoredition` 镜像归属与内联 workflow,不引入上游发布脚本。 + +## 验证 + +- 使用 `js-yaml` 解析 `.github/workflows/release-all.yml`:通过。 +- 抽取 workflow 原始 Bash 片段并使用模拟 `docker` 验证: + - `v1.2.3 + auto`:两个镜像各推版本号、`stable`、`latest`。 + - `v1.2.3-rc.1 + auto`:两个镜像各只推版本号。 + - `v1.2.3-rc.1 + false`:两个镜像各推三个标签。 + - `v1.2.3 + true`:两个镜像各只推版本号。 diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index bebc7854e..5b336f8d6 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### Changed +- GHCR 正式版发布会在版本标签之外同步更新 `stable` 与 `latest`;预发布默认或显式标记为预发布时只推送版本标签,不会覆盖稳定浮动标签。 - 新增出口网络诊断:启动时异步首检,区域阻断事件触发节流检查,并在存在区域阻断账号时低频兜底;诊断按固定白名单 IP 服务失败切换,沿用上游代理,仅向管理员展示缓存后的 IP、国家与 ASN 信息,不直接改变账号状态。 - Docker Compose 与多语言部署文档统一使用 `ghcr.io/creatoredition` 镜像;`db-optimize` 改为必须显式指定数据库路径、默认只读检查,并仅在传入 `--vacuum` 时执行 checkpoint/VACUUM。 - Dashboard 日级趋势、用户排行和来源统计改用真实本地自然日边界,夏令时切换日按 23/25 小时聚合;后台维护只处理本批真实存在待汇总明细的日期,避免稀疏多年历史产生大量空事务。 diff --git "a/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" "b/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" index 5281a3cbf..ffdcdb654 100644 --- "a/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" +++ "b/docs/zh-CN/report/\350\277\220\350\241\214\344\270\216\351\203\250\347\275\262\346\214\207\345\215\227.md" @@ -110,9 +110,10 @@ wire_api = "responses" ### GitHub Packages / GHCR - Release 发布后会同时推送 `codexmanager-service` 和 `codexmanager-web` 镜像到 GitHub Packages(GHCR)。 -- 直接拉取对应发布 tag 即可,例如:`docker pull ghcr.io/creatoredition/codexmanager-service:v0.1.15` +- 正式版本会同时更新版本号、`stable` 和 `latest` 标签;预发布版本只推送版本号标签,不会覆盖浮动标签。 +- 自动更新可使用 `stable`;固定版本可直接拉取对应发布 tag,例如:`docker pull ghcr.io/creatoredition/codexmanager-service:v0.3.11` - 仓库里的 [`docker/docker-compose.release.yml`](../../../docker/docker-compose.release.yml) 也直接引用 GHCR,使用前先设置 `CODEXMANAGER_RELEASE_TAG`。 -- 例如:`CODEXMANAGER_RELEASE_TAG=v0.1.15 docker compose -f docker/docker-compose.release.yml up -d` +- 例如:`CODEXMANAGER_RELEASE_TAG=stable docker compose -f docker/docker-compose.release.yml up -d` ### 方式 1:all-in-one 单容器 ```bash diff --git a/task.md b/task.md index 2b3f5edd1..0d5a2ae44 100644 --- a/task.md +++ b/task.md @@ -31,7 +31,7 @@ - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`)仍有效,但 `22a47d89` 不再作为待移植项:CE 当前 Codex wire header 透传策略会丢弃该 header,已天然避免冲突。 - 2026-08-06 增量复核(详见最新巡检文档): - 已完成语义移植:`c98047eb` 原生会话锚点与 prompt cache key 对齐;`3cd72771` 失败请求明细保留但不计 Token、费用和配额;`6d72131e` 按真实窗口时长应用短周期/长周期配额阈值并隔离旧并发环境覆盖。 - - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量、`552e3f4d` 日志分页跳转、`0f4f79bd` 正式容器补 `stable/latest` 标签。 + - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量、`552e3f4d` 日志分页跳转。 - 条件评估:`1617272a` GPT-5.6 价格与用户已确认的 CE 价格规则冲突,核对官方来源前不得覆盖;`a127e27a` 依赖上游 model_catalog_v2,只能按 CE 路由模型重写;`84802156` 桌面诊断、账号批量动作和 UI 响应式改造按页面拆分。 - 当前不适用:`15a90056` 针对入站 zstd 解压,CE 当前没有该解压路径;`9c6ab64d` 删除图片工具自动注入与 CE 已确认行为冲突;作者、赞助、AtomGit 与整包 README/docs 推广继续不移植。 From 0b201808c5811a057b4f552af09ceaa04d735d1f Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:09:00 +0800 Subject: [PATCH 20/35] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E9=A1=B5=E7=A0=81=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...56\345\274\202\345\267\241\346\243\200.md" | 5 +- ...06\351\241\265\350\267\263\350\275\254.md" | 23 ++++ apps/playwright.config.ts | 2 +- apps/src/app/logs/page-sections.tsx | 125 +++++++++++++++--- apps/src/app/logs/page.tsx | 7 +- apps/src/lib/i18n/messages/en.ts | 2 + apps/src/lib/i18n/messages/ko.ts | 2 + apps/src/lib/i18n/messages/ru.ts | 2 + apps/tests/request-logs-duration.spec.ts | 38 +++++- docs/zh-CN/CHANGELOG.md | 1 + task.md | 3 +- 11 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 ".teamwork/progress/2026-08-06_\350\257\267\346\261\202\346\227\245\345\277\227\345\210\206\351\241\265\350\267\263\350\275\254.md" diff --git "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" index f93652102..cd4a152be 100644 --- "a/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" +++ "b/.teamwork/discussions/2026-08-06_\344\270\212\346\270\270v0.5.2\345\220\216\347\273\255\345\267\256\345\274\202\345\267\241\346\243\200.md" @@ -14,6 +14,7 @@ 2. `3cd72771`:已按 CE 的原始明细、日级 rollup、长期 rollup、日志汇总和平台 Key 配额架构改写。失败请求保留诊断明细,但 Token 与费用只统计 2xx。 3. `6d72131e`:已按 CE 的内存候选池结构语义移植。配额保护按真实窗口时长选择短周期/长周期阈值,并保留旧快照缺失时长时 primary=短周期、secondary=长周期的兼容约定;旧 `envOverrides` 也不再覆盖专用并发设置。 4. `0f4f79bd`:已按 CE 的内联发布 workflow 重写。正式版推送版本号、`stable`、`latest`,预发布只推送版本号;未引入上游独立发布脚本或上游镜像地址。 +5. `552e3f4d`:已按 CE 当前日志页布局重写首页与页码跳转。使用图标按钮、边界钳制和窄内容区内部换行,没有复制上游会扩大工具栏宽度的文本按钮布局。 ## 下一批优先级 @@ -22,17 +23,19 @@ | P1 | `2aa9bec3` | CE 的 `login_sessions` 已有 `group_name` 列,登录流程也传入该值,但存取 SQL 忽略它,属于已确认的数据丢失 bug,无需新增迁移即可修复。 | | P1 | `7716828f` | CE failover 目前只递归删除 `encrypted_content` 字段,会留下缺字段的 reasoning/compaction 壳对象;应按 item 类型删除整个账号绑定历史项并补索引位置回归。 | | P1 | `85cf8c73` | CE WebSocket 仅使用应用内上游代理,未读取 `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`;应为 HTTP 与 WS 对齐环境代理语义。 | -| P2 | `552e3f4d` | 日志页分页跳转是独立低风险 UI 能力,可在后端分页稳定后直接按 CE 组件样式实现。 | `6d72131e` 验证:`gateway::selection::tests` 18 项通过,`app_settings` 31 项单线程通过,`cargo fmt --all --check` 通过。 `0f4f79bd` 验证:`release-all.yml` 经 `js-yaml` 解析通过;内联 Bash 对稳定版、自动预发布、显式正式版和显式预发布四种输入的推送数量断言通过。 +`552e3f4d` 验证:Next 生产构建、TypeScript、相关 ESLint、i18n 覆盖均通过;Playwright 使用系统 Chrome 验证页码 4、越界钳制到第 5 页、缓存返回首页及 390px 分页区域零横向溢出。 + ## 条件评估 - `1617272a`:上游把 GPT-5.6 Terra/Luna 价格改成另一组数值,与用户此前明确提供的 CE 规则冲突。没有官方价格页面或用户裁决前不覆盖。 - `a127e27a`:修复“混合模式 + 纯聚合模型路由”,方向正确,但实现依赖上游 `model_catalog_v2`。CE 必须基于现有 model source mapping 和 aggregate candidate 语义重写。 - `84802156`:桌面启动诊断和有界日志有价值,但改动约 900 行并耦合上游 updater/app storage,先拆成日志上限、诊断 RPC、设置页展示三个子项。 +- 390px 实测确认 CE 侧边栏不会折叠,主内容会被压缩到约 118px;这是应用外壳级响应式问题,应从 `84802156` 的 UI 改造中单独拆项,不和日志分页局部修复混合。 - `02c17c45`:包含流预检、失败切换、Skills 市场和 WebSocket 大重构,单提交超过 4,000 行,禁止整包移植;仅按可复现故障逐项抽取。 - `331fb0d0`、`e44bfe35`、账号 move-to-top/批量状态、窄屏表格与 header 改造:属于产品/UI 能力,需结合 CE 已有轻量关闭和响应式修复逐页验收。 - `2c61d38e`、`afe86293`、`1dbe17da`:桌面 dev server 清理、NVM 环境保留和预期断连降噪可合并为启动脚本健壮性主题处理。 diff --git "a/.teamwork/progress/2026-08-06_\350\257\267\346\261\202\346\227\245\345\277\227\345\210\206\351\241\265\350\267\263\350\275\254.md" "b/.teamwork/progress/2026-08-06_\350\257\267\346\261\202\346\227\245\345\277\227\345\210\206\351\241\265\350\267\263\350\275\254.md" new file mode 100644 index 000000000..73ca29b71 --- /dev/null +++ "b/.teamwork/progress/2026-08-06_\350\257\267\346\261\202\346\227\245\345\277\227\345\210\206\351\241\265\350\267\263\350\275\254.md" @@ -0,0 +1,23 @@ +# 请求日志分页跳转进度 + +执行与审计身份:【CodeX-GPT】 + +## 完成内容 + +- 语义移植上游 `552e3f4d` 的首页与页码跳转能力,并按 CE 当前日志页工具栏重新布局。 +- 首页、上一页、下一页和确认使用 Lucide 图标按钮,均保留可访问名称和悬停说明。 +- 跳转页码只接受安全整数,自动钳制到 `1..totalPages`;空值或无效值恢复当前页。 +- 窄内容区会在控件组内部换行,并隐藏可由 `aria-label` 替代的辅助标签,避免横向溢出。 +- Playwright 启动命令改为 `corepack pnpm`,避免本地无 TTY 包装器误触发依赖重装。 + +## 验证 + +- `corepack pnpm run build:desktop`:Next 生产构建通过。 +- `tsc --noEmit`:通过。 +- 相关日志页、测试与 Playwright 配置 ESLint:0 error、0 warning。 +- `node --test apps/tests/i18n-page-coverage.test.mjs`:3 passed。 +- Playwright `request-logs-duration.spec.ts`:1 passed,覆盖第 4 页、越界钳制到第 5 页、首页缓存回退和 390px 分页区域零横向溢出。 + +## 独立发现 + +- 390px 下应用侧边栏仍固定占位,导致主内容宽度约 118px;分页控件已能在该极端宽度内无溢出,但应用外壳响应式需要作为独立任务处理。 diff --git a/apps/playwright.config.ts b/apps/playwright.config.ts index dcbd2fc3b..87cf9a2cd 100644 --- a/apps/playwright.config.ts +++ b/apps/playwright.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ video: "retain-on-failure", }, webServer: { - command: "pnpm run build:desktop && node tests/support/static-server.mjs", + command: "corepack pnpm run build:desktop && node tests/support/static-server.mjs", url: `http://${LOCAL_TEST_HOST}:${PORT}`, reuseExistingServer: false, timeout: 120_000, diff --git a/apps/src/app/logs/page-sections.tsx b/apps/src/app/logs/page-sections.tsx index d8ebde4d1..939f111b5 100644 --- a/apps/src/app/logs/page-sections.tsx +++ b/apps/src/app/logs/page-sections.tsx @@ -1,6 +1,18 @@ "use client"; -import { AlertTriangle, CheckCircle2, Database, RefreshCw, Trash2, Zap } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + ChevronLeft, + ChevronRight, + ChevronsLeft, + CornerDownLeft, + Database, + RefreshCw, + Trash2, + Zap, +} from "lucide-react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -21,7 +33,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { buildStaticRouteUrl } from "@/lib/utils/static-routes"; import { formatTsFromSeconds } from "@/lib/utils/usage"; import { cn } from "@/lib/utils"; import { @@ -78,8 +89,10 @@ export function RequestLogsTabContent({ onEndTimeChange, onClearTimeRange, onPageSizeChange, + onFirstPage, onPreviousPage, onNextPage, + onJumpPage, }: { t: TranslateFn; isAdminMode: boolean; @@ -113,9 +126,29 @@ export function RequestLogsTabContent({ onEndTimeChange: (value: string) => void; onClearTimeRange: () => void; onPageSizeChange: (value: string | null) => void; + onFirstPage: () => void; onPreviousPage: () => void; onNextPage: () => void; + onJumpPage: (page: number) => void; }) { + const [jumpPageInput, setJumpPageInput] = useState(String(currentPage)); + + useEffect(() => { + setJumpPageInput(String(currentPage)); + }, [currentPage]); + + const submitJumpPage = () => { + const normalizedInput = jumpPageInput.trim(); + const parsedPage = normalizedInput ? Number(normalizedInput) : Number.NaN; + const targetPage = Number.isSafeInteger(parsedPage) + ? Math.min(totalPages, Math.max(1, parsedPage)) + : currentPage; + setJumpPageInput(String(targetPage)); + if (targetPage !== currentPage) { + onJumpPage(targetPage); + } + }; + return (
@@ -404,17 +437,23 @@ export function RequestLogsTabContent({ -
+
{t("共")} {summary.filteredCount} {t("条匹配日志")}
-
-
- +
+
+ {t("每页显示")}
-
+
+ -
+
{t("第")} {currentPage} / {totalPages} {t("页")}
+
{ + event.preventDefault(); + submitJumpPage(); + }} + > + + setJumpPageInput(event.target.value)} + onBlur={() => { + if (!jumpPageInput.trim()) { + setJumpPageInput(String(currentPage)); + } + }} + /> + + {t("页")} + + +
diff --git a/apps/src/app/logs/page.tsx b/apps/src/app/logs/page.tsx index 671c87bbf..f3db4b8e3 100644 --- a/apps/src/app/logs/page.tsx +++ b/apps/src/app/logs/page.tsx @@ -35,9 +35,10 @@ import { fromDateTimeLocalValue, } from "./page-helpers"; import { buildSummaryPlaceholder } from "./page-cells"; -import { AccountListResult, ApiKey, RequestLogListResult, StartupSnapshot } from "@/types"; +import { ApiKey, RequestLogListResult, StartupSnapshot } from "@/types"; const REQUEST_LOG_LIST_REFETCH_INTERVAL_MS = 30_000; +const EMPTY_REQUEST_LOGS: RequestLogListResult["items"] = []; function LogsPageContent() { const { t } = useI18n(); @@ -161,7 +162,7 @@ function LogsPageContent() { }, }); - const logs = logsResult?.items || []; + const logs = logsResult?.items || EMPTY_REQUEST_LOGS; const apiKeyLookupIds = useMemo(() => { const ids = logs .map((item) => String(item.keyId || "").trim()) @@ -459,8 +460,10 @@ function LogsPageContent() { setPageSize(value || "10"); setPage(1); }} + onFirstPage={() => setPage(1)} onPreviousPage={() => setPage(Math.max(1, currentPage - 1))} onNextPage={() => setPage(Math.min(totalPages, currentPage + 1))} + onJumpPage={setPage} /> diff --git a/apps/src/lib/i18n/messages/en.ts b/apps/src/lib/i18n/messages/en.ts index 06b7c764e..388fd489e 100644 --- a/apps/src/lib/i18n/messages/en.ts +++ b/apps/src/lib/i18n/messages/en.ts @@ -402,7 +402,9 @@ export const EN_MESSAGES: MessageCatalog = { 已选择: "Selected", 个: "", 每页显示: "Per page", + 首页: "First page", 上一页: "Previous", + 跳至: "Go to", 第: "Page", 页: "", 删除账号: "Delete account", diff --git a/apps/src/lib/i18n/messages/ko.ts b/apps/src/lib/i18n/messages/ko.ts index 141057f51..23e68a49a 100644 --- a/apps/src/lib/i18n/messages/ko.ts +++ b/apps/src/lib/i18n/messages/ko.ts @@ -362,7 +362,9 @@ export const KO_MESSAGES: MessageCatalog = { 已选择: "선택됨", 个: "", 每页显示: "페이지당", + 首页: "첫 페이지", 上一页: "이전", + 跳至: "이동", 第: "페이지", 页: "", 删除账号: "계정 삭제", diff --git a/apps/src/lib/i18n/messages/ru.ts b/apps/src/lib/i18n/messages/ru.ts index b8ea3e5f9..80ffdee91 100644 --- a/apps/src/lib/i18n/messages/ru.ts +++ b/apps/src/lib/i18n/messages/ru.ts @@ -364,7 +364,9 @@ export const RU_MESSAGES: MessageCatalog = { 已选择: "Выбрано", 个: "", 每页显示: "На странице", + 首页: "Первая страница", 上一页: "Назад", + 跳至: "Перейти", 第: "Страница", 页: "", 删除账号: "Удалить аккаунт", diff --git a/apps/tests/request-logs-duration.spec.ts b/apps/tests/request-logs-duration.spec.ts index 7bcf9216b..b60325ef5 100644 --- a/apps/tests/request-logs-duration.spec.ts +++ b/apps/tests/request-logs-duration.spec.ts @@ -54,6 +54,7 @@ const SETTINGS_SNAPSHOT = { test("request logs display total duration and first-response latency", async ({ page, }) => { + const requestedPages: number[] = []; await page.route("**/api/runtime*", async (route) => { await route.fulfill({ contentType: "application/json; charset=utf-8", @@ -118,6 +119,8 @@ test("request logs display total duration and first-response latency", async ({ return; } if (method === "requestlog/list") { + const requestedPage = Number(payload?.params?.page) || 1; + requestedPages.push(requestedPage); await ok({ items: [ { @@ -142,8 +145,8 @@ test("request logs display total duration and first-response latency", async ({ created_at: 1770000000, }, ], - total: 1, - page: 1, + total: 50, + page: requestedPage, pageSize: 10, }); return; @@ -184,4 +187,35 @@ test("request logs display total duration and first-response latency", async ({ page.getByText("=> chatgpt.com/backend-api/codex/responses"), ).toBeVisible(); await expect(page.getByText("转发 gpt-5.4-openai-compact")).toBeVisible(); + + const jumpPageInput = page.getByLabel("跳至"); + await jumpPageInput.fill("4"); + await jumpPageInput.press("Enter"); + await expect.poll(() => requestedPages.at(-1)).toBe(4); + await expect(page.getByText("第 4 / 5 页")).toBeVisible(); + + await jumpPageInput.fill("999"); + await jumpPageInput.press("Enter"); + await expect.poll(() => requestedPages.at(-1)).toBe(5); + await expect(page.getByRole("button", { name: "下一页" })).toBeDisabled(); + + await page.getByRole("button", { name: "首页" }).click(); + await expect(page.getByText("第 1 / 5 页")).toBeVisible(); + await expect(page.getByRole("button", { name: "首页" })).toBeDisabled(); + + await page.setViewportSize({ width: 390, height: 844 }); + const pagination = page.getByTestId("request-log-pagination"); + await pagination.scrollIntoViewIfNeeded(); + const paginationGeometry = await pagination + .evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + children: Array.from(element.children).map((child) => ({ + clientWidth: (child as HTMLElement).clientWidth, + scrollWidth: (child as HTMLElement).scrollWidth, + rect: (child as HTMLElement).getBoundingClientRect().toJSON(), + })), + })); + const paginationOverflow = paginationGeometry.scrollWidth - paginationGeometry.clientWidth; + expect(paginationOverflow).toBeLessThanOrEqual(1); }); diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 5b336f8d6..9d4a857fe 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### Changed +- 请求日志分页新增首页与页码跳转控件,输入会按有效页数钳制;分页工具栏在窄内容区自动换行并使用图标按钮,避免新增控件再次挤出视口。 - GHCR 正式版发布会在版本标签之外同步更新 `stable` 与 `latest`;预发布默认或显式标记为预发布时只推送版本标签,不会覆盖稳定浮动标签。 - 新增出口网络诊断:启动时异步首检,区域阻断事件触发节流检查,并在存在区域阻断账号时低频兜底;诊断按固定白名单 IP 服务失败切换,沿用上游代理,仅向管理员展示缓存后的 IP、国家与 ASN 信息,不直接改变账号状态。 - Docker Compose 与多语言部署文档统一使用 `ghcr.io/creatoredition` 镜像;`db-optimize` 改为必须显式指定数据库路径、默认只读检查,并仅在传入 `--vacuum` 时执行 checkpoint/VACUUM。 diff --git a/task.md b/task.md index 0d5a2ae44..52b1507b9 100644 --- a/task.md +++ b/task.md @@ -31,7 +31,8 @@ - 2026-07-25 增量巡检分类(详见 `.teamwork/discussions/2026-07-25_上游v0.5.0差异巡检.md`)仍有效,但 `22a47d89` 不再作为待移植项:CE 当前 Codex wire header 透传策略会丢弃该 header,已天然避免冲突。 - 2026-08-06 增量复核(详见最新巡检文档): - 已完成语义移植:`c98047eb` 原生会话锚点与 prompt cache key 对齐;`3cd72771` 失败请求明细保留但不计 Token、费用和配额;`6d72131e` 按真实窗口时长应用短周期/长周期配额阈值并隔离旧并发环境覆盖。 - - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量、`552e3f4d` 日志分页跳转。 + - P1 最小移植:`2aa9bec3` 登录会话分组持久化、`7716828f` failover 时删除账号绑定的 compaction/reasoning 历史项、`85cf8c73` Responses WebSocket 读取标准代理环境变量。 + - P1 前端响应式:390px 实测侧边栏仍固定占位,主内容被压到约 118px;需要把移动端侧边栏折叠从上游大体量 UI 改造中独立拆出并逐页验收。 - 条件评估:`1617272a` GPT-5.6 价格与用户已确认的 CE 价格规则冲突,核对官方来源前不得覆盖;`a127e27a` 依赖上游 model_catalog_v2,只能按 CE 路由模型重写;`84802156` 桌面诊断、账号批量动作和 UI 响应式改造按页面拆分。 - 当前不适用:`15a90056` 针对入站 zstd 解压,CE 当前没有该解压路径;`9c6ab64d` 删除图片工具自动注入与 CE 已确认行为冲突;作者、赞助、AtomGit 与整包 README/docs 推广继续不移植。 From 8148202cbb5fe81889448b05c1019ca70ef50ea2 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:24:34 +0800 Subject: [PATCH 21/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E7=99=BB=E5=BD=95=E4=BC=9A=E8=AF=9D=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/storage/mod.rs | 14 ++-- crates/core/tests/storage.rs | 9 ++- crates/core/tests/storage/migration_tests.rs | 81 +++++++++++++++++--- docs/zh-CN/CHANGELOG.md | 1 + 4 files changed, 88 insertions(+), 17 deletions(-) diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index d12af687d..50e2d2d81 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -1277,6 +1277,9 @@ impl Storage { include_str!("../../migrations/077_request_token_stats_successful_usage.sql"), |s| s.ensure_request_token_stats_usage_included_column(), )?; + self.apply_compat_migration("078_login_sessions_group_name", |s| { + s.ensure_column("login_sessions", "group_name", "TEXT") + })?; self.ensure_api_key_rotation_columns()?; self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; @@ -1375,7 +1378,7 @@ impl Storage { /// 返回函数执行结果 pub fn insert_login_session(&self, session: &LoginSession) -> Result<()> { self.conn.execute( - "INSERT INTO login_sessions (login_id, code_verifier, state, status, error, workspace_id, note, tags, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO login_sessions (login_id, code_verifier, state, status, error, workspace_id, note, tags, group_name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ( &session.login_id, &session.code_verifier, @@ -1385,6 +1388,7 @@ impl Storage { &session.workspace_id, &session.note, &session.tags, + &session.group_name, session.created_at, session.updated_at, ), @@ -1406,7 +1410,7 @@ impl Storage { /// 返回函数执行结果 pub fn get_login_session(&self, login_id: &str) -> Result> { let mut stmt = self.conn.prepare( - "SELECT login_id, code_verifier, state, status, error, workspace_id, note, tags, created_at, updated_at FROM login_sessions WHERE login_id = ?1", + "SELECT login_id, code_verifier, state, status, error, workspace_id, note, tags, group_name, created_at, updated_at FROM login_sessions WHERE login_id = ?1", )?; let mut rows = stmt.query([login_id])?; if let Some(row) = rows.next()? { @@ -1419,9 +1423,9 @@ impl Storage { workspace_id: row.get(5)?, note: row.get(6)?, tags: row.get(7)?, - group_name: None, - created_at: row.get(8)?, - updated_at: row.get(9)?, + group_name: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, })) } else { Ok(None) diff --git a/crates/core/tests/storage.rs b/crates/core/tests/storage.rs index 6bf386ef3..ade6167d1 100644 --- a/crates/core/tests/storage.rs +++ b/crates/core/tests/storage.rs @@ -657,9 +657,9 @@ fn storage_login_session_roundtrip() { status: "pending".to_string(), error: None, workspace_id: Some("org_123".to_string()), - note: None, - tags: None, - group_name: None, + note: Some("主账号".to_string()), + tags: Some("工作,Plus".to_string()), + group_name: Some("团队 A".to_string()), created_at: now_ts(), updated_at: now_ts(), }; @@ -672,6 +672,9 @@ fn storage_login_session_roundtrip() { .expect("session exists"); assert_eq!(loaded.status, "pending"); assert_eq!(loaded.workspace_id.as_deref(), Some("org_123")); + assert_eq!(loaded.note.as_deref(), Some("主账号")); + assert_eq!(loaded.tags.as_deref(), Some("工作,Plus")); + assert_eq!(loaded.group_name.as_deref(), Some("团队 A")); } /// 函数 `storage_account_metadata_roundtrip_and_delete_cleanup` diff --git a/crates/core/tests/storage/migration_tests.rs b/crates/core/tests/storage/migration_tests.rs index d7fa743fc..a9cc533f5 100644 --- a/crates/core/tests/storage/migration_tests.rs +++ b/crates/core/tests/storage/migration_tests.rs @@ -322,15 +322,24 @@ fn init_tracks_schema_migrations_and_is_idempotent() { ) .expect("count 067 migration"); assert_eq!(applied_067, 1); - let applied_068: i64 = storage - .conn - .query_row( + let applied_068: i64 = storage + .conn + .query_row( "SELECT COUNT(1) FROM schema_migrations WHERE version = '068_request_logs_route_strategy_source'", [], |row| row.get(0), ) - .expect("count 068 migration"); - assert_eq!(applied_068, 1); + .expect("count 068 migration"); + assert_eq!(applied_068, 1); + let applied_078: i64 = storage + .conn + .query_row( + "SELECT COUNT(1) FROM schema_migrations WHERE version = '078_login_sessions_group_name'", + [], + |row| row.get(0), + ) + .expect("count 078 migration"); + assert_eq!(applied_078, 1); assert!(!storage .has_column("accounts", "note") @@ -413,9 +422,12 @@ fn init_tracks_schema_migrations_and_is_idempotent() { assert!(storage .has_column("app_settings", "value") .expect("check app_settings.value")); - assert!(storage - .has_column("login_sessions", "workspace_id") - .expect("check login_sessions.workspace_id")); + assert!(storage + .has_column("login_sessions", "workspace_id") + .expect("check login_sessions.workspace_id")); + assert!(storage + .has_column("login_sessions", "group_name") + .expect("check login_sessions.group_name")); assert!(storage .has_column("conversation_bindings", "thread_anchor") .expect("check conversation_bindings.thread_anchor")); @@ -476,7 +488,58 @@ fn init_tracks_schema_migrations_and_is_idempotent() { assert!(!storage .has_column("request_logs", "reasoning_output_tokens") .expect("check request_logs.reasoning_output_tokens")); -} +} + +/// 函数 `login_session_group_name_migration_preserves_legacy_rows` +/// +/// 中文说明:模拟迁移 036 之后尚无分组列的旧库,确认补列后既有会话按缺省值读取。 +#[test] +fn login_session_group_name_migration_preserves_legacy_rows() { + let storage = Storage::open_in_memory().expect("open in memory"); + storage + .conn + .execute_batch( + "CREATE TABLE login_sessions ( + login_id TEXT PRIMARY KEY, + code_verifier TEXT NOT NULL, + state TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT, + workspace_id TEXT, + note TEXT, + tags TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO login_sessions ( + login_id, code_verifier, state, status, error, workspace_id, + note, tags, created_at, updated_at + ) VALUES ( + 'legacy-login', 'legacy-verifier', 'legacy-state', 'pending', NULL, + 'legacy-workspace', 'legacy-note', 'legacy-tag', 100, 200 + );", + ) + .expect("create legacy login session"); + storage + .ensure_migrations_table() + .expect("ensure migration tracker"); + storage + .apply_compat_migration("078_login_sessions_group_name", |s| { + s.ensure_column("login_sessions", "group_name", "TEXT") + }) + .expect("apply login session group migration"); + + let loaded = storage + .get_login_session("legacy-login") + .expect("load legacy login session") + .expect("legacy login session exists"); + assert_eq!(loaded.workspace_id.as_deref(), Some("legacy-workspace")); + assert_eq!(loaded.note.as_deref(), Some("legacy-note")); + assert_eq!(loaded.tags.as_deref(), Some("legacy-tag")); + assert_eq!(loaded.group_name, None); + assert_eq!(loaded.created_at, 100); + assert_eq!(loaded.updated_at, 200); +} /// 函数 `file_open_enables_wal_and_normal_synchronous` /// diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 9d4a857fe..597a33c7d 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -30,6 +30,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- OAuth 登录会话会完整持久化所选账号分组;升级旧数据库时自动补齐 `login_sessions.group_name`,避免登录完成后账号静默丢失预选分组。 - 配额保护不再把 primary/secondary 字段位置固定解释为 5 小时/周额度,而是按服务端返回的 `window_minutes` 选择对应阈值;长短窗口互换、仅长窗口及旧快照缺失时长的账号均保持正确候选语义。单账号并发上限的专用持久化设置也不再被旧 `envOverrides` 残留覆盖或伪装成进程环境变量。 - 非 2xx 请求仍保留请求日志与 Token 明细用于诊断,但不再进入 Token、费用或平台 Key 配额汇总;迁移会重标仍保留的明细并清零纯失败的日级汇总桶,后续长期压缩也保持相同口径。 - 原生 `conversation_id` 与完整 `session_id` / `x-codex-turn-state` 会覆盖冲突的 `prompt_cache_key`,HTTP 与 Responses WebSocket 使用一致的会话锚点规则,避免重试或恢复原生会话时继续绑定到错误缓存键。 From 067307f4804885b6378437c153b573e1216de8b8 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:25:25 +0800 Subject: [PATCH 22/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=88=87=E5=8F=B7?= =?UTF-8?q?=E6=97=B6=E6=B8=85=E7=90=86=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/gateway/auth/openai_fallback.rs | 79 +------ .../proxy_pipeline/candidate_executor.rs | 2 +- .../proxy_pipeline/candidate_state.rs | 63 ++++- .../upstream/proxy_pipeline/request_setup.rs | 8 +- .../upstream/support/payload_rewrite.rs | 220 +++++++++++++++--- docs/zh-CN/CHANGELOG.md | 1 + 6 files changed, 262 insertions(+), 111 deletions(-) diff --git a/crates/service/src/gateway/auth/openai_fallback.rs b/crates/service/src/gateway/auth/openai_fallback.rs index 44d7348d5..db817bfb9 100644 --- a/crates/service/src/gateway/auth/openai_fallback.rs +++ b/crates/service/src/gateway/auth/openai_fallback.rs @@ -2,9 +2,12 @@ use bytes::Bytes; use codexmanager_core::storage::{Account, Storage, Token}; use reqwest::blocking::Client; use reqwest::Method; -use serde_json::Value; use std::time::Instant; +use super::upstream::support::payload_rewrite::{ + body_has_account_scoped_history_hint, strip_encrypted_content_from_body, +}; + /// 函数 `should_force_connection_close` /// /// 作者: gaohongshun @@ -45,78 +48,6 @@ fn force_connection_close(headers: &mut Vec<(String, String)>) { } } -/// 函数 `body_has_encrypted_content_hint` -/// -/// 作者: gaohongshun -/// -/// 时间: 2026-04-02 -/// -/// # 参数 -/// - body: 参数 body -/// -/// # 返回 -/// 返回函数执行结果 -fn body_has_encrypted_content_hint(body: &[u8]) -> bool { - // Fast path: avoid JSON parsing unless we hit the recovery path. - std::str::from_utf8(body) - .ok() - .is_some_and(|text| text.contains("\"encrypted_content\"")) -} - -/// 函数 `strip_encrypted_content_value` -/// -/// 作者: gaohongshun -/// -/// 时间: 2026-04-02 -/// -/// # 参数 -/// - value: 参数 value -/// -/// # 返回 -/// 返回函数执行结果 -fn strip_encrypted_content_value(value: &mut Value) -> bool { - match value { - Value::Object(map) => { - let mut changed = map.remove("encrypted_content").is_some(); - for v in map.values_mut() { - if strip_encrypted_content_value(v) { - changed = true; - } - } - changed - } - Value::Array(items) => { - let mut changed = false; - for item in items.iter_mut() { - if strip_encrypted_content_value(item) { - changed = true; - } - } - changed - } - _ => false, - } -} - -/// 函数 `strip_encrypted_content_from_body` -/// -/// 作者: gaohongshun -/// -/// 时间: 2026-04-02 -/// -/// # 参数 -/// - body: 参数 body -/// -/// # 返回 -/// 返回函数执行结果 -fn strip_encrypted_content_from_body(body: &[u8]) -> Option> { - let mut value: Value = serde_json::from_slice(body).ok()?; - if !strip_encrypted_content_value(&mut value) { - return None; - } - serde_json::to_vec(&value).ok() -} - /// 函数 `extract_prompt_cache_key` /// /// 作者: gaohongshun @@ -220,7 +151,7 @@ pub(super) fn try_openai_fallback( let strip_session_affinity = strip_session_affinity || incoming_headers.turn_state().is_some() || is_openai_api_target; let body_for_request = - if strip_session_affinity && body_has_encrypted_content_hint(body.as_ref()) { + if strip_session_affinity && body_has_account_scoped_history_hint(body.as_ref()) { strip_encrypted_content_from_body(body.as_ref()) .map(Bytes::from) .unwrap_or_else(|| body.clone()) diff --git a/crates/service/src/gateway/upstream/proxy_pipeline/candidate_executor.rs b/crates/service/src/gateway/upstream/proxy_pipeline/candidate_executor.rs index f4c7b3991..a3a145132 100644 --- a/crates/service/src/gateway/upstream/proxy_pipeline/candidate_executor.rs +++ b/crates/service/src/gateway/upstream/proxy_pipeline/candidate_executor.rs @@ -387,7 +387,7 @@ pub(in super::super) fn execute_candidate_sequence( CandidateUpstreamDecision::RespondUpstream(mut resp) => { if resp.status().as_u16() == 400 && !strip_session_affinity - && (incoming_turn_state.is_some() || setup.has_body_encrypted_content) + && (incoming_turn_state.is_some() || setup.has_account_scoped_history) { let retry_body = state.retry_body( path, diff --git a/crates/service/src/gateway/upstream/proxy_pipeline/candidate_state.rs b/crates/service/src/gateway/upstream/proxy_pipeline/candidate_state.rs index fd15bfa2e..1aa8585c0 100644 --- a/crates/service/src/gateway/upstream/proxy_pipeline/candidate_state.rs +++ b/crates/service/src/gateway/upstream/proxy_pipeline/candidate_state.rs @@ -208,7 +208,7 @@ impl CandidateExecutionState { ) -> Bytes { let rewritten = self.rewrite_body_for_model(path, body, setup, model_override, prompt_cache_key); - if strip_session_affinity && setup.has_body_encrypted_content { + if strip_session_affinity && setup.has_account_scoped_history { if let Some(cache_key) = Self::rewrite_cache_key(model_override, prompt_cache_key) { return self .stripped_rewritten_bodies @@ -255,7 +255,7 @@ impl CandidateExecutionState { ) -> Bytes { let rewritten = self.rewrite_body_for_model(path, body, setup, model_override, prompt_cache_key); - if setup.has_body_encrypted_content { + if setup.has_account_scoped_history { if let Some(cache_key) = Self::rewrite_cache_key(model_override, prompt_cache_key) { return self .stripped_rewritten_bodies @@ -299,7 +299,7 @@ mod tests { anthropic_has_thread_anchor: false, has_sticky_fallback_session: false, has_sticky_fallback_conversation: false, - has_body_encrypted_content: false, + has_account_scoped_history: false, conversation_routing: None, route_strategy_for_log: "ordered", route_source_for_log: "route_strategy", @@ -528,6 +528,63 @@ mod tests { ); } + #[test] + fn stripped_candidate_removes_account_scoped_history_items() { + let mut state = CandidateExecutionState::default(); + let body = Bytes::from_static( + br#"{ + "model":"gpt-5.6", + "input":[ + { + "type":"context_compaction", + "id":"ctx-1" + }, + { + "type":"compaction", + "id":"cmp-1" + }, + { + "type":"function_call", + "call_id":"call-1", + "name":"lookup", + "arguments":"{}" + }, + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"continue"}] + } + ] + }"#, + ); + let mut setup = sample_setup(); + setup.has_account_scoped_history = + super::super::super::support::payload_rewrite::body_has_account_scoped_history_hint( + body.as_ref(), + ); + + let actual = state.body_for_attempt("/v1/responses", &body, true, &setup, None, None); + let value: serde_json::Value = + serde_json::from_slice(actual.as_ref()).expect("parse stripped candidate body"); + + assert_eq!( + value["input"], + serde_json::json!([ + { + "type": "function_call", + "call_id": "call-1", + "name": "lookup", + "arguments": "{}" + }, + { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "continue" }] + } + ]) + ); + } + #[test] fn strip_session_affinity_preserves_same_workspace_when_thread_anchor_exists() { let mut state = CandidateExecutionState::default(); diff --git a/crates/service/src/gateway/upstream/proxy_pipeline/request_setup.rs b/crates/service/src/gateway/upstream/proxy_pipeline/request_setup.rs index 82d3f8b64..c47da3858 100644 --- a/crates/service/src/gateway/upstream/proxy_pipeline/request_setup.rs +++ b/crates/service/src/gateway/upstream/proxy_pipeline/request_setup.rs @@ -14,7 +14,7 @@ pub(in super::super) struct UpstreamRequestSetup { pub(in super::super) anthropic_has_thread_anchor: bool, pub(in super::super) has_sticky_fallback_session: bool, pub(in super::super) has_sticky_fallback_conversation: bool, - pub(in super::super) has_body_encrypted_content: bool, + pub(in super::super) has_account_scoped_history: bool, pub(in super::super) conversation_routing: Option, pub(in super::super) route_strategy_for_log: &'static str, pub(in super::super) route_source_for_log: &'static str, @@ -98,8 +98,10 @@ pub(in super::super) fn prepare_request_setup( incoming_headers, ) .is_some(), - has_body_encrypted_content: - super::super::support::payload_rewrite::body_has_encrypted_content_hint(body.as_ref()), + has_account_scoped_history: + super::super::support::payload_rewrite::body_has_account_scoped_history_hint( + body.as_ref(), + ), conversation_routing, route_strategy_for_log: rotation_plan.strategy_label, route_source_for_log: rotation_plan.source.as_str(), diff --git a/crates/service/src/gateway/upstream/support/payload_rewrite.rs b/crates/service/src/gateway/upstream/support/payload_rewrite.rs index 8c3848110..1d14ec2c6 100644 --- a/crates/service/src/gateway/upstream/support/payload_rewrite.rs +++ b/crates/service/src/gateway/upstream/support/payload_rewrite.rs @@ -1,34 +1,40 @@ use serde_json::Value; -/// 函数 `body_has_encrypted_content_hint` -/// -/// 作者: gaohongshun -/// -/// 时间: 2026-04-02 -/// -/// # 参数 -/// - in super: 参数 in super -/// -/// # 返回 -/// 返回函数执行结果 -pub(in super::super) fn body_has_encrypted_content_hint(body: &[u8]) -> bool { - // Fast path: avoid JSON parsing unless we hit a recovery path. - std::str::from_utf8(body) - .ok() - .is_some_and(|text| text.contains("\"encrypted_content\"")) +/// 快速判断请求体是否可能包含账号绑定的响应历史。 +pub(in crate::gateway) fn body_has_account_scoped_history_hint(body: &[u8]) -> bool { + // 只在切换账号或恢复路径上进一步解析 JSON;误报只会多做一次解析,不会删除普通消息。 + [ + b"encrypted_content".as_slice(), + b"reasoning".as_slice(), + b"compaction".as_slice(), + ] + .iter() + .any(|needle| { + body.windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) + }) } -/// 函数 `strip_encrypted_content_value` -/// -/// 作者: gaohongshun -/// -/// 时间: 2026-04-02 -/// -/// # 参数 -/// - value: 参数 value -/// -/// # 返回 -/// 返回函数执行结果 +/// 判断数组元素是否为账号绑定的响应历史项。 +fn is_account_scoped_history_item(value: &Value) -> bool { + let Some(item_type) = value + .as_object() + .and_then(|item| item.get("type")) + .and_then(Value::as_str) + .map(str::trim) + else { + return false; + }; + + // 这些响应历史项绑定生成它们的账号,切换账号后不能继续重放。 + item_type.eq_ignore_ascii_case("reasoning") + || item_type.eq_ignore_ascii_case("encrypted_content") + || item_type.eq_ignore_ascii_case("compaction") + || item_type.eq_ignore_ascii_case("compaction_summary") + || item_type.eq_ignore_ascii_case("context_compaction") +} + +/// 递归清理加密字段,并从数组中移除账号绑定历史项。 fn strip_encrypted_content_value(value: &mut Value) -> bool { match value { Value::Object(map) => { @@ -42,11 +48,16 @@ fn strip_encrypted_content_value(value: &mut Value) -> bool { } Value::Array(items) => { let mut changed = false; - for item in items.iter_mut() { + items.retain_mut(|item| { + if is_account_scoped_history_item(item) { + changed = true; + return false; + } if strip_encrypted_content_value(item) { changed = true; } - } + true + }); changed } _ => false, @@ -64,10 +75,159 @@ fn strip_encrypted_content_value(value: &mut Value) -> bool { /// /// # 返回 /// 返回函数执行结果 -pub(in super::super) fn strip_encrypted_content_from_body(body: &[u8]) -> Option> { +pub(in crate::gateway) fn strip_encrypted_content_from_body(body: &[u8]) -> Option> { let mut value: Value = serde_json::from_slice(body).ok()?; if !strip_encrypted_content_value(&mut value) { return None; } serde_json::to_vec(&value).ok() } + +#[cfg(test)] +mod tests { + use super::{body_has_account_scoped_history_hint, strip_encrypted_content_from_body}; + use serde_json::{json, Value}; + + fn rewrite(body: &Value) -> Value { + let serialized = serde_json::to_vec(body).expect("serialize request body"); + let rewritten = strip_encrypted_content_from_body(&serialized).expect("rewrite body"); + serde_json::from_slice(&rewritten).expect("parse rewritten body") + } + + #[test] + fn account_scoped_history_hint_detects_compaction_without_encrypted_content() { + let body = br#"{"input":[{"type":"context_compaction","id":"ctx-1"}]}"#; + + assert!(body_has_account_scoped_history_hint(body)); + assert!(body_has_account_scoped_history_hint( + br#"{"input":[{"type":" ReAsOnInG "}]}"# + )); + assert!(!body_has_account_scoped_history_hint( + br#"{"input":[{"type":"message","content":"keep"}]}"# + )); + } + + #[test] + fn strip_encrypted_content_removes_compaction_at_reported_index() { + let mut input = (0..24) + .map(|index| { + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!("history-{index}") + }] + }) + }) + .collect::>(); + input.push(json!({ + "type": "compaction", + "id": "cmp-reported-24", + "encrypted_content": "account-scoped" + })); + input.push(json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "continue" }] + })); + + let rewritten = rewrite(&json!({ "model": "gpt-5.6", "input": input })); + let input = rewritten["input"].as_array().expect("input array"); + + assert_eq!(input.len(), 25); + assert_eq!(input[23]["content"][0]["text"], "history-23"); + assert_eq!(input[24]["content"][0]["text"], "continue"); + } + + #[test] + fn strip_encrypted_content_removes_consecutive_account_scoped_items_in_place() { + let before = json!({ + "type": "message", + "role": "assistant", + "content": [{ "type": "output_text", "text": "before" }] + }); + let function_call = json!({ + "type": "function_call", + "call_id": "call-1", + "name": "lookup", + "arguments": "{\"type\":\"compaction\"}" + }); + let function_output = json!({ + "type": "function_call_output", + "call_id": "call-1", + "output": "result" + }); + let after = json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "after" }] + }); + let body = json!({ + "input": [ + before.clone(), + { "type": " ReAsOnInG ", "encrypted_content": "r" }, + { "type": "compaction", "encrypted_content": "c" }, + { "type": "compaction_summary", "summary": "s" }, + { "type": "context_compaction", "id": "ctx" }, + { "type": "encrypted_content", "encrypted_content": "e" }, + function_call.clone(), + function_output.clone(), + after.clone() + ] + }); + + let rewritten = rewrite(&body); + + assert_eq!( + rewritten["input"], + json!([before, function_call, function_output, after]) + ); + } + + #[test] + fn strip_encrypted_content_respects_nested_item_boundaries() { + let arguments = r#"{"type":"compaction","encrypted_content":"literal"}"#; + let body = json!({ + "metadata": { + "reasoning_envelope": { + "type": "reasoning", + "id": "metadata-reasoning", + "summary": ["keep"], + "encrypted_content": "remove" + } + }, + "input": [ + { + "type": "agent_message", + "content": [ + { "type": "input_text", "text": "keep me" }, + { "type": "encrypted_content", "encrypted_content": "remove" } + ] + }, + { + "type": "function_call", + "call_id": "call-2", + "name": "echo", + "arguments": arguments + } + ] + }); + + let rewritten = rewrite(&body); + + assert_eq!( + rewritten["metadata"]["reasoning_envelope"], + json!({ + "type": "reasoning", + "id": "metadata-reasoning", + "summary": ["keep"] + }) + ); + assert_eq!( + rewritten["input"][0]["content"], + json!([{ "type": "input_text", "text": "keep me" }]) + ); + assert_eq!(rewritten["input"][1]["arguments"], arguments); + } +} diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 597a33c7d..5ac7e414e 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -30,6 +30,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- 网关切换账号或进入恢复路径时,会删除与原账号绑定的 reasoning、加密内容及 compaction 历史项;即使历史项已缺少 `encrypted_content` 也不会把无效空壳转发给下一账号,HTTP fallback 与多候选链路保持一致。 - OAuth 登录会话会完整持久化所选账号分组;升级旧数据库时自动补齐 `login_sessions.group_name`,避免登录完成后账号静默丢失预选分组。 - 配额保护不再把 primary/secondary 字段位置固定解释为 5 小时/周额度,而是按服务端返回的 `window_minutes` 选择对应阈值;长短窗口互换、仅长窗口及旧快照缺失时长的账号均保持正确候选语义。单账号并发上限的专用持久化设置也不再被旧 `envOverrides` 残留覆盖或伪装成进程环境变量。 - 非 2xx 请求仍保留请求日志与 Token 明细用于诊断,但不再进入 Token、费用或平台 Key 配额汇总;迁移会重标仍保留的明细并清零纯失败的日级汇总桶,后续长期压缩也保持相同口径。 From 24d787302173fddb719ed1f6290aebb2ab2248ec Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:44:24 +0800 Subject: [PATCH 23/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20WebSocket=E9=81=B5?= =?UTF-8?q?=E5=BE=AA=E6=A0=87=E5=87=86=E7=8E=AF=E5=A2=83=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + README.md | 2 +- crates/service/Cargo.toml | 1 + .../src/gateway/core/runtime_config.rs | 79 ++++++++++++ .../core/tests/runtime_config_tests.rs | 122 ++++++++++++++++++ crates/service/src/gateway/mod.rs | 7 +- .../upstream/attempt_flow/transport.rs | 3 +- .../service/src/http/responses_websocket.rs | 42 +++++- docs/zh-CN/CHANGELOG.md | 1 + ...15\347\275\256\350\257\264\346\230\216.md" | 4 + 10 files changed, 252 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 722076768..071f8bebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,7 @@ dependencies = [ "env_logger", "eventsource-stream", "futures-util", + "hyper-util", "log", "os_info", "rand 0.8.5", diff --git a/README.md b/README.md index 5b4e6989b..5820fb91a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - 模型管理:维护结构化模型目录、远端并入、自定义模型、空目录自动远端拉取开关、`visibility` / `supportedInApi` 管理,以及桌面端 Codex 缓存同步 / Web 端缓存导出 - 聚合 API:管理第三方最小转发上游,支持创建、编辑、测试连通性、供应商名称、顺序优先级,以及按 Codex / Claude 分类展示 - 插件中心:路由为 `/plugins/`,支持内置精选、企业私有、自定义源三种市场模式,并提供插件清单、任务、日志与 Rhai 对接接口 -- 设置页:支持“系统推导”按钮、单账号并发上限、上游代理、请求总超时、流式空闲超时、SSE 保活间隔,以及更保守的高并发退化策略;通用页可查看和手动刷新出口 IP / 国家 / ASN 诊断,该结果仅用于排障,不会直接改变账号状态;实验性上游 WebSocket 可通过 `CODEXMANAGER_USE_WEBSOCKET_UPSTREAM=1` 开启,默认关闭 +- 设置页:支持“系统推导”按钮、单账号并发上限、上游代理、请求总超时、流式空闲超时、SSE 保活间隔,以及更保守的高并发退化策略;通用页可查看和手动刷新出口 IP / 国家 / ASN 诊断,该结果仅用于排障,不会直接改变账号状态;实验性上游 WebSocket 可通过 `CODEXMANAGER_USE_WEBSOCKET_UPSTREAM=1` 开启,默认关闭,并在未配置应用代理时支持标准 `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` - 系统内部接口总表:列出当前桌面端与服务端所有可对接命令、RPC 方法、以及插件内建函数 - 本地服务:自动拉起、可自定义端口与监听地址 - 本地网关:为 Codex CLI、Gemini CLI、Claude Code 和第三方工具提供统一 OpenAI 兼容入口;Gemini 请求可转发到 `/v1/responses`,并兼容 SSE、tools、MCP、skill、请求总超时与流式空闲超时等调用链路 diff --git a/crates/service/Cargo.toml b/crates/service/Cargo.toml index 1fbe93883..167a6518e 100644 --- a/crates/service/Cargo.toml +++ b/crates/service/Cargo.toml @@ -19,6 +19,7 @@ axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["rt-multi-thread", "net", "time"] } futures-util = "0.3" eventsource-stream = "0.2.3" +hyper-util = { version = "0.1.19", features = ["client-proxy"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } rustls = { version = "0.23", features = ["ring"] } url = "2" diff --git a/crates/service/src/gateway/core/runtime_config.rs b/crates/service/src/gateway/core/runtime_config.rs index ceba6dcb0..3b0f475de 100644 --- a/crates/service/src/gateway/core/runtime_config.rs +++ b/crates/service/src/gateway/core/runtime_config.rs @@ -1,5 +1,6 @@ use codexmanager_core::auth::DEFAULT_ORIGINATOR; use codexmanager_core::auth::{DEFAULT_CLIENT_ID, DEFAULT_ISSUER}; +use hyper_util::client::proxy::matcher::Matcher; use reqwest::blocking::Client; use reqwest::Proxy; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; @@ -240,6 +241,84 @@ pub(crate) fn upstream_proxy_url_for_account(account_id: &str) -> Option current_upstream_proxy_url() } +/// 返回 WebSocket 上游使用的代理;应用内显式代理优先于进程环境代理。 +pub(crate) fn websocket_proxy_url_for_account( + account_id: &str, + target_url: &str, +) -> Result, String> { + if let Some(proxy_url) = upstream_proxy_url_for_account(account_id) { + return Ok(Some(proxy_url)); + } + environment_proxy_url_for_websocket_target(target_url) +} + +fn first_environment_value(keys: &[&str]) -> Option { + keys.iter().find_map(|key| env_non_empty(key)) +} + +fn normalize_websocket_environment_proxy_url(raw_proxy_url: &str) -> Result { + let raw_proxy_url = raw_proxy_url.trim(); + let proxy_url = if raw_proxy_url.starts_with("//") { + format!("http:{raw_proxy_url}") + } else if raw_proxy_url.contains("://") { + raw_proxy_url.to_string() + } else { + format!("http://{raw_proxy_url}") + }; + normalize_upstream_proxy_url(Some(proxy_url.as_str())) + .map_err(|err| format!("invalid websocket environment proxy: {err}"))? + .ok_or_else(|| "websocket environment proxy is empty".to_string()) +} + +/// 使用 hyper-util 的标准域名、IP、CIDR 与通配符规则匹配 NO_PROXY。 +fn environment_proxy_url_for_websocket_target(target_url: &str) -> Result, String> { + let mut target = url::Url::parse(target_url.trim()) + .map_err(|err| format!("invalid websocket target url: {err}"))?; + let (proxy_keys, matcher_scheme) = match target.scheme() { + "wss" | "https" => ( + ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"], + "https", + ), + "ws" | "http" => ( + ["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"], + "http", + ), + other => { + return Err(format!("unsupported websocket target scheme: {other}")); + } + }; + target + .set_scheme(matcher_scheme) + .map_err(|_| "failed to normalize websocket target scheme".to_string())?; + + let Some(raw_proxy_url) = first_environment_value(&proxy_keys) else { + return Ok(None); + }; + let proxy_url = normalize_websocket_environment_proxy_url(raw_proxy_url.as_str())?; + let no_proxy = first_environment_value(&["NO_PROXY", "no_proxy"]) + .unwrap_or_default() + .to_ascii_lowercase(); + let matcher = match matcher_scheme { + "https" => Matcher::builder() + .https(proxy_url.as_str()) + .no(no_proxy) + .build(), + _ => Matcher::builder() + .http(proxy_url.as_str()) + .no(no_proxy) + .build(), + }; + let target_uri: axum::http::Uri = target + .as_str() + .parse() + .map_err(|err| format!("invalid websocket target uri: {err}"))?; + + Ok(matcher + .intercept(&target_uri) + .is_some() + .then_some(proxy_url)) +} + /// 函数 `upstream_connect_timeout_cached` /// /// 作者: gaohongshun diff --git a/crates/service/src/gateway/core/tests/runtime_config_tests.rs b/crates/service/src/gateway/core/tests/runtime_config_tests.rs index 2a74f8bcf..a966f584b 100644 --- a/crates/service/src/gateway/core/tests/runtime_config_tests.rs +++ b/crates/service/src/gateway/core/tests/runtime_config_tests.rs @@ -63,6 +63,24 @@ impl Drop for EnvGuard { } } +fn clear_standard_proxy_environment() -> Vec { + let keys: &[&str] = if cfg!(windows) { + &["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"] + } else { + &[ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + ] + }; + keys.iter().copied().map(EnvGuard::clear).collect() +} + /// 函数 `reload_from_env_updates_timeout_and_proxy` /// /// 作者: gaohongshun @@ -669,3 +687,107 @@ fn terminal_user_agent_sanitizes_header_like_official_codex() { "Weird_Terminal__/1.2_beta" ); } + +#[test] +fn websocket_environment_proxy_prefers_https_and_normalizes_socks() { + let _guard = crate::test_env_guard(); + let _environment = clear_standard_proxy_environment(); + let _all_proxy = EnvGuard::set("ALL_PROXY", "http://127.0.0.1:7001"); + let _https_proxy = EnvGuard::set( + "https_proxy", + "socks5://proxy-user:proxy-pass@127.0.0.1:7002", + ); + + let proxy = + environment_proxy_url_for_websocket_target("wss://chatgpt.com/backend-api/codex/responses") + .expect("resolve environment proxy"); + + assert_eq!( + proxy.as_deref(), + Some("socks5h://proxy-user:proxy-pass@127.0.0.1:7002") + ); +} + +#[test] +fn websocket_environment_proxy_accepts_host_port_without_scheme() { + let _guard = crate::test_env_guard(); + let _environment = clear_standard_proxy_environment(); + let _https_proxy = EnvGuard::set("HTTPS_PROXY", "127.0.0.1:7002"); + + assert_eq!( + environment_proxy_url_for_websocket_target("wss://chatgpt.com/responses") + .expect("resolve schemeless proxy") + .as_deref(), + Some("http://127.0.0.1:7002") + ); +} + +#[test] +fn websocket_environment_proxy_honors_domain_cidr_and_wildcard_no_proxy() { + let _guard = crate::test_env_guard(); + let _environment = clear_standard_proxy_environment(); + let _https_proxy = EnvGuard::set("HTTPS_PROXY", "http://127.0.0.1:7002"); + let no_proxy = EnvGuard::set("NO_PROXY", ".EXAMPLE.TEST,10.42.0.0/16"); + + assert_eq!( + environment_proxy_url_for_websocket_target("wss://api.example.test/responses") + .expect("resolve domain bypass"), + None + ); + assert_eq!( + environment_proxy_url_for_websocket_target("wss://10.42.3.9/responses") + .expect("resolve CIDR bypass"), + None + ); + assert_eq!( + environment_proxy_url_for_websocket_target("wss://chatgpt.com/responses") + .expect("resolve proxied target") + .as_deref(), + Some("http://127.0.0.1:7002") + ); + + drop(no_proxy); + let _wildcard = EnvGuard::set("NO_PROXY", "*"); + assert_eq!( + environment_proxy_url_for_websocket_target("wss://chatgpt.com/responses") + .expect("resolve wildcard bypass"), + None + ); +} + +#[test] +fn websocket_managed_proxy_precedes_environment_and_no_proxy() { + let _guard = crate::test_env_guard(); + let _environment = clear_standard_proxy_environment(); + let _pool_proxy = EnvGuard::clear(ENV_PROXY_LIST); + let _managed_proxy = EnvGuard::set(ENV_UPSTREAM_PROXY_URL, "http://127.0.0.1:7101"); + let _https_proxy = EnvGuard::set("HTTPS_PROXY", "http://127.0.0.1:7102"); + let _no_proxy = EnvGuard::set("NO_PROXY", "*"); + reload_from_env(); + + assert_eq!( + websocket_proxy_url_for_account("acc-managed", "wss://chatgpt.com/responses") + .expect("resolve managed proxy") + .as_deref(), + Some("http://127.0.0.1:7101") + ); + + drop(_managed_proxy); + drop(_pool_proxy); + reload_from_env(); +} + +#[test] +fn websocket_environment_proxy_error_redacts_credentials() { + let _guard = crate::test_env_guard(); + let _environment = clear_standard_proxy_environment(); + let _https_proxy = EnvGuard::set("HTTPS_PROXY", "://proxy-user:top-secret@127.0.0.1:7002"); + + let error = + environment_proxy_url_for_websocket_target("wss://chatgpt.com/backend-api/codex/responses") + .expect_err("invalid proxy must fail closed"); + + assert!(error.contains("invalid websocket environment proxy")); + assert!(!error.contains("proxy-user")); + assert!(!error.contains("top-secret")); +} diff --git a/crates/service/src/gateway/mod.rs b/crates/service/src/gateway/mod.rs index d78b963bb..c6c6ba137 100644 --- a/crates/service/src/gateway/mod.rs +++ b/crates/service/src/gateway/mod.rs @@ -816,8 +816,11 @@ pub(crate) fn apply_async_upstream_proxy( runtime_config::apply_async_upstream_proxy(builder, proxy_url, invalid_event) } -pub(crate) fn current_upstream_proxy_url_for_account(account_id: &str) -> Option { - runtime_config::upstream_proxy_url_for_account(account_id) +pub(crate) fn current_websocket_proxy_url_for_account( + account_id: &str, + target_url: &str, +) -> Result, String> { + runtime_config::websocket_proxy_url_for_account(account_id, target_url) } /// 函数 `set_upstream_proxy_url` diff --git a/crates/service/src/gateway/upstream/attempt_flow/transport.rs b/crates/service/src/gateway/upstream/attempt_flow/transport.rs index 9c7f3d634..8cafd2317 100644 --- a/crates/service/src/gateway/upstream/attempt_flow/transport.rs +++ b/crates/service/src/gateway/upstream/attempt_flow/transport.rs @@ -1299,7 +1299,8 @@ fn send_websocket_upstream_request( target_url.to_string() }; let request_headers = request_headers.to_vec(); - let proxy_url = super::super::super::current_upstream_proxy_url_for_account(account_id); + let proxy_url = + super::super::super::current_websocket_proxy_url_for_account(account_id, ws_url.as_str())?; let handshake_timeout = websocket_handshake_timeout(request_deadline); let (meta_tx, meta_rx) = mpsc::sync_channel::>(1); diff --git a/crates/service/src/http/responses_websocket.rs b/crates/service/src/http/responses_websocket.rs index 00630725d..b9ba22ea8 100644 --- a/crates/service/src/http/responses_websocket.rs +++ b/crates/service/src/http/responses_websocket.rs @@ -900,7 +900,10 @@ async fn connect_upstream_websocket( }; let request = build_upstream_websocket_request(ws_url.as_str(), &account, bearer.as_str(), context)?; - let proxy_url = crate::gateway::current_upstream_proxy_url_for_account(account.id.as_str()); + let proxy_url = crate::gateway::current_websocket_proxy_url_for_account( + account.id.as_str(), + ws_url.as_str(), + )?; match connect_upstream_websocket_request_detailed( request, ws_url.as_str(), @@ -1120,8 +1123,7 @@ pub(crate) async fn connect_upstream_websocket_request_detailed( async fn connect_websocket_proxy_tcp(ws_url: &str, proxy_url: &str) -> Result { let target = parse_websocket_target(ws_url)?; - let proxy = url::Url::parse(proxy_url) - .map_err(|err| format!("invalid websocket proxy url {proxy_url}: {err}"))?; + let proxy = parse_websocket_proxy_url(proxy_url)?; match proxy.scheme() { "http" => connect_http_proxy_tunnel(&proxy, &target).await, "socks" | "socks5" | "socks5h" => connect_socks5_proxy_tunnel(&proxy, &target).await, @@ -1129,6 +1131,10 @@ async fn connect_websocket_proxy_tcp(ws_url: &str, proxy_url: &str) -> Result Result { + url::Url::parse(proxy_url).map_err(|err| format!("invalid websocket proxy url: {err}")) +} + fn parse_websocket_target(ws_url: &str) -> Result { let url = url::Url::parse(ws_url).map_err(|err| format!("invalid websocket url: {err}"))?; let raw_host = url @@ -1746,7 +1752,21 @@ async fn try_rotate_ws_upstream_after_terminal( }; ensure_rustls_crypto_provider(); - let proxy_url = crate::gateway::current_upstream_proxy_url_for_account(account.id.as_str()); + let proxy_url = match crate::gateway::current_websocket_proxy_url_for_account( + account.id.as_str(), + upstream.upstream_url.as_str(), + ) { + Ok(proxy_url) => proxy_url, + Err(err) => { + log::warn!( + "event=responses_ws_failover_proxy_failed account_id={} status={} err={}", + current_account_id, + status_code, + err + ); + return false; + } + }; let replacement = match connect_upstream_websocket_request( request, upstream.upstream_url.as_str(), @@ -2008,8 +2028,8 @@ mod tests { use super::{ build_socks5_connect_request, build_upstream_websocket_request, infer_ws_terminal_status, inspect_ws_terminal_event, is_previous_response_not_found_terminal, merge_client_metadata, - parse_websocket_target, parse_ws_usage, proxy_basic_auth_header, rewrite_client_frame, - strip_previous_response_id_from_ws_text, WsRequestContext, + parse_websocket_proxy_url, parse_websocket_target, parse_ws_usage, proxy_basic_auth_header, + rewrite_client_frame, strip_previous_response_id_from_ws_text, WsRequestContext, }; use axum::http::{HeaderMap, HeaderValue}; use codexmanager_core::storage::{Account, ApiKey}; @@ -2123,6 +2143,16 @@ mod tests { ); } + #[test] + fn websocket_proxy_parse_error_does_not_expose_credentials() { + let error = parse_websocket_proxy_url("://proxy-user:top-secret@127.0.0.1:7890") + .expect_err("invalid proxy must fail"); + + assert!(error.contains("invalid websocket proxy url")); + assert!(!error.contains("proxy-user")); + assert!(!error.contains("top-secret")); + } + #[test] fn websocket_connect_error_preserves_http_unauthorized_status() { let mut response = super::WsClientResponse::new(None); diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 5ac7e414e..7596eb108 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -6,6 +6,7 @@ ## [Unreleased] ### Changed +- 实验性 Responses WebSocket 在没有应用内上游代理时支持 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` 环境兜底,并通过 `NO_PROXY` 的域名、IP、CIDR 与通配符规则决定旁路;设置页代理和账号代理池保持更高优先级,代理解析错误不再回显凭据。 - 请求日志分页新增首页与页码跳转控件,输入会按有效页数钳制;分页工具栏在窄内容区自动换行并使用图标按钮,避免新增控件再次挤出视口。 - GHCR 正式版发布会在版本标签之外同步更新 `stable` 与 `latest`;预发布默认或显式标记为预发布时只推送版本标签,不会覆盖稳定浮动标签。 - 新增出口网络诊断:启动时异步首检,区域阻断事件触发节流检查,并在存在区域阻断账号时低频兜底;诊断按固定白名单 IP 服务失败切换,沿用上游代理,仅向管理员展示缓存后的 IP、国家与 ASN 信息,不直接改变账号状态。 diff --git "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" index 08fc8ac22..f83e76ddf 100644 --- "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -86,6 +86,8 @@ - `CODEXMANAGER_UPSTREAM_TOTAL_TIMEOUT_MS`:网关请求总超时,单位毫秒;默认 `0`,表示服务端不主动按总时长截断请求。 - `CODEXMANAGER_UPSTREAM_STREAM_TIMEOUT_MS` - `CODEXMANAGER_USE_WEBSOCKET_UPSTREAM`:是否让 ChatGPT `/v1/responses` 流式上游优先尝试 WebSocket 传输;默认 `0`。这是实验开关,失败会回退 HTTP 流式路径,会沿用上游代理与连接超时配置。 +- `HTTPS_PROXY` / `https_proxy`、`ALL_PROXY` / `all_proxy`:仅在实验性 Responses WebSocket 没有命中 `CODEXMANAGER_UPSTREAM_PROXY_URL` 或 `CODEXMANAGER_PROXY_LIST` 时作为环境代理兜底;`ws://` 目标对应读取 `HTTP_PROXY` / `http_proxy`。 +- `NO_PROXY` / `no_proxy`:只旁路上述 WebSocket 环境代理,支持域名、IP、CIDR 和 `*`;不会覆盖设置页代理或账号稳定分流代理池。 - `CODEXMANAGER_SSE_KEEPALIVE_INTERVAL_MS` - `CODEXMANAGER_PROXY_LIST` - `CODEXMANAGER_ROUTE_STRATEGY` @@ -311,6 +313,8 @@ codexmanager-service-bundle/ - 如果同时设置了 `CODEXMANAGER_UPSTREAM_PROXY_URL` 和 `CODEXMANAGER_PROXY_LIST`,单个上游代理优先,代理池会被旁路。 - `CODEXMANAGER_PROXY_LIST` 更适合“按账号稳定分流多个出口”;`CODEXMANAGER_UPSTREAM_PROXY_URL` 更适合“全局统一走一个出口”。 +- 实验性 Responses WebSocket 的代理优先级为:应用内代理/代理池 -> `HTTPS_PROXY`(`ws://` 使用 `HTTP_PROXY`)-> `ALL_PROXY` -> 直连。`NO_PROXY` 只决定环境代理是否旁路,不能绕过应用内显式代理。 +- 代理 URL 解析错误不会回显用户名或密码;环境代理配置无效时 WebSocket 会失败关闭,不会静默直连,是否回退 HTTP 由对应调用链的既有策略决定。 ### 改了 env 文件但没生效 From 5601aa0c9d1c0899e5973c9503c3316e9affd0c7 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:49:45 +0800 Subject: [PATCH 24/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BF=9D=E7=95=99W?= =?UTF-8?q?ebSocket=E6=98=BE=E5=BC=8F=E7=BC=93=E5=AD=98=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/service/src/http/responses_websocket.rs | 14 ++++++++++++-- docs/zh-CN/CHANGELOG.md | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/service/src/http/responses_websocket.rs b/crates/service/src/http/responses_websocket.rs index b9ba22ea8..e8ca459a7 100644 --- a/crates/service/src/http/responses_websocket.rs +++ b/crates/service/src/http/responses_websocket.rs @@ -34,6 +34,7 @@ const RESPONSES_WEBSOCKETS_BETA_HEADER_VALUE: &str = "responses_websockets=2026- struct WsRequestContext { api_key: codexmanager_core::storage::ApiKey, incoming_headers: crate::gateway::IncomingHeaderSnapshot, + native_anchor_headers: crate::gateway::IncomingHeaderSnapshot, prompt_cache_key: Option, effective_upstream_base: String, prefer_raw_errors: bool, @@ -562,6 +563,7 @@ fn authorize_websocket_request(headers: &HeaderMap) -> Result Result(&rewritten_body).map_err(|err| { WsSessionError::bad_gateway_bilingual( @@ -2231,9 +2234,11 @@ mod tests { #[test] fn websocket_frame_aligns_prompt_cache_key_with_native_conversation_anchor() { let _guard = crate::test_env_guard(); + let incoming_headers = sample_incoming_headers(Some("conversation-1"), None); let context = WsRequestContext { api_key: sample_api_key(), - incoming_headers: sample_incoming_headers(Some("conversation-1"), None), + incoming_headers: incoming_headers.clone(), + native_anchor_headers: incoming_headers, prompt_cache_key: Some("sticky-thread".to_string()), effective_upstream_base: "https://chatgpt.com/backend-api/codex".to_string(), prefer_raw_errors: false, @@ -2261,6 +2266,9 @@ mod tests { let context = WsRequestContext { api_key: sample_api_key(), incoming_headers: crate::gateway::IncomingHeaderSnapshot::from_http_headers(&headers), + native_anchor_headers: crate::gateway::IncomingHeaderSnapshot::from_http_headers( + &headers, + ), prompt_cache_key: None, effective_upstream_base: "https://chatgpt.com/backend-api/codex".to_string(), prefer_raw_errors: false, @@ -2330,6 +2338,7 @@ mod tests { let context = WsRequestContext { api_key: sample_api_key(), incoming_headers: sample_incoming_headers_with_metadata(), + native_anchor_headers: sample_incoming_headers_with_metadata(), prompt_cache_key: None, effective_upstream_base: "https://chatgpt.com/backend-api/codex".to_string(), prefer_raw_errors: false, @@ -2361,6 +2370,7 @@ mod tests { let context = WsRequestContext { api_key: sample_api_key(), incoming_headers: sample_incoming_headers_with_metadata(), + native_anchor_headers: sample_incoming_headers_with_metadata(), prompt_cache_key: None, effective_upstream_base: "https://chatgpt.com/backend-api/codex".to_string(), prefer_raw_errors: false, diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 7596eb108..1104b4c89 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -31,6 +31,7 @@ - 补齐账号排序、模型目录自动拉取与 Web RPC 超时提示的英/韩/俄翻译,并让首页启动快照显式声明完整模型目录需求,恢复 `test:runtime` 全量门禁。 ### Fixed +- Responses WebSocket 的本地粘性路由合成会话标识不再冒充客户端原生 `conversation_id`;只有真实原生会话锚点会覆盖或移除冲突的 `prompt_cache_key`,仅有 `session_id` 时继续保留客户端显式缓存键。 - 网关切换账号或进入恢复路径时,会删除与原账号绑定的 reasoning、加密内容及 compaction 历史项;即使历史项已缺少 `encrypted_content` 也不会把无效空壳转发给下一账号,HTTP fallback 与多候选链路保持一致。 - OAuth 登录会话会完整持久化所选账号分组;升级旧数据库时自动补齐 `login_sessions.group_name`,避免登录完成后账号静默丢失预选分组。 - 配额保护不再把 primary/secondary 字段位置固定解释为 5 小时/周额度,而是按服务端返回的 `window_minutes` 选择对应阈值;长短窗口互换、仅长窗口及旧快照缺失时长的账号均保持正确候选语义。单账号并发上限的专用持久化设置也不再被旧 `envOverrides` 残留覆盖或伪装成进程环境变量。 From f0fe925ca3bba6e9d6b1aaa9ecaa15c80447fe98 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:17:58 +0800 Subject: [PATCH 25/35] =?UTF-8?q?=E6=B5=8B=E8=AF=95:=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=B1=BB=E5=9E=8B=E4=B8=8E=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tests/accounts-toolbar.spec.ts | 77 +++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/tests/accounts-toolbar.spec.ts b/apps/tests/accounts-toolbar.spec.ts index 5a3ca41fd..097b9854c 100644 --- a/apps/tests/accounts-toolbar.spec.ts +++ b/apps/tests/accounts-toolbar.spec.ts @@ -56,9 +56,10 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { const usageRefreshPayloads: Record[] = []; const rtRefreshPayloads: Record[] = []; + const accountListPayloads: Record[] = []; let refreshAllRtCount = 0; - await page.route("**/api/runtime", async (route) => { + await page.route("**/api/runtime**", async (route) => { await route.fulfill({ contentType: "application/json; charset=utf-8", body: JSON.stringify({ @@ -74,7 +75,7 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { }); }); - await page.route("**/api/rpc", async (route) => { + await page.route("**/api/rpc**", async (route) => { const payload = route.request().postDataJSON(); const method = typeof payload?.method === "string" ? payload.method : ""; const id = payload?.id ?? 1; @@ -102,7 +103,22 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { }); return; } + if (method === "accountManager/session/current") { + await ok({ + mode: "none", + currentUser: null, + role: "system_admin", + permissions: ["system:admin"], + distributionEnabled: false, + }); + return; + } if (method === "account/list") { + accountListPayloads.push( + payload?.params && typeof payload.params === "object" + ? (payload.params as Record) + : {}, + ); await ok({ items: [ { @@ -113,10 +129,23 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { status: "active", sort: 0, }, + { + id: "acct-k12-1", + name: "student@example.com", + label: "student@example.com", + plan_type: "unknown", + plan_type_raw: "k12", + status: "unknown", + sort: 5, + }, ], - total: 1, + total: 2, page: 1, pageSize: 20, + planTypes: [ + { value: "plus", count: 1 }, + { value: "k12", count: 1 }, + ], }); return; } @@ -183,13 +212,51 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { await page.goto("/accounts/"); - await expect(page.getByRole("heading", { name: "账号管理" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "OpenAI 账号池" })).toBeVisible(); const searchInput = page.getByPlaceholder("搜索账号名 / 编号..."); await expect(searchInput).toBeVisible(); const searchBox = await searchInput.boundingBox(); expect(searchBox?.x ?? -1).toBeGreaterThanOrEqual(0); expect(searchBox?.width ?? 0).toBeGreaterThan(180); + const planFilter = page + .locator('[data-slot="select-trigger"]') + .filter({ hasText: "全部类型" }); + await planFilter.click(); + await page.getByRole("option", { name: "K12 (1)" }).click(); + await expect + .poll(() => accountListPayloads.some((item) => item.planFilter === "k12")) + .toBe(true); + + const statusFilter = page + .locator("main") + .locator('[data-slot="select-trigger"]') + .filter({ hasText: "全部" }); + await statusFilter.click(); + await page.getByRole("option", { name: "不可用" }).click(); + await expect + .poll(() => + accountListPayloads.some((item) => item.statusFilter === "unavailable"), + ) + .toBe(true); + + await page.setViewportSize({ width: 390, height: 844 }); + const resetLabels = page + .getByRole("row", { name: /qxcnms@gmail\.com/ }) + .getByText("未知后刷新", { exact: true }); + await expect(resetLabels).toHaveCount(2); + const resetRowsDoNotOverlap = await resetLabels.evaluateAll((labels) => + labels.every((label) => { + const absoluteTime = label.previousElementSibling; + if (!(absoluteTime instanceof HTMLElement)) return false; + const absoluteBox = absoluteTime.getBoundingClientRect(); + const relativeBox = label.getBoundingClientRect(); + return absoluteBox.bottom <= relativeBox.top + 1; + }), + ); + expect(resetRowsDoNotOverlap).toBe(true); + await page.setViewportSize({ width: 1100, height: 800 }); + const warmupButton = page.getByRole("button", { name: "预热" }); await expect(warmupButton).toBeVisible(); await warmupButton.hover(); @@ -199,7 +266,7 @@ test("accounts toolbar shows warmup button and tooltip", async ({ page }) => { ), ).toBeVisible(); - await page.getByRole("button", { name: "用量详情" }).click(); + await page.getByRole("button", { name: "用量详情" }).first().click(); const usageDialog = page.getByRole("dialog", { name: "用量详情" }); await expect(usageDialog.getByRole("button", { name: "刷新 AT/RT" })).toBeVisible(); From caebf81f78a7bc1cc789b83f8b8c9334919d8d42 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:27:13 +0800 Subject: [PATCH 26/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=94=B9=E5=96=84?= =?UTF-8?q?=E7=AA=84=E5=B1=8F=E5=B8=83=E5=B1=80=E4=B8=8E=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/package.json | 2 +- apps/src/app/globals.css | 12 ++++++-- apps/src/components/layout/app-frame.tsx | 20 ++++++++++++- .../layout/page-keep-alive-viewport.tsx | 2 +- apps/src/components/layout/sidebar.tsx | 5 ++-- apps/tests/accounts-toolbar.spec.ts | 8 ++++++ apps/tests/app-frame-responsive.test.mjs | 26 +++++++++++++++++ apps/tests/chart-tooltip-compositing.test.mjs | 28 +++++++++++++++++++ 8 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 apps/tests/app-frame-responsive.test.mjs create mode 100644 apps/tests/chart-tooltip-compositing.test.mjs diff --git a/apps/package.json b/apps/package.json index 71a9c5c66..0e42f2304 100644 --- a/apps/package.json +++ b/apps/package.json @@ -10,7 +10,7 @@ "build:desktop": "next build", "test:e2e": "playwright test", "test:navigation": "playwright test tests/navigation-cache.spec.ts", - "test:runtime": "node --test tests/runtime-capabilities.test.mjs tests/gateway-endpoints.test.mjs tests/transport-errors.test.mjs tests/gateway-settings.test.mjs tests/transport-web-commands.test.mjs tests/codex-profile-cache.test.mjs tests/i18n-page-coverage.test.mjs tests/account-list-cache.test.mjs tests/tauri-command-registry.test.mjs tests/dashboard-direct-mode.test.mjs tests/settings-theme-preview.test.mjs tests/models-search-style.test.mjs tests/codex-cli-onboarding-density.test.mjs tests/release-notes-action.test.mjs tests/next-dev-runtime-rewrites.test.mjs tests/switch-contrast-style.test.mjs tests/rpc-http.test.mjs tests/request.test.mjs tests/app-updates.test.mjs tests/account-auth.test.mjs tests/account-maintenance.test.mjs tests/startup-snapshot.test.mjs tests/app-bootstrap-startup.test.mjs tests/dialog-layout.test.mjs tests/usage-response.test.mjs tests/ccswitch.test.mjs tests/billing-mode-lock.test.mjs tests/top-level-routes.test.mjs tests/timeout.test.mjs", + "test:runtime": "node --test tests/runtime-capabilities.test.mjs tests/gateway-endpoints.test.mjs tests/transport-errors.test.mjs tests/gateway-settings.test.mjs tests/transport-web-commands.test.mjs tests/codex-profile-cache.test.mjs tests/i18n-page-coverage.test.mjs tests/account-list-cache.test.mjs tests/tauri-command-registry.test.mjs tests/dashboard-direct-mode.test.mjs tests/settings-theme-preview.test.mjs tests/models-search-style.test.mjs tests/codex-cli-onboarding-density.test.mjs tests/release-notes-action.test.mjs tests/next-dev-runtime-rewrites.test.mjs tests/switch-contrast-style.test.mjs tests/chart-tooltip-compositing.test.mjs tests/app-frame-responsive.test.mjs tests/rpc-http.test.mjs tests/request.test.mjs tests/app-updates.test.mjs tests/account-auth.test.mjs tests/account-maintenance.test.mjs tests/startup-snapshot.test.mjs tests/app-bootstrap-startup.test.mjs tests/dialog-layout.test.mjs tests/usage-response.test.mjs tests/ccswitch.test.mjs tests/billing-mode-lock.test.mjs tests/top-level-routes.test.mjs tests/timeout.test.mjs", "start": "next start", "lint": "eslint" }, diff --git a/apps/src/app/globals.css b/apps/src/app/globals.css index 4df9cb564..58ef308b6 100644 --- a/apps/src/app/globals.css +++ b/apps/src/app/globals.css @@ -348,7 +348,6 @@ [data-appearance='modern'] [data-slot="dropdown-menu-content"], [data-appearance='modern'] [data-slot="dropdown-menu-sub-content"], [data-appearance='modern'] [data-slot="select-content"], -[data-appearance='modern'] [data-slot="chart-tooltip"], [data-appearance='modern'] [data-slot="tooltip-content"], [data-appearance='modern'] .sonner-toast, [data-appearance='modern'] .table-sticky-action-head, @@ -368,7 +367,6 @@ [data-appearance='modern'] [data-slot="dropdown-menu-content"], [data-appearance='modern'] [data-slot="dropdown-menu-sub-content"], [data-appearance='modern'] [data-slot="select-content"], -[data-appearance='modern'] [data-slot="chart-tooltip"], [data-appearance='modern'] [data-slot="tooltip-content"], [data-appearance='modern'] .sonner-toast, [data-appearance='modern'] .table-sticky-action-head, @@ -376,6 +374,16 @@ background: var(--glass-floating); } +/* 移动中的图表提示层禁用背景模糊,避免 WebView2 留下旧合成帧。 */ +[data-appearance='modern'] [data-slot="chart-tooltip"] { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: color-mix(in srgb, var(--popover) 96%, var(--background)); + border-color: var(--glass-border); + box-shadow: 0 12px 28px -20px rgb(0 0 0 / 0.52); + contain: paint; +} + [data-appearance='modern'] [data-slot="card-footer"], [data-appearance='modern'] [data-slot="dialog-footer"], [data-appearance='modern'] [data-slot="table-footer"] { diff --git a/apps/src/components/layout/app-frame.tsx b/apps/src/components/layout/app-frame.tsx index 120da9aad..b4d19ad36 100644 --- a/apps/src/components/layout/app-frame.tsx +++ b/apps/src/components/layout/app-frame.tsx @@ -6,9 +6,11 @@ import { Header } from "@/components/layout/header"; import { PageKeepAliveViewport } from "@/components/layout/page-keep-alive-viewport"; import { RouteTransitionOverlay } from "@/components/layout/route-transition-overlay"; import { Sidebar } from "@/components/layout/sidebar"; +import { useAppStore } from "@/lib/store/useAppStore"; import { normalizeRoutePath } from "@/lib/utils/static-routes"; const TRAY_PREVIEW_PATH = "/tray-preview"; +const NARROW_VIEWPORT_QUERY = "(max-width: 639px)"; export function isTrayPreviewPath(pathname: string): boolean { return normalizeRoutePath(pathname) === TRAY_PREVIEW_PATH; @@ -17,6 +19,7 @@ export function isTrayPreviewPath(pathname: string): boolean { export function AppFrame({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isTrayPreview = isTrayPreviewPath(pathname); + const setSidebarOpen = useAppStore((state) => state.setSidebarOpen); useEffect(() => { document.documentElement.classList.toggle("tray-preview-mode", isTrayPreview); @@ -27,6 +30,21 @@ export function AppFrame({ children }: { children: React.ReactNode }) { }; }, [isTrayPreview]); + useEffect(() => { + const narrowViewport = window.matchMedia(NARROW_VIEWPORT_QUERY); + const collapseSidebar = () => { + if (narrowViewport.matches) { + setSidebarOpen(false); + } + }; + + collapseSidebar(); + narrowViewport.addEventListener("change", collapseSidebar); + return () => { + narrowViewport.removeEventListener("change", collapseSidebar); + }; + }, [setSidebarOpen]); + if (isTrayPreview) { return
{children}
; } @@ -36,7 +54,7 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
-
+
diff --git a/apps/src/components/layout/page-keep-alive-viewport.tsx b/apps/src/components/layout/page-keep-alive-viewport.tsx index 94c4e161b..6525b9e61 100644 --- a/apps/src/components/layout/page-keep-alive-viewport.tsx +++ b/apps/src/components/layout/page-keep-alive-viewport.tsx @@ -57,7 +57,7 @@ function PagePanelFallback({ title }: { title: string }) {
diff --git a/apps/src/components/layout/sidebar.tsx b/apps/src/components/layout/sidebar.tsx index 341dac92b..6e6dec951 100644 --- a/apps/src/components/layout/sidebar.tsx +++ b/apps/src/components/layout/sidebar.tsx @@ -179,8 +179,9 @@ export function Sidebar() { return (
@@ -219,7 +220,7 @@ export function Sidebar() {
-
+
+ + } + nativeButton={false} + /> + } + nativeButton={false} + disabled={!isServiceReady} + title={t("更多账号操作")} + aria-label={t("更多账号操作")} + > + + {t("更多账号操作")} + + + + refreshAccount(account.id)} + > + + {t("刷新用量")} + + refreshAccountRt(account.id)} + > + + {t("刷新 AT/RT")} + RT + + + + account.preferred + ? clearPreferredAccount(account.id) + : setPreferredAccount(account.id) + } + > + + {account.preferred ? t("取消优先") : t("设为优先")} + + + statusAction.action && + toggleAccountStatus( + account.id, + statusAction.action === "enable", + account.status, + ) + } + > + + {statusAction.label} + + + handleDeleteSingle(account)} + > + {t("删除")} + + + + +
+ ); + }; return (
@@ -772,10 +1003,15 @@ export function AccountsPageView(props: AccountsPageViewProps) { - - - - +
+
+
+ + + 0 && @@ -785,24 +1021,35 @@ export function AccountsPageView(props: AccountsPageViewProps) { } onCheckedChange={toggleSelectAllVisible} /> - - - {t("账号信息")} - - - {t("额度详情")} - - {t("顺序")} - {t("状态")} - - {t("操作")} - - - - + + + {t("账号信息")} + + + {t("额度详情")} + + + {t("顺序")} + + + {t("状态")} + + + + {isLoading ? ( - Array.from({ length: 5 }).map((_, index) => ( - + tableRowKeys.map((rowKey) => ( + @@ -822,14 +1069,14 @@ export function AccountsPageView(props: AccountsPageViewProps) { - - - )) - ) : visibleAccounts.length === 0 ? ( - - + ) : accountRows.length === 0 ? ( + +

{t("未找到符合条件的账号")}

@@ -837,35 +1084,34 @@ export function AccountsPageView(props: AccountsPageViewProps) { ) : ( - visibleAccounts.map((account) => { - const quotaItems = buildQuotaSummaryItems(account, t); - const statusAction = getAccountStatusAction(account, t); - const StatusActionIcon = statusAction.icon; - const modelPoolText = account.modelSlugs.length - ? account.modelSlugs.slice(0, 2).join(", ") - : t("全部 API 模型"); - const modelPoolDisplayText = `${t("模型池")}: ${modelPoolText}${ - account.modelSlugs.length > 2 - ? ` +${account.modelSlugs.length - 2}` - : "" - }`; - const isRefreshingCurrentAccount = - isRefreshingAccountId === account.id; - const isRefreshingCurrentRt = - isRefreshingRtAccountId === account.id; - const filteredIndex = - filteredAccountIndexMap.get(account.id) ?? -1; - const canMoveUp = canReorderAccounts && filteredIndex > 0; - const canMoveDown = - canReorderAccounts && - filteredIndex !== -1 && - filteredIndex < filteredAccounts.length - 1; - return ( - + accountRows.map( + ({ + account, + rowKey, + quotaItems, + modelPoolDisplayText, + canMoveUp, + canMoveDown, + }) => ( + toggleSelect(account.id)} + aria-label={`${t("选择账号")} ${ + account.label || account.name || account.id + }`} /> @@ -933,6 +1179,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { } onClick={() => void handleMoveAccount(account, "up")} title={t("上移一位")} + aria-label={t("上移一位")} > @@ -950,6 +1197,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { void handleMoveAccount(account, "down") } title={t("下移一位")} + aria-label={t("下移一位")} > @@ -964,129 +1212,88 @@ export function AccountsPageView(props: AccountsPageViewProps) { } onClick={() => openAccountEditor(account)} title={t("编辑账号信息")} + aria-label={t("编辑账号信息")} >
- + - -
- - - - - - - - refreshAccount(account.id)} - > - - {t("刷新用量")} - - refreshAccountRt(account.id)} - > - - {t("刷新 AT/RT")} - RT - - - - account.preferred - ? clearPreferredAccount(account.id) - : setPreferredAccount(account.id) - } - > - - {account.preferred ? t("取消优先") : t("设为优先")} - - - statusAction.action && - toggleAccountStatus( - account.id, - statusAction.action === "enable", - account.status, - ) - } - > - - {statusAction.label} - - - handleDeleteSingle(account)} - > - {t("删除")} - - - - -
-
- ); - }) + ), + ) )} -
-
+ + +
+ +
+ + + + + {t("操作")} + + + + + {isLoading ? ( + tableRowKeys.map((rowKey, index) => ( + + + + + + )) + ) : accountRows.length === 0 ? ( + + + + + ) : ( + accountRows.map(({ account, rowKey }) => ( + + + {renderAccountActions(account)} + + + )) + )} + +
+
+
diff --git a/apps/src/app/globals.css b/apps/src/app/globals.css index 58ef308b6..5f660aa23 100644 --- a/apps/src/app/globals.css +++ b/apps/src/app/globals.css @@ -349,9 +349,7 @@ [data-appearance='modern'] [data-slot="dropdown-menu-sub-content"], [data-appearance='modern'] [data-slot="select-content"], [data-appearance='modern'] [data-slot="tooltip-content"], -[data-appearance='modern'] .sonner-toast, -[data-appearance='modern'] .table-sticky-action-head, -[data-appearance='modern'] .table-sticky-action-cell { +[data-appearance='modern'] .sonner-toast { backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border-color: var(--glass-border); @@ -368,12 +366,17 @@ [data-appearance='modern'] [data-slot="dropdown-menu-sub-content"], [data-appearance='modern'] [data-slot="select-content"], [data-appearance='modern'] [data-slot="tooltip-content"], -[data-appearance='modern'] .sonner-toast, -[data-appearance='modern'] .table-sticky-action-head, -[data-appearance='modern'] .table-sticky-action-cell { +[data-appearance='modern'] .sonner-toast { background: var(--glass-floating); } +[data-appearance='modern'] .account-actions-rail { + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + background: var(--glass-floating); + border-color: var(--glass-border); +} + /* 移动中的图表提示层禁用背景模糊,避免 WebView2 留下旧合成帧。 */ [data-appearance='modern'] [data-slot="chart-tooltip"] { backdrop-filter: none; @@ -480,30 +483,68 @@ tr { height: 100%; } -.table-sticky-action-head, -.table-sticky-action-cell { - position: sticky; - right: 0; +.account-pool-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 7rem; + min-width: 0; + width: 100%; +} + +.account-pool-main { + min-width: 0; + overflow: hidden; +} + +.account-pool-main [data-slot="table-container"] { + overscroll-behavior-inline: contain; +} + +.account-pool-main-table { + min-width: 56rem; +} + +.account-pool-main [data-slot="table-body"] [data-slot="table-row"] { + height: 3rem; +} + +.account-actions-rail { + position: relative; + z-index: 2; + min-width: 0; border-left: 1px solid var(--border); + background: rgb(var(--surface-rgb) / 0.98); box-shadow: -10px 0 16px -14px rgb(15 23 42 / 0.18); } -.table-sticky-action-head { - z-index: 4; - background: rgb(var(--surface-rgb) / 0.98); +.account-actions-rail [data-slot="table-container"] { + height: 100%; + overflow: hidden; } -.table-sticky-action-cell { - z-index: 3; - background: rgb(var(--surface-rgb) / 0.96); +.account-actions-table { + width: 100%; + table-layout: fixed; } -[data-slot="table-body"] [data-slot="table-row"]:hover .table-sticky-action-cell { - background: var(--table-row-hover-bg); +.account-action-rail-head, +.account-action-rail-cell { + width: 100%; + padding-right: 0.25rem; + padding-left: 0.25rem; } -[data-slot="table-body"] [data-slot="table-row"][data-state="selected"] .table-sticky-action-cell { - background: var(--table-row-selected-bg); +.account-action-rail-cell { + height: 100%; +} + +.account-action-rail-cell .table-action-cell { + min-height: 2rem; +} + +@media (max-width: 639px) { + .account-pool-layout { + grid-template-columns: minmax(0, 1fr) 6rem; + } } /* 🚀 Performance Mode Logic (Fixed Selectors) */ @@ -535,8 +576,7 @@ body.low-transparency [data-slot="button"][data-variant="outline"], body.low-transparency [data-slot="input"], body.low-transparency [data-slot="textarea"], body.low-transparency [data-slot="select-trigger"], -body.low-transparency .table-sticky-action-head, -body.low-transparency .table-sticky-action-cell, +body.low-transparency .account-actions-rail, body.low-transparency .sonner-toast { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; diff --git a/apps/tests/accounts-action-rail.spec.ts b/apps/tests/accounts-action-rail.spec.ts new file mode 100644 index 000000000..957988c79 --- /dev/null +++ b/apps/tests/accounts-action-rail.spec.ts @@ -0,0 +1,403 @@ +import { expect, test, type Page } from "@playwright/test"; + +const SETTINGS_SNAPSHOT = { + updateAutoCheck: true, + closeToTrayOnClose: false, + closeToTraySupported: false, + lowTransparency: false, + lightweightModeOnCloseToTray: false, + codexCliGuideDismissed: true, + webAccessPasswordConfigured: false, + locale: "zh-CN", + localeOptions: ["zh-CN", "en"], + serviceAddr: "localhost:48760", + serviceListenMode: "loopback", + serviceListenModeOptions: ["loopback", "all_interfaces"], + routeStrategy: "ordered", + routeStrategyOptions: ["ordered", "balanced"], + freeAccountMaxModel: "auto", + freeAccountMaxModelOptions: ["auto", "gpt-5"], + modelForwardRules: "", + accountMaxInflight: 1, + gatewayOriginator: "codex-cli", + gatewayOriginatorDefault: "codex-cli", + gatewayUserAgentVersion: "1.0.0", + gatewayUserAgentVersionDefault: "1.0.0", + gatewayResidencyRequirement: "", + gatewayResidencyRequirementOptions: ["", "us"], + pluginMarketMode: "builtin", + pluginMarketSourceUrl: "", + upstreamProxyUrl: "", + upstreamStreamTimeoutMs: 600_000, + sseKeepaliveIntervalMs: 15_000, + backgroundTasks: { + usagePollingEnabled: true, + usagePollIntervalSecs: 600, + gatewayKeepaliveEnabled: true, + gatewayKeepaliveIntervalSecs: 180, + tokenRefreshPollingEnabled: true, + tokenRefreshPollIntervalSecs: 60, + usageRefreshWorkers: 4, + httpWorkerFactor: 4, + httpWorkerMin: 8, + httpStreamWorkerFactor: 1, + httpStreamWorkerMin: 2, + }, + envOverrides: {}, + envOverrideCatalog: [], + envOverrideReservedKeys: [], + envOverrideUnsupportedKeys: [], + theme: "tech", + appearancePreset: "classic", +}; + +const PAGE_ONE_ACCOUNTS = [ + { + id: "acct-tall-1", + name: "long-status@example.com", + label: "long-status@example.com", + plan_type: "unknown", + plan_type_raw: "k12-education-workspace", + status: "unknown", + status_reason: "refresh_token_invalid:refresh_token_invalidated", + sort: 0, + model_slugs: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], + quota_capacity_primary_window_tokens: 12_345_678, + quota_capacity_secondary_window_tokens: 98_765_432, + }, + { + id: "acct-short-2", + name: "short@example.com", + label: "short@example.com", + plan_type: "plus", + status: "active", + sort: 1, + }, +]; + +const PAGE_TWO_ACCOUNTS = [ + { + id: "acct-page-2", + name: "page-two@example.com", + label: "page-two@example.com", + plan_type: "team", + status: "active", + sort: 2, + }, +]; + +async function installRpcMocks(page: Page) { + let releaseInitialList = () => {}; + const initialListGate = new Promise((resolve) => { + releaseInitialList = resolve; + }); + let delayInitialList = true; + let compactFirstAccount = false; + const usageRefreshAccountIds: string[] = []; + + await page.route("**/api/runtime**", async (route) => { + await route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + mode: "web-gateway", + rpcBaseUrl: "/api/rpc", + canManageService: false, + canSelfUpdate: false, + canCloseToTray: false, + canOpenLocalDir: false, + canUseBrowserFileImport: true, + canUseBrowserDownloadExport: true, + }), + }); + }); + + await page.route("**/api/rpc**", async (route) => { + const payload = route.request().postDataJSON(); + const method = typeof payload?.method === "string" ? payload.method : ""; + const id = payload?.id ?? 1; + const params = + payload?.params && typeof payload.params === "object" + ? (payload.params as Record) + : {}; + const ok = (result: unknown) => + route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ jsonrpc: "2.0", id, result }), + }); + + if (method === "appSettings/get") { + await ok(SETTINGS_SNAPSHOT); + return; + } + if (method === "initialize") { + await ok({ + userAgent: "codex_cli_rs/0.1.19", + codexHome: "C:/Users/Test/.codex", + platformFamily: "windows", + platformOs: "windows", + }); + return; + } + if (method === "accountManager/session/current") { + await ok({ + mode: "none", + currentUser: null, + role: "system_admin", + permissions: ["system:admin"], + distributionEnabled: false, + }); + return; + } + if (method === "account/list") { + if (delayInitialList) { + delayInitialList = false; + await initialListGate; + } + const query = String(params.query ?? params.search ?? "").trim(); + const requestedPage = Number(params.page ?? 1); + const items = query + ? [] + : requestedPage >= 2 + ? PAGE_TWO_ACCOUNTS + : compactFirstAccount + ? [ + { + ...PAGE_ONE_ACCOUNTS[0], + status: "active", + status_reason: "", + model_slugs: [], + quota_capacity_primary_window_tokens: null, + quota_capacity_secondary_window_tokens: null, + }, + PAGE_ONE_ACCOUNTS[1], + ] + : PAGE_ONE_ACCOUNTS; + await ok({ + items, + total: query ? 0 : 3, + page: requestedPage, + pageSize: 2, + planTypes: [ + { value: "k12-education-workspace", count: 1 }, + { value: "plus", count: 1 }, + { value: "team", count: 1 }, + ], + }); + return; + } + if (method === "account/usage/list") { + await ok([]); + return; + } + if (method === "account/usage/refresh") { + usageRefreshAccountIds.push( + String(params.accountId ?? params.account_id ?? ""), + ); + await ok({}); + return; + } + + await route.fulfill({ + status: 500, + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + error: { + code: -32_000, + message: `Unhandled RPC method in test: ${method}`, + }, + }), + }); + }); + + return { + releaseInitialList, + usageRefreshAccountIds, + setCompactFirstAccount: (compact: boolean) => { + compactFirstAccount = compact; + }, + }; +} + +async function readRowAlignment(page: Page) { + return page.evaluate(() => { + const readRows = (testId: string) => + Array.from( + document.querySelectorAll( + `[data-testid="${testId}"] tbody [data-account-row-key]`, + ), + ).map((row) => ({ + key: row.dataset.accountRowKey || "", + accountId: row.dataset.accountId || "", + height: row.getBoundingClientRect().height, + })); + const mainRows = readRows("account-pool-main"); + const actionRows = readRows("account-actions-rail"); + return { + mainRows, + actionRows, + maximumDelta: mainRows.reduce((maximum, row, index) => { + const actionRow = actionRows[index]; + if (!actionRow || actionRow.key !== row.key) { + return Number.POSITIVE_INFINITY; + } + return Math.max(maximum, Math.abs(row.height - actionRow.height)); + }, 0), + }; + }); +} + +async function expectRowsAligned(page: Page, expectedCount: number) { + await expect + .poll(async () => { + const alignment = await readRowAlignment(page); + const mainKeys = alignment.mainRows.map((row) => row.key); + const actionKeys = alignment.actionRows.map((row) => row.key); + return { + mainCount: alignment.mainRows.length, + actionCount: alignment.actionRows.length, + keysMatch: JSON.stringify(mainKeys) === JSON.stringify(actionKeys), + heightsMatch: + Number.isFinite(alignment.maximumDelta) && + alignment.maximumDelta <= 1, + }; + }) + .toEqual({ + mainCount: expectedCount, + actionCount: expectedCount, + keysMatch: true, + heightsMatch: true, + }); + + const alignment = await readRowAlignment(page); + expect(alignment.mainRows.map((row) => row.key)).toEqual( + alignment.actionRows.map((row) => row.key), + ); + expect(alignment.maximumDelta).toBeLessThanOrEqual(1); +} + +test("account action rail stays visible and aligned across responsive widths", async ({ + page, +}) => { + await page.setViewportSize({ width: 1024, height: 900 }); + const { + releaseInitialList, + usageRefreshAccountIds, + setCompactFirstAccount, + } = + await installRpcMocks(page); + + await page.goto("/accounts/"); + await expect(page.getByRole("heading", { name: "OpenAI 账号池" })).toBeVisible(); + await expectRowsAligned(page, 5); + releaseInitialList(); + + const mainTable = page.getByRole("table", { name: "账号管理" }); + const actionTable = page.getByRole("table", { name: "账号操作" }); + await expect( + mainTable.getByRole("row", { name: /long-status@example\.com/ }), + ).toBeVisible(); + await expectRowsAligned(page, 2); + + const mainAccountRow = mainTable.getByRole("row", { + name: /short@example\.com/, + }); + await expect(mainAccountRow.getByRole("button", { name: "上移一位" })).toBeVisible(); + await expect(mainAccountRow.getByRole("button", { name: "下移一位" })).toBeVisible(); + await expect( + mainAccountRow.getByRole("button", { name: "编辑账号信息" }), + ).toBeVisible(); + + for (const width of [390, 640, 1024]) { + await page.setViewportSize({ width, height: 900 }); + await expectRowsAligned(page, 2); + const geometry = await page.evaluate(() => { + const layout = document.querySelector(".account-pool-layout"); + const mainScroller = document.querySelector( + '[data-testid="account-pool-main"] [data-slot="table-container"]', + ); + const rail = document.querySelector( + '[data-testid="account-actions-rail"]', + ); + if (!layout || !mainScroller || !rail) { + return null; + } + const layoutRect = layout.getBoundingClientRect(); + const railRectBefore = rail.getBoundingClientRect(); + mainScroller.scrollLeft = mainScroller.scrollWidth; + const railRectAfter = rail.getBoundingClientRect(); + return { + layoutLeft: layoutRect.left, + layoutRight: layoutRect.right, + railLeft: railRectAfter.left, + railRight: railRectAfter.right, + railShift: Math.abs(railRectAfter.left - railRectBefore.left), + hasHorizontalOverflow: + mainScroller.scrollWidth > mainScroller.clientWidth + 1, + reachedScrollEnd: + mainScroller.scrollLeft + mainScroller.clientWidth >= + mainScroller.scrollWidth - 1, + }; + }); + expect(geometry).not.toBeNull(); + expect(geometry?.hasHorizontalOverflow).toBe(true); + expect(geometry?.reachedScrollEnd).toBe(true); + expect(geometry?.railShift ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(1); + expect(geometry?.railLeft ?? -1).toBeGreaterThanOrEqual( + (geometry?.layoutLeft ?? 0) - 1, + ); + expect(geometry?.railRight ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + (geometry?.layoutRight ?? 0) + 1, + ); + } + + const expandedRow = mainTable.locator('[data-account-id="acct-tall-1"]'); + const expandedRowKey = await expandedRow.getAttribute("data-account-row-key"); + const expandedRowHeight = (await expandedRow.boundingBox())?.height ?? 0; + setCompactFirstAccount(true); + await page.getByText("账号操作", { exact: true }).click(); + await page.getByRole("menuitem", { name: /刷新列表/ }).click(); + await expect(mainTable.getByText("Refresh Token 已被撤销,需要重新登录")).toHaveCount( + 0, + ); + await expectRowsAligned(page, 2); + const compactRow = mainTable.locator('[data-account-id="acct-tall-1"]'); + expect(await compactRow.getAttribute("data-account-row-key")).toBe( + expandedRowKey, + ); + const compactRowHeight = (await compactRow.boundingBox())?.height ?? 0; + expect(expandedRowHeight).toBeGreaterThan(compactRowHeight + 1); + + const shortActionRow = actionTable.getByRole("row", { + name: /short@example\.com/, + }); + await shortActionRow.getByRole("button", { name: "用量详情" }).click(); + const usageDialog = page.getByRole("dialog", { name: "用量详情" }); + await expect(usageDialog).toContainText("short@example.com"); + await usageDialog.getByRole("button", { name: "关闭" }).click(); + + await shortActionRow + .getByRole("button", { name: "更多账号操作" }) + .click(); + await page.getByRole("menuitem", { name: "刷新用量" }).click(); + await expect.poll(() => usageRefreshAccountIds).toEqual(["acct-short-2"]); + + await page.getByRole("button", { name: "下一页" }).click(); + await expect( + mainTable.getByRole("row", { name: /page-two@example\.com/ }), + ).toBeVisible(); + await expectRowsAligned(page, 1); + const pagedRows = await readRowAlignment(page); + expect(pagedRows.mainRows.map((row) => row.accountId)).toEqual([ + "acct-page-2", + ]); + expect(pagedRows.actionRows.map((row) => row.accountId)).toEqual([ + "acct-page-2", + ]); + + await page.getByPlaceholder("搜索账号名 / 编号...").fill("missing-account"); + await expect(mainTable.getByText("未找到符合条件的账号")).toBeVisible(); + await expectRowsAligned(page, 1); + await expect(actionTable.getByRole("button", { name: "用量详情" })).toHaveCount(0); +}); diff --git a/apps/tests/desktop-log-bounds.test.mjs b/apps/tests/desktop-log-bounds.test.mjs new file mode 100644 index 000000000..4db70df9e --- /dev/null +++ b/apps/tests/desktop-log-bounds.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +const appsRoot = path.resolve(import.meta.dirname, ".."); + +test("桌面文件日志设置合理的单文件上限和有限轮转", async () => { + const source = await fs.readFile( + path.join(appsRoot, "src-tauri", "src", "lib.rs"), + "utf8", + ); + const maxFileSize = source.match( + /const DESKTOP_LOG_MAX_FILE_SIZE_BYTES: u128 = (\d+) \* 1024 \* 1024;/, + ); + const rotatedFileLimit = source.match( + /const DESKTOP_LOG_ROTATED_FILE_LIMIT: usize = (\d+);/, + ); + + assert.ok(maxFileSize, "缺少具名的桌面日志单文件上限"); + assert.ok(rotatedFileLimit, "缺少具名的桌面日志轮转归档上限"); + + const maxFileSizeMiB = Number(maxFileSize[1]); + const rotatedFiles = Number(rotatedFileLimit[1]); + assert.ok(maxFileSizeMiB >= 4, "日志上限过小,容易丢失排障上下文"); + assert.ok(maxFileSizeMiB <= 32, "日志单文件上限应保持有界"); + assert.ok(rotatedFiles >= 1 && rotatedFiles <= 8, "日志轮转数量必须有限且合理"); + + assert.match( + source, + /\.rotation_strategy\(tauri_plugin_log::RotationStrategy::KeepSome\(\s*DESKTOP_LOG_ROTATED_FILE_LIMIT,\s*\)\)/, + ); + assert.match( + source, + /\.max_file_size\(DESKTOP_LOG_MAX_FILE_SIZE_BYTES\)/, + ); + assert.match( + source, + /tauri_plugin_log::TargetKind::LogDir \{ file_name: None \}/, + ); + assert.doesNotMatch(source, /RotationStrategy::KeepAll/); +}); From 31a981e373b56f5a9797aa93cb6e2552277f4861 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:14:51 +0800 Subject: [PATCH 31/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=B7=B7=E5=90=88=E8=B7=AF=E7=94=B1=E8=81=9A=E5=90=88=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/service/src/gateway/upstream/proxy.rs | 12 + .../proxy_pipeline/execution_context.rs | 6 +- .../gateway/upstream/support/candidates.rs | 18 +- crates/service/tests/gateway_logs.rs | 2 + .../tests/gateway_logs/model_routing.rs | 376 ++++++++++++++++++ 5 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 crates/service/tests/gateway_logs/model_routing.rs diff --git a/crates/service/src/gateway/upstream/proxy.rs b/crates/service/src/gateway/upstream/proxy.rs index 5279027e0..2df464fe6 100644 --- a/crates/service/src/gateway/upstream/proxy.rs +++ b/crates/service/src/gateway/upstream/proxy.rs @@ -851,6 +851,17 @@ pub(in super::super) fn proxy_validated_request( model_for_log.as_deref(), trace_id.as_str(), ); + let has_hybrid_aggregate_fallback = + should_fallback_to_aggregate_after_account_exhaustion(execution_plan) + && resolve_aggregate_candidates_for_route( + &storage, + protocol_type.as_str(), + aggregate_api_id.as_deref(), + model_for_log.as_deref(), + ) + .is_ok_and(|candidates| !candidates.is_empty()); + // 中文注释:聚合兜底是 hybrid 路由中的后续逻辑路由,不是账号候选。 + // 显式传递该状态,使最后一个账号可进入既有 Exhausted 分支,同时保持候选日志数量真实。 let base = setup.upstream_base.as_str(); let context = GatewayUpstreamExecutionContext::new( @@ -875,6 +886,7 @@ pub(in super::super) fn proxy_validated_request( Some(setup.route_strategy_for_log), Some(setup.route_source_for_log), setup.candidate_count, + has_hybrid_aggregate_fallback, setup.account_max_inflight, ); let allow_openai_fallback = setup.upstream_fallback_base.is_some(); diff --git a/crates/service/src/gateway/upstream/proxy_pipeline/execution_context.rs b/crates/service/src/gateway/upstream/proxy_pipeline/execution_context.rs index ca106d6c9..28b6e7768 100644 --- a/crates/service/src/gateway/upstream/proxy_pipeline/execution_context.rs +++ b/crates/service/src/gateway/upstream/proxy_pipeline/execution_context.rs @@ -23,6 +23,7 @@ pub(in super::super) struct GatewayUpstreamExecutionContext<'a> { route_strategy_for_log: Option<&'a str>, route_source_for_log: Option<&'a str>, candidate_count: usize, + has_follow_up_route: bool, account_max_inflight: usize, } @@ -61,6 +62,7 @@ impl<'a> GatewayUpstreamExecutionContext<'a> { route_strategy_for_log: Option<&'a str>, route_source_for_log: Option<&'a str>, candidate_count: usize, + has_follow_up_route: bool, account_max_inflight: usize, ) -> Self { Self { @@ -85,6 +87,7 @@ impl<'a> GatewayUpstreamExecutionContext<'a> { route_strategy_for_log, route_source_for_log, candidate_count, + has_follow_up_route, account_max_inflight, } } @@ -101,7 +104,7 @@ impl<'a> GatewayUpstreamExecutionContext<'a> { /// # 返回 /// 返回函数执行结果 pub(in super::super) fn has_more_candidates(&self, idx: usize) -> bool { - idx + 1 < self.candidate_count + idx + 1 < self.candidate_count || self.has_follow_up_route } pub(in super::super) fn protocol_type(&self) -> &str { @@ -128,6 +131,7 @@ impl<'a> GatewayUpstreamExecutionContext<'a> { account_id, idx, self.candidate_count, + self.has_follow_up_route, self.account_max_inflight, self.protocol_type == crate::apikey_profile::PROTOCOL_ANTHROPIC_NATIVE, ) diff --git a/crates/service/src/gateway/upstream/support/candidates.rs b/crates/service/src/gateway/upstream/support/candidates.rs index f36da50af..d862c077c 100644 --- a/crates/service/src/gateway/upstream/support/candidates.rs +++ b/crates/service/src/gateway/upstream/support/candidates.rs @@ -171,10 +171,11 @@ pub(in super::super) fn candidate_skip_reason_for_proxy( account_id: &str, idx: usize, candidate_count: usize, + has_follow_up_route: bool, account_max_inflight: usize, skip_last_cooldown: bool, ) -> Option { - let has_more_candidates = idx + 1 < candidate_count; + let has_more_candidates = idx + 1 < candidate_count || has_follow_up_route; if super::super::super::is_account_in_cooldown(account_id) && (has_more_candidates || skip_last_cooldown) { @@ -626,8 +627,15 @@ mod tests { #[test] fn candidate_skip_reason_for_proxy_allows_failover_when_head_account_is_inflight_limited() { let _guard = crate::gateway::acquire_account_inflight("acc-preferred"); - let actual = candidate_skip_reason_for_proxy("acc-preferred", 0, 2, 1, false); + let actual = candidate_skip_reason_for_proxy("acc-preferred", 0, 2, false, 1, false); + let last_without_follow_up = + candidate_skip_reason_for_proxy("acc-preferred", 0, 1, false, 1, false); + let last_with_follow_up = + candidate_skip_reason_for_proxy("acc-preferred", 0, 1, true, 1, false); + assert_eq!(actual, Some(CandidateSkipReason::Inflight)); + assert_eq!(last_without_follow_up, None); + assert_eq!(last_with_follow_up, Some(CandidateSkipReason::Inflight)); } #[test] @@ -635,10 +643,12 @@ mod tests { let account_id = "acc-cooldown-last-skip-test"; crate::gateway::gateway_mark_account_cooldown_for_status(account_id, 403); - let default_last = candidate_skip_reason_for_proxy(account_id, 0, 1, 0, false); - let strict_last = candidate_skip_reason_for_proxy(account_id, 0, 1, 0, true); + let default_last = candidate_skip_reason_for_proxy(account_id, 0, 1, false, 0, false); + let follow_up_last = candidate_skip_reason_for_proxy(account_id, 0, 1, true, 0, false); + let strict_last = candidate_skip_reason_for_proxy(account_id, 0, 1, false, 0, true); assert_eq!(default_last, None); + assert_eq!(follow_up_last, Some(CandidateSkipReason::Cooldown)); assert_eq!(strict_last, Some(CandidateSkipReason::Cooldown)); } } diff --git a/crates/service/tests/gateway_logs.rs b/crates/service/tests/gateway_logs.rs index fd65ac2e6..8213229ee 100644 --- a/crates/service/tests/gateway_logs.rs +++ b/crates/service/tests/gateway_logs.rs @@ -4,6 +4,8 @@ mod anthropic; mod basic; #[path = "gateway_logs/images.rs"] mod images; +#[path = "gateway_logs/model_routing.rs"] +mod model_routing; #[path = "gateway_logs/prompt_cache.rs"] mod prompt_cache; #[path = "gateway_logs/retry_logging.rs"] diff --git a/crates/service/tests/gateway_logs/model_routing.rs b/crates/service/tests/gateway_logs/model_routing.rs new file mode 100644 index 000000000..67c6d564b --- /dev/null +++ b/crates/service/tests/gateway_logs/model_routing.rs @@ -0,0 +1,376 @@ +use super::*; +use codexmanager_core::storage::{AggregateApi, RequestLog, UsageSnapshotRecord}; + +const ACCOUNT_ROTATION: &str = "account_rotation"; +const HYBRID_ROTATION: &str = "hybrid_rotation"; +const OPENAI_COMPAT: &str = "openai_compat"; + +struct RouteTestEnv { + db_path: PathBuf, + _db_path_guard: EnvGuard, + _upstream_guard: EnvGuard, + _candidate_cache_guard: EnvGuard, + _aggregate_cache_guard: EnvGuard, + _storage_idle_guard: EnvGuard, +} + +impl RouteTestEnv { + /// 创建使用独立数据库且关闭候选缓存的路由测试环境。 + fn new(prefix: &str, account_upstream: &str) -> Self { + let dir = new_test_dir(prefix); + let db_path = dir.join("codexmanager.db"); + let db_path_guard = + EnvGuard::set("CODEXMANAGER_DB_PATH", db_path.to_string_lossy().as_ref()); + let upstream_guard = EnvGuard::set("CODEXMANAGER_UPSTREAM_BASE_URL", account_upstream); + let candidate_cache_guard = EnvGuard::set("CODEXMANAGER_CANDIDATE_CACHE_TTL_MS", "0"); + let aggregate_cache_guard = + EnvGuard::set("CODEXMANAGER_AGGREGATE_API_CANDIDATE_CACHE_TTL_MS", "0"); + let storage_idle_guard = EnvGuard::set("CODEXMANAGER_STORAGE_MAX_IDLE_CONNECTIONS", "0"); + Self { + db_path, + _db_path_guard: db_path_guard, + _upstream_guard: upstream_guard, + _candidate_cache_guard: candidate_cache_guard, + _aggregate_cache_guard: aggregate_cache_guard, + _storage_idle_guard: storage_idle_guard, + } + } + + /// 打开并初始化当前测试数据库。 + fn storage(&self) -> Storage { + let storage = Storage::open(&self.db_path).expect("open route test db"); + storage.init().expect("init route test db"); + storage + } +} + +/// 写入路由测试使用的平台密钥。 +fn insert_api_key(storage: &Storage, key_id: &str, platform_key: &str, rotation_strategy: &str) { + storage + .insert_api_key(&ApiKey { + id: key_id.to_string(), + name: Some(key_id.to_string()), + model_slug: None, + reasoning_effort: None, + service_tier: None, + rotation_strategy: rotation_strategy.to_string(), + aggregate_api_id: None, + account_plan_filter: None, + aggregate_api_url: None, + client_type: "codex".to_string(), + protocol_type: OPENAI_COMPAT.to_string(), + auth_scheme: "authorization_bearer".to_string(), + upstream_base_url: None, + static_headers_json: None, + key_hash: hash_platform_key_for_test(platform_key), + status: "active".to_string(), + created_at: now_ts(), + last_used_at: None, + }) + .expect("insert route test api key"); +} + +/// 写入一个额度正常且可参与轮转的账号。 +fn insert_account(storage: &Storage, account_id: &str) { + let now = now_ts(); + storage + .insert_account(&Account { + id: account_id.to_string(), + label: account_id.to_string(), + issuer: "https://auth.openai.com".to_string(), + chatgpt_account_id: Some(format!("chatgpt-{account_id}")), + workspace_id: None, + group_name: None, + sort: 0, + status: "active".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert route test account"); + storage + .insert_token(&Token { + account_id: account_id.to_string(), + id_token: String::new(), + access_token: format!("access-{account_id}"), + refresh_token: format!("refresh-{account_id}"), + api_key_access_token: None, + last_refresh: now, + }) + .expect("insert route test token"); + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: account_id.to_string(), + used_percent: Some(10.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(20.0), + secondary_window_minutes: Some(10_080), + secondary_resets_at: None, + credits_json: Some(r#"{"planType":"plus"}"#.to_string()), + captured_at: now, + }) + .expect("insert route test usage"); +} + +/// 写入聚合 API 及其密钥。 +fn insert_aggregate_api(storage: &Storage, api_id: &str, upstream_addr: &str) { + let now = now_ts(); + storage + .insert_aggregate_api(&AggregateApi { + id: api_id.to_string(), + provider_type: "codex".to_string(), + supplier_name: Some(api_id.to_string()), + sort: 0, + url: format!("http://{upstream_addr}"), + auth_type: "apikey".to_string(), + auth_params_json: None, + action: None, + model_override: None, + status: "active".to_string(), + created_at: now, + updated_at: now, + last_test_at: None, + last_test_status: None, + last_test_error: None, + balance_query_enabled: false, + balance_query_template: None, + balance_query_base_url: None, + balance_query_user_id: None, + balance_query_config_json: None, + last_balance_at: None, + last_balance_status: None, + last_balance_error: None, + last_balance_json: None, + }) + .expect("insert route test aggregate api"); + storage + .upsert_aggregate_api_secret(api_id, "aggregate-route-secret") + .expect("insert route test aggregate secret"); +} + +/// 写入真实模型来源记录和平台模型映射。 +fn insert_source_mapping( + storage: &Storage, + mapping_id: &str, + platform_model: &str, + source_kind: &str, + source_id: &str, + upstream_model: &str, +) { + storage + .upsert_discovered_model_source_models( + source_kind, + source_id, + &[upstream_model.to_string()], + "synced", + ) + .expect("insert route source model"); + let now = now_ts(); + storage + .upsert_model_source_mapping(&ModelSourceMapping { + id: mapping_id.to_string(), + platform_model_slug: platform_model.to_string(), + source_kind: source_kind.to_string(), + source_id: source_id.to_string(), + upstream_model: upstream_model.to_string(), + enabled: true, + priority: 0, + weight: 1, + billing_model_slug: None, + created_at: now, + updated_at: now, + }) + .expect("insert route source mapping"); +} + +/// 通过真实 HTTP 入口发送聊天请求。 +fn post_chat(server_addr: &str, platform_key: &str, model: &str) -> (u16, String) { + let body = serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "route test"}], + "stream": false + }) + .to_string(); + post_http_raw( + server_addr, + "/v1/chat/completions", + &body, + &[ + ("Content-Type", "application/json"), + ("Authorization", &format!("Bearer {platform_key}")), + ], + ) +} + +/// 等待当前测试数据库中的最终请求日志落盘。 +fn wait_for_request_log(storage: &Storage) -> RequestLog { + for _ in 0..40 { + if let Some(log) = storage + .list_request_logs(None, 20) + .expect("list route request logs") + .into_iter() + .find(|item| item.request_path == "/v1/chat/completions") + { + return log; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("route request log was not written"); +} + +/// 验证混合路由遇到纯聚合映射时不会请求账号上游。 +#[test] +fn hybrid_aggregate_only_mapping_skips_account_pool() { + let _lock = test_env_guard(); + let ok_body = r#"{"id":"chatcmpl-aggregate-only","choices":[{"index":0,"message":{"role":"assistant","content":"aggregate-ok"},"finish_reason":"stop"}]}"#; + let (aggregate_addr, aggregate_rx, aggregate_join) = + start_mock_upstream_sequence(vec![(200, ok_body.to_string())]); + let env = RouteTestEnv::new("gateway-hybrid-aggregate-only", "http://127.0.0.1:1"); + let storage = env.storage(); + let platform_key = "pk_hybrid_aggregate_only"; + let platform_model = "platform-aggregate-only"; + seed_model_catalog_models(&storage, &[platform_model]); + insert_api_key( + &storage, + "key_hybrid_aggregate_only", + platform_key, + HYBRID_ROTATION, + ); + insert_account(&storage, "acc-shadow-aggregate-only"); + insert_aggregate_api(&storage, "agg-aggregate-only", &aggregate_addr); + insert_source_mapping( + &storage, + "mapping-aggregate-only", + platform_model, + "aggregate_api", + "agg-aggregate-only", + "provider-aggregate-only", + ); + + let server = TestServer::start(); + let (status, body) = post_chat(&server.addr, platform_key, platform_model); + assert_eq!(status, 200, "gateway response: {body}"); + assert!(body.contains("aggregate-ok")); + let captured = aggregate_rx + .recv_timeout(Duration::from_secs(3)) + .expect("capture aggregate-only request"); + assert_eq!(captured.path, "/v1/chat/completions"); + let captured_body: serde_json::Value = + serde_json::from_slice(&captured.body).expect("parse aggregate-only request"); + assert_eq!(captured_body["model"], "provider-aggregate-only"); + aggregate_join.join().expect("join aggregate-only mock"); + + let log = wait_for_request_log(&storage); + assert_eq!(log.status_code, Some(200)); + assert_eq!(log.actual_source_kind.as_deref(), Some("aggregate_api")); + assert_eq!(log.actual_source_id.as_deref(), Some("agg-aggregate-only")); +} + +/// 验证纯账号轮转拒绝只有聚合来源的模型。 +#[test] +fn account_rotation_rejects_aggregate_only_mapping() { + let _lock = test_env_guard(); + let env = RouteTestEnv::new( + "gateway-account-rejects-aggregate-only", + "http://127.0.0.1:1", + ); + let storage = env.storage(); + let platform_key = "pk_account_rejects_aggregate_only"; + let platform_model = "platform-account-rejects-aggregate-only"; + seed_model_catalog_models(&storage, &[platform_model]); + insert_api_key( + &storage, + "key_account_rejects_aggregate_only", + platform_key, + ACCOUNT_ROTATION, + ); + insert_source_mapping( + &storage, + "mapping-account-rejects-aggregate-only", + platform_model, + "aggregate_api", + "agg-not-routable", + "provider-account-rejects-aggregate-only", + ); + + let server = TestServer::start(); + let (status, body) = post_chat(&server.addr, platform_key, platform_model); + assert_eq!(status, 503, "gateway response: {body}"); + assert!(body.contains("model_unavailable")); +} + +/// 验证混合路由在账号 404 耗尽后回退到聚合 API。 +#[test] +fn hybrid_falls_back_to_aggregate_after_account_exhaustion() { + let _lock = test_env_guard(); + let account_error = r#"{"error":{"message":"account model unavailable"}}"#; + let (account_addr, account_rx, account_join) = + start_mock_upstream_sequence(vec![(404, account_error.to_string())]); + let aggregate_ok = r#"{"id":"chatcmpl-hybrid-fallback","choices":[{"index":0,"message":{"role":"assistant","content":"aggregate-fallback-ok"},"finish_reason":"stop"}]}"#; + let (aggregate_addr, aggregate_rx, aggregate_join) = + start_mock_upstream_sequence(vec![(200, aggregate_ok.to_string())]); + let env = RouteTestEnv::new( + "gateway-hybrid-account-exhaustion", + &format!("http://{account_addr}"), + ); + let storage = env.storage(); + let platform_key = "pk_hybrid_account_exhaustion"; + let platform_model = "platform-hybrid-account-exhaustion"; + seed_model_catalog_models(&storage, &[platform_model]); + insert_api_key( + &storage, + "key_hybrid_account_exhaustion", + platform_key, + HYBRID_ROTATION, + ); + insert_account(&storage, "acc-hybrid-account-exhaustion"); + insert_aggregate_api(&storage, "agg-hybrid-account-exhaustion", &aggregate_addr); + insert_source_mapping( + &storage, + "mapping-hybrid-account", + platform_model, + "openai_account", + "acc-hybrid-account-exhaustion", + platform_model, + ); + insert_source_mapping( + &storage, + "mapping-hybrid-aggregate", + platform_model, + "aggregate_api", + "agg-hybrid-account-exhaustion", + "provider-aggregate-fallback-model", + ); + + let server = TestServer::start(); + let (status, body) = post_chat(&server.addr, platform_key, platform_model); + assert_eq!(status, 200, "gateway response: {body}"); + assert!(body.contains("aggregate-fallback-ok")); + + let account_request = account_rx + .recv_timeout(Duration::from_secs(3)) + .expect("capture account request"); + assert_eq!(account_request.path, "/v1/responses"); + let account_body: serde_json::Value = + serde_json::from_slice(&decode_upstream_request_body(&account_request)) + .expect("parse account request"); + assert_eq!(account_body["model"], platform_model); + + let aggregate_request = aggregate_rx + .recv_timeout(Duration::from_secs(3)) + .expect("capture aggregate fallback request"); + assert_eq!(aggregate_request.path, "/v1/chat/completions"); + let aggregate_body: serde_json::Value = + serde_json::from_slice(&aggregate_request.body).expect("parse aggregate request"); + assert_eq!(aggregate_body["model"], "provider-aggregate-fallback-model"); + account_join.join().expect("join account mock"); + aggregate_join.join().expect("join aggregate mock"); + + let log = wait_for_request_log(&storage); + assert_eq!(log.status_code, Some(200)); + assert_eq!(log.actual_source_kind.as_deref(), Some("aggregate_api")); + assert_eq!( + log.actual_source_id.as_deref(), + Some("agg-hybrid-account-exhaustion") + ); +} From 6ff49b54e01c9afe2619dd0b561ae940b3aa8bf2 Mon Sep 17 00:00:00 2001 From: Creator <12270262+CreatorEdition@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:15:21 +0800 Subject: [PATCH 32/35] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=94=B6=E5=8F=A3W?= =?UTF-8?q?ebSocket=E4=BB=A3=E7=90=86=E8=B6=85=E6=97=B6=E4=B8=8E=E9=89=B4?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/src-tauri/Cargo.lock | 1 + crates/service/Cargo.toml | 2 +- .../src/gateway/core/runtime_config.rs | 127 +++++- .../core/tests/runtime_config_tests.rs | 126 +++++- .../src/gateway/local_validation/mod.rs | 10 + crates/service/src/gateway/mod.rs | 5 + .../service/src/http/responses_websocket.rs | 272 ++++++++---- .../src/http/tests/proxy_runtime_tests.rs | 400 +++++++++++++++++- crates/service/src/log_redaction.rs | 50 ++- 9 files changed, 887 insertions(+), 106 deletions(-) diff --git a/apps/src-tauri/Cargo.lock b/apps/src-tauri/Cargo.lock index f221f8aa7..0025c5501 100644 --- a/apps/src-tauri/Cargo.lock +++ b/apps/src-tauri/Cargo.lock @@ -840,6 +840,7 @@ dependencies = [ "env_logger", "eventsource-stream", "futures-util", + "hyper-util", "log", "os_info", "rand 0.8.5", diff --git a/crates/service/Cargo.toml b/crates/service/Cargo.toml index 167a6518e..7b1a19423 100644 --- a/crates/service/Cargo.toml +++ b/crates/service/Cargo.toml @@ -19,7 +19,7 @@ axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["rt-multi-thread", "net", "time"] } futures-util = "0.3" eventsource-stream = "0.2.3" -hyper-util = { version = "0.1.19", features = ["client-proxy"] } +hyper-util = { version = "0.1.19", features = ["client-proxy", "client-proxy-system"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } rustls = { version = "0.23", features = ["ring"] } url = "2" diff --git a/crates/service/src/gateway/core/runtime_config.rs b/crates/service/src/gateway/core/runtime_config.rs index 3b0f475de..0395d1135 100644 --- a/crates/service/src/gateway/core/runtime_config.rs +++ b/crates/service/src/gateway/core/runtime_config.rs @@ -1,6 +1,7 @@ +use base64::Engine as _; use codexmanager_core::auth::DEFAULT_ORIGINATOR; use codexmanager_core::auth::{DEFAULT_CLIENT_ID, DEFAULT_ISSUER}; -use hyper_util::client::proxy::matcher::Matcher; +use hyper_util::client::proxy::matcher::{Intercept, Matcher}; use reqwest::blocking::Client; use reqwest::Proxy; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; @@ -232,23 +233,26 @@ pub(crate) fn fresh_async_upstream_client_for_account(account_id: &str) -> reqwe build_async_upstream_client() } -pub(crate) fn upstream_proxy_url_for_account(account_id: &str) -> Option { - ensure_runtime_config_loaded(); - let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); - if let Some(proxy_url) = pool.proxy_for_account(account_id) { - return Some(proxy_url.to_string()); - } - current_upstream_proxy_url() -} - -/// 返回 WebSocket 上游使用的代理;应用内显式代理优先于进程环境代理。 +/// 返回 WebSocket 上游代理:全局应用代理会旁路账号稳定代理池;否则依次使用代理池、环境与系统代理。 pub(crate) fn websocket_proxy_url_for_account( account_id: &str, target_url: &str, ) -> Result, String> { - if let Some(proxy_url) = upstream_proxy_url_for_account(account_id) { + ensure_runtime_config_loaded(); + if let Some(proxy_url) = current_upstream_proxy_url() { return Ok(Some(proxy_url)); } + if env_non_empty(ENV_UPSTREAM_PROXY_URL).is_some() { + return Err("invalid websocket application proxy; refusing fallback".to_string()); + } + let pool = crate::lock_utils::read_recover(upstream_client_pool_lock(), "upstream_client_pool"); + if let Some(proxy_url) = pool.proxy_for_account(account_id) { + return Ok(Some(proxy_url.to_string())); + } + drop(pool); + if env_non_empty(ENV_PROXY_LIST).is_some() { + return Err("websocket proxy pool has no valid entries; refusing fallback".to_string()); + } environment_proxy_url_for_websocket_target(target_url) } @@ -292,7 +296,7 @@ fn environment_proxy_url_for_websocket_target(target_url: &str) -> Result