Compare commits

...
11 Commits
Author SHA1 Message Date
ZyyandCodex 2221f400da chore(release): v3.29.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:45:14 +08:00
ZyyandCodex 38bcfb9da1 fix(dashboard): correct yesterday trend comparisons
Read yesterday card counters independently using deployment calendar boundaries. Hide trends when today has no requests or cache rates are unavailable, and label arrows as comparisons with yesterday.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:35:05 +08:00
ZyyandCodex 10f122aac8 fix(frontend): align theme preview data and input hints
Match the supplied Codex Desktop identity reference and avoid undefined verification labels. Remove ellipses from input placeholders while preserving OAuth callback URL examples.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:51 +08:00
ZyyandCodex 598d56baed fix(frontend): stabilize async state and session lifecycles
Preserve plugin pages during session renewal, restore pagination after failed requests, and cancel plugin resources on reload or disposal. Reuse request state for quota forecasts and disable logout while authentication changes.

Validated with frontend lint and build, 23 temporary behavioral checks, and browser checks using simulated pagination and authentication responses.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:46 +08:00
ZyyandCodex 5f61309941 refactor(frontend): standardize API request contracts
Use named request and response types with consistent data arguments and request configuration. Update affected callers while retaining request options where needed.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:27 +08:00
ZyyandCodex ab8c61b118 chore(toolchain): upgrade Node to 26.12.0 and Rust to 1.99.1
Align Docker images, CI inputs, Rust requirements and development commands.
Adapt strict compiler checks and reuse owned buffers in UTF-8 conversion.

Validate 4,021 backend tests, SDK feature configurations, Clippy, frontend
checks, and the amd64 container build and startup.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:57:48 +08:00
ZyyandCodex c3146dc10a fix(usage): align request tables and detail dialogs
Share data columns and status filters between admin and key usage. Keep final error summaries in the error view and focus error details in a separate dialog.

Use logical request outcomes for key usage records and keep concurrency lists at a stable height without pagination.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:28:41 +08:00
ZyyandCodex 72bdc234ca docs(skills): streamline routing and clarify development boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:14:22 +08:00
ZyyandCodex 733e9aa8a0 fix(release): guard publication and consolidate release tooling
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:02:17 +08:00
ZyyandCodex 743a3aba70 chore(release): v3.29.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 22:01:49 +08:00
ZyyandCodex 349e983eb7 fix(ui): correct account labels, proxy actions and usage defaults
Reuse account identity labels in concurrency details and use a text button
for reset-credit lead time settings. Disable row tests during batch proxy
testing while retaining loading feedback for individual tests.

