mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 16:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d8a3f873 | ||
|
|
ac6033f6fa | ||
|
|
cef9f0b21e | ||
|
|
591f95cc18 | ||
|
|
99ba5b1120 | ||
|
|
2221f400da | ||
|
|
38bcfb9da1 | ||
|
|
10f122aac8 | ||
|
|
598d56baed | ||
|
|
5f61309941 | ||
|
|
ab8c61b118 | ||
|
|
c3146dc10a | ||
|
|
72bdc234ca | ||
|
|
733e9aa8a0 | ||
|
|
743a3aba70 | ||
|
|
349e983eb7 |
@@ -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#信任与资源边界)评估,不假定完整信任的插件具有安全沙箱
|
||||
- 开放已有能力时复用宿主服务与数据模型,不为插件的导入、修正、重算等编排新增表、字段、账本或任务系统;确有宿主能力缺口时,界定可独立使用的基础接口及输入输出、权限、事务、失败和重复调用语义,不夹带插件私有模型与工作流
|
||||
- 新增防御、兼容、重试、缓存或状态前,说明要保护的宿主不变量、触发依据和最小处理范围;仅为迁就某个插件或猜测未来行为时不加入宿主
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合;验证失败与取消后的资源释放、状态一致性和其他调用受影响范围,不以插件恢复成功代替宿主边界验证
|
||||
|
||||
@@ -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-回复口吻)
|
||||
|
||||
## 发布与收尾
|
||||
|
||||
|
||||
@@ -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 的批准或请求修改状态。
|
||||
|
||||
@@ -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 实例
|
||||
|
||||
@@ -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/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
|
||||
@@ -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,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#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
|
||||
+27
-5
@@ -19,15 +19,31 @@
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 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 +163,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 +179,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
|
||||
|
||||
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
|
||||
|
||||
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
|
||||
Generated
+1
@@ -1915,6 +1915,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"redis",
|
||||
"secrecy",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
)
|
||||
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
|
||||
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
|
||||
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
|
||||
.env(
|
||||
"CPR_DATABASE_PASSWORD",
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh",
|
||||
)
|
||||
.env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh")
|
||||
.status()
|
||||
.expect("run isolated environment override test");
|
||||
assert!(status.success());
|
||||
@@ -282,13 +285,53 @@ fn config_loader_should_reject_embedded_database_password() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_non_hex_postgres_password() {
|
||||
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
|
||||
fn config_loader_should_accept_managed_store_passwords() {
|
||||
for password in [
|
||||
"RdsTest9!abcdefGh".to_owned(),
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(),
|
||||
POSTGRES_PASSWORD.to_owned(),
|
||||
"x".repeat(128),
|
||||
"Managed@:/?#%&+Password9!".to_owned(),
|
||||
"数据库密码".repeat(4),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("managed store password");
|
||||
assert!(!format!("{config:?}").contains(&password));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_wrong_length_redis_password() {
|
||||
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
|
||||
fn config_loader_should_reject_invalid_store_passwords() {
|
||||
for password in [
|
||||
String::new(),
|
||||
"x".repeat(15),
|
||||
"x".repeat(129),
|
||||
" ".repeat(32),
|
||||
"\u{3000}".repeat(6),
|
||||
"数据库密码".repeat(9),
|
||||
"RdsTest9!abcdefGh\u{0000}".to_owned(),
|
||||
"RdsTest9!abcdefGh\n".to_owned(),
|
||||
"RdsTest9!abcdefGh\t".to_owned(),
|
||||
"RdsTest9!abcdefGh\u{007f}".to_owned(),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_embedded_redis_password() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"redis://127.0.0.1:6379/",
|
||||
"redis://default:embedded@127.0.0.1:6379/",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,
|
||||
@@ -706,10 +722,6 @@ pub struct UsageListRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -787,10 +799,6 @@ pub struct UsageRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
|
||||
@@ -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(),
|
||||
@@ -1267,10 +1390,6 @@ fn total_record(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(100),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
|
||||
@@ -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,
|
||||
@@ -302,10 +304,6 @@ pub(crate) fn usage_list_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
first_token_latency_ms: record.first_token_ms,
|
||||
latency_ms: record.latency_ms,
|
||||
@@ -409,10 +407,6 @@ pub(crate) fn usage_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
attempt_index: None,
|
||||
attempt_count: u64::from(record.attempt_count),
|
||||
|
||||
@@ -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,
|
||||
@@ -228,14 +230,6 @@ pub struct UsageLatencyDetailsView {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -213,14 +213,6 @@ struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
@@ -228,8 +220,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,
|
||||
@@ -248,68 +252,36 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
|
||||
upstream_response_ms: value.upstream_response_ms,
|
||||
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: value.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
},
|
||||
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(
|
||||
|
||||
@@ -58,7 +58,8 @@ fn decode_response_create_inner(
|
||||
else {
|
||||
return Err(ResponseCreateFrameError::ExpectedObject);
|
||||
};
|
||||
match body.remove("type") {
|
||||
// 保持剩余字段顺序,避免末尾元数据交换到模型等路由字段之前
|
||||
match body.shift_remove("type") {
|
||||
Some(Value::String(message_type)) if message_type == "response.create" => {}
|
||||
_ => return Err(ResponseCreateFrameError::UnsupportedType),
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
@@ -274,10 +288,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
record.upstream_response_ms = Some(1_000);
|
||||
record.upstream_api_overhead_ms = Some(120.25);
|
||||
record.upstream_engine_ms = Some(6400.0);
|
||||
record.upstream_engine_iapi_ttft_ms = Some(650.5);
|
||||
record.upstream_engine_service_ttft_ms = Some(720.25);
|
||||
record.upstream_engine_iapi_tbt_ms = Some(18.45);
|
||||
record.upstream_engine_service_tbt_ms = Some(20.12);
|
||||
fixture
|
||||
.usage_detail
|
||||
.lock()
|
||||
@@ -348,22 +358,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
value["data"]["latencyDetails"]["upstreamEngineMs"],
|
||||
json!(6400.0)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTtftMs"],
|
||||
json!(650.5)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTtftMs"],
|
||||
json!(720.25)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
json!(18.45)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTbtMs"],
|
||||
json!(20.12)
|
||||
);
|
||||
assert!(value["data"].get("firstTokenMs").is_none());
|
||||
assert!(value["data"].get("firstTokenLatencyMsDisplay").is_none());
|
||||
assert!(
|
||||
@@ -720,10 +714,6 @@ pub(super) fn usage_record_with_account(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: None,
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
@@ -801,6 +791,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(),
|
||||
@@ -860,10 +853,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
upstream_response_ms: Some(2_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(31),
|
||||
admission_decision_ms: Some(1),
|
||||
account_selection_wait_ms: Some(2),
|
||||
@@ -892,12 +881,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 +906,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 +1077,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(),
|
||||
@@ -139,11 +140,7 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_processing_ms: None,
|
||||
upstream_response_ms: Some(1_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(1250),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
@@ -158,61 +155,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");
|
||||
@@ -308,7 +320,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
&record["latencyDetails"],
|
||||
&[
|
||||
"upstreamResponseMs",
|
||||
"upstreamEngineIapiTbtMs",
|
||||
"upstreamEngineMs",
|
||||
"firstEventMs",
|
||||
"firstReasoningMs",
|
||||
"firstTextMs",
|
||||
@@ -316,29 +328,58 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["firstEventMs"], 100);
|
||||
assert_eq!(record["latencyDetails"]["upstreamResponseMs"], 1000);
|
||||
assert_eq!(
|
||||
record["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["upstreamEngineMs"], 2.450638);
|
||||
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 +433,7 @@ async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
|
||||
"¤tPage=0",
|
||||
"&pageSize=0",
|
||||
"&pageSize=101",
|
||||
"&kind=all",
|
||||
"&kind=unknown",
|
||||
"¤tPage=-1",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -8,6 +8,26 @@ use serde_json::json;
|
||||
|
||||
use super::decode_response_create;
|
||||
|
||||
#[test]
|
||||
fn response_create_should_preserve_field_order_when_removing_frame_type() {
|
||||
let expected = r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#;
|
||||
for payload in [
|
||||
r#"{"type":"response.create","model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"type":"response.create","input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"},"type":"response.create"}"#,
|
||||
] {
|
||||
let decoded = decode_response_create(payload).expect("decode response.create");
|
||||
let body = decoded
|
||||
.operation()
|
||||
.middleware_body()
|
||||
.expect("encode transparent body");
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&body).expect("UTF-8 JSON body"),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_retry_identity_should_follow_each_frame_instead_of_handshake_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -955,10 +955,6 @@ pub struct ModelRequestTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -807,10 +807,6 @@ impl ResponseObservation {
|
||||
self.timings.upstream_response_ms = None;
|
||||
self.timings.upstream_api_overhead_ms = None;
|
||||
self.timings.upstream_engine_ms = None;
|
||||
self.timings.upstream_engine_iapi_ttft_ms = None;
|
||||
self.timings.upstream_engine_service_ttft_ms = None;
|
||||
self.timings.upstream_engine_iapi_tbt_ms = None;
|
||||
self.timings.upstream_engine_service_tbt_ms = None;
|
||||
}
|
||||
|
||||
pub(super) fn observe_response(&mut self, observation: &ProviderResponseObservation) {
|
||||
@@ -848,17 +844,5 @@ impl ResponseObservation {
|
||||
if let Some(value) = observed.upstream_engine_ms {
|
||||
self.timings.upstream_engine_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_ttft_ms {
|
||||
self.timings.upstream_engine_iapi_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_ttft_ms {
|
||||
self.timings.upstream_engine_service_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_tbt_ms {
|
||||
self.timings.upstream_engine_iapi_tbt_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_tbt_ms {
|
||||
self.timings.upstream_engine_service_tbt_ms = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -536,12 +536,8 @@ pub struct ProviderResponseTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
/// 上游排除引擎与客户端工具时间后的 API 耗时
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
/// 上游响应级引擎耗时;以下专项指标保留毫秒小数及各自内部计量层级
|
||||
/// 上游响应级引擎耗时,保留毫秒小数
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// Provider 已筛选的响应观测 JSON
|
||||
|
||||
@@ -505,10 +505,6 @@ struct FinalState {
|
||||
upstream_response_ms: Option<u64>,
|
||||
upstream_api_overhead_ms: Option<f64>,
|
||||
upstream_engine_ms: Option<f64>,
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
provider_metadata_json: Option<String>,
|
||||
cost_source: CostSource,
|
||||
cost_ticks: Option<u128>,
|
||||
@@ -684,12 +680,6 @@ impl ExecutionStore for FakeStore {
|
||||
upstream_response_ms: finalization.timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: finalization.timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: finalization.timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: finalization.timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: finalization
|
||||
.timings
|
||||
.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: finalization.timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: finalization.timings.upstream_engine_service_tbt_ms,
|
||||
provider_metadata_json: finalization.provider_metadata_json,
|
||||
cost_source: finalization.cost.source(),
|
||||
cost_ticks: finalization
|
||||
@@ -2067,10 +2057,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_api_overhead_ms: Some(120.25),
|
||||
upstream_engine_ms: Some(6400.0),
|
||||
upstream_engine_iapi_ttft_ms: Some(650.5),
|
||||
upstream_engine_service_ttft_ms: Some(720.25),
|
||||
upstream_engine_iapi_tbt_ms: Some(18.45),
|
||||
upstream_engine_service_tbt_ms: Some(20.12),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let second_observation = ProviderResponseObservation::new(
|
||||
@@ -2164,10 +2150,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
assert_eq!(finalization.upstream_response_ms, None);
|
||||
assert_eq!(finalization.upstream_api_overhead_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_tbt_ms, None);
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -543,10 +543,6 @@ fn wire_timings(observation: &RequestObservation) -> RequestTimings {
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: timings.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,10 +132,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: None,
|
||||
},
|
||||
client_headers: value
|
||||
|
||||
@@ -817,7 +817,7 @@ async fn completed_observation_contains_terminal_cost_timings_and_failure() {
|
||||
.with_timings(ModelRequestTimings {
|
||||
first_text_ms: Some(12),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(34),
|
||||
..ModelRequestTimings::default()
|
||||
}),
|
||||
@@ -831,10 +831,7 @@ async fn completed_observation_contains_terminal_cost_timings_and_failure() {
|
||||
assert_eq!(observed["usage"]["cost"]["total"]["currency"], "USD");
|
||||
assert_eq!(observed["usage"]["timings"]["first_text_ms"], 12);
|
||||
assert_eq!(observed["usage"]["timings"]["upstream_response_ms"], 7_000);
|
||||
assert_eq!(
|
||||
observed["usage"]["timings"]["upstream_engine_iapi_tbt_ms"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(observed["usage"]["timings"]["upstream_engine_ms"], 2.450638);
|
||||
assert_eq!(observed["usage"]["timings"]["latency_ms"], 34);
|
||||
assert_eq!(observed["usage"]["failure"]["outcome"], "failed");
|
||||
assert_eq!(observed["usage"]["failure"]["attempt_count"], 2);
|
||||
|
||||
@@ -186,7 +186,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
|
||||
.with_timings(ProviderResponseTimings {
|
||||
headers_ms: Some(19),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
@@ -273,7 +273,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
|
||||
);
|
||||
assert_eq!(facts["observation"]["timings"]["headers_ms"], 19);
|
||||
assert_eq!(
|
||||
facts["observation"]["timings"]["upstream_engine_iapi_tbt_ms"],
|
||||
facts["observation"]["timings"]["upstream_engine_ms"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -110,14 +110,6 @@ pub struct RequestTimings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -204,13 +204,13 @@ fn cost_timings_and_failure_keep_unknown_facts_explicit_and_safe() {
|
||||
let timings = RequestTimings {
|
||||
first_text_ms: Some(12),
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(34),
|
||||
..RequestTimings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&timings).unwrap(),
|
||||
json!({"first_text_ms":12,"upstream_response_ms":7000,"upstream_engine_iapi_tbt_ms":2.450638,"latency_ms":34})
|
||||
json!({"first_text_ms":12,"upstream_response_ms":7000,"upstream_engine_ms":2.450638,"latency_ms":34})
|
||||
);
|
||||
|
||||
let failure = RequestFailure {
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
@@ -37,6 +37,7 @@ url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
percent-encoding = "2.3"
|
||||
tempfile = "3.23"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "process", "time", "test-util"] }
|
||||
|
||||
|
||||
@@ -184,12 +184,17 @@ pub(crate) struct StoreConnectionConfig {
|
||||
impl StoreConnectionConfig {
|
||||
fn validate(&self, field: &'static str) -> StoreResult<()> {
|
||||
require_nonempty("store config", field, &self.url)?;
|
||||
if self.password.len() != 48 || !self.password.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
// 托管存储的具体密码策略由服务端约束,客户端共用长度与控制字符边界
|
||||
if !(16..=128).contains(&self.password.len())
|
||||
|| self.password.chars().any(char::is_control)
|
||||
|| self.password.trim().is_empty()
|
||||
{
|
||||
return Err(StoreError::InvalidData {
|
||||
source: None,
|
||||
entity: "store config",
|
||||
message: format!("{field}.password must be exactly 48 hexadecimal characters"),
|
||||
message: format!(
|
||||
"{field}.password must be 16 to 128 bytes, not blank, without control characters"
|
||||
),
|
||||
});
|
||||
}
|
||||
self.connection_url(field).map(|_| ())
|
||||
@@ -208,7 +213,8 @@ impl StoreConnectionConfig {
|
||||
message: format!("{field}.url must not contain a password"),
|
||||
});
|
||||
}
|
||||
url.set_password(Some(&self.password))
|
||||
// set_password 保留已有百分号序列,先转义字面量 %,避免驱动解码后改变原始密码
|
||||
url.set_password(Some(&self.password.replace('%', "%25")))
|
||||
.map_err(|()| StoreError::InvalidData {
|
||||
source: None,
|
||||
entity: "store config",
|
||||
|
||||
@@ -107,14 +107,6 @@ struct UpstreamTimings {
|
||||
api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
@@ -229,16 +221,9 @@ impl RequestObservation {
|
||||
value.retry_after_ms,
|
||||
])?;
|
||||
// serde_json 会把非有限浮点数写成 null,先拒绝以免把非法指标伪装成缺失
|
||||
for value in [
|
||||
timings.upstream_api_overhead_ms,
|
||||
timings.upstream_engine_ms,
|
||||
timings.upstream_engine_iapi_ttft_ms,
|
||||
timings.upstream_engine_service_ttft_ms,
|
||||
timings.upstream_engine_iapi_tbt_ms,
|
||||
timings.upstream_engine_service_tbt_ms,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
for value in [timings.upstream_api_overhead_ms, timings.upstream_engine_ms]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !value.is_finite() || value < 0.0 || value >= i64::MAX as f64 {
|
||||
return Err(invalid(
|
||||
@@ -263,10 +248,6 @@ impl RequestObservation {
|
||||
response_ms: timings.upstream_response_ms,
|
||||
api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
engine_ms: timings.upstream_engine_ms,
|
||||
engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
}),
|
||||
}),
|
||||
transport: Some(TransportObservation {
|
||||
|
||||
@@ -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")?,
|
||||
@@ -345,10 +350,6 @@ pub(crate) fn usage_list_record_from_row(
|
||||
upstream_response_ms: optional_unsigned(row, "upstream_response_ms")?,
|
||||
upstream_api_overhead_ms: get(row, "upstream_api_overhead_ms")?,
|
||||
upstream_engine_ms: get(row, "upstream_engine_ms")?,
|
||||
upstream_engine_iapi_ttft_ms: get(row, "upstream_engine_iapi_ttft_ms")?,
|
||||
upstream_engine_service_ttft_ms: get(row, "upstream_engine_service_ttft_ms")?,
|
||||
upstream_engine_iapi_tbt_ms: get(row, "upstream_engine_iapi_tbt_ms")?,
|
||||
upstream_engine_service_tbt_ms: get(row, "upstream_engine_service_tbt_ms")?,
|
||||
latency_ms: optional_unsigned(row, "latency_ms")?,
|
||||
admission_decision_ms: optional_unsigned(row, "admission_decision_ms")?,
|
||||
account_selection_wait_ms: optional_unsigned(row, "account_selection_wait_ms")?,
|
||||
@@ -441,10 +442,6 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
|
||||
upstream_response_ms: optional_unsigned(row, "upstream_response_ms")?,
|
||||
upstream_api_overhead_ms: get(row, "upstream_api_overhead_ms")?,
|
||||
upstream_engine_ms: get(row, "upstream_engine_ms")?,
|
||||
upstream_engine_iapi_ttft_ms: get(row, "upstream_engine_iapi_ttft_ms")?,
|
||||
upstream_engine_service_ttft_ms: get(row, "upstream_engine_service_ttft_ms")?,
|
||||
upstream_engine_iapi_tbt_ms: get(row, "upstream_engine_iapi_tbt_ms")?,
|
||||
upstream_engine_service_tbt_ms: get(row, "upstream_engine_service_tbt_ms")?,
|
||||
latency_ms: optional_unsigned(row, "latency_ms")?,
|
||||
admission_decision_ms: optional_unsigned(row, "admission_decision_ms")?,
|
||||
account_selection_wait_ms: optional_unsigned(row, "account_selection_wait_ms")?,
|
||||
|
||||
@@ -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,
|
||||
@@ -128,7 +128,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
|
||||
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
|
||||
mr.provider_processing_ms, mr.upstream_response_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.upstream_engine_iapi_ttft_ms, mr.upstream_engine_service_ttft_ms, mr.upstream_engine_iapi_tbt_ms, mr.upstream_engine_service_tbt_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
|
||||
host(mr.client_ip) as client_ip, mr.user_agent,
|
||||
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
|
||||
@@ -157,7 +157,7 @@ pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
|
||||
mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
|
||||
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
|
||||
mr.provider_processing_ms, mr.upstream_response_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.upstream_engine_iapi_ttft_ms, mr.upstream_engine_service_ttft_ms, mr.upstream_engine_iapi_tbt_ms, mr.upstream_engine_service_tbt_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.upstream_api_overhead_ms, mr.upstream_engine_ms, mr.latency_ms, mr.admission_decision_ms,
|
||||
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
|
||||
host(mr.client_ip) as client_ip,
|
||||
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 验证只读检查 Bundle 不执行迁移且拒绝业务写入
|
||||
//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界
|
||||
|
||||
use gateway_core::{
|
||||
account::{
|
||||
@@ -33,11 +33,6 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
|
||||
database
|
||||
.query_pairs_mut()
|
||||
.append_pair("options", &format!("-csearch_path={schema}"));
|
||||
let config_connection = |mut url: url::Url| {
|
||||
let password = url.password().unwrap().to_owned();
|
||||
url.set_password(None).unwrap();
|
||||
json!({"url":url.as_str(),"password":password})
|
||||
};
|
||||
let mut config: StoreConfig = serde_json::from_value(json!({
|
||||
"database":config_connection(database), "redis":config_connection(url::Url::parse(&redis).unwrap()),
|
||||
})).unwrap();
|
||||
@@ -109,3 +104,136 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_bundle_authenticates_configured_and_redis_acl_credentials() {
|
||||
let (Some(database), Some(redis_url)) = (
|
||||
crate::support::test_env("CPR_TEST_DATABASE_URL"),
|
||||
crate::support::test_env("CPR_TEST_REDIS_URL"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let document = json!({
|
||||
"database": config_connection(url::Url::parse(&database).unwrap()),
|
||||
"redis": config_connection(url::Url::parse(&redis_url).unwrap()),
|
||||
});
|
||||
let mut admin = redis::Client::open(redis_url.as_str())
|
||||
.unwrap()
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.unwrap();
|
||||
let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple());
|
||||
let password = "Managed%41%25%FF@:/?#%&+$'\"\\ secret9!";
|
||||
redis::cmd("ACL")
|
||||
.arg("SETUSER")
|
||||
.arg(&username)
|
||||
.arg("on")
|
||||
.arg(format!(">{password}"))
|
||||
.arg("~*")
|
||||
.arg("&*")
|
||||
.arg("+@all")
|
||||
.query_async::<()>(&mut admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut acl_url = url::Url::parse(&redis_url).unwrap();
|
||||
acl_url.set_username(&username).unwrap();
|
||||
acl_url.set_password(None).unwrap();
|
||||
let mut acl_document = document.clone();
|
||||
acl_document["redis"] = json!({"url": acl_url.as_str(), "password": password});
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut results = Vec::new();
|
||||
// 同时走 Store 的配置解析、URL 构造与真实连接路径,断言前先清理临时 ACL 用户
|
||||
for document in [document, acl_document] {
|
||||
let mut config: StoreConfig = serde_json::from_value(document).unwrap();
|
||||
let result = async {
|
||||
config.resolve_and_validate(directory.path())?;
|
||||
let bundle = gateway_store::initialize_read_only(config).await?;
|
||||
let mut states = Vec::new();
|
||||
for probe in bundle.health_probes() {
|
||||
states.push(probe.check().await);
|
||||
}
|
||||
Ok::<_, gateway_store::StoreError>(states)
|
||||
}
|
||||
.await;
|
||||
results.push(result);
|
||||
}
|
||||
redis::cmd("ACL")
|
||||
.arg("DELUSER")
|
||||
.arg(&username)
|
||||
.query_async::<i64>(&mut admin)
|
||||
.await
|
||||
.unwrap();
|
||||
for result in results {
|
||||
let states = result.expect("Store credentials authenticate without double encoding");
|
||||
assert!(!states.is_empty());
|
||||
assert!(
|
||||
states
|
||||
.iter()
|
||||
.all(|state| *state == gateway_core::health::HealthState::Healthy)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_bundle_authenticates_postgres_password_with_percent_sequences() {
|
||||
let (Some(database), Some(redis_url)) = (
|
||||
crate::support::test_env("CPR_TEST_DATABASE_URL"),
|
||||
crate::support::test_env("CPR_TEST_REDIS_URL"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let admin = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database)
|
||||
.await
|
||||
.unwrap();
|
||||
let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple());
|
||||
let password = "Managed%41%25%FFPassword9!";
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
|
||||
"CREATE ROLE {username} LOGIN PASSWORD '{password}'"
|
||||
)))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut database_url = url::Url::parse(&database).unwrap();
|
||||
database_url.set_username(&username).unwrap();
|
||||
database_url.set_password(None).unwrap();
|
||||
let mut config: StoreConfig = serde_json::from_value(json!({
|
||||
"database": {"url": database_url.as_str(), "password": password},
|
||||
"redis": config_connection(url::Url::parse(&redis_url).unwrap()),
|
||||
}))
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = async {
|
||||
config.resolve_and_validate(directory.path())?;
|
||||
let bundle = gateway_store::initialize_read_only(config).await?;
|
||||
let mut states = Vec::new();
|
||||
for probe in bundle.health_probes() {
|
||||
states.push(probe.check().await);
|
||||
}
|
||||
Ok::<_, gateway_store::StoreError>(states)
|
||||
}
|
||||
.await;
|
||||
// 连接失败时也先清理临时角色,避免回归断言留下测试账号
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP ROLE {username}")))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.unwrap();
|
||||
admin.close().await;
|
||||
let states = result.expect("PostgreSQL password percent sequences remain literal");
|
||||
assert!(!states.is_empty());
|
||||
assert!(
|
||||
states
|
||||
.iter()
|
||||
.all(|state| *state == gateway_core::health::HealthState::Healthy)
|
||||
);
|
||||
}
|
||||
|
||||
fn config_connection(mut url: url::Url) -> serde_json::Value {
|
||||
let password = percent_encoding::percent_decode_str(url.password().unwrap())
|
||||
.decode_utf8()
|
||||
.expect("UTF-8 fixture password")
|
||||
.into_owned();
|
||||
url.set_password(None).unwrap();
|
||||
json!({"url": url.as_str(), "password": password})
|
||||
}
|
||||
|
||||
@@ -2168,10 +2168,6 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
upstream_response_ms: Some(1_000),
|
||||
upstream_api_overhead_ms: Some(120.25),
|
||||
upstream_engine_ms: Some(6400.0),
|
||||
upstream_engine_iapi_ttft_ms: Some(650.5),
|
||||
upstream_engine_service_ttft_ms: Some(720.25),
|
||||
upstream_engine_iapi_tbt_ms: Some(18.45),
|
||||
upstream_engine_service_tbt_ms: Some(20.12),
|
||||
latency_ms: Some(9),
|
||||
};
|
||||
ExecutionStore::finalize_model_request(&store, finalization)
|
||||
@@ -2202,22 +2198,6 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
}
|
||||
assert_eq!(persisted["upstream_api_overhead_ms"].as_f64(), Some(120.25));
|
||||
assert_eq!(persisted["upstream_engine_ms"].as_f64(), Some(6400.0));
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_iapi_ttft_ms"].as_f64(),
|
||||
Some(650.5)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_service_ttft_ms"].as_f64(),
|
||||
Some(720.25)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_iapi_tbt_ms"].as_f64(),
|
||||
Some(18.45)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted["upstream_engine_service_tbt_ms"].as_f64(),
|
||||
Some(20.12)
|
||||
);
|
||||
let invalid_id = "req_invalid_phase";
|
||||
seed_running_request(&database.pool, invalid_id)
|
||||
.await
|
||||
@@ -2233,7 +2213,7 @@ async fn shared_core_usage_and_timings_preserve_fields_and_reject_invalid_phases
|
||||
assert_eq!(stored_row(&database.pool, invalid_id).await, before);
|
||||
for invalid_ms in [-1.0, f64::NAN, f64::INFINITY, i64::MAX as f64] {
|
||||
let mut invalid = successful_core_finalization(invalid_id);
|
||||
invalid.timings.upstream_engine_iapi_tbt_ms = Some(invalid_ms);
|
||||
invalid.timings.upstream_engine_ms = Some(invalid_ms);
|
||||
let error = ExecutionStore::finalize_model_request(&store, invalid)
|
||||
.await
|
||||
.expect_err("invalid official timing must not silently become missing");
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac
|
||||
'firstTokenMs', $2::bigint,
|
||||
'latencyMs', $3::bigint),
|
||||
'upstream', coalesce(request_observation_json #> '{timings,upstream}', '{}'::jsonb) || jsonb_build_object(
|
||||
'responseMs', $4::bigint, 'engineIapiTbtMs', 2.450638))))
|
||||
'responseMs', $4::bigint, 'engineMs', 2.450638))))
|
||||
where id = 'req_observe_success'",
|
||||
)
|
||||
.bind(output)
|
||||
@@ -150,8 +150,6 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac
|
||||
detail.request.upstream_response_ms,
|
||||
record.upstream_response_ms
|
||||
);
|
||||
assert_eq!(record.upstream_engine_iapi_tbt_ms, Some(2.450638));
|
||||
assert_eq!(detail.request.upstream_engine_iapi_tbt_ms, Some(2.450638));
|
||||
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let summary = repository
|
||||
@@ -417,10 +415,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 +1261,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 +1362,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 +1595,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 +1969,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 +2023,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 +2095,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 +2755,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 +2885,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 +2951,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)
|
||||
|
||||
@@ -776,12 +776,12 @@ fn authorization_url(
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", oauth_client_id)
|
||||
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("code_challenge", challenge.as_str())
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true")
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("originator", AUTHORIZATION_ORIGINATOR)
|
||||
.append_pair("codex_app_version", desktop_version)
|
||||
.append_pair("source_surface_stable_id", &stable_id)
|
||||
|
||||
@@ -73,6 +73,10 @@ const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
|
||||
const PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const QUOTA_RESET_FAST_REFRESH_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
const QUOTA_RESET_FAST_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const QUOTA_RESET_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const EXHAUSTED_QUOTA_RESET_GRACE: Duration = Duration::from_secs(10);
|
||||
const QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
|
||||
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限
|
||||
/// 随机启动延迟已把候选摊到多轮,该上限仅作单轮安全边界
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::BTreeMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::{QuotaEvidence, QuotaState};
|
||||
use gateway_core::account::{QuotaAccessState, QuotaEvidence, QuotaState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -127,11 +127,8 @@ fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<U
|
||||
}
|
||||
|
||||
/// 统一手动刷新和 worker 的恢复规则
|
||||
/// 已耗尽窗口的解除有两条证据路径:
|
||||
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
|
||||
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)
|
||||
/// 后者防止账号被钉在旧
|
||||
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照
|
||||
/// 到期前仍要求窗口滚动且低用量,或同窗口连续两次未触顶
|
||||
/// 已记录的耗尽窗口全部到期后,允许新的明确放行观察解除,兼容用量百分比滞后
|
||||
pub(super) fn reconcile_refresh(
|
||||
current: QuotaState,
|
||||
refreshed: &mut CodexAccountQuotaSnapshot,
|
||||
@@ -139,6 +136,11 @@ pub(super) fn reconcile_refresh(
|
||||
document: &mut Map<String, Value>,
|
||||
) -> Result<QuotaState, CodexCredentialQuotaError> {
|
||||
document.remove(RECOVERY_FIELD);
|
||||
if refreshed.credits_usable() {
|
||||
// 点数独立于套餐窗口,恢复后不能继续携带已失效的窗口恢复进度
|
||||
refreshed.recovery = None;
|
||||
return Ok(QuotaState::allowed(refreshed.observed_at()));
|
||||
}
|
||||
let state = if current.is_exhausted() {
|
||||
current
|
||||
} else {
|
||||
@@ -155,7 +157,18 @@ pub(super) fn reconcile_refresh(
|
||||
if recovery.pending.is_empty() {
|
||||
recovery = QuotaRecovery::new(state, refreshed);
|
||||
}
|
||||
if current.is_exhausted() && recovery.observe(refreshed) {
|
||||
// 只信任本次耗尽之后的边界,防止再次拒绝后复用已经过期的 reset 反复解封
|
||||
// 多个耗尽窗口必须全部到期,未知 reset 仍走原有恢复规则
|
||||
let allowed_after_reset = refreshed.quota().access() == QuotaAccessState::Allowed
|
||||
&& !recovery.pending.is_empty()
|
||||
&& recovery.pending.values().all(|reset| {
|
||||
reset.is_some_and(|reset| {
|
||||
reset.timestamp_micros() > recovery.exhausted_at_micros
|
||||
&& SystemTime::from(reset) <= refreshed.observed_at()
|
||||
})
|
||||
});
|
||||
if current.is_exhausted() && (allowed_after_reset || recovery.observe(refreshed)) {
|
||||
refreshed.recovery = None;
|
||||
return Ok(QuotaState::allowed(refreshed.observed_at()));
|
||||
}
|
||||
let reset_at = recovery
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 请求级额度投影、首查与周期刷新排程
|
||||
|
||||
use super::*;
|
||||
use tokio::time::Instant as TokioInstant;
|
||||
|
||||
impl CodexCredentialQuotaService {
|
||||
/// 批量预热请求级额度投影;持久层或 Provider JSON 异常只退化为未知额度
|
||||
@@ -67,7 +68,7 @@ struct CodexQuotaProjectionState {
|
||||
}
|
||||
|
||||
struct CodexQuotaRefreshAttempt {
|
||||
monotonic_at: Instant,
|
||||
monotonic_at: TokioInstant,
|
||||
wall_at: SystemTime,
|
||||
}
|
||||
|
||||
@@ -230,7 +231,7 @@ impl CodexQuotaSchedulingProjection {
|
||||
.iter()
|
||||
.map(|(account, _)| account.id().clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let refreshed_at = Instant::now();
|
||||
let refreshed_at = TokioInstant::now();
|
||||
let mut state = self
|
||||
.state
|
||||
.write()
|
||||
@@ -239,12 +240,11 @@ impl CodexQuotaSchedulingProjection {
|
||||
.last_periodic_refresh_at
|
||||
.retain(|account_id, _| candidate_ids.contains(account_id));
|
||||
|
||||
// 已耗尽账号首次立即复核,之后每 30 分钟复核,以发现官方提前重置
|
||||
// reset + 2 分钟额外触发一次复核,给上游重置留出传播时间
|
||||
// 已耗尽账号到达 reset 后短周期复核,其余时间保留 30 分钟兜底
|
||||
// 正常账号仅在非零用量窗口经过宽限期后参与,未更新时复用周期节流
|
||||
let mut reserved = Vec::new();
|
||||
for (account, target_reset) in candidates {
|
||||
if !periodic_quota_refresh_due(&state, account.id(), target_reset, now, refreshed_at) {
|
||||
if !periodic_quota_refresh_due(&state, &account, target_reset, now, refreshed_at) {
|
||||
continue;
|
||||
}
|
||||
state.last_periodic_refresh_at.insert(
|
||||
@@ -364,22 +364,34 @@ fn quota_refresh_candidate(
|
||||
|
||||
fn periodic_quota_refresh_due(
|
||||
state: &CodexQuotaProjectionState,
|
||||
account_id: &ProviderAccountId,
|
||||
account: &ProviderAccount,
|
||||
reset_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
monotonic_now: Instant,
|
||||
monotonic_now: TokioInstant,
|
||||
) -> bool {
|
||||
state
|
||||
.last_periodic_refresh_at
|
||||
.get(account_id)
|
||||
.is_none_or(|last| {
|
||||
monotonic_now.saturating_duration_since(last.monotonic_at)
|
||||
>= PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL
|
||||
|| reset_at
|
||||
.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
|
||||
// 已在该边界之后复核过时回到周期重试,避免过期 reset 每轮触发
|
||||
.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
|
||||
})
|
||||
let mut interval = PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL;
|
||||
let due_at = if account.quota().is_exhausted() {
|
||||
if let Some(since_reset) = reset_at.and_then(|reset| now.duration_since(reset).ok()) {
|
||||
// 上游边界附近可能仍返回旧额度,首次复核也保留传播余量
|
||||
if since_reset < EXHAUSTED_QUOTA_RESET_GRACE {
|
||||
return false;
|
||||
}
|
||||
interval = if since_reset < QUOTA_RESET_FAST_REFRESH_WINDOW {
|
||||
QUOTA_RESET_FAST_REFRESH_INTERVAL
|
||||
} else {
|
||||
QUOTA_RESET_REFRESH_RETRY_INTERVAL
|
||||
};
|
||||
}
|
||||
reset_at.and_then(|reset| reset.checked_add(EXHAUSTED_QUOTA_RESET_GRACE))
|
||||
} else {
|
||||
reset_at.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
|
||||
};
|
||||
let Some(last) = state.last_periodic_refresh_at.get(account.id()) else {
|
||||
return true;
|
||||
};
|
||||
monotonic_now.saturating_duration_since(last.monotonic_at) >= interval
|
||||
// 跨越 reset 宽限期的首轮复核不受旧间隔阻挡,之后按对应间隔节流
|
||||
|| due_at.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
|
||||
}
|
||||
|
||||
fn insert_projection_entry(
|
||||
|
||||
@@ -22,7 +22,7 @@ enum CodexQuotaFetchError {
|
||||
InvalidCredential,
|
||||
Upstream {
|
||||
account: Box<ProviderAccount>,
|
||||
error: CodexClientError,
|
||||
error: Box<CodexClientError>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ impl CodexCredentialQuotaService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 真实限额失败后的异步刷新只补齐展示快照,不允许 usage 快照撤销已确认的失败状态
|
||||
/// 真实限额失败后的异步回读仅允许明确可用的点数恢复访问,套餐状态仍保留失败保护
|
||||
pub(crate) async fn refresh_account_after_failure(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -589,9 +589,10 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if account.quota().is_exhausted()
|
||||
&& matches!(authority, QuotaRefreshAuthority::PreserveAccess)
|
||||
{
|
||||
// 失败后回读只允许明确可用的点数恢复访问,套餐 allowed 仍可能滞后
|
||||
let observe_access =
|
||||
matches!(authority, QuotaRefreshAuthority::ObserveAccess) || snapshot.credits_usable();
|
||||
if account.quota().is_exhausted() && !observe_access {
|
||||
if self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
@@ -615,14 +616,15 @@ impl CodexCredentialQuotaService {
|
||||
.with_observed_at(observed_at),
|
||||
});
|
||||
}
|
||||
let state = match authority {
|
||||
QuotaRefreshAuthority::ObserveAccess => reconcile_refresh(
|
||||
let state = if observe_access {
|
||||
reconcile_refresh(
|
||||
account.quota(),
|
||||
&mut snapshot,
|
||||
previous.as_ref(),
|
||||
&mut object,
|
||||
)?,
|
||||
QuotaRefreshAuthority::PreserveAccess => account.quota(),
|
||||
)?
|
||||
} else {
|
||||
account.quota()
|
||||
};
|
||||
let snapshot = snapshot.with_quota_state(state);
|
||||
if self
|
||||
@@ -692,7 +694,7 @@ impl CodexCredentialQuotaService {
|
||||
Err(CodexQuotaFetchAttemptError::Upstream(error)) => {
|
||||
Err(CodexQuotaFetchError::Upstream {
|
||||
account: Box::new(prepared.account),
|
||||
error,
|
||||
error: Box::new(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct CodexAccountQuotaSnapshot {
|
||||
observed_at: SystemTime,
|
||||
plan_type: Option<String>,
|
||||
credits: Option<CreditsSnapshot>,
|
||||
credits_usable: bool,
|
||||
fact: CodexQuotaFact,
|
||||
quota: QuotaState,
|
||||
windows: Vec<CodexQuotaWindow>,
|
||||
@@ -144,12 +145,16 @@ impl CodexAccountQuotaSnapshot {
|
||||
self.plan_type.as_deref()
|
||||
}
|
||||
|
||||
/// 点数只用于展示,不改变套餐限额或账号可用性
|
||||
/// 上游明确返回的点数事实;点数可用性独立于套餐窗口
|
||||
#[must_use]
|
||||
pub fn credits(&self) -> Option<&CreditsSnapshot> {
|
||||
self.credits.as_ref()
|
||||
}
|
||||
|
||||
pub(super) const fn credits_usable(&self) -> bool {
|
||||
self.credits_usable
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn fact(&self) -> CodexQuotaFact {
|
||||
self.fact
|
||||
@@ -209,7 +214,7 @@ fn parse_codex_quota_object(
|
||||
for limit in canonical_rate_limits(object)? {
|
||||
aggregate.observe_rate_limit(limit.rate_limit)?;
|
||||
}
|
||||
// credits / spend_control 只证明文档结构已识别,不参与账号级访问结论
|
||||
// 点数与支出控制不换算成套餐剩余百分比,访问结论由账号快照统一判断
|
||||
if let Some(spend_control) = object.get("spend_control") {
|
||||
aggregate.observe_metadata_object(spend_control, "reached")?;
|
||||
}
|
||||
@@ -345,6 +350,12 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
.then_with(|| left.key.cmp(&right.key))
|
||||
});
|
||||
let quota = authoritative_quota_state(&object, &windows, observed_at)?;
|
||||
let credits_usable = has_usable_credits(&object);
|
||||
let quota = if credits_usable {
|
||||
QuotaState::allowed(observed_at)
|
||||
} else {
|
||||
quota
|
||||
};
|
||||
let recovery = object
|
||||
.get(RECOVERY_FIELD)
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
@@ -359,6 +370,7 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
credits: object.get("credits").and_then(parse_credits_from_object),
|
||||
credits_usable,
|
||||
fact,
|
||||
quota,
|
||||
windows,
|
||||
@@ -374,7 +386,27 @@ fn quota_source_order(source: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认 `codex` 桶是唯一可改变账号额度访问结论的 quota 响应字段
|
||||
fn has_usable_credits(usage: &Map<String, Value>) -> bool {
|
||||
let Some(credits) = usage.get("credits") else {
|
||||
return false;
|
||||
};
|
||||
let available = credits.get("has_credits").and_then(Value::as_bool) == Some(true)
|
||||
|| credits.get("unlimited").and_then(Value::as_bool) == Some(true);
|
||||
// 套餐耗尽不阻止点数支出,但不能绕过显式支出上限或工作区限制
|
||||
let spend_reached = usage
|
||||
.get("spend_control")
|
||||
.and_then(|spend| spend.get("reached"))
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true);
|
||||
let other_limit_reached = usage
|
||||
.get("rate_limit_reached_type")
|
||||
.and_then(|reason| reason.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|reason| reason != "rate_limit_reached");
|
||||
available && !spend_reached && !other_limit_reached
|
||||
}
|
||||
|
||||
/// 只有默认 `codex` 桶能提供账号级套餐访问结论,具名桶只保留展示事实
|
||||
fn authoritative_quota_state(
|
||||
usage: &Map<String, Value>,
|
||||
windows: &[CodexQuotaWindow],
|
||||
|
||||
@@ -199,18 +199,6 @@ impl OpenAiResponseObservationState {
|
||||
if metrics.upstream_engine_ms.is_some() {
|
||||
self.timings.upstream_engine_ms = metrics.upstream_engine_ms;
|
||||
}
|
||||
if metrics.upstream_engine_iapi_ttft_ms.is_some() {
|
||||
self.timings.upstream_engine_iapi_ttft_ms = metrics.upstream_engine_iapi_ttft_ms;
|
||||
}
|
||||
if metrics.upstream_engine_service_ttft_ms.is_some() {
|
||||
self.timings.upstream_engine_service_ttft_ms = metrics.upstream_engine_service_ttft_ms;
|
||||
}
|
||||
if metrics.upstream_engine_iapi_tbt_ms.is_some() {
|
||||
self.timings.upstream_engine_iapi_tbt_ms = metrics.upstream_engine_iapi_tbt_ms;
|
||||
}
|
||||
if metrics.upstream_engine_service_tbt_ms.is_some() {
|
||||
self.timings.upstream_engine_service_tbt_ms = metrics.upstream_engine_service_tbt_ms;
|
||||
}
|
||||
self.timings != previous
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,12 +165,6 @@ impl CodexBackendClient {
|
||||
context: CodexRequestContext<'_>,
|
||||
) -> CodexClientResult<HeaderMap> {
|
||||
let mut headers = self.response_headers(profile, request, context)?;
|
||||
if !headers.contains_key("x-responsesapi-include-timing-metrics") {
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-responsesapi-include-timing-metrics"),
|
||||
HeaderValue::from_static("true"),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
HeaderName::from_static("openai-beta"),
|
||||
HeaderValue::from_static("responses_websockets=2026-02-06"),
|
||||
|
||||
@@ -255,10 +255,6 @@ pub(crate) fn response_duration_ms(value: &Value) -> Option<u64> {
|
||||
pub(crate) struct ResponseTimingMetrics {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
impl ResponseTimingMetrics {
|
||||
@@ -287,7 +283,6 @@ impl ResponseTimingMetrics {
|
||||
return Self {
|
||||
upstream_api_overhead_ms: overhead,
|
||||
upstream_engine_ms: milliseconds(path, "engine_wall_ms"),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
// 直接返回的专项指标保留原口径,明确标为整轮累计的值不能归入单次请求
|
||||
@@ -303,16 +298,6 @@ impl ResponseTimingMetrics {
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms",
|
||||
),
|
||||
upstream_engine_ms: milliseconds(metrics, "engine_service_total_ms"),
|
||||
upstream_engine_iapi_ttft_ms: milliseconds(metrics, "engine_iapi_ttft_total_ms"),
|
||||
upstream_engine_service_ttft_ms: milliseconds(metrics, "engine_service_ttft_total_ms"),
|
||||
upstream_engine_iapi_tbt_ms: milliseconds(
|
||||
metrics,
|
||||
"engine_iapi_tbt_across_engine_calls_ms",
|
||||
),
|
||||
upstream_engine_service_tbt_ms: milliseconds(
|
||||
metrics,
|
||||
"engine_service_tbt_across_engine_calls_ms",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,18 +308,6 @@ impl ResponseTimingMetrics {
|
||||
if incoming.upstream_engine_ms.is_some() {
|
||||
self.upstream_engine_ms = incoming.upstream_engine_ms;
|
||||
}
|
||||
if incoming.upstream_engine_iapi_ttft_ms.is_some() {
|
||||
self.upstream_engine_iapi_ttft_ms = incoming.upstream_engine_iapi_ttft_ms;
|
||||
}
|
||||
if incoming.upstream_engine_service_ttft_ms.is_some() {
|
||||
self.upstream_engine_service_ttft_ms = incoming.upstream_engine_service_ttft_ms;
|
||||
}
|
||||
if incoming.upstream_engine_iapi_tbt_ms.is_some() {
|
||||
self.upstream_engine_iapi_tbt_ms = incoming.upstream_engine_iapi_tbt_ms;
|
||||
}
|
||||
if incoming.upstream_engine_service_tbt_ms.is_some() {
|
||||
self.upstream_engine_service_tbt_ms = incoming.upstream_engine_service_tbt_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -265,12 +265,12 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
|
||||
"response_type",
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"scope",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"state",
|
||||
"scope",
|
||||
"id_token_add_organizations",
|
||||
"codex_cli_simplified_flow",
|
||||
"state",
|
||||
"originator",
|
||||
"codex_app_version",
|
||||
"source_surface_stable_id",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! 点数独立可用性、耗尽恢复和过期查询隔离
|
||||
|
||||
use super::*;
|
||||
|
||||
fn exhausted_usage() -> serde_json::Value {
|
||||
json!({"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000},
|
||||
"secondary_window": {"used_percent": 100, "reset_at": 1_900_604_800}
|
||||
}})
|
||||
}
|
||||
|
||||
async fn mount_usage(server: &MockServer, usage: serde_json::Value) {
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(usage))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_allow_exhausted_subscription_without_bypassing_spend_restrictions() {
|
||||
for (metadata, allowed) in [
|
||||
(
|
||||
json!({"credits": {"has_credits": true, "unlimited": false}}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": false, "unlimited": true}}),
|
||||
true,
|
||||
),
|
||||
(json!({"credits": {"has_credits": true}}), true),
|
||||
(json!({"credits": {"unlimited": true}}), true),
|
||||
(
|
||||
json!({"credits": {"has_credits": false, "unlimited": false, "balance": "500"}}),
|
||||
false,
|
||||
),
|
||||
(json!({"credits": null}), false),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "spend_control": {"reached": true}}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "spend_limit_reached"}}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
json!({"credits": {"has_credits": true}, "rate_limit_reached_type": {"type": "rate_limit_reached"}}),
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account_with_enabled(&store, "acct_credits", false).await;
|
||||
let account = store.account("acct_credits").expect("account");
|
||||
persist_credential_state(&store, &account, CredentialState::Expired).await;
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut usage = exhausted_usage();
|
||||
usage
|
||||
.as_object_mut()
|
||||
.expect("usage")
|
||||
.extend(metadata.as_object().expect("metadata").clone());
|
||||
mount_usage(&server, usage).await;
|
||||
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh credits");
|
||||
assert_eq!(
|
||||
snapshot.quota().access() == QuotaAccessState::Allowed,
|
||||
allowed,
|
||||
"{metadata}"
|
||||
);
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
let current = store.account("acct_credits").expect("current account");
|
||||
assert_eq!(current.quota().is_exhausted(), !allowed, "{metadata}");
|
||||
assert!(!current.enabled());
|
||||
assert_eq!(current.credential_state(), CredentialState::Expired);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_refresh_clears_old_recovery_and_locks_again_only_when_spent() {
|
||||
for periodic in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_credits_recovery").await;
|
||||
let account = store.account("acct_credits_recovery").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
mount_usage(&server, exhausted_usage()).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhaustion");
|
||||
assert!(
|
||||
store
|
||||
.quota_json("acct_credits_recovery")
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let mut usage = exhausted_usage();
|
||||
usage["credits"] =
|
||||
json!({"has_credits": !periodic, "unlimited": periodic, "balance": "20"});
|
||||
mount_usage(&server, usage.clone()).await;
|
||||
if periodic {
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("periodic recovery")
|
||||
.updated,
|
||||
1
|
||||
);
|
||||
} else {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual recovery");
|
||||
}
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("persisted snapshot")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.quota().access(), QuotaAccessState::Allowed);
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
assert_eq!(
|
||||
snapshot.credits().expect("credits").balance.as_deref(),
|
||||
Some("20")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.quota_json("acct_credits_recovery")
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
usage["credits"] = json!({"has_credits": false, "unlimited": false, "balance": "0"});
|
||||
mount_usage(&server, usage).await;
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("spent credits");
|
||||
assert!(snapshot.quota().is_exhausted());
|
||||
assert!(
|
||||
store
|
||||
.account("acct_credits_recovery")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credits_query_started_before_new_exhaustion_cannot_restore_access() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_stale_credits").await;
|
||||
let account = store.account("acct_stale_credits").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut usage = exhausted_usage();
|
||||
usage["credits"] = json!({"has_credits": true, "unlimited": false});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_json(usage),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (result, ()) = tokio::join!(service.refresh_account(account.id()), async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests")
|
||||
.is_empty()
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("query started");
|
||||
persist_quota_state(
|
||||
&store,
|
||||
&account,
|
||||
QuotaState::exhausted(QuotaEvidence::UsageLimitReached, SystemTime::now(), None),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
result.expect("old display observation can persist independently of newer access state");
|
||||
assert!(
|
||||
store
|
||||
.account("acct_stale_credits")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
assert!(
|
||||
service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("persisted quota")
|
||||
.expect("snapshot")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! OpenAI 额度事实边界与展示快照回归
|
||||
|
||||
mod capacity_freeze;
|
||||
mod credits;
|
||||
mod initial_sync;
|
||||
mod recovery;
|
||||
mod refresh_timing;
|
||||
mod reset_credits;
|
||||
mod reset_recovery;
|
||||
mod scheduling;
|
||||
mod slots;
|
||||
mod snapshot;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 通过 quota 服务验证周期复核与 reset 宽限期,不依赖内部调度状态
|
||||
//! 通过 quota 服务验证耗尽复核节奏、恢复与正常窗口宽限期
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -17,8 +17,17 @@ fn reset_grace(reset: i64) -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs((reset + 120) as u64)
|
||||
}
|
||||
|
||||
async fn advance_clock(now: &mut SystemTime, seconds: u64) {
|
||||
let duration = Duration::from_secs(seconds);
|
||||
// 只在推进时暂停 Tokio 时钟,HTTP 请求期间保持真实计时
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(duration).await;
|
||||
tokio::time::resume();
|
||||
*now += duration;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window() {
|
||||
async fn exhausted_reset_bypasses_periodic_throttle_and_rechecks_each_pending_window() {
|
||||
let mut now = SystemTime::now();
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_quota_timing").await;
|
||||
@@ -30,8 +39,8 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
- 115;
|
||||
let week_reset = short_reset + 3;
|
||||
+ 5;
|
||||
let week_reset = short_reset + 30;
|
||||
let usage = |short_used, short_reset, week_used, week_reset| {
|
||||
json!({"rate_limit": {
|
||||
"allowed": false,
|
||||
@@ -55,20 +64,28 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
let requests = server.received_requests().await.expect("requests").len();
|
||||
|
||||
assert!(
|
||||
now < reset_grace(short_reset),
|
||||
"fixture must precede grace deadline"
|
||||
now < SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64),
|
||||
"fixture must precede reset"
|
||||
);
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("before grace deadline");
|
||||
service.synchronize_at(now).await.expect("before reset");
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
requests
|
||||
);
|
||||
|
||||
mount_usage(&server, usage(0, short_reset + 18_000, 100, week_reset)).await;
|
||||
now = reset_grace(short_reset);
|
||||
for delay in [0, 9] {
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64 + delay);
|
||||
service.synchronize_at(now).await.expect("reset grace");
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("requests")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(short_reset as u64 + 10);
|
||||
let partial = service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
@@ -85,11 +102,11 @@ async fn reset_grace_bypasses_periodic_throttle_once_then_allows_the_next_window
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("next scan before weekly grace deadline");
|
||||
.expect("next scan before weekly reset");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
|
||||
// 周窗口的到期复核仍未恢复时,也不能每轮扫描重复请求
|
||||
now = reset_grace(week_reset);
|
||||
now = SystemTime::UNIX_EPOCH + Duration::from_secs(week_reset as u64 + 10);
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
@@ -127,19 +144,139 @@ async fn periodic_checks_continue_when_reset_is_unknown_or_far_in_the_future() {
|
||||
.expect("seed exhaustion");
|
||||
mount_usage(&server, usage).await;
|
||||
|
||||
let mut now = SystemTime::now();
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize()
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("periodic check")
|
||||
.exhausted,
|
||||
1
|
||||
);
|
||||
service.synchronize().await.expect("throttled repeat check");
|
||||
advance_clock(&mut now, 29 * 60).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("throttled repeat check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
advance_clock(&mut now, 60).await;
|
||||
service.synchronize_at(now).await.expect("periodic retry");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_reset_refreshes_quickly_then_backs_off_and_recovers_from_usage() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_reset_retry").await;
|
||||
let account = store.account("acct_reset_retry").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let reset = Utc::now().timestamp();
|
||||
let mut now = SystemTime::UNIX_EPOCH + Duration::from_secs(reset as u64);
|
||||
let usage = |allowed, used, reset| {
|
||||
json!({"rate_limit": {
|
||||
"allowed": allowed,
|
||||
"primary_window": {"used_percent": used, "reset_at": reset}
|
||||
}})
|
||||
};
|
||||
mount_usage(&server, usage(false, 100, reset)).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhaustion");
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("reset grace without periodic history");
|
||||
advance_clock(&mut now, 9).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("still in reset grace");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
advance_clock(&mut now, 1).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("initial reset check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 2);
|
||||
|
||||
// 本次拒绝发生在 reset 之后,滞后的 allowed 不能复用旧边界解封
|
||||
mount_usage(&server, usage(true, 100, reset)).await;
|
||||
for (elapsed, requests) in [
|
||||
(29, 0),
|
||||
(1, 1),
|
||||
(29, 1),
|
||||
(1, 2),
|
||||
(229, 3),
|
||||
(1, 3),
|
||||
(298, 3),
|
||||
(1, 4),
|
||||
] {
|
||||
advance_clock(&mut now, elapsed).await;
|
||||
service.synchronize_at(now).await.expect("scheduled check");
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
requests
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.account("acct_reset_retry")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
}
|
||||
|
||||
// 查询失败也占用一次尝试,避免网络故障时取消节流
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
advance_clock(&mut now, 300).await;
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("failed check")
|
||||
.transient,
|
||||
1
|
||||
);
|
||||
advance_clock(&mut now, 299).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("throttled failed check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
|
||||
mount_usage(&server, usage(true, 0, reset + 18_000)).await;
|
||||
advance_clock(&mut now, 1).await;
|
||||
assert_eq!(
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("recovery check")
|
||||
.updated,
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.account("acct_reset_retry")
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
advance_clock(&mut now, 300).await;
|
||||
service
|
||||
.synchronize_at(now)
|
||||
.await
|
||||
.expect("recovered account leaves retry schedule");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_account_with_expired_window_synchronizes_at_reset_grace() {
|
||||
let mut now = SystemTime::now();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 到期后的明确放行恢复,保留到期前、多窗口与再次拒绝的保护
|
||||
|
||||
use gateway_core::account::{OpaqueProviderData, QuotaObservation};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn usage(now: i64, resets: &[Option<i64>], allowed: Option<bool>) -> serde_json::Value {
|
||||
let mut value = json!({"rate_limit": {}});
|
||||
if let Some(allowed) = allowed {
|
||||
value["rate_limit"]["allowed"] = json!(allowed);
|
||||
}
|
||||
for ((role, seconds), reset) in [("primary_window", 18_000), ("secondary_window", 604_800)]
|
||||
.into_iter()
|
||||
.zip(resets)
|
||||
{
|
||||
let mut window = json!({"used_percent": 100, "limit_window_seconds": seconds});
|
||||
if let Some(offset) = reset {
|
||||
window["reset_at"] = json!(now + offset);
|
||||
}
|
||||
value["rate_limit"][role] = window;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_refresh_recovers_full_windows_only_after_the_confirmed_resets() {
|
||||
for (name, resets, failure_offset, allowed, recovered) in [
|
||||
("before_reset", vec![Some(3600)], -120, Some(true), false),
|
||||
("after_reset", vec![Some(-60)], -120, Some(true), true),
|
||||
("still_denied", vec![Some(-60)], -120, Some(false), false),
|
||||
("unknown_access", vec![Some(-60)], -120, None, false),
|
||||
("unknown_reset", vec![None], -120, Some(true), false),
|
||||
("new_failure", vec![Some(-60)], -30, Some(true), false),
|
||||
(
|
||||
"weekly_not_due",
|
||||
vec![Some(-60), Some(3600)],
|
||||
-120,
|
||||
Some(true),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"both_due",
|
||||
vec![Some(-90), Some(-60)],
|
||||
-120,
|
||||
Some(true),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"weekly_unknown",
|
||||
vec![Some(-60), None],
|
||||
-120,
|
||||
Some(true),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
for worker in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = format!("acct_reset_{name}_{worker}");
|
||||
create_account(&store, &account_id).await;
|
||||
let account = store.account(&account_id).expect("account");
|
||||
let now = Utc::now().timestamp();
|
||||
let observed_at =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs((now + failure_offset) as u64);
|
||||
let reset_at = resets
|
||||
.iter()
|
||||
.filter_map(|reset| *reset)
|
||||
.min()
|
||||
.map(|offset| SystemTime::UNIX_EPOCH + Duration::from_secs((now + offset) as u64));
|
||||
let old_usage = usage(now, &resets, Some(false));
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
plan_type: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: OpaqueProviderData::new(old_usage.as_object().expect("usage").clone()),
|
||||
observed_at,
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
observed_at,
|
||||
reset_at,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist confirmed exhaustion before refresh");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(usage(now, &resets, allowed)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
if worker {
|
||||
let summary = service.synchronize().await.expect("worker refresh");
|
||||
assert_eq!(summary.updated, u64::from(recovered), "{name}");
|
||||
} else {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual refresh");
|
||||
}
|
||||
let refreshed = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read")
|
||||
.expect("snapshot");
|
||||
assert_eq!(
|
||||
refreshed.quota().is_exhausted(),
|
||||
!recovered,
|
||||
"{name}, worker={worker}"
|
||||
);
|
||||
assert!(
|
||||
refreshed
|
||||
.windows()
|
||||
.iter()
|
||||
.all(|window| window.used_percent() == Some(100.0))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(&account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted(),
|
||||
!recovered
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.quota_json(&account_id)
|
||||
.expect("quota")
|
||||
.get("_quota_recovery")
|
||||
.is_none(),
|
||||
recovered
|
||||
);
|
||||
|
||||
if recovered {
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("allowed remains allowed");
|
||||
assert!(
|
||||
!store
|
||||
.account(&account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
// 新拒绝不能沿用刚刚解除时的旧 reset,即使查询仍然 allowed=true
|
||||
persist_quota_state(&store, &account, exhausted_quota(reset_at)).await;
|
||||
for _ in 0..2 {
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh after new refusal");
|
||||
assert!(snapshot.quota().is_exhausted(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8421,6 +8421,82 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_failure_recovers_with_credits_without_replaying_the_failed_request() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
},
|
||||
"credits": {"has_credits": true, "unlimited": false, "balance": "20"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
|
||||
"error": {"type": "usage_limit_reached", "message": "usage limit reached", "resets_at": 1_900_000_000}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
context("req_usage_limit_credits", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
let failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("quota failure must reach the caller"),
|
||||
}
|
||||
};
|
||||
assert_eq!(failure.kind(), ProviderErrorKind::QuotaExhausted);
|
||||
assert!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
while store
|
||||
.account(account_id)
|
||||
.expect("account")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("credits restore access after background refresh");
|
||||
assert!(stream.next().await.is_none());
|
||||
let account = store.account(account_id).expect("recovered account");
|
||||
assert_eq!(account.quota().access(), QuotaAccessState::Allowed);
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
let quota = store.quota_json(account_id).expect("persisted credits");
|
||||
assert_eq!(quota.pointer("/credits/has_credits"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
quota.pointer("/rate_limits_by_limit_id/codex/primary_window/used_percent"),
|
||||
Some(&json!(100))
|
||||
);
|
||||
assert!(quota.get("_quota_recovery").is_none());
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_retry() {
|
||||
for use_websocket in [false, true] {
|
||||
|
||||
@@ -66,9 +66,10 @@ async fn observe_timing_events(websocket: bool, events: Vec<Value>) -> ProviderR
|
||||
let mut ws = crate::transport::accept_codex_test_websocket_with(
|
||||
socket,
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| {
|
||||
assert_eq!(
|
||||
request.headers()["x-responsesapi-include-timing-metrics"],
|
||||
"true"
|
||||
assert!(
|
||||
!request
|
||||
.headers()
|
||||
.contains_key("x-responsesapi-include-timing-metrics")
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"sec-websocket-extensions",
|
||||
@@ -125,19 +126,11 @@ async fn official_response_duration_is_observed_independently_of_the_request_clo
|
||||
let metrics = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":120.25,
|
||||
"engine_service_total_ms":6400.0,
|
||||
"engine_iapi_ttft_total_ms":650.5,
|
||||
"engine_service_ttft_total_ms":720.25,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":18.45,
|
||||
"engine_service_tbt_across_engine_calls_ms":20.12
|
||||
}});
|
||||
let wrong_id = json!({"type":"responsesapi.websocket_timing","response_id":"resp_other","timing_metrics":{"engine_service_total_ms":999.0}});
|
||||
let invalid = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":null,
|
||||
"engine_service_total_ms":"100",
|
||||
"engine_iapi_ttft_total_ms":-1,
|
||||
"engine_service_ttft_total_ms":1e99,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":{},
|
||||
"engine_service_tbt_across_engine_calls_ms":[]
|
||||
}});
|
||||
let stale = json!({"type":"responsesapi.websocket_timing","timing_metrics":{"engine_service_total_ms":999.0}});
|
||||
let events = [stale, created, metrics, wrong_id, invalid, completed];
|
||||
@@ -145,10 +138,6 @@ async fn official_response_duration_is_observed_independently_of_the_request_clo
|
||||
assert_eq!(timings.upstream_response_ms, Some(7_000));
|
||||
assert_eq!(timings.upstream_api_overhead_ms, Some(120.25));
|
||||
assert_eq!(timings.upstream_engine_ms, Some(6400.0));
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, Some(650.5));
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, Some(720.25));
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, Some(18.45));
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, Some(20.12));
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
assert!(timings.first_event_ms.is_some_and(|value| value >= 20_000));
|
||||
}
|
||||
@@ -197,7 +186,6 @@ async fn response_critical_path_is_observed_without_logical_turn_totals_on_both_
|
||||
let mut metrics = response_critical_path_event();
|
||||
// 同帧即使携带整轮专项值,也不能与响应级 critical path 混算
|
||||
metrics["timing_metrics"]["engine_service_total_ms"] = json!(25_000);
|
||||
metrics["timing_metrics"]["engine_iapi_ttft_total_ms"] = json!(500);
|
||||
let mut events = critical_path_response_events(metrics);
|
||||
let mut wrong_id = response_critical_path_event();
|
||||
wrong_id["timing_metrics"]["response_id"] = json!("resp_other");
|
||||
@@ -208,10 +196,6 @@ async fn response_critical_path_is_observed_without_logical_turn_totals_on_both_
|
||||
assert!((timings.upstream_api_overhead_ms.unwrap() - 81.409594).abs() < 0.000001);
|
||||
assert_eq!(timings.upstream_response_ms, Some(7000));
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,12 +236,10 @@ async fn critical_path_rejects_mismatched_identity_and_incomplete_or_cumulative_
|
||||
"timing_scope":"logical_turn",
|
||||
"engine_service_total_ms":25_000,
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":500,
|
||||
"engine_iapi_ttft_total_ms":200
|
||||
}});
|
||||
let timings = observe_timing_events(false, critical_path_response_events(metrics)).await;
|
||||
assert_eq!(timings.upstream_engine_ms, None);
|
||||
assert_eq!(timings.upstream_api_overhead_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -288,10 +270,6 @@ async fn timing_frames_outside_the_active_response_do_not_enter_request_metrics(
|
||||
let timing = json!({"type":"responsesapi.websocket_timing","timing_metrics":{
|
||||
"responses_duration_excl_engine_and_client_tool_time_ms":0,
|
||||
"engine_service_total_ms":450,
|
||||
"engine_iapi_ttft_total_ms":211,
|
||||
"engine_service_ttft_total_ms":233,
|
||||
"engine_iapi_tbt_across_engine_calls_ms":2.450638,
|
||||
"engine_service_tbt_across_engine_calls_ms":5.267279
|
||||
}});
|
||||
let created = json!({"type":"response.created","response":{"id":"resp_boundary"}});
|
||||
let completed = json!({"type":"response.completed","response":{"id":"resp_boundary","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}});
|
||||
@@ -311,10 +289,6 @@ async fn timing_frames_outside_the_active_response_do_not_enter_request_metrics(
|
||||
let timings = finish_timings(&mut stream, ProviderResponseTimings::default()).await;
|
||||
assert_eq!(timings.upstream_api_overhead_ms, None);
|
||||
assert_eq!(timings.upstream_engine_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(timings.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(timings.upstream_engine_service_tbt_ms, None);
|
||||
assert_eq!(timings.first_token_ms, None);
|
||||
server.await.unwrap();
|
||||
}
|
||||
@@ -395,9 +369,10 @@ async fn structural_frames_start_ttft_before_content_on_http_and_websocket() {
|
||||
socket,
|
||||
|request: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
response| {
|
||||
assert_eq!(
|
||||
request.headers()["x-responsesapi-include-timing-metrics"],
|
||||
"true"
|
||||
assert!(
|
||||
!request
|
||||
.headers()
|
||||
.contains_key("x-responsesapi-include-timing-metrics")
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"sec-websocket-extensions",
|
||||
|
||||
@@ -430,7 +430,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
|
||||
async fn compare_and_swap_quota(
|
||||
&self,
|
||||
observation: QuotaObservation,
|
||||
mut observation: QuotaObservation,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
@@ -446,7 +446,18 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
{
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let quota = observation.state;
|
||||
// 与 PostgreSQL 一致,展示快照与访问事实分别按各自观察时间隔离
|
||||
let current = stored.account.quota();
|
||||
let quota = if observation
|
||||
.state
|
||||
.observed_at()
|
||||
.is_some_and(|observed| current.observed_at().is_none_or(|time| time <= observed))
|
||||
{
|
||||
observation.state
|
||||
} else {
|
||||
current
|
||||
};
|
||||
observation.state = quota;
|
||||
let mut replacement = AccountRebuild::preserving(&stored.account).with_quota(quota);
|
||||
if let Some(plan_type) = &observation.plan_type {
|
||||
replacement.profile = Some((
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user