Default the usage date range to today.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 22:00:16 +08:00
135 changed files with 2532 additions and 1852 deletions
+20 -37
View File
@@ -1,59 +1,42 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
---
# 开发指南
## 参考仓库的优先级
## 先匹配任务与文件
**Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案;这是开发、排障与审查的前置约束,不只是引用资料的要求**
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
- 以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 先对照官方与 CPR 的实际请求、响应及状态生命周期,确认本项目是否改写、遗漏或破坏官方合同;真实上游报错不自动等于 CPR 缺陷,源码推断、代理链路复现与官方直连结果分别说明
- 官方路径已能满足场景时,优先复用已有能力或正确透传合同,不额外解释请求意图、扩展工具权限或改变续接语义;补偿后请求成功、测试通过或第三方采用同一做法,都不能单独证明代理应改变行为
- 偏离官方行为必须说明官方路径为何不足、本项目的实际约束、最小必要改动及行为代价,并提供对应验证;依据不足时保持现有合同并报告缺口,不先加兼容分支再以通过测试作为必要性依据;用户明确要求的行为变更限定在授权范围内
- 网关独有的调度、隔离和资源管理按本项目职责评估,不能机械照搬单客户端实现,也不能借此任意改变客户端可观察行为;新增默认限制或回收阈值须有实际容量依据,并验证对续接、重试与延迟的影响
- 三方仓库只作线索或实现参考,不能替代官方行为依据;结论注明适用版本或提交,区分官方事实、三方参考与本项目推断,不把自定义策略表述为官方要求
## 加载边界
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
## 按任务读取
| 当前任务 | 必读的相关部分 |
| 命中条件,操作或文件满足其一即可 | 必须执行 |
| --- | --- |
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
| 页面开发 | `$frontend-design`、[前端职责](../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
## 参考仓库的优先级
开发、排障和代码审查先按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实问题与方案。涉及 Codex 行为时执行其中的官方行为优先约束:对应版本的官方源码优先,以透明转发和最小必要适配为默认方案,先判断偏离是否必要再评价实现
## 执行与收尾
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 后端注释
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
## 文档硬约束
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
policy:
allow_implicit_invocation: true
@@ -1,16 +1,10 @@
# 文档检查
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
## 写之前
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
## 逐段验收
@@ -30,9 +24,8 @@
## 交付检查
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
@@ -0,0 +1,32 @@
# 前端开发与审查
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
## 开始前
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
按改动补读,不把“未修改主题算法”当作跳过页面样式规范的理由:
| 改动 | 规范入口 |
| --- | --- |
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
## 交付前
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
- **样式与状态**:是否使用对应 Token 与 utility,局部样式是否符合适用边界,受影响的主题、窄屏及交互状态是否正确
- **查询与动作**:区分首次加载、刷新、分页、单行与批量操作,核对 loading、禁用状态的影响范围;沿用请求层错误处理,不在页面叠加同一错误的提示
- **运行证据**:按界面验证核对截图与实际交互,证据对应当前实现;页面 PR 的可访问图片和 Draft 条件按该节执行
交付简述所复用的页面或组件、实际验证范围及缺口;环境不足时如实说明,不能仅凭构建、源码或截图推断未验证的行为
@@ -3,9 +3,13 @@
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
**宿主可以暴露底层能力,不能替插件完成业务**
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
- 插件行为不可预测,只保留宿主安全解码、权限、资源限制及一致性所需的检查,不为插件业务正确性增加语义校验、未知字段拒绝或重复检查;失败按已有调用、会话、实例边界及能力策略拒绝、隔离和回收,不把单次业务错误扩大为全局停用
- 不靠猜测插件意图、补造业务字段、修正业务结果、重放操作或增加插件专属兼容分支让错误插件继续工作;问题归插件时在插件侧修复,不把补偿搬进宿主 SDK 或 Runtime
- 通用协议与会话辅助、公开合同规定的默认值和失败恢复仍按既有合同执行,不能因拒绝业务兜底而删掉必要的宿主保护;故障隔离按[信任与资源边界](../../../../docs/architecture.md#信任与资源边界)评估,不假定完整信任的插件具有安全沙箱
- 开放已有能力时复用宿主服务与数据模型,不为插件的导入、修正、重算等编排新增表、字段、账本或任务系统;确有宿主能力缺口时,界定可独立使用的基础接口及输入输出、权限、事务、失败和重复调用语义,不夹带插件私有模型与工作流
- 新增防御、兼容、重试、缓存或状态前,说明要保护的宿主不变量、触发依据和最小处理范围;仅为迁就某个插件或猜测未来行为时不加入宿主
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合;验证失败与取消后的资源释放、状态一致性和其他调用受影响范围,不以插件恢复成功代替宿主边界验证
+3 -8
View File
@@ -20,7 +20,7 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级);注明适用版本、来源与缺口,不把上游报错直接定性为 CPR 缺陷,也不把第三方补偿方案当成预期行为;症状反馈仍不以证明根因为前提
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
## 正文与证据
@@ -36,16 +36,11 @@ YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
Issue 可记录复现、排查和变更经过;涉及常规文档修改时执行[文档检查](../cpr-dev-guide/references/documentation.md)
## 回复口吻
草拟和发布 Issue 回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
草拟和发布 Issue 回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
## 发布与收尾
+8 -13
View File
@@ -15,7 +15,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
涉及 Codex 行为时,优先执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级),先判断是否保持官方合同、偏离是否必要,再评价实现;引用源码和验证通过不能替代方案依据
涉及上游行为的判断按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行
## 按模式读取
@@ -23,30 +23,25 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
| 当前动作 | 读取与执行 |
| --- | --- |
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案;不读取推送或审查流程 |
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案,不自行修改实现;不读取推送或审查流程 |
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
| 含页面改动 | [界面验证](../../../CONTRIBUTING.md#界面验证),必需截图须可访问且对应当前实现 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md),常规文档只说明当前状态,变更经过留在 PR |
| 含前端改动 | [前端流程](../cpr-dev-guide/references/frontend.md),核对适用规范与当前实现的运行证据 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
## 回复口吻
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
草拟和发布 PR 讨论、审查意见的回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
## 创建与审查的公共核对
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状,分别判断问题是否成立、是否属于目标项目的职责;涉及 Codex 时核对方案是否满足上述官方行为优先约束
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状,分别判断问题是否成立、是否属于目标项目职责及方案是否必要
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
- 自审或审查覆盖完整差异与调用链,前端与文档按模式表检查
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只核对现有说明是否失真
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
@@ -6,19 +6,13 @@
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
3. 已完成的改动也须按入口公共核对复核问题与方案。原问题不成立时不包装成已修复的缺陷,证据不足时保留结论。
实现与自审按 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)执行,包括适用的后端生命周期和前端检查;需要修正时只修改已授权范围。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查,记录对应提交或工作区状态;文档、页面证据与模型披露按入口核对。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及上游行为时,按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)写明官方源码位置、版本、处理方式及本次方案关系。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
@@ -32,4 +26,4 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
创建完成返回链接、目标分支和验证摘要,不称为已合并。
@@ -4,30 +4,18 @@
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;按入口的官方行为优先约束核对偏离的必要性、最小范围、行为代价与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
真实复现上游错误或补偿后成功,也不能单独证明 CPR 应改写请求;缺少必要性依据的行为偏离须在解决方向中明确指出,
不因测试通过而给出方案合理的结论。网关资源策略单独核对容量依据与客户端影响,不以官方客户端没有同类池策略就判错。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
5. 前端改动执行[前端流程](../../cpr-dev-guide/references/frontend.md),浏览器可用时检查相关交互;缺少必需截图时要求补齐并列为未具备审阅条件。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
文档差异按[文档检查](../../cpr-dev-guide/references/documentation.md)核对最终 diff。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
7. 按审查标准用中文输出问题是否成立、方案是否合理及对应依据,再列实现问题与验证缺口;复用建议指出具体已有实现。
没有问题的类别可省略,未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
+2 -2
View File
@@ -14,7 +14,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
- 涉及 Codex 兼容性适配时执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级),不因实现位于插件中扩大请求改写或补偿范围;用户明确要求的业务改写限定在授权范围,宿主能力以目标版本的 SDK 为准
- Codex 兼容性适配按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行,不因位于插件中扩大改写或补偿范围;业务改写限于用户授权,宿主能力以目标 SDK 为准
## 按当前步骤读取
@@ -29,7 +29,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md) |
## 交付
@@ -58,7 +58,7 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
```bash
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
```
直接打包的示意命令:
@@ -1,8 +1,7 @@
# 插件页面
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.md)的页面分支
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
- 沿用已有插件页面与 [管理端主题](../../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../../CONTRIBUTING.md#界面验证) 提供实际截图
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
+2 -2
View File
@@ -31,9 +31,9 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- 发行范围涉及 Codex 兼容性变化时,按[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级)核对已有方案依据和实际验证范围;缺少依据如实列为发行风险,不以 CI 通过代替,不把网关自定义策略写成官方要求
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
+6 -1
View File
@@ -12,10 +12,15 @@ runs:
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
shell: bash
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
+79
View File
@@ -0,0 +1,79 @@
require 'digest'
require 'fileutils'
require 'json'
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
module ReleasePackage
APP_NAME = 'codex-proxy-rs'
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
def self.install_manifest(directory)
manifest = JSON.parse(File.read(MANIFEST))
unless manifest['schema_version'] == 1 && manifest['sealed'] == true && manifest['plugins'] == []
raise 'Expected a sealed host manifest without bundled plugins'
end
FileUtils.mkdir_p(directory)
destination = File.join(directory, File.basename(MANIFEST))
FileUtils.cp(MANIFEST, destination)
File.chmod(0o444, destination)
File.chmod(0o555, directory)
end
def self.archives
platforms = JSON.parse(ENV.fetch('PLATFORM_MATRIX')).fetch('include')
release_dir = 'dist/release'
FileUtils.rm_rf(release_dir)
FileUtils.mkdir_p(release_dir)
# 上一次打包生成的只读清单目录需要先恢复写权限
FileUtils.chmod_R('u+w', 'package') if File.directory?('package')
FileUtils.rm_rf('package')
config = 'deploy/config.example.yaml'
FileUtils.cp(config, release_dir)
compose = File.read('deploy/compose.yaml')
image = "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}"
default_image = "${CPR_IMAGE:-#{image}:latest}"
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
FileUtils.cp(MANIFEST, release_dir)
platforms.each do |platform|
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
name = "#{platform.fetch('os')}-#{platform.fetch('arch')}"
binary = platform.fetch('binary')
root = "package/#{name}/#{APP_NAME}"
FileUtils.mkdir_p(["#{root}/web/dist", "#{root}/deploy"])
FileUtils.cp("dist/binaries/#{APP_NAME}-#{name}/#{binary}", "#{root}/#{binary}")
File.chmod(0o755, "#{root}/#{binary}")
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
FileUtils.cp(config, "#{root}/deploy")
install_manifest("#{root}/plugins/official")
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
end
checksums = Dir.glob("#{release_dir}/*").sort.map { |path| "#{Digest::SHA256.file(path).hexdigest} #{File.basename(path)}\n" }
File.write("#{release_dir}/checksums.txt", checksums.join)
end
def self.image
platform = "#{ENV.fetch('MATRIX_OS')}-#{ENV.fetch('MATRIX_ARCH')}"
target = "dist/docker/#{platform}"
FileUtils.mkdir_p(target)
FileUtils.cp("dist/docker-artifacts/#{APP_NAME}-#{platform}/#{ENV.fetch('MATRIX_BINARY')}", "#{target}/#{APP_NAME}")
File.chmod(0o755, "#{target}/#{APP_NAME}")
install_manifest("#{target}/plugins/official")
end
end
if $PROGRAM_NAME == __FILE__
begin
case ARGV
when ['archives'] then ReleasePackage.archives
when ['image'] then ReleasePackage.image
else raise 'usage: package-release.rb archives|image'
end
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+84
View File
@@ -0,0 +1,84 @@
require_relative 'release-metadata'
# 构建和验证可以并行;调用方必须在仓库级发布锁内执行本脚本
module ReleasePublication
def self.run(*command)
output, error, status = Open3.capture3(*command)
raise "#{command.first} failed: #{error.strip}" unless status.success?
output.strip
end
def self.latest_release(repository)
output, error, status = Open3.capture3('gh', 'api', '--include', "repos/#{repository}/releases/latest")
code = output.lines.first.to_s.split[1]
return nil if code == '404'
raise "Cannot read latest GitHub release: #{error.strip}" unless status.success? && code == '200'
JSON.parse(output.split(/\r?\n\r?\n/, 2).fetch(1)).fetch('tag_name')
end
def self.latest_image(image)
ref = "#{image}:latest"
output, error, status = Open3.capture3('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{json .Image}}')
# 只把明确的缺失标签当作首次发布,鉴权和网络错误必须终止提升
return nil if !status.success? && error.include?("#{ref}: not found")
raise "Cannot read latest image: #{error.strip}" unless status.success?
versions = JSON.parse(output).values.map do |config|
config.fetch('config').fetch('Labels').fetch('org.opencontainers.image.version')
end.uniq
raise 'Latest image must have one consistent version across platforms' unless versions.length == 1
versions.first
end
def self.can_promote?(version, current_versions)
return false if ReleaseMetadata.prerelease?(version)
current_versions.compact.all? do |current|
raise "Latest points to a prerelease: #{current}" if ReleaseMetadata.prerelease?(current)
comparison = ReleaseMetadata.sort_key(version) <=> ReleaseMetadata.sort_key(current)
# 构建元数据不决定版本先后;同优先级只允许相同版本重试
comparison.positive? || (comparison.zero? && ReleaseMetadata.version(current) == version)
end
end
def self.tag(image, digest, name)
ref = "#{image}:#{name}"
run('docker', 'buildx', 'imagetools', 'create', '-t', ref, "#{image}@#{digest}")
actual = run('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{.Manifest.Digest}}')
raise "Promoted image digest changed: #{ref}" unless actual == digest
end
def self.publish(version:, image:, digest:, repository:, release_id:)
version = ReleaseMetadata.version(version)
raise 'Invalid image digest' unless digest.match?(/\Asha256:[a-f0-9]{64}\z/)
raise 'Invalid GitHub release ID' unless release_id.match?(/\A\d+\z/)
endpoint = "repos/#{repository}/releases/#{release_id}"
# 附件上传及镜像证明、签名已完成,再公开版本入口;失败可从本 job 重跑
tag(image, digest, version.tr('+', '-'))
run('gh', 'api', '--method', 'PATCH', endpoint, '-F', 'draft=false', '-f', 'make_latest=false')
return if ReleaseMetadata.prerelease?(version)
current_versions = [latest_release(repository), latest_image(image)]
unless can_promote?(version, current_versions)
puts "Published #{version}; latest remains at #{current_versions.compact.join(', ')}"
return
end
# 两个服务不能原子更新;同时检查两侧版本,让失败重试可以补齐而旧版不能回退
tag(image, digest, 'latest')
run('gh', 'api', '--method', 'PATCH', endpoint, '-f', 'make_latest=true')
puts "Published #{version} and promoted latest"
end
end
if $PROGRAM_NAME == __FILE__
begin
ReleasePublication.publish(
version: ENV.fetch('VERSION'), image: "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}",
digest: ENV.fetch('DIGEST'), repository: ENV.fetch('GITHUB_REPOSITORY'),
release_id: ENV.fetch('RELEASE_ID')
)
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+129
View File
@@ -0,0 +1,129 @@
require 'fileutils'
require 'json'
require 'open3'
require 'rubygems'
require 'time'
require 'yaml'
# 本地入口与发行工作流共用版本、说明和平台合同
module ReleaseMetadata
APP_NAME = 'codex-proxy-rs'
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def self.version(raw)
value = raw.delete_prefix('v')
raise "Invalid release version: #{raw}" unless value.match?(SEMVER)
value
end
def self.prerelease?(value)
version(value).split('+', 2).first.include?('-')
end
def self.sort_key(value)
Gem::Version.new(version(value).split('+', 2).first)
end
def self.validate_notes(value)
tag = "v#{version(value)}"
lines = File.readlines('release/notes.md')
unless lines.first&.strip == "# #{tag}" && !lines.drop(1).join.strip.empty?
raise "release/notes.md: expected non-empty notes starting with '# #{tag}'"
end
end
def self.git(*arguments)
output, status = Open3.capture2('git', *arguments)
raise "git #{arguments.join(' ')} failed" unless status.success?
output.strip
end
def self.platforms
entries = YAML.load_file('release/platforms.yaml').fetch('platforms')
raise 'release/platforms.yaml: platforms must be a non-empty array' unless entries.is_a?(Array) && !entries.empty?
seen = {}
entries.each do |entry|
raise 'Invalid release platform' unless entry.is_a?(Hash)
%w[os arch runner target binary archive].each do |key|
raise "Invalid platform #{key}" unless entry[key].is_a?(String) && !entry[key].strip.empty?
end
key = "#{entry['os']}-#{entry['arch']}"
raise "Duplicate release platform: #{key}" if seen[key]
seen[key] = true
raise "Unsupported archive: #{entry['archive']}" unless entry['archive'] == 'tar.gz'
if entry['docker'] == true
platform = entry['dockerPlatform']
raise "Missing Docker platform: #{key}" unless platform.is_a?(String) && !platform.strip.empty?
entry['dockerPlatform'] = platform.strip
end
entry['artifact'] = "#{APP_NAME}-#{key}"
end
raise 'At least one Docker platform is required' unless entries.any? { |entry| entry['docker'] == true }
entries
end
def self.outputs(tag)
value = version(YAML.load_file('release/version.yaml').fetch('version'))
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
validate_notes(value)
entries = platforms
docker, native = entries.partition { |entry| entry['docker'] == true }
previous = git('tag', '--list', 'v*', '--merged', 'HEAD').lines.map(&:strip).filter_map do |candidate|
next unless candidate.delete_prefix('v').match?(SEMVER)
key = sort_key(candidate)
[key, candidate] if key < sort_key(value)
end.max_by(&:first)&.last
{
'tag' => tag,
'version' => value,
'docker_version' => value.tr('+', '-'),
'build_time' => Time.now.utc.iso8601,
'git_sha' => git('rev-parse', 'HEAD'),
'platform_matrix' => JSON.generate('include' => entries),
'docker_matrix' => JSON.generate('include' => docker),
'non_docker_matrix' => JSON.generate('include' => native),
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
'previous_tag' => previous,
'prerelease' => prerelease?(value),
'build_type' => value.split('+', 2).first.include?('-exp.') ? 'experimental' : 'release'
}
end
def self.seal_manifest(metadata)
path = 'backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json'
host = JSON.parse(File.read(path))
raise "Invalid plugin host compatibility declaration: #{path}" unless host['schema_version'] == 2
manifest = {
'schema_version' => 1,
'sealed' => true,
'gateway_version' => metadata.fetch('version'),
'gateway_git_sha' => metadata.fetch('git_sha'),
'plugin_host' => host,
'plugins' => []
}
FileUtils.mkdir_p('dist/plugin-release-assets')
File.write('dist/plugin-release-assets/plugin-release-manifest.json', JSON.pretty_generate(manifest) + "\n")
end
end
if $PROGRAM_NAME == __FILE__
begin
command, value = ARGV
raise 'usage: release-metadata.rb validate VERSION | outputs TAG' unless ARGV.length == 2
case command
when 'validate'
value = ReleaseMetadata.version(value)
ReleaseMetadata.validate_notes(value)
puts value
when 'outputs'
metadata = ReleaseMetadata.outputs(value)
ReleaseMetadata.seal_manifest(metadata)
metadata.each { |key, entry| puts "#{key}=#{entry}" }
else
raise "Unknown command: #{command}"
end
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+27
View File
@@ -0,0 +1,27 @@
# 从构建声明读取 CI 工具链,阻止 Docker 与本地 Rust 版本漂移
module BuildToolchains
def self.read(root = '.')
declaration = File.read(File.join(root, 'backend/rust-toolchain.toml'))
rust = declaration[/^\s*channel\s*=\s*["'](\d+\.\d+(?:\.\d+)?)["']\s*(?:#.*)?$/, 1]
raise 'Expected a pinned Rust channel in backend/rust-toolchain.toml' unless rust
dockerfile = File.read(File.join(root, 'deploy/Dockerfile'))
image = lambda do |stage, name|
match = dockerfile.match(/^FROM (#{name}:([^\s@]+)@sha256:[a-f0-9]{64}) AS #{stage}$/)
raise "Missing pinned #{stage} image" unless match
[match[1], match[2].split('-', 2).first]
end
rust_image, image_rust = image.call('backend-builder', 'rust')
_, node = image.call('frontend-builder', 'node')
raise "Rust toolchain #{rust} differs from Docker builder #{image_rust}" unless rust == image_rust
{ 'rust' => rust, 'rust_image' => rust_image, 'node' => node }
end
end
if $PROGRAM_NAME == __FILE__
begin
BuildToolchains.read.each { |key, value| puts "#{key}=#{value}" }
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+13 -2
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
@@ -97,9 +97,14 @@ jobs:
fi
fi
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
components: rustfmt,clippy
- name: Cache Rust
@@ -202,6 +207,8 @@ jobs:
with:
ref: ${{ inputs.ref }}
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
# https://github.com/rhysd/actionlint/issues/657
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
@@ -209,6 +216,7 @@ jobs:
--workdir /repo
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
.github/workflows/*.yml
- name: Check workflow script syntax
@@ -216,3 +224,6 @@ jobs:
bash -n release/publish
shopt -s globstar nullglob
for script in .github/**/*.sh; do bash -n "$script"; done
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
+16 -2
View File
@@ -42,18 +42,26 @@ jobs:
predicate-quantifier: some-with-excludes
filters: |
backend:
- '.github/scripts/toolchains.rb'
- 'deploy/Dockerfile'
- 'backend/**'
- 'release/version.yaml'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
frontend:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- 'frontend/**'
- '.github/actions/build-frontend/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
@@ -106,12 +114,18 @@ jobs:
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
+45 -337
View File
@@ -36,6 +36,8 @@ jobs:
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
rust: ${{ steps.toolchains.outputs.rust }}
rust_image: ${{ steps.toolchains.outputs.rust_image }}
steps:
- name: Checkout
@@ -44,151 +46,15 @@ jobs:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Read release metadata
- name: Read release metadata and seal plugin manifest
id: version
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
require "json"
require "open3"
require "rubygems"
require "time"
require "yaml"
run: ruby .github/scripts/release-metadata.rb outputs "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
APP_NAME = "codex-proxy-rs"
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def fail(message)
warn(message)
exit(1)
end
def require_string(data, key, path)
value = data[key]
fail("#{path}: #{key} must be a non-empty string") unless value.is_a?(String) && !value.strip.empty?
value.strip
end
def version_sort_key(version)
Gem::Version.new(version.split("+", 2).first)
rescue ArgumentError
nil
end
version_path = "release/version.yaml"
platforms_path = "release/platforms.yaml"
version_data = YAML.load_file(version_path)
platforms_data = YAML.load_file(platforms_path)
version = require_string(version_data, "version", version_path).delete_prefix("v")
tag_prefix = "v"
expected_tag = "#{tag_prefix}#{version}"
release_tag = ENV.fetch("RELEASE_TAG")
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
required_platform_keys = %w[os arch runner target binary archive]
seen_platforms = {}
docker_platform_jobs = []
non_docker_platform_jobs = []
docker_platforms = []
platforms.each_with_index do |platform, index|
fail("#{platforms_path}: platforms[#{index}] must be a table") unless platform.is_a?(Hash)
required_platform_keys.each do |key|
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
end
archive = platform["archive"]
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
platform_key = "#{platform["os"]}-#{platform["arch"]}"
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
seen_platforms[platform_key] = true
platform["artifact"] = "#{APP_NAME}-#{platform_key}"
if platform["docker"] == true
docker_platform = require_string(platform, "dockerPlatform", "#{platforms_path}: platforms[#{index}]")
platform["dockerPlatform"] = docker_platform
docker_platform_jobs << platform
docker_platforms << docker_platform
else
non_docker_platform_jobs << platform
end
end
fail("#{platforms_path}: at least one platform must set docker = true") if docker_platforms.empty?
git_sha, status = Open3.capture2("git", "rev-parse", "HEAD")
fail("failed to resolve git sha") unless status.success?
matrix = JSON.generate({ "include" => platforms })
docker_matrix = JSON.generate({ "include" => docker_platform_jobs })
non_docker_matrix = JSON.generate({ "include" => non_docker_platform_jobs })
current_sort_key = version_sort_key(version)
tags, status = Open3.capture2("git", "tag", "--list", "#{tag_prefix}*", "--merged", "HEAD")
fail("failed to list git tags") unless status.success?
previous_tag = tags
.lines
.map(&:strip)
.reject(&:empty?)
.reject { |tag| tag == release_tag }
.filter_map do |tag|
tag_version = tag.delete_prefix(tag_prefix)
next unless tag_version.match?(SEMVER)
sort_key = version_sort_key(tag_version)
next unless sort_key && current_sort_key && sort_key < current_sort_key
[sort_key, tag]
end
.max_by(&:first)
&.last
puts "tag=#{release_tag}"
puts "version=#{version}"
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
puts "docker_version=#{version.tr("+", "-")}"
puts "build_time=#{Time.now.utc.iso8601}"
puts "git_sha=#{git_sha.strip}"
puts "platform_matrix=#{matrix}"
puts "docker_matrix=#{docker_matrix}"
puts "non_docker_matrix=#{non_docker_matrix}"
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
RUBY
- name: Seal plugin release manifest
env:
VERSION: ${{ steps.version.outputs.version }}
GIT_SHA: ${{ steps.version.outputs.git_sha }}
run: |
ruby <<'RUBY'
require "fileutils"
require "json"
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
"sealed" => true,
"gateway_version" => ENV.fetch("VERSION"),
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
"plugin_host" => host,
"plugins" => [],
}
FileUtils.mkdir_p("dist/plugin-release-assets")
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
RUBY
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -245,7 +111,7 @@ jobs:
~/.cargo/git
backend/target
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
@@ -265,11 +131,12 @@ jobs:
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
"${RUST_IMAGE}" \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
env:
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
@@ -299,8 +166,9 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Cache Cargo
@@ -310,7 +178,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
@@ -373,101 +241,8 @@ jobs:
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
require "json"
require "yaml"
print JSON.generate(YAML.load_file("release/platforms.yaml"))
RUBY
python3 - <<'PY'
import hashlib
import json
import os
import pathlib
import shutil
import tarfile
APP_NAME = "codex-proxy-rs"
def sha256(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
version = os.environ["VERSION"]
platforms = json.loads(pathlib.Path("release-platforms.json").read_text())["platforms"]
frontend_dir = pathlib.Path("dist/frontend")
binary_artifacts_dir = pathlib.Path("dist/binaries")
release_dir = pathlib.Path("dist/release")
package_dir = pathlib.Path("package")
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
shutil.rmtree(package_dir, ignore_errors=True)
release_packages = []
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
artifact_name = f"{APP_NAME}-{platform_name}"
binary_src = binary_artifacts_dir / artifact_name / platform["binary"]
if not binary_src.is_file():
raise SystemExit(f"Missing binary artifact: {binary_src}")
package_root = package_dir / platform_name / APP_NAME
web_dist = package_root / "web" / "dist"
web_dist.mkdir(parents=True)
binary_dest = package_root / platform["binary"]
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
if platform["archive"] != "tar.gz":
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
release_packages.append((platform, package_root, archive_path))
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
assets.append(plugin_manifest_path)
# 保留运行时清单目录合同,宿主包不再携带教学示例。
for platform, package_root, archive_path in release_packages:
official_dir = package_root / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / plugin_manifest_path.name
shutil.copy2(plugin_manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
official_dir.chmod(0o555)
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
run: ruby .github/scripts/package-release.rb archives
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
@@ -531,56 +306,7 @@ jobs:
MATRIX_OS: ${{ matrix.os }}
MATRIX_ARCH: ${{ matrix.arch }}
MATRIX_BINARY: ${{ matrix.binary }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import shutil
import os
import pathlib
app_name = "codex-proxy-rs"
platform_name = f"{os.environ['MATRIX_OS']}-{os.environ['MATRIX_ARCH']}"
binary_src = pathlib.Path("dist/docker-artifacts") / f"{app_name}-{platform_name}" / os.environ["MATRIX_BINARY"]
if not binary_src.is_file():
raise SystemExit(f"Missing Docker binary artifact: {binary_src}")
target_dir = pathlib.Path("dist/docker") / platform_name
target_dir.mkdir(parents=True, exist_ok=True)
binary_dest = target_dir / app_name
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
release_dir = pathlib.Path("dist/plugin-release-assets")
manifest_path = release_dir / "plugin-release-manifest.json"
manifest = json.loads(manifest_path.read_text())
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
raise SystemExit("Invalid sealed plugin release manifest")
official_dir = target_dir / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / manifest_path.name
shutil.copy2(manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
selected = 0
for plugin in manifest.get("plugins", []):
artifacts = [
artifact for artifact in plugin.get("artifacts", [])
if artifact.get("release_os") == os.environ["MATRIX_OS"]
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
]
if len(artifacts) != 1:
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
archive = release_dir / artifacts[0]["archive"]
if not archive.is_file():
raise SystemExit(f"Missing official plugin archive: {archive.name}")
bundled_archive = official_dir / archive.name
shutil.copy2(archive, bundled_archive)
bundled_archive.chmod(0o444)
selected += 1
if selected != len(manifest.get("plugins", [])):
raise SystemExit("Official plugin release bundle is incomplete")
official_dir.chmod(0o555)
PY
run: ruby .github/scripts/package-release.rb image
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -644,6 +370,9 @@ jobs:
retention-days: 1
publish:
concurrency:
group: release-publication
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
@@ -686,14 +415,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create app image manifest
- name: Assemble candidate image manifest
id: manifest
shell: bash
env:
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
@@ -717,16 +444,10 @@ jobs:
exit 1
fi
target_args=(
-t "${image}:${DOCKER_VERSION}"
-t "${image}:sha-${GIT_SHA}"
)
if [ "$PRERELEASE" != true ]; then
target_args+=(-t "${image}:latest")
fi
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
candidate="${image}:sha-${GIT_SHA}"
docker buildx imagetools create -t "$candidate" "${sources[@]}"
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
inspect_output="$(docker buildx imagetools inspect "$candidate")"
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
for platform in "${platforms[@]}"; do
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
@@ -736,7 +457,7 @@ jobs:
fi
done
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
digest="$(docker buildx imagetools inspect "$candidate" --format '{{ .Manifest.Digest }}')"
if [ -z "${digest}" ]; then
echo "missing image digest"
exit 1
@@ -750,31 +471,10 @@ jobs:
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Sign app image
shell: bash
- name: Sign app image digest
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
if [ -z "${DIGEST}" ]; then
echo "missing image digest"
exit 1
fi
image="${REGISTRY}/${IMAGE_NAME}"
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
if [ "$PRERELEASE" != true ]; then
tags+=("${image}:latest")
fi
images=()
for tag in "${tags[@]}"; do
images+=("${tag}@${DIGEST}")
done
cosign sign --yes "${images[@]}"
run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}"
- name: Prepare release notes
shell: bash
@@ -803,25 +503,33 @@ jobs:
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
ruby <<'RUBY'
notes = File.readlines('release/notes.md').drop(1).join.strip
installation = ENV.fetch('INSTALLATION_NOTES').strip
output = File.join(ENV.fetch('RUNNER_TEMP'), 'release-notes.md')
File.write(output, "#{notes}\n\n#{installation}\n")
RUBY
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
# action 保留已公开 Release 的状态,新 Release 上传完成后仍保持草稿
draft: true
make_latest: 'false'
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
- name: Publish version and advance latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-info.outputs.version }}
DIGEST: ${{ steps.manifest.outputs.digest }}
RELEASE_ID: ${{ steps.release.outputs.id }}
run: ruby .github/scripts/publish-release.rb
+5 -1
View File
@@ -51,10 +51,14 @@ jobs:
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
+4 -4
View File
@@ -4,7 +4,7 @@
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md),按实际操作和文件匹配规范,跨前后端同时执行 |
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
@@ -16,7 +16,7 @@
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复口吻)执行
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
@@ -34,10 +34,10 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
## 工作方式
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题及方案必要性;涉及 Codex 行为、协议或兼容性时,以官方对应版本源码为第一手资料,三方仓库仅作参考
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 前端开发与审查执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),先复用已有页面设计与组件,不因个人偏好重排页面或另建交互;页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
+26 -5
View File
@@ -19,15 +19,30 @@
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
### GitHub 回复口吻
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
## 问题与方案依据
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
涉及上游协议、客户端行为或兼容性时,遵守以下依据与行为约束;无上游依赖的内部变更以本项目代码、合同和运行证据为依据
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
- **资料来源**:以对应版本的官方源码与调用链为首要资料,Codex 对照 [openai/codex](https://github.com/openai/codex)。先查用户提供或本机已有的仓库,核对来源、版本或提交;不足时再查官方对应版本源码,结合测试、文档、发布说明和运行证据。三方资料只作线索或实现参考,不能替代官方依据
- **证据表达**:引用具体文件、符号或行号,对外优先使用固定提交的源码链接;注明适用版本,区分官方事实、三方参考与本项目推断。源码不可得或版本不匹配时报告缺口,不把最新实现当作部署行为,也不把自定义策略写成官方要求
- **默认方案**:Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案。沿用官方协议语义、职责归属和解决路径,优先复用已有能力或正确传递合同,不额外解释请求意图、扩展工具权限或改变续接语义
- **问题核实**:对照官方与 CPR 的实际请求、响应及状态生命周期,检查本项目是否误解、改写、遗漏或破坏合同;分别说明源码推断、代理链路复现与官方直连结果。真实上游报错不自动等于 CPR 缺陷
- **审查官方对接**:审查 OpenAI 官方上游与 Codex 的对接时,默认二者兼容、直接对接正常。不得仅凭自行构造的异常上游数据揣测官方不兼容,据此报告缺陷、提出改进建议或要求防御分支与测试;特定版本或场景的例外须有真实响应或实际复现证据
- **偏离依据**:新增覆盖配置、补偿逻辑或三方兼容方案前,说明官方路径为何不足、本项目实际约束、最小改动、行为代价及验证。补偿后成功、测试通过或三方采用同一做法不能单独证明必要性;依据不足时保持现有合同并报告缺口,不先加分支再倒推理由。用户明确要求的行为变更限定在授权范围内
- **网关职责**:调度、隔离和资源管理按网关职责评估,不机械照搬单客户端实现,不因官方客户端没有同类池策略就判错,也不任意改变客户端可观察行为。新增默认限制或回收阈值须有容量依据,并验证续接、重试与延迟影响
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
反馈 Issue 时区分症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。症状反馈不以找到官方源码或证明根因为前提;引用上游行为或提出方案时注明上述依据与缺口
## PR 流程
@@ -147,7 +162,7 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
AI 进行前端开发或审查时,执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),覆盖页面、组件、弹窗、表单及局部文案和样式。用户指定设计技能时使用该技能;未指定时,只有任务明确需要建立或重塑视觉方向才使用 `$frontend-design`。新增页面也优先沿用 [管理端主题](docs/theme.md) 和已有设计,设计技能不构成重做页面的理由
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
@@ -163,11 +178,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
+1 -1
View File
@@ -20,7 +20,7 @@ version = "0.0.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
[workspace.lints.rust]
warnings = "deny"
+3 -3
View File
@@ -8,7 +8,7 @@
从仓库根目录安装到本地工具目录:
```bash
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
```
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
@@ -45,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
```bash
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
```
@@ -3,8 +3,8 @@
use super::{
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, RequestMetricPoint, TimeRange, UsageOverview,
UsagePage,
},
};
@@ -18,6 +18,7 @@ pub struct KeyUsageQuery {
pub enum KeyUsageRecordKind {
Success,
Error,
All,
}
#[derive(Debug, Clone)]
@@ -35,7 +36,4 @@ pub struct KeyUsageOverview {
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
pub type KeyUsageRecords = UsagePage;
@@ -195,6 +195,7 @@ pub struct UsageQuery {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DashboardQuery {
pub range: TimeRange,
pub yesterday_range: TimeRange,
pub granularity: Granularity,
pub account_limit: u16,
pub recent_request_limit: u16,
@@ -202,10 +203,21 @@ pub struct DashboardQuery {
}
impl DashboardQuery {
#[must_use]
pub fn new(range: TimeRange) -> Self {
Self {
pub fn new(
range: TimeRange,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, AdminModelError> {
let yesterday_range = TimeRange::new(
timezone
.days_before(range.end, 1)
.ok_or(AdminModelError::InvalidTimeRange)?,
timezone
.day_start(range.end)
.ok_or(AdminModelError::InvalidTimeRange)?,
)?;
Ok(Self {
range,
yesterday_range,
granularity: Granularity::for_range(range),
account_limit: 4,
recent_request_limit: 10,
@@ -213,7 +225,7 @@ impl DashboardQuery {
outcome: Some(RequestOutcome::Succeeded),
..UsageFilter::default()
},
}
})
}
}
@@ -653,6 +665,7 @@ pub struct DashboardObservation {
pub analytics: AnalyticsMetadata,
pub range: TimeRange,
pub totals: DashboardTotals,
pub yesterday: RequestMetrics,
pub provider_accounts: AccountPoolMetrics,
pub runtime_slots: Option<DashboardRuntimeSlots>,
pub trend: Vec<RequestMetricPoint>,
@@ -664,6 +677,9 @@ pub struct DashboardObservation {
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub outcome: RequestOutcome,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub client_status_code: Option<u16>,
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
@@ -14,9 +14,7 @@ use crate::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
},
observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
@@ -221,35 +219,21 @@ impl KeyUsageService for DefaultKeyUsageService {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let result = match query.kind {
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
self.observations
.list_usage_records(UsageQuery {
status: Default::default(),
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?,
),
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
self.observations
.list_ops_errors(OpsErrorQuery {
range: query.usage.range,
filter: OpsErrorFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model: query.usage.model,
..OpsErrorFilter::default()
},
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage errors"))?,
),
};
let result = self
.observations
.list_usage_records(UsageQuery {
status: match query.kind {
KeyUsageRecordKind::Success => UsageRecordStatus::Success,
KeyUsageRecordKind::Error => UsageRecordStatus::Error,
KeyUsageRecordKind::All => UsageRecordStatus::All,
},
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?;
Ok(Some(result))
}
}
@@ -111,9 +111,11 @@ impl DefaultObservabilityService {
range: TimeRange,
) -> Result<DashboardResult, AdminError> {
let observed_at = Utc::now();
let query = DashboardQuery::new(range, self.timezone)
.map_err(|_| AdminError::internal("时间超出支持范围"))?;
let today_start = query.yesterday_range.end;
let (mut observation, settings) = futures::try_join!(
self.store
.dashboard_summary(DashboardQuery::new(range), observed_at),
self.store.dashboard_summary(query, observed_at),
self.settings.load_runtime_settings(),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
@@ -126,17 +128,16 @@ impl DefaultObservabilityService {
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = self
.timezone
.day_start(observation.range.end)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let yesterday_start = self
.timezone
.days_before(observation.range.end, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let today =
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
let today = dashboard_period_metrics(
observation
.trend
.iter()
.filter(|point| {
point.bucket_start >= today_start && point.bucket_start < observation.range.end
})
.map(|point| &point.metrics),
);
let yesterday = dashboard_period_metrics(std::iter::once(&observation.yesterday));
let total_billing_usd = observation.totals.billing_usd.clone();
let total_cached_token_rate = rate_or_zero(
observation.totals.cached_input_tokens,
@@ -1051,17 +1052,11 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
}
}
fn dashboard_period_metrics(
points: &[RequestMetricPoint],
start: DateTime<Utc>,
end: DateTime<Utc>,
fn dashboard_period_metrics<'a>(
metrics: impl IntoIterator<Item = &'a RequestMetrics>,
) -> DashboardPeriodMetrics {
let mut result = DashboardPeriodMetrics::default();
for metrics in points
.iter()
.filter(|point| point.bucket_start >= start && point.bucket_start < end)
.map(|point| &point.metrics)
{
for metrics in metrics {
result.request_count = result.request_count.saturating_add(metrics.request_count);
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
@@ -147,6 +147,123 @@ async fn dashboard_summary_should_derive_first_token_average_from_trend() {
assert_eq!(summary.average_first_token_latency_ms, Some(100));
}
#[test]
fn dashboard_comparison_should_follow_deployment_calendar_across_dst() {
let timezone = "America/New_York".parse().unwrap();
for (end, previous_start, previous_end, hours) in [
(
"2026-03-09T16:00:00Z",
"2026-03-08T05:00:00Z",
"2026-03-09T04:00:00Z",
23,
),
(
"2026-11-02T17:00:00Z",
"2026-11-01T04:00:00Z",
"2026-11-02T05:00:00Z",
25,
),
(
"2026-11-02T05:00:00Z",
"2026-11-01T04:00:00Z",
"2026-11-02T05:00:00Z",
25,
),
] {
let end = end.parse().unwrap();
let range = TimeRange::calendar_at(
gateway_admin::model::observability::CalendarPeriod::Today,
end,
timezone,
)
.unwrap();
let query = DashboardQuery::new(range, timezone).unwrap();
assert_eq!(query.range, range);
assert_eq!(
query.yesterday_range.start,
previous_start.parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(
query.yesterday_range.end,
previous_end.parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(
(query.yesterday_range.end - query.yesterday_range.start).num_hours(),
hours
);
}
}
#[tokio::test]
async fn dashboard_summary_should_compare_separate_yesterday_metrics_with_today_only_trend() {
let end: DateTime<Utc> = "2026-10-18T04:00:00Z".parse().unwrap();
let range = TimeRange::calendar_at(
gateway_admin::model::observability::CalendarPeriod::Today,
end,
Default::default(),
)
.unwrap();
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_trend(vec![health_metric_point(
range.start,
RequestMetrics {
request_count: 2,
input_tokens: 100,
cached_input_tokens: 20,
total_tokens: 120,
..Default::default()
},
)]);
*store.yesterday.lock().unwrap() = RequestMetrics {
request_count: 4,
input_tokens: 400,
cached_input_tokens: 200,
total_tokens: 500,
..Default::default()
};
let result = observability_services(store.clone())
.await
.observability()
.dashboard_summary(range, TrendKind::Usage)
.await
.unwrap();
assert_eq!(result.today.request_count, 2);
assert_eq!(result.today.total_tokens, 120);
assert_eq!(result.today.observed_cached_token_rate, Some(0.2));
assert_eq!(result.yesterday.request_count, 4);
assert_eq!(result.yesterday.total_tokens, 500);
assert_eq!(result.yesterday.observed_cached_token_rate, Some(0.5));
assert_eq!(result.observation.range, range);
assert_eq!(result.observation.trend.len(), 1);
assert_eq!(result.observation.trend[0].bucket_start, range.start);
assert_eq!(
store
.summary_query
.lock()
.unwrap()
.as_ref()
.unwrap()
.yesterday_range
.end,
range.start
);
}
#[tokio::test]
async fn dashboard_summary_should_keep_missing_yesterday_cache_rate_unknown() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let result = observability_services(store)
.await
.observability()
.dashboard_summary(range, TrendKind::Usage)
.await
.unwrap();
assert_eq!(result.yesterday.request_count, 0);
assert_eq!(result.yesterday.total_tokens, 0);
assert_eq!(result.yesterday.observed_cached_token_rate, None);
}
#[tokio::test]
async fn health_timeline_should_apply_status_precedence_and_thresholds() {
let now = Utc::now();
@@ -866,6 +983,7 @@ async fn dashboard_should_preserve_provider_plan_names() {
}
struct FixtureObservabilityStore {
yesterday: Mutex<RequestMetrics>,
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
projected_costs: Mutex<Vec<UsageProjectedCostPoint>>,
@@ -886,6 +1004,7 @@ struct FixtureObservabilityStore {
impl FixtureObservabilityStore {
fn new(range: TimeRange) -> Self {
Self {
yesterday: Mutex::new(RequestMetrics::default()),
trend: Mutex::new(Vec::new()),
overview: Mutex::new(UsageOverview {
analytics: Default::default(),
@@ -973,6 +1092,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
tokio::time::sleep(dashboard_delay).await;
}
Ok(DashboardObservation {
yesterday: self.yesterday.lock().unwrap().clone(),
analytics: self.overview.lock().expect("overview").analytics.clone(),
range: query.range,
totals: Default::default(),
@@ -1219,6 +1339,9 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
autotests = false
[[test]]
@@ -262,6 +262,8 @@ pub(crate) fn usage_list_record_view(
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
error_code: record.error_code,
error_message: record.error_message,
logical_outcome: record.outcome.as_str().to_owned(),
client_api_key_name: record.client_api_key_name,
id: record.id,
@@ -86,6 +86,8 @@ pub struct ImageBillingView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub error_code: Option<String>,
pub error_message: Option<String>,
pub logical_outcome: String,
pub client_api_key_name: Option<String>,
pub id: String,
@@ -5,7 +5,7 @@ use gateway_admin::model::{
client_keys::ClientKeySecret,
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord,
},
system::SystemVersion,
};
@@ -228,8 +228,20 @@ struct OutputTimingView {
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = [
value.input_tokens,
value.output_tokens,
value.cached_input_tokens,
value.cache_write_input_tokens,
value.reasoning_output_tokens,
value.image_input_tokens,
value.image_output_tokens,
value.total_tokens,
]
.iter()
.any(Option::is_some)
.then(|| usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
@@ -258,58 +270,30 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
},
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "success",
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实
status_code: None,
}
}
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
created_at_display: time.datetime(&value.occurred_at),
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
client_transport: value.client_transport,
upstream_transport: value.upstream_transport,
token_details: None,
billing: None,
latency_ms: value.latency_ms,
first_token_latency_ms: None,
latency_details: OutputTimingView::default(),
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "error",
status: match value.outcome {
RequestOutcome::Succeeded => "success",
RequestOutcome::Failed => "error",
RequestOutcome::Incomplete => "incomplete",
RequestOutcome::Cancelled => "cancelled",
RequestOutcome::Running => "running",
RequestOutcome::Other(_) => "unknown",
},
status_code: value.client_status_code,
}
}
pub(super) fn records(
value: KeyUsageRecords,
page: KeyUsageRecords,
time: crate::time::TimePresenter,
) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page
.items
.into_iter()
.map(|value| success_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page
.items
.into_iter()
.map(|value| error_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
PageData {
items: page
.items
.into_iter()
.map(|value| usage_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
@@ -67,6 +67,7 @@ enum RecordKind {
#[default]
Success,
Error,
All,
}
#[derive(Deserialize)]
@@ -109,6 +110,7 @@ impl RecordsQuery {
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
RecordKind::All => KeyUsageRecordKind::All,
},
current_page,
page_size,
@@ -96,7 +96,7 @@ fn percent_decode(value: &str) -> String {
index += 1;
}
}
String::from_utf8_lossy(&output).into_owned()
String::from_utf8_lossy_owned(output)
}
async fn open_sideband(
@@ -25,6 +25,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: Default::default(),
analytics: gateway_admin::model::observability::AnalyticsMetadata {
coverage_start: Some(now - Duration::hours(1)),
coverage_end: Some(now),
@@ -106,14 +107,14 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
}
#[tokio::test]
async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
async fn dashboard_summary_should_keep_yesterday_comparison_separate_from_today_and_lifetime() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::{Duration, Utc};
use gateway_admin::model::observability::{
AccountPoolMetrics, DashboardObservation, DashboardTotals, TimeRange,
AccountPoolMetrics, DashboardObservation, DashboardTotals, RequestMetrics, TimeRange,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -127,6 +128,13 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: RequestMetrics {
request_count: 7,
input_tokens: 400,
cached_input_tokens: 200,
total_tokens: 500,
..Default::default()
},
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
@@ -161,6 +169,11 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
.expect("dashboard response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("dashboard response JSON");
assert_eq!(status, StatusCode::OK, "{value}");
let cards = &value["data"]["cards"];
assert_eq!(cards["traffic"]["todayRequestsValue"], 0);
assert_eq!(cards["traffic"]["yesterdayRequestsValue"], 7);
assert_eq!(cards["tokens"]["yesterdayTokensValue"], 500);
assert_eq!(cards["cache"]["yesterdayHitRateValue"], 0.5);
assert_eq!(
value["data"]["analytics"]["coverageStartDisplay"],
serde_json::Value::Null
@@ -208,6 +221,7 @@ async fn dashboard_summary_should_default_to_current_deployment_day() {
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: Default::default(),
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
@@ -801,6 +815,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
@@ -892,12 +909,18 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
});
records.push(image);
}
let mut failed = records[0].clone();
failed.id = "request_failed".to_owned();
failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed;
failed.error_code = Some("server_error".to_owned());
failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned());
records.push(failed);
}
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records")
.uri("/api/admin/usage/records?status=all")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_endpoint")
.body(Body::empty())
@@ -911,6 +934,13 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.expect("usage response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert!(value["data"]["items"][0]["errorCode"].is_null());
assert!(value["data"]["items"][0]["errorMessage"].is_null());
assert_eq!(value["data"]["items"][3]["errorCode"], "server_error");
assert_eq!(
value["data"]["items"][3]["errorMessage"],
"The upstream service is temporarily unavailable."
);
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
@@ -1075,6 +1105,7 @@ async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor
let fixture = AdminTestFixture::with_timezone(timezone).await;
fixture.auth.insert_session("valid-session");
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
yesterday: Default::default(),
analytics: gateway_admin::model::observability::AnalyticsMetadata {
coverage_start: Some(range.start),
coverage_end: Some(end),
@@ -5,9 +5,8 @@ use chrono::{Duration, Utc};
use gateway_admin::model::{
client_keys::ClientKeyRecord,
observability::{
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
UsageOverview,
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, RequestMetricPoint,
RequestMetrics, TimeRange, UsageBilling, UsageListRecord, UsageOverview,
},
};
use gateway_core::{
@@ -87,12 +86,14 @@ pub(super) async fn fixture() -> AdminTestFixture {
}]);
}
fixture.usage_records.lock().unwrap().push(usage_record());
fixture.ops_errors.lock().unwrap().push(error_record());
fixture
}
fn usage_record() -> UsageListRecord {
UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "req-visible".to_owned(),
@@ -158,61 +159,3 @@ fn usage_record() -> UsageListRecord {
started_at: Utc::now(),
}
}
fn error_record() -> OpsError {
OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "private-sentinel".to_owned(),
event_id: "error-visible".to_owned(),
request_id: Some("private-sentinel".to_owned()),
attempt_index: None,
client_api_key_ref: Some("private-sentinel".to_owned()),
component: "private-sentinel".to_owned(),
operation: "private-sentinel".to_owned(),
protocol: Some("private-sentinel".to_owned()),
client_transport: Some("http_sse".to_owned()),
requested_model_id: Some("coding".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("private-sentinel".to_owned()),
provider_account_ref: Some("private-sentinel".to_owned()),
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
upstream_send_state: Some("private-sentinel".to_owned()),
client_status_code: Some(502),
upstream_status_code: Some(503),
provider_error_code: Some("private-sentinel".to_owned()),
client_response_id: Some("private-sentinel".to_owned()),
upstream_request_id: Some("private-sentinel".to_owned()),
latency_ms: Some(420),
message: "private-sentinel".to_owned(),
error_details: Some("private-sentinel".to_owned()),
client_ip: Some("192.0.2.42".to_owned()),
user_agent: Some("key-usage-test/1.0".to_owned()),
reasoning_effort: Some("xhigh".to_owned()),
reasoning_preset: Some("private-sentinel".to_owned()),
request_kind: Some("private-sentinel".to_owned()),
subagent_kind: Some("private-sentinel".to_owned()),
compact: None,
continuation_affinity_hash: Some("private-sentinel".to_owned()),
continuation_previous_response_id_hash: Some("private-sentinel".to_owned()),
continuation_unavailable_reason: Some("private-sentinel".to_owned()),
upstream_connection_id: Some("private-sentinel".to_owned()),
upstream_connection_exit_reason: Some("private-sentinel".to_owned()),
upstream_connection_age_ms: None,
upstream_connection_idle_ms: None,
recovery_request_id: Some("private-sentinel".to_owned()),
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "private-sentinel".to_owned(),
}
}
@@ -8,7 +8,7 @@ use axum::{
response::Response,
};
use chrono::Utc;
use gateway_admin::model::observability::RequestMetrics;
use gateway_admin::model::observability::{RequestMetrics, RequestOutcome, UsageRecordStatus};
use serde_json::{Value, json};
use tower::ServiceExt as _;
@@ -255,7 +255,16 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for kind in ["success", "error"] {
for (kind, outcome) in [
("success", RequestOutcome::Succeeded),
("error", RequestOutcome::Failed),
("all", RequestOutcome::Incomplete),
] {
{
let mut records = fixture.usage_records.lock().unwrap();
records[0].outcome = outcome;
records[0].client_status_code = (kind != "success").then_some(502);
}
let response = get(
&app,
"records",
@@ -291,7 +300,10 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
"statusCode",
],
);
assert_eq!(record["status"], kind);
assert_eq!(
record["status"],
if kind == "all" { "incomplete" } else { kind }
);
assert_eq!(record["model"], "coding");
assert_eq!(record["reasoningEffort"], "xhigh");
assert_eq!(record["clientTransport"], "http_sse");
@@ -323,22 +335,54 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
assert!(record["statusCode"].is_null());
} else {
assert_eq!(record["statusCode"], 502);
assert!(record["billing"].is_null());
assert!(record["tokenDetails"].is_null());
assert_eq!(record["latencyDetails"], json!({}));
assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235");
assert_eq!(record["tokenDetails"]["totalTokens"], 1100);
}
}
let data = fixture.observations.lock().unwrap();
assert_eq!(
data.records[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(
data.errors[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(data.records[0].filter.model.as_deref(), Some("coding"));
assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding"));
assert!(data.errors.is_empty());
assert_eq!(data.records.len(), 3);
for (query, status) in data.records.iter().zip([
UsageRecordStatus::Success,
UsageRecordStatus::Error,
UsageRecordStatus::All,
]) {
assert_eq!(query.status, status);
assert_eq!(query.filter.client_api_key_ref.as_deref(), Some("key-42"));
assert_eq!(query.filter.model.as_deref(), Some("coding"));
assert!(query.filter.provider_account_ref.is_none());
assert!(query.filter.provider_kind.is_none());
}
}
#[tokio::test]
async fn failed_records_without_usage_keep_token_and_billing_details_unknown() {
let fixture = fixtures::fixture().await;
{
let mut records = fixture.usage_records.lock().unwrap();
let record = &mut records[0];
record.outcome = RequestOutcome::Failed;
record.client_status_code = Some(502);
record.input_tokens = None;
record.output_tokens = None;
record.cached_input_tokens = None;
record.cache_write_input_tokens = None;
record.reasoning_output_tokens = None;
record.image_input_tokens = None;
record.image_output_tokens = None;
record.total_tokens = None;
record.billing = None;
}
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
let response = get(&app, "records", "&kind=error", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await;
let record = &data["data"]["items"][0];
assert_eq!(record["status"], "error");
assert_eq!(record["statusCode"], 502);
assert!(record["tokenDetails"].is_null());
assert!(record["billing"].is_null());
}
#[tokio::test]
@@ -392,7 +436,7 @@ async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
"&currentPage=0",
"&pageSize=0",
"&pageSize=101",
"&kind=all",
"&kind=unknown",
"&currentPage=-1",
] {
assert!(
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
autotests = false
[[test]]
@@ -118,7 +118,7 @@ impl NestedExecutionGraph {
pub(super) fn acquire(self: &Arc<Self>) -> Result<NestedExecutionPermit, GatewayError> {
if self
.active
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
(active < MAX_CONCURRENT_NESTED_EXECUTIONS).then_some(active + 1)
})
.is_err()
@@ -130,7 +130,7 @@ impl NestedExecutionGraph {
}
if self
.total_started
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |total| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |total| {
(total < MAX_NESTED_EXECUTIONS).then_some(total + 1)
})
.is_err()
@@ -100,12 +100,12 @@ pub struct AccountProbeError {
gateway: GatewayError,
source: AccountProbeErrorSource,
send_state: Option<UpstreamSendState>,
upstream_response: Option<AccountProbeUpstreamResponse>,
upstream_response: Option<Box<AccountProbeUpstreamResponse>>,
}
impl AccountProbeError {
#[must_use]
pub const fn new(
pub fn new(
gateway: GatewayError,
source: AccountProbeErrorSource,
send_state: Option<UpstreamSendState>,
@@ -115,7 +115,7 @@ impl AccountProbeError {
gateway,
source,
send_state,
upstream_response,
upstream_response: upstream_response.map(Box::new),
}
}
@@ -150,8 +150,8 @@ impl AccountProbeError {
}
#[must_use]
pub const fn upstream_response(&self) -> Option<&AccountProbeUpstreamResponse> {
self.upstream_response.as_ref()
pub fn upstream_response(&self) -> Option<&AccountProbeUpstreamResponse> {
self.upstream_response.as_deref()
}
}
@@ -37,7 +37,7 @@ impl Budget {
fn reserve(self: &Arc<Self>, bytes: usize) -> Option<Reservation> {
self.items
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |items| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |items| {
items
.checked_add(1)
.filter(|items| *items <= self.maximum_items)
@@ -45,7 +45,7 @@ impl Budget {
.ok()?;
if self
.bytes
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
used.checked_add(bytes)
.filter(|used| *used <= self.maximum_bytes)
})
@@ -428,7 +428,7 @@ async fn service_chain_preserves_onion_order_parent_scope_and_native_results_wit
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(events.len(), 8);
for round in events.chunks_exact(4) {
for round in events.as_chunks::<4>().0 {
let phases = round
.iter()
.map(|event| {
+6 -6
View File
@@ -121,9 +121,9 @@ SDK 帧读写器自动拆装这些字段,不拼接复制二进制正文。`Mes
修改宿主合同后,在仓库根目录更新生成文件;Admin 合同测试会检查生成结果是否同步:
```bash
CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_settings_contract_matches_host_declarations --locked
CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_accounts_contract_matches_host_declarations --locked
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml
CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_settings_contract_matches_host_declarations --locked
CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_accounts_contract_matches_host_declarations --locked
cargo +1.99.1 fmt --all --manifest-path backend/Cargo.toml
```
用量服务合同由 Admin 与 SDK 分别持有,`sdk_wire_types_match_host_contracts` 检查数值、明细、聚合、查询和计价类型一致性;修改合同需同步两侧类型与 [使用说明](docs/usage-data.md)
@@ -131,9 +131,9 @@ cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml
SDK 自身的验证命令:
```bash
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --no-default-features --locked
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --all-features --locked
RUST_MIN_STACK=16777216 RUSTDOCFLAGS='-D warnings' cargo +1.97.0 doc --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --all-features --no-deps --locked
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --no-default-features --locked
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --all-features --locked
RUST_MIN_STACK=16777216 RUSTDOCFLAGS='-D warnings' cargo +1.99.1 doc --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --all-features --no-deps --locked
```
源码中的类型和字段是当前合同;宿主接入、目录边界、内置 Provider 与 Runtime 集成验证见
@@ -352,11 +352,9 @@ impl ModelEventBatch {
}
let mut cursor = payload_start;
let mut events = Vec::with_capacity(count);
for chunk in bytes[8..payload_start].chunks_exact(4) {
let length = usize::try_from(u32::from_be_bytes(
chunk.try_into().map_err(|_| ModelEventBatchError)?,
))
.map_err(|_| ModelEventBatchError)?;
for chunk in bytes[8..payload_start].as_chunks::<4>().0 {
let length =
usize::try_from(u32::from_be_bytes(*chunk)).map_err(|_| ModelEventBatchError)?;
let end = cursor.checked_add(length).ok_or(ModelEventBatchError)?;
let event = bytes.get(cursor..end).ok_or(ModelEventBatchError)?;
events.push(ExecutionEvent::decode(event).map_err(|_| ModelEventBatchError)?);
@@ -92,13 +92,13 @@ pub(in crate::call) fn unpack<T: DeserializeOwned, const N: usize>(
let mut payloads = [&[][..]; N];
// 先验证全部分段边界,不能根据对端的长度声明直接分配
for (chunk, target) in bytes[4..header_bytes]
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.zip(std::iter::once(&mut metadata).chain(payloads.iter_mut()))
{
let length = usize::try_from(u32::from_be_bytes(
chunk.try_into().map_err(|_| ExecutionEncodingError)?,
))
.map_err(|_| ExecutionEncodingError)?;
let length =
usize::try_from(u32::from_be_bytes(*chunk)).map_err(|_| ExecutionEncodingError)?;
let end = cursor.checked_add(length).ok_or(ExecutionEncodingError)?;
*target = bytes.get(cursor..end).ok_or(ExecutionEncodingError)?;
cursor = end;
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.0.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
autotests = false
[[test]]
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
autotests = false
[[test]]
@@ -81,13 +81,13 @@ impl ExecutionBufferState {
0
});
self.queued_items
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
current.checked_add(1).filter(|next| *next <= item_limit)
})
.map_err(|_| ReservationFailure::ItemCapacity)?;
let bytes_reserved = self
.queued_bytes
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
current
.checked_add(bytes)
.filter(|next| *next <= byte_limit)
@@ -158,7 +158,7 @@ impl ReservationFailure {
}
fn saturating_increment(counter: &AtomicU64, increment: u64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
let _ = counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(increment))
});
}
@@ -170,6 +170,7 @@ impl PgObservabilityRepository {
observed_at: DateTime<Utc>,
) -> StoreResult<DashboardObservation> {
let range = store_range(query.range)?;
let yesterday_range = store_range(query.yesterday_range)?;
let account_usage_query = ProviderAccountUsageQuery::recent(range, query.account_limit)?
.with_hourly_request_buckets()?;
let recent_query = UsageRecordQuery {
@@ -180,7 +181,7 @@ impl PgObservabilityRepository {
page_size: ObservabilityPageSize::new(query.recent_request_limit)
.map_err(|_| invalid("invalid dashboard page size"))?,
};
let (totals, trend, analytics, account_usage) = self
let (totals, yesterday, trend, analytics, account_usage) = self
.query_budget
.run(
&self.pool,
@@ -188,6 +189,7 @@ impl PgObservabilityRepository {
|mut connection| async move {
let mut snapshot = analytics::AnalyticsSnapshot::new(&mut connection).await?;
let totals = snapshot.totals().await?;
let yesterday = snapshot.dashboard_card_metrics(yesterday_range).await?;
let trend = snapshot
.trend(
range,
@@ -201,7 +203,7 @@ impl PgObservabilityRepository {
provider_account_usage(&mut snapshot, account_usage_query).await?;
let metadata = snapshot.metadata();
snapshot.finish().await?;
Ok((totals, trend, metadata, account_usage))
Ok((totals, yesterday, trend, metadata, account_usage))
},
)
.await?;
@@ -222,6 +224,7 @@ impl PgObservabilityRepository {
analytics,
range,
totals,
yesterday,
provider_accounts,
runtime_slots,
trend,
@@ -144,6 +144,36 @@ impl<'a> AnalyticsSnapshot<'a> {
})
}
pub async fn dashboard_card_metrics(
&mut self,
range: ObservabilityRange,
) -> StoreResult<RequestMetrics> {
// 日对比只读取卡片计数,复用整桶汇总及脏桶回读,不加载费用和延迟分布
let states = self
.load_selected(
range,
&UsageRecordFilter::default(),
None,
None,
Default::default(),
ReadSelection {
distributions: false,
costs: false,
keys: None,
counters: Some(&[
"request_count",
"input_tokens",
"output_tokens",
"cached_input_tokens",
"total_tokens",
]),
distribution_names: None,
},
)
.await?;
merge_states(states.into_values())?.request_metrics()
}
pub async fn overview(
&mut self,
range: ObservabilityRange,
@@ -67,6 +67,7 @@ pub(crate) fn admin_dashboard_observation(
analytics,
range,
totals,
yesterday,
provider_accounts,
runtime_slots,
trend,
@@ -77,6 +78,7 @@ pub(crate) fn admin_dashboard_observation(
analytics,
range: admin_range(range),
totals,
yesterday,
provider_accounts,
runtime_slots: Some(runtime_slots),
trend: trend.into_iter().map(admin_request_metric_point).collect(),
@@ -303,6 +305,9 @@ pub(crate) fn usage_list_record_from_row(
cost_currency.as_deref(),
)?;
Ok(UsageListRecord {
error_code: get(row, "error_code")?,
error_message: get(row, "error_message")?,
client_status_code: optional_status(row, "client_status_code")?,
outcome: admin_observability::RequestOutcome::new(get::<String>(row, "outcome")?)
.map_err(|_| invalid("invalid request outcome"))?,
client_api_key_name: get(row, "client_api_key_name")?,
@@ -315,6 +315,7 @@ pub struct DashboardObservation {
pub analytics: admin_observability::AnalyticsMetadata,
pub range: ObservabilityRange,
pub totals: DashboardTotals,
pub yesterday: RequestMetrics,
pub provider_accounts: ProviderAccountMetrics,
pub runtime_slots: admin_observability::DashboardRuntimeSlots,
pub trend: Vec<RequestMetricPoint>,
@@ -113,7 +113,7 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String {
}
pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
"select mr.id, mr.outcome, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
"select mr.id, mr.outcome, mr.provider_error_code as error_code, mr.error_message, mr.client_status_code, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
@@ -417,10 +417,14 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() {
super::drain_analytics(&database.pool).await;
let dashboard = store
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -1259,10 +1263,14 @@ async fn dashboard_account_metrics_should_partition_account_statuses() {
super::drain_analytics(&database.pool).await;
let metrics = repository
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -1356,10 +1364,14 @@ async fn dashboard_account_metrics_with_cooldowns_should_only_reclassify_eligibl
super::drain_analytics(&database.pool).await;
let metrics = repository
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -1585,10 +1597,14 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
super::drain_analytics(&database.pool).await;
let dashboard = store
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -1955,10 +1971,14 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
super::drain_analytics(&database.pool).await;
let dashboard = repository
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -2005,6 +2025,61 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
database.close().await;
}
#[tokio::test]
async fn dashboard_summary_should_load_yesterday_without_widening_today() {
let Some(database) = TestDatabase::create("dashboard_yesterday").await else {
return;
};
let timezone = gateway_core::time::DeploymentTimeZone::default();
let end = timezone.day_start(Utc::now()).unwrap() + TimeDelta::hours(12);
seed_observability_facts(&database.pool, end).await.unwrap();
let range = admin_observability::TimeRange::calendar_at(
admin_observability::CalendarPeriod::Today,
end,
timezone,
)
.unwrap();
let query = admin_observability::DashboardQuery::new(range, timezone).unwrap();
sqlx::query("update model_requests set started_at=$1,completed_at=$1 + interval '1 minute' where id='req_observe_success'")
.bind(query.yesterday_range.start + TimeDelta::hours(1)).execute(&database.pool).await.unwrap();
sqlx::query("update model_requests set started_at=$1,completed_at=$1 + interval '1 minute' where id='req_observe_uncommitted'")
.bind(query.yesterday_range.start - TimeDelta::hours(1)).execute(&database.pool).await.unwrap();
super::drain_analytics(&database.pool).await;
let repository = observability_repository(&database.pool);
let result = repository
.dashboard_summary(query.clone(), end)
.await
.unwrap();
assert_eq!(result.yesterday.request_count, 1);
assert_eq!(result.yesterday.input_tokens, 100);
assert_eq!(result.yesterday.output_tokens, 20);
assert_eq!(result.yesterday.cached_input_tokens, 40);
assert_eq!(result.yesterday.total_tokens, 120);
assert_eq!(
result
.trend
.iter()
.map(|point| point.metrics.request_count)
.sum::<u64>(),
1
);
assert!(
result
.trend
.iter()
.all(|point| point.bucket_start >= range.start)
);
assert_eq!(result.recent_requests.len(), 0);
// 昨日迟到修正经过同一聚合管道更新,不能固化为跨日缓存
sqlx::query("update model_requests set input_tokens=200,total_tokens=220 where id='req_observe_success'")
.execute(&database.pool).await.unwrap();
super::drain_analytics(&database.pool).await;
let updated = repository.dashboard_summary(query, end).await.unwrap();
assert_eq!(updated.yesterday.input_tokens, 200);
assert_eq!(updated.yesterday.total_tokens, 220);
}
#[tokio::test]
async fn observability_queries_preserve_request_account_cost_and_diagnostic_facts() {
let Some(database) = TestDatabase::create("observability").await else {
@@ -2022,10 +2097,14 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
super::drain_analytics(&database.pool).await;
let dashboard = repository
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -2678,10 +2757,14 @@ async fn account_hourly_buckets_use_utc_hours_independently_of_calendar_metrics(
let dashboard = admin_observability_store(&database.pool)
.with_timezone(timezone)
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
end,
)
.await
@@ -2804,7 +2887,9 @@ async fn dashboard_shares_account_facts_and_bounds_slow_cooldown_reads() {
.dashboard_summary(
admin_observability::DashboardQuery::new(
admin_observability::TimeRange::new(now - TimeDelta::hours(1), now).unwrap(),
),
Default::default(),
)
.expect("dashboard query"),
now,
)
.await
@@ -2868,7 +2953,7 @@ async fn observability_store_executes_caller_selection_without_replacing_policy(
let range = TimeRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)).unwrap();
super::drain_analytics(&database.pool).await;
let store = admin_observability_store(&database.pool);
let mut query = DashboardQuery::new(range);
let mut query = DashboardQuery::new(range, Default::default()).expect("dashboard query");
query.account_limit = 1;
query.recent_request_limit = 1;
query.recent_request_filter = UsageFilter::default();
@@ -159,10 +159,14 @@ async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audi
assert_eq!(detail.request.service_tier.as_deref(), Some("auto"));
let dashboard = repository
.dashboard_summary(
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
start: range.start,
end: range.end,
}),
admin_observability::DashboardQuery::new(
admin_observability::TimeRange {
start: range.start,
end: range.end,
},
Default::default(),
)
.expect("dashboard query"),
end,
)
.await
@@ -67,10 +67,14 @@ async fn short_retention_keeps_statistics_readable_until_each_deletion_batch_is_
let range = ObservabilityRange::new(now - ChronoDuration::days(41), now).unwrap();
let statistics = observability_repository(&database.pool);
let dashboard = admin_observability_store(&database.pool);
let dashboard_query = DashboardQuery::new(TimeRange {
start: now - ChronoDuration::days(1),
end: now,
});
let dashboard_query = DashboardQuery::new(
TimeRange {
start: now - ChronoDuration::days(1),
end: now,
},
Default::default(),
)
.expect("dashboard query");
let original = statistics
.usage_summary(range, Default::default())
.await
@@ -311,7 +311,7 @@ async fn request_status_pages_share_one_request_timeline() {
.bind(id).bind(outcome).execute(&database.pool).await.unwrap();
}
sqlx::query(
"update model_requests set requested_model_id='model_status_b' where id='status_b'",
r#"update model_requests set requested_model_id='model_status_b',request_observation_json=jsonb_set(request_observation_json,'{error}','{"providerErrorCode":"server_error","message":"The upstream service is temporarily unavailable."}'::jsonb),client_status_code=502 where id='status_b'"#,
)
.execute(&database.pool)
.await
@@ -377,6 +377,12 @@ async fn request_status_pages_share_one_request_timeline() {
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "status_b");
assert_eq!(page.items[0].client_status_code, Some(502));
assert_eq!(page.items[0].error_code.as_deref(), Some("server_error"));
assert_eq!(
page.items[0].error_message.as_deref(),
Some("The upstream service is temporarily unavailable.")
);
// 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制
let errors = super::admin_observability_store(&database.pool)
.diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100)
@@ -22,7 +22,7 @@ enum CodexQuotaFetchError {
InvalidCredential,
Upstream {
account: Box<ProviderAccount>,
error: CodexClientError,
error: Box<CodexClientError>,
},
}
@@ -692,7 +692,7 @@ impl CodexCredentialQuotaService {
Err(CodexQuotaFetchAttemptError::Upstream(error)) => {
Err(CodexQuotaFetchError::Upstream {
account: Box::new(prepared.account),
error,
error: Box::new(error),
})
}
}
@@ -905,7 +905,7 @@ impl CappedResponseBody {
}
pub(super) fn into_string(self) -> String {
String::from_utf8_lossy(&self.bytes).into_owned()
String::from_utf8_lossy_owned(self.bytes)
}
}
@@ -595,7 +595,7 @@ impl CodexBackendClient {
.flatten();
let body = read_model_catalog_body(response).await?;
if !status.is_success() {
let body = String::from_utf8_lossy(&body).into_owned();
let body = String::from_utf8_lossy_owned(body);
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
@@ -79,7 +79,7 @@ impl CodexWebSocketPool {
// 不限额时也跟踪 socket,后续调低或启用预算不会丢失已有连接的占用
if capacity
.connections
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
if limit != 0 && used >= limit {
None
} else {
@@ -429,7 +429,7 @@ impl LimitedProxy {
peak.fetch_max(count, Ordering::SeqCst);
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("CONNECT upstream.invalid:80 "));
if reject_connects.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)).is_ok() {
if reject_connects.try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)).is_ok() {
stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n").await.unwrap();
return;
}
@@ -349,11 +349,11 @@ impl ReqwestGrokInferenceTransport {
async fn client_for(
&self,
binding: &GrokSessionBinding,
) -> Result<(Client, GrokInferenceClientCacheStatus), GrokInferenceTransportError> {
) -> Result<(Client, GrokInferenceClientCacheStatus), Box<GrokInferenceTransportError>> {
let entry = self
.clients
.entry(binding.clone())
.ok_or_else(inference_client_pool_unavailable_for_cache_miss)?;
.ok_or_else(|| Box::new(inference_client_pool_unavailable_for_cache_miss()))?;
let status = if entry.initialized() {
GrokInferenceClientCacheStatus::Hit
} else {
@@ -374,7 +374,7 @@ impl ReqwestGrokInferenceTransport {
.build_inference_client(None, binding.outbound_proxy())
})
.await
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?;
.map_err(|_| Box::new(inference_client_pool_unavailable_for_cache_miss()))?;
Ok((client.clone(), status))
}
}
@@ -410,7 +410,10 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
.map(|header| (header.name(), header.value().expose().as_bytes())),
);
trace.capture("upstream.request.body", request.body());
let (client, client_cache_status) = self.client_for(request.binding()).await?;
let (client, client_cache_status) = self
.client_for(request.binding())
.await
.map_err(|error| *error)?;
let mut builder = client
.post(request.endpoint().clone())
.body(request.body().to_vec());
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.97"
channel = "1.99.1"
components = ["clippy", "rustfmt"]
profile = "minimal"
+2 -2
View File
@@ -4,7 +4,7 @@
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
FROM node:26-bookworm-slim@sha256:86f07bc9c5dce4578cf37e5a418b7bfc7f817cda25cde66e2b66e95ed86c4567 AS frontend-builder
FROM node:26.12.0-bookworm-slim@sha256:298992629d69bbd5b2b03ad57094d8e4fcb3a8eeb7c4e25d29de656519568913 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -13,7 +13,7 @@ RUN npm install --global "$(node -p 'require("./package.json").packageManager')"
COPY frontend ./
RUN pnpm build
FROM rust:1.98-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS backend-builder
FROM rust:1.99.1-bookworm@sha256:e12566d5abc17a0da741628782cdb504b68b8961f5bfcf677936bf3e8b32de29 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+7 -4
View File
@@ -656,13 +656,14 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期;
| 方法 | 路由 | 查询 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/key-usage/overview` | `period?`、`asOf?` 或 `startTime?`、`endTime?`,另含 `model?` | 用量汇总、趋势、当前额度和锚点所在日期的健康时间线 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的请求记录 |
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
用量查询支持[页面时间合同](#页面时间合同),默认 `period=7d`,一次最多 31 天。模型按完整名称匹配;
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
`kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }`
`kind` 为 `success`(默认)、`error` 或 `all`,沿用使用统计的请求结果筛选:错误包括失败和未完成请求,
全部还包括取消与进行中的请求;一次请求只占一条记录。分页响应为 `{ items, currentPage, pageSize, total }`
overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。
`key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限,
@@ -677,8 +678,8 @@ overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summ
日志只返回时间、公开请求模型、推理强度、接口、上下游传输方式、当前请求的 IP / User-Agent、
Token 明细、费用明细、用时/首字与状态。Token 和费用复用现有展示合同;延迟仅包含当前请求的首事件、
首推理、首文本、总耗时与上游性能指标,口径见[记录范围与统计口径](#记录范围与统计口径),不含账号容量或调度诊断。
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
`status` 为 `success`、`error`、`incomplete`、`cancelled`、`running` 或 `unknown`,`statusCode` 只返回已记录的客户端状态码。
各结果均保留已采集的 Token/费用明细,缺失时为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断
@@ -1974,6 +1975,8 @@ request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细
重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表,
不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询
请求列表的 `errorCode` / `errorMessage` 提供最终请求的错误码与错误信息,未记录时为 `null`
诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列
+3 -3
View File
@@ -1109,9 +1109,9 @@ PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模
后端验证从 `backend/Cargo.toml` 执行,仓库根目录没有 Cargo manifest:
```bash
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
RUST_MIN_STACK=16777216 cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
cargo +1.99.1 fmt --all --manifest-path backend/Cargo.toml -- --check
RUST_MIN_STACK=16777216 cargo +1.99.1 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml --test main --locked
```
线程栈设置与当前 CI 一致。PostgreSQL/Redis 集成测试需按
+3 -3
View File
@@ -14,7 +14,7 @@
## 环境与依赖
需要 Node.js 24、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
需要 Node.js 26.12.0、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
各前端的 `packageManager` 固定 pnpm 版本,宿主 CI 和 Docker 从 `frontend/package.json` 读取
@@ -41,7 +41,7 @@ pnpm --dir frontend install --frozen-lockfile
```bash
docker compose -f deploy/compose.yaml up -d postgres redis
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-rs --locked
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-rs --locked
```
后端从当前目录向上查找 `deploy/config.yaml`,本机运行时数据库与 Redis 地址应指向可访问的本机端口
@@ -130,7 +130,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$cpr_sdk_check_dir/backend/"
cp modules/plugins/examples/workbench/plugin.json "$cpr_sdk_check_dir/"
RUST_MIN_STACK=16777216 CARGO_TARGET_DIR="$PWD/backend/target/plugin-development" \
cargo +1.97.0 test --manifest-path "$cpr_sdk_check_dir/backend/Cargo.toml" \
cargo +1.99.1 test --manifest-path "$cpr_sdk_check_dir/backend/Cargo.toml" \
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$PWD/backend/crates/gateway-plugin/sdk\""
)
```
+29 -5
View File
@@ -485,6 +485,30 @@ interface AccountExportParam {
confirm: string
}
interface AccountExportResponse {
exportedAt: string
fileName: string
documents: unknown[]
}
interface AccountImportTaskListResponse {
items: AccountImportTask[]
}
interface AccountDetailResponse {
account: Account
credentialConfiguration?: Record<string, unknown>
}
interface AccountResetCreditAutomationParam extends AccountIdParam {
enabled: boolean
}
interface AccountConcurrencyParams extends AccountIdParam {
page: number
pageSize: number
}
export function getAccounts(data: AccountListParams, options: RequestOptions = {}) {
return request<AccountListResponse>({
url: '/api/admin/accounts',
@@ -495,7 +519,7 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
}
export function exportAccounts(data: AccountExportParam) {
return request<{ exportedAt: string, fileName: string, documents: unknown[] }>({
return request<AccountExportResponse>({
url: '/api/admin/accounts/export',
method: 'GET',
params: data,
@@ -623,7 +647,7 @@ export function createAccountImportTask(data: CreateAccountImportTaskParam) {
}
export function getAccountImportTasks(options: RequestOptions = {}) {
return request<{ items: AccountImportTask[] }>({
return request<AccountImportTaskListResponse>({
url: '/api/admin/accounts/import-tasks',
method: 'GET',
...options,
@@ -701,7 +725,7 @@ export interface ApiKeyConfiguration {
}
export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) {
return request<{ account: Account, credentialConfiguration?: Record<string, unknown> }>({
return request<AccountDetailResponse>({
url: '/api/admin/accounts/detail',
method: 'GET',
params: data,
@@ -726,7 +750,7 @@ export function getAccountResetCreditAutomation(data: AccountIdParam, options: R
})
}
export function setAccountResetCreditAutomation(data: AccountIdParam & { enabled: boolean }, options: RequestOptions = {}) {
export function setAccountResetCreditAutomation(data: AccountResetCreditAutomationParam, options: RequestOptions = {}) {
return request<AccountResetCreditAutomation>({
url: '/api/admin/accounts/reset-credits/automation',
method: 'POST',
@@ -756,7 +780,7 @@ export interface AccountConcurrencyPage {
observedAt: string
}
export function getAccountConcurrency(data: { accountId: string, page: number, pageSize: number }, options: RequestOptions = {}) {
export function getAccountConcurrency(data: AccountConcurrencyParams, options: RequestOptions = {}) {
return request<AccountConcurrencyPage>({
url: '/api/admin/accounts/concurrency',
method: 'GET',
+12 -9
View File
@@ -59,7 +59,7 @@ export interface KeyUsageOverview {
healthTimeline: DashboardHealthTimeline
}
export type KeyUsageRecordKind = 'success' | 'error'
export type KeyUsageRecordKind = 'success' | 'error' | 'all'
export interface KeyUsageRecord {
id: string
@@ -77,7 +77,7 @@ export interface KeyUsageRecord {
latencyDetails: Pick<UsageLatencyDetails, 'firstEventMs' | 'firstReasoningMs' | 'firstTextMs'>
clientIp: string | null
userAgent: string | null
status: KeyUsageRecordKind
status: 'success' | 'error' | 'incomplete' | 'cancelled' | 'running' | 'unknown'
statusCode: number | null
}
@@ -94,11 +94,17 @@ export interface KeyUsageQuery {
model?: string
}
export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptions = {}) {
interface KeyUsageRecordsQuery extends KeyUsageQuery {
kind: KeyUsageRecordKind
currentPage: number
pageSize: number
}
export function getKeyUsageOverview(data: KeyUsageQuery, options: RequestOptions = {}) {
return request<KeyUsageOverview>({
url: '/api/key-usage/overview',
method: 'GET',
params,
params: data,
...options,
})
}
@@ -119,14 +125,11 @@ export function getKeyUsageConfig(options: RequestOptions = {}) {
})
}
export function getKeyUsageRecords(
params: KeyUsageQuery & { kind: KeyUsageRecordKind, currentPage: number, pageSize: number },
options: RequestOptions = {},
) {
export function getKeyUsageRecords(data: KeyUsageRecordsQuery, options: RequestOptions = {}) {
return request<KeyUsagePage>({
url: '/api/key-usage/records',
method: 'GET',
params,
params: data,
...options,
})
}
+5 -1
View File
@@ -92,6 +92,10 @@ interface ApiKeyIdParam {
id: string
}
interface ApiKeyResetBudgetParam extends ApiKeyIdParam {
period: ApiKeyBudgetPeriod
}
export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {}) {
return request<ApiKeyListResponse>({
url: '/api/admin/client-keys',
@@ -133,7 +137,7 @@ export function deleteApiKey(data: ApiKeyIdParam) {
})
}
export function resetApiKeyBudget(data: ApiKeyIdParam & { period: ApiKeyBudgetPeriod }) {
export function resetApiKeyBudget(data: ApiKeyResetBudgetParam) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/reset-budget',
method: 'POST',
+13 -4
View File
@@ -18,6 +18,15 @@ export interface LogoutResponse {
message: string
}
interface ChangeAdminPasswordParam {
currentPassword: string
newPassword: string
}
interface ChangeAdminPasswordResponse {
message: string
}
let pendingMutation: Promise<unknown> = Promise.resolve()
// Cookie 写入由浏览器完成,跨标签页串行认证操作以免晚到的续期覆盖登录或退出
@@ -34,8 +43,8 @@ export function login(data: LoginParam) {
return withSessionLock(() => request<AuthSession>({
url: '/api/auth/login',
method: 'POST',
skipSessionRecovery: true,
data,
skipSessionRecovery: true,
}))
}
@@ -68,11 +77,11 @@ export function logout() {
}))
}
export function changeAdminPassword(data: { currentPassword: string, newPassword: string }) {
return withSessionLock(() => request<{ message: string }>({
export function changeAdminPassword(data: ChangeAdminPasswordParam) {
return withSessionLock(() => request<ChangeAdminPasswordResponse>({
url: '/api/auth/password',
method: 'POST',
skipSessionRecovery: true,
data,
skipSessionRecovery: true,
}))
}
@@ -62,6 +62,11 @@ export interface PluginManagementCallbackTicket {
expiresAtMs: number
}
interface PluginManagementCallbackTicketParam {
path: string
ttlSeconds: number
}
export function getPluginManagementViews(options: RequestOptions = {}) {
return request<PluginManagementView[]>({
url: '/api/admin/plugins/extensions',
@@ -122,7 +127,7 @@ export function callPluginModelResponses(
export function createPluginManagementCallbackTicket(
target: PluginManagementTarget,
data: { path: string, ttlSeconds: number },
data: PluginManagementCallbackTicketParam,
options: RequestOptions = {},
) {
return request<PluginManagementCallbackTicket>({
+47 -15
View File
@@ -271,6 +271,38 @@ export interface PluginRollbackPlan {
targets: { artifactSha256: string, version: string, platforms: string[] }[]
}
interface PluginArtifactParam {
sha256: string
}
interface PluginSourceCredentialIdParam {
id: string
}
interface PluginUpdateCheckParam {
pluginId: string
credentialIds: string[]
}
interface PluginInstanceIdParam {
id: string
}
interface PluginInstanceUpdateParam extends PluginInstanceIdParam {
instance: ConfigurePluginInstanceRequest
}
interface PluginVersionPlanParams extends PluginInstanceIdParam {
artifactSha256: string
}
interface PluginVersionChangeParam extends PluginInstanceIdParam {
target: {
artifactSha256: string
expectedRevision: number
}
}
export function getPluginArtifacts(options: RequestOptions = {}) {
return request<PluginArtifact[]>({
url: '/api/admin/plugins/artifacts',
@@ -297,11 +329,11 @@ export function uploadPluginArtifact(file: File, sha256: string, options: Reques
})
}
export function verifyUploadedPlugin(file: File, options: RequestOptions = {}) {
export function verifyUploadedPlugin(data: File, options: RequestOptions = {}) {
return request<VerifiedPluginArtifact>({
url: '/api/admin/plugins/artifacts/upload/verify',
method: 'POST',
data: file,
data,
headers: {
'Content-Type': 'application/octet-stream',
},
@@ -320,7 +352,7 @@ export function installRemotePlugin(data: InstallRemotePluginRequest, options: R
})
}
export function acceptPluginArtifact(data: { sha256: string }, options: RequestOptions = {}) {
export function acceptPluginArtifact(data: PluginArtifactParam, options: RequestOptions = {}) {
return request<PluginArtifactMutationResponse>({
url: '/api/admin/plugins/artifacts/accept',
method: 'POST',
@@ -339,7 +371,7 @@ export function verifyRemotePlugin(data: VerifyRemotePluginRequest, options: Req
})
}
export function deletePluginArtifact(data: { sha256: string }, options: RequestOptions = {}) {
export function deletePluginArtifact(data: PluginArtifactParam, options: RequestOptions = {}) {
return request<void>({
url: '/api/admin/plugins/artifacts/delete',
method: 'POST',
@@ -375,7 +407,7 @@ export function createPluginSourceCredential(data: CreatePluginSourceCredentialR
})
}
export function deletePluginSourceCredential(data: { id: string }, options: RequestOptions = {}) {
export function deletePluginSourceCredential(data: PluginSourceCredentialIdParam, options: RequestOptions = {}) {
return request<PluginMutationResponse>({
url: '/api/admin/plugins/source-credentials/delete',
method: 'POST',
@@ -401,7 +433,7 @@ export function updatePluginSource(data: PluginUpdateSourceBinding, options: Req
})
}
export function checkPluginUpdate(data: { pluginId: string, credentialIds: string[] }, options: RequestOptions = {}) {
export function checkPluginUpdate(data: PluginUpdateCheckParam, options: RequestOptions = {}) {
return request<PluginUpdateCheck>({
url: '/api/admin/plugins/updates/check',
method: 'POST',
@@ -418,7 +450,7 @@ export function getPluginInstances(options: RequestOptions = {}) {
})
}
export function updatePluginInstance(data: { id: string, instance: ConfigurePluginInstanceRequest }, options: RequestOptions = {}) {
export function updatePluginInstance(data: PluginInstanceUpdateParam, options: RequestOptions = {}) {
return request<PluginInstanceMutationResponse>({
url: '/api/admin/plugins/instances/update',
method: 'POST',
@@ -427,16 +459,16 @@ export function updatePluginInstance(data: { id: string, instance: ConfigurePlug
})
}
export function getPluginRollbackPlan(id: string, options: RequestOptions = {}) {
export function getPluginRollbackPlan(data: PluginInstanceIdParam, options: RequestOptions = {}) {
return request<PluginRollbackPlan>({
url: '/api/admin/plugins/instances/rollback-plan',
method: 'GET',
params: { id },
params: data,
...options,
})
}
export function rollbackPluginInstance(data: { id: string, target: { artifactSha256: string, expectedRevision: number } }, options: RequestOptions = {}) {
export function rollbackPluginInstance(data: PluginVersionChangeParam, options: RequestOptions = {}) {
return request<PluginInstanceMutationResponse>({
url: '/api/admin/plugins/instances/rollback',
method: 'POST',
@@ -445,7 +477,7 @@ export function rollbackPluginInstance(data: { id: string, target: { artifactSha
})
}
export function disablePluginInstance(data: { id: string }, options: RequestOptions = {}) {
export function disablePluginInstance(data: PluginInstanceIdParam, options: RequestOptions = {}) {
return request<PluginMutationResponse>({
url: '/api/admin/plugins/instances/disable',
method: 'POST',
@@ -454,7 +486,7 @@ export function disablePluginInstance(data: { id: string }, options: RequestOpti
})
}
export function deletePluginInstance(data: { id: string }, options: RequestOptions = {}) {
export function deletePluginInstance(data: PluginInstanceIdParam, options: RequestOptions = {}) {
return request<PluginMutationResponse>({
url: '/api/admin/plugins/instances/delete',
method: 'POST',
@@ -472,16 +504,16 @@ export interface PluginVersionPlan {
restored: boolean
}
export function getPluginVersionPlan(id: string, artifactSha256: string, options: RequestOptions = {}) {
export function getPluginVersionPlan(data: PluginVersionPlanParams, options: RequestOptions = {}) {
return request<PluginVersionPlan>({
url: '/api/admin/plugins/instances/version-plan',
method: 'GET',
params: { id, artifactSha256 },
params: data,
...options,
})
}
export function switchPluginVersion(data: { id: string, target: { artifactSha256: string, expectedRevision: number } }, options: RequestOptions = {}) {
export function switchPluginVersion(data: PluginVersionChangeParam, options: RequestOptions = {}) {
return request<PluginInstanceMutationResponse>({
url: '/api/admin/plugins/instances/switch-version',
method: 'POST',
+63 -14
View File
@@ -69,7 +69,57 @@ interface ProxyAccountPage {
page: ProxyPage['page']
}
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
interface ProxyListParams {
page: number
pageSize: number
search?: string
}
interface ProxyAccountListParams extends ProxyListParams {
proxyId: string
}
interface ProxyAccountRemoveParam {
proxyId: string
accountId: string
}
interface ProxyCreateParam {
name: string
proxyUrl: string
maxWebsocketConnections?: number
autoLocation?: boolean
location?: RequestLocation | null
}
interface ProxyIdParam {
id: string
revision: number
}
interface ProxyUpdateParam extends ProxyIdParam {
name: string
enabled?: boolean
maxWebsocketConnections?: number
proxyUrl?: string
autoLocation?: boolean
location?: RequestLocation | null
}
interface ProxyTestParam extends ProxyIdParam {
detectLocation?: boolean
}
interface ProxyProbeParam {
proxyUrl: string
detectLocation?: boolean
}
interface ProxyMutationResponse {
configRevision: number
}
export function getProxyAccounts(data: ProxyAccountListParams, options: RequestOptions = {}) {
return request<ProxyAccountPage>({
url: '/api/admin/proxies/accounts',
method: 'GET',
@@ -78,8 +128,8 @@ export function getProxyAccounts(data: { proxyId: string, page: number, pageSize
})
}
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
return request<{ configRevision: number }>({
export function removeProxyAccount(data: ProxyAccountRemoveParam) {
return request<ProxyMutationResponse>({
url: '/api/admin/proxies/accounts/remove',
method: 'POST',
data,
@@ -91,12 +141,11 @@ export type ProxyBatchResult<T>
| { status: 'failed', httpStatus: number, code: number, message: string }
| { status: 'skipped' }
interface ProxyMutation {
interface ProxyMutation extends ProxyMutationResponse {
record: OutboundProxyRecord
configRevision: number
}
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
export function getProxies(data: ProxyListParams, options: RequestOptions = {}) {
return request<ProxyPage>({
url: '/api/admin/proxies',
method: 'GET',
@@ -105,34 +154,34 @@ export function getProxies(data: { page: number, pageSize: number, search?: stri
})
}
export function createProxy(data: { name: string, proxyUrl: string, maxWebsocketConnections?: number, autoLocation?: boolean, location?: RequestLocation | null }[], options: RequestOptions = {}) {
export function createProxy(data: ProxyCreateParam[], options: RequestOptions = {}) {
return request<ProxyBatchResult<ProxyMutation>[]>({
url: '/api/admin/proxies/create',
timeout: 25000 * Math.max(1, data.length),
method: 'POST',
data,
timeout: 25000 * Math.max(1, data.length),
...options,
})
}
export function updateProxy(data: { id: string, revision: number, name: string, enabled?: boolean, maxWebsocketConnections?: number, proxyUrl?: string, autoLocation?: boolean, location?: RequestLocation | null }) {
export function updateProxy(data: ProxyUpdateParam) {
return request<ProxyMutation>({
url: '/api/admin/proxies/update',
timeout: 25000,
method: 'POST',
data,
timeout: 25000,
})
}
export function deleteProxy(data: { id: string, revision: number }) {
return request<{ configRevision: number }>({
export function deleteProxy(data: ProxyIdParam) {
return request<ProxyMutationResponse>({
url: '/api/admin/proxies/delete',
method: 'POST',
data,
})
}
export function testProxy(data: { id: string, revision: number, detectLocation?: boolean }[], options: RequestOptions = {}) {
export function testProxy(data: ProxyTestParam[], options: RequestOptions = {}) {
return request<ProxyBatchResult<OutboundProxyRecord>[]>({
url: '/api/admin/proxies/test',
method: 'POST',
@@ -142,7 +191,7 @@ export function testProxy(data: { id: string, revision: number, detectLocation?:
})
}
export function probeProxy(data: { proxyUrl: string, detectLocation?: boolean }) {
export function probeProxy(data: ProxyProbeParam) {
return request<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
+6 -2
View File
@@ -103,6 +103,10 @@ export interface DownloadUrlResult {
expiresInSeconds: number
}
interface BackupIdParam {
backupId: string
}
export function getBackupSettings() {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups',
@@ -149,7 +153,7 @@ export function createBackup() {
})
}
export function getBackupDownloadUrl(data: { backupId: string }) {
export function getBackupDownloadUrl(data: BackupIdParam) {
return request<DownloadUrlResult>({
url: '/api/admin/settings/backups/download-url',
method: 'POST',
@@ -157,7 +161,7 @@ export function getBackupDownloadUrl(data: { backupId: string }) {
})
}
export function deleteBackup(data: { backupId: string }) {
export function deleteBackup(data: BackupIdParam) {
return request<BackupRecord>({
url: '/api/admin/settings/backups/delete',
method: 'POST',
+5 -1
View File
@@ -157,7 +157,11 @@ export function deleteAdminApiKey() {
})
}
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
interface CodexDesktopWindowsDownloadsParams {
refresh?: boolean
}
export function getCodexDesktopWindowsDownloads(data: CodexDesktopWindowsDownloadsParams = {}) {
return request<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
+5 -1
View File
@@ -40,7 +40,11 @@ export interface PrivacyPreviewResult extends PrivacySample {
outcomes: { ruleId: string, matches: number, status: string, reason: string | null }[]
}
export function previewPrivacyPolicy(data: PrivacySample & { policy: CodexPrivacyPolicy }, options: RequestOptions = {}) {
interface PrivacyPreviewParam extends PrivacySample {
policy: CodexPrivacyPolicy
}
export function previewPrivacyPolicy(data: PrivacyPreviewParam, options: RequestOptions = {}) {
return request<PrivacyPreviewResult>({
url: '/api/admin/settings/privacy/preview',
method: 'POST',
+18 -5
View File
@@ -73,11 +73,15 @@ export function getClientProfileOptions() {
})
}
export function previewClientProfile(configuration: ClientProfileSelection | null) {
interface ClientProfilePreviewParam {
configuration: ClientProfileSelection | null
}
export function previewClientProfile(data: ClientProfilePreviewParam) {
return request<ClientProfilePreview>({
url: '/api/admin/settings/client-profiles/openai/preview',
method: 'POST',
data: { configuration },
data,
silent: true,
})
}
@@ -103,19 +107,28 @@ export interface XaiClientProfilePreview extends Omit<XaiClientProfileSelection,
error: string | null
}
interface XaiClientProfileOptions {
defaults: XaiClientProfileSelection
globalConfiguration: XaiClientProfileSelection
}
interface XaiClientProfilePreviewParam {
configuration: XaiClientProfileSelection | null
}
export function getXaiClientProfileOptions() {
return request<{ defaults: XaiClientProfileSelection, globalConfiguration: XaiClientProfileSelection }>({
return request<XaiClientProfileOptions>({
url: '/api/admin/settings/client-profiles/xai',
method: 'GET',
silent: true,
})
}
export function previewXaiClientProfile(configuration: XaiClientProfileSelection | null) {
export function previewXaiClientProfile(data: XaiClientProfilePreviewParam) {
return request<XaiClientProfilePreview>({
url: '/api/admin/settings/client-profiles/xai/preview',
method: 'POST',
data: { configuration },
data,
silent: true,
})
}
+10 -3
View File
@@ -111,14 +111,21 @@ export interface SystemRestartPlan {
}
export function checkSystemRestart() {
return request<SystemRestartPlan>({ url: '/api/admin/system/restart/check', method: 'GET' })
return request<SystemRestartPlan>({
url: '/api/admin/system/restart/check',
method: 'GET',
})
}
export function restartSystem(confirmation?: SystemRestartPlan, options: RequestOptions = {}) {
interface SystemRestartParam {
confirmation?: SystemRestartPlan
}
export function restartSystem(data: SystemRestartParam = {}, options: RequestOptions = {}) {
return request<SystemRestartAccepted>({
url: '/api/admin/system/restart',
method: 'POST',
data: { confirmation },
data,
...options,
})
}
+5 -1
View File
@@ -85,6 +85,8 @@ export type UsageRecordStatus = 'success' | 'error' | 'all'
export interface UsageListRecord {
logicalOutcome: string
errorCode: string | null
errorMessage: string | null
clientApiKeyName: string | null
id: string
provider: string | null
@@ -394,6 +396,8 @@ interface UsageDetailQuery {
type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string }
type DiagnosticsDownloadParams = { requestId: string } | { errorId: string }
export function getUsageRecords(data: UsagePageQuery, options: RequestOptions = {}) {
return request<UsageRecordsResponse>({
url: '/api/admin/usage/records',
@@ -439,7 +443,7 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery, o
})
}
export function downloadDiagnostics(data: { requestId: string } | { errorId: string }, options: RequestOptions = {}) {
export function downloadDiagnostics(data: DiagnosticsDownloadParams, options: RequestOptions = {}) {
return requestRaw({
url: '/api/admin/diagnostics/export',
method: 'GET',
@@ -70,7 +70,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
return
const timer = setTimeout(async () => {
try {
const result = await previewClientProfile(configuration)
const result = await previewClientProfile({ configuration })
if (!cancelled)
preview.value = result
}
@@ -73,7 +73,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
return
const timer = setTimeout(async () => {
try {
const result = await previewXaiClientProfile(configuration)
const result = await previewXaiClientProfile({ configuration })
if (!cancelled)
preview.value = result
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { UsageListRecord } from '@/api'
import type { UsageModelRecord } from './shared/presenter'
import { CornerDownRight } from '@lucide/vue'
@@ -7,7 +7,7 @@ import { computed } from 'vue'
import { usageModelDisplay } from './shared/presenter'
const props = defineProps<{
record: UsageListRecord
record: UsageModelRecord
}>()
const modelDisplay = computed(() => usageModelDisplay(props.record))
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { UsageListRecord } from '@/api'
import type { UsageReasoningRecord } from './shared/presenter'
import { Bot, SquareDashedBottomCode } from '@lucide/vue'
import { usageReasoningEffort } from './shared/presenter'
defineProps<{
record: UsageListRecord
record: UsageReasoningRecord
}>()
</script>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { UsageRecordStatus } from '@/api/modules/usage'
import { ZSegmented } from '@codex-proxy/ui'
import { CloudRain, List, Sun } from '@lucide/vue'
const model = defineModel<UsageRecordStatus>({ required: true })
const options = [
{ label: '成功', value: 'success', icon: Sun },
{ label: '错误', value: 'error', icon: CloudRain },
{ label: '全部', value: 'all', icon: List },
]
</script>
<template>
<ZSegmented v-model="model" aria-label="请求明细类型" :options="options" display="icon" class="w-30 shrink-0" />
</template>
@@ -3,7 +3,6 @@ import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { ZTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
@@ -12,6 +11,8 @@ import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
import UsageModelCell from '@/components/usage/UsageModelCell.vue'
import UsagePerformanceCell from '@/components/usage/UsagePerformanceCell.vue'
import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue'
import UsageRouteCell from '@/components/usage/UsageRouteCell.vue'
import UsageTimeCell from '@/components/usage/UsageTimeCell.vue'
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
import {
@@ -83,6 +84,10 @@ withDefaults(
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #createdAtDisplay="{ row }">
<UsageTimeCell :value="row.createdAtDisplay" />
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
@@ -102,16 +107,17 @@ withDefaults(
</template>
<template #route="{ row }">
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
<code class="font-mono text-cp-sm font-emphasis">{{ row.route || '—' }}</code>
<span
v-if="row.compact"
class="inline-flex shrink-0 text-cp-orange-text"
title="压缩请求"
aria-label="压缩请求"
>
<Minimize2 class="size-3.5" stroke-width="2.4" />
</span>
<UsageRouteCell :record="row" />
</template>
<template #errorMessage="{ row }">
<div class="min-w-0 py-0.5" :title="row.errorMessage || row.errorCode || undefined">
<code class="block truncate font-mono text-cp-sm font-bold text-cp-error-text">
{{ row.errorCode || (row.logicalOutcome === 'incomplete' ? '响应未完成' : '请求失败') }}
</code>
<p v-if="row.errorMessage" class="mt-1 mb-0 truncate text-cp-xs leading-[1.45] font-emphasis text-cp-text-secondary">
{{ row.errorMessage }}
</p>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { Minimize2 } from '@lucide/vue'
defineProps<{ record: { route: string | null, compact?: boolean } }>()
</script>
<template>
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
<code class="font-mono text-cp-sm font-emphasis">{{ record.route || '—' }}</code>
<span
v-if="record.compact"
class="inline-flex shrink-0 text-cp-orange-text"
title="压缩请求"
aria-label="压缩请求"
>
<Minimize2 class="size-3.5" stroke-width="2.4" />
</span>
</div>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{ value: string }>()
</script>
<template>
<span class="block truncate font-mono text-cp-sm leading-none font-bold text-cp-text-secondary" :title="value">
{{ value || '—' }}
</span>
</template>
+17 -12
View File
@@ -1,7 +1,23 @@
import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { defineTableColumns } from '@codex-proxy/ui'
import { formatProviderLabel } from '@/utils/providers'
export const usageDataColumns = [
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
] satisfies TableColumn[]
export const usageRecordColumns = defineTableColumns<UsageListRecord>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{
@@ -20,17 +36,6 @@ export const usageRecordColumns = defineTableColumns<UsageListRecord>([
fixedWidth: true,
formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
...usageDataColumns,
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false },
])
@@ -9,6 +9,9 @@ export type UsagePerformanceRecord = Pick<UsageCommonRecord, 'latencyDetails' |
tokenDetails: Pick<UsageListRecord['tokenDetails'], 'outputTokens'> | null
}
export type UsageModelRecord = Pick<UsageCommonRecord, 'model'> & Partial<Pick<UsageCommonRecord, 'requestedModel' | 'upstreamModel' | 'upstreamResponseModel'>>
export type UsageReasoningRecord = Pick<UsageCommonRecord, 'reasoningEffort'> & Partial<Pick<UsageCommonRecord, 'reasoningPreset' | 'subagentKind'>>
interface UsageTimingItem {
label: string
value: string
@@ -58,7 +61,7 @@ export function usageTransportTypeClass(transport?: string | null) {
return 'bg-cp-fill-tertiary text-cp-text-secondary'
}
export function usageAccountText(record: UsageCommonRecord) {
export function usageAccountText(record: Pick<UsageCommonRecord, 'accountEmail' | 'accountName' | 'accountId'>) {
return record.accountEmail || record.accountName || record.accountId || '—'
}
@@ -70,14 +73,14 @@ export function usageUserAgent(record: { userAgent?: string | null }) {
return record.userAgent || '—'
}
export function usageReasoningEffort(record: UsageCommonRecord) {
export function usageReasoningEffort(record: UsageReasoningRecord) {
const reasoningEffort = record.reasoningEffort || '—'
if (record.subagentKind)
return reasoningEffort
return record.reasoningPreset || reasoningEffort
}
export function usageModelDisplay(record: UsageCommonRecord) {
export function usageModelDisplay(record: UsageModelRecord) {
const requestedModel = record.requestedModel || ''
const upstreamModel = record.upstreamModel || ''
const storedModel = record.model || ''
+42 -8
View File
@@ -14,6 +14,13 @@ interface PageResult {
}
}
export interface PagedQueryExecution {
page?: number
pageSize?: number
silent?: boolean
background?: boolean
}
export function usePagedQuery<Result extends PageResult>(options: {
initialPageSize: number
load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise<Result>
@@ -25,34 +32,52 @@ export function usePagedQuery<Result extends PageResult>(options: {
const total = shallowRef(0)
const items = shallowRef<Result['items'][number][]>([])
const request = useRequestState(options.onError)
const { loading, error, invalidate } = request
const { loading, error } = request
let confirmedPagination = { page: page.value, pageSize: pageSize.value }
async function execute(execution: { silent?: boolean, background?: boolean } = {}) {
function restorePagination() {
page.value = confirmedPagination.page
pageSize.value = confirmedPagination.pageSize
}
function invalidate(options?: Parameters<typeof request.invalidate>[0]) {
request.invalidate(options)
restorePagination()
}
async function execute(execution: PagedQueryExecution = {}): Promise<boolean> {
// 保留当前列表的刷新不一定静默;手动刷新仍需由请求层提示失败。
const background = execution.background || execution.silent
const requestId = request.start(background)
const pagination = {
page: execution.page ?? page.value,
pageSize: execution.pageSize ?? pageSize.value,
}
// 同步控件的目标值,失败时再回退;只在成功时写入会让控件内部模型无法恢复。
// 轮询或操作后的回读也沿用当前目标页,避免把尚未完成的翻页拉回旧页。
page.value = pagination.page
pageSize.value = pagination.pageSize
try {
const result = await options.load({
page: page.value,
pageSize: pageSize.value,
}, { silent: execution.silent, signal: request.signal })
const result = await options.load(pagination, { silent: execution.silent, signal: request.signal })
if (!request.isCurrent(requestId))
return false
if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) {
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
return execute(execution)
return execute({ ...execution, page: clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY) })
}
items.value = result.items
page.value = result.page.page
pageSize.value = result.page.pageSize
total.value = result.page.total
confirmedPagination = { page: page.value, pageSize: pageSize.value }
options.onSuccess?.(result)
return true
}
catch (cause: unknown) {
if (request.isCurrent(requestId))
restorePagination()
request.fail(requestId, cause, background)
return false
}
@@ -61,6 +86,14 @@ export function usePagedQuery<Result extends PageResult>(options: {
}
}
function reloadFromStart() {
// 筛选条件已改变,旧结果不能继续对应当前查询,也不能在失败时恢复旧页。
items.value = []
total.value = 0
confirmedPagination = { page: 1, pageSize: confirmedPagination.pageSize }
return execute(confirmedPagination)
}
return {
page,
pageSize,
@@ -69,6 +102,7 @@ export function usePagedQuery<Result extends PageResult>(options: {
loading,
error,
execute,
reloadFromStart,
invalidate,
}
}
@@ -577,6 +577,7 @@ onBeforeUnmount(() => {
:size="isCollapsed ? 'default' : 'small'"
aria-label="退出登录"
type="danger"
:disabled="authStore.loading || !authStore.isAuthenticated"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
+8 -1
View File
@@ -8,6 +8,8 @@ import { resetUnauthorizedHandling } from '@/api/request'
export const useAuthStore = defineStore('auth', () => {
const session = shallowRef<AuthSession | null>(null)
// 登录身份变化使关联数据失效,正常续期只更新会话有效期。
const sessionGeneration = shallowRef(0)
const isAuthenticated = computed(() => session.value !== null)
const isAdmin = computed(() => session.value?.role === 'admin')
const sessionChecked = shallowRef(false)
@@ -41,7 +43,10 @@ export const useAuthStore = defineStore('auth', () => {
// 登录或退出之后到达的旧状态响应,不覆盖新会话。
if (currentRevision === revision) {
const wasAuthenticated = isAuthenticated.value
const previousRole = session.value?.role
session.value = status.session
if (previousRole !== status.session?.role)
sessionGeneration.value += 1
sessionChecked.value = true
if (status.authenticated && !wasAuthenticated)
resetUnauthorizedHandling()
@@ -61,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => {
revision += 1
pendingCheck.value = undefined
session.value = result
sessionGeneration.value += 1
sessionChecked.value = true
resetUnauthorizedHandling()
return result
@@ -97,9 +103,10 @@ export const useAuthStore = defineStore('auth', () => {
revision += 1
pendingCheck.value = undefined
session.value = null
sessionGeneration.value += 1
sessionChecked.value = true
resetUnauthorizedHandling()
}
return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
return { session, sessionGeneration, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
})
+1 -4
View File
@@ -70,10 +70,7 @@ export const usePluginViewsStore = defineStore('plugin-views', () => {
return pending ?? refresh()
}
watch(() => authStore.session, (session, previous) => {
if (session !== previous)
reset()
})
watch(() => authStore.sessionGeneration, reset, { flush: 'sync' })
return {
views,
+1 -1
View File
@@ -471,7 +471,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
try {
// 进程可能在返回响应前退出,由下面的目标版本探测判定是否完成。
await restartSystem(confirmation, { silent: true })
await restartSystem({ confirmation }, { silent: true })
}
catch (error: unknown) {
if (error instanceof ApiError && error.status > 0) {

Some files were not shown because too many files have changed in this diff Show More