mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-09 00:05:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
@@ -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#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
|
||||
+26
-5
@@ -19,15 +19,30 @@
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
|
||||
|
||||
### GitHub 回复口吻
|
||||
|
||||
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
|
||||
|
||||
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
|
||||
涉及上游协议、客户端行为或兼容性时,遵守以下依据与行为约束;无上游依赖的内部变更以本项目代码、合同和运行证据为依据
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
|
||||
- **资料来源**:以对应版本的官方源码与调用链为首要资料,Codex 对照 [openai/codex](https://github.com/openai/codex)。先查用户提供或本机已有的仓库,核对来源、版本或提交;不足时再查官方对应版本源码,结合测试、文档、发布说明和运行证据。三方资料只作线索或实现参考,不能替代官方依据
|
||||
- **证据表达**:引用具体文件、符号或行号,对外优先使用固定提交的源码链接;注明适用版本,区分官方事实、三方参考与本项目推断。源码不可得或版本不匹配时报告缺口,不把最新实现当作部署行为,也不把自定义策略写成官方要求
|
||||
- **默认方案**:Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案。沿用官方协议语义、职责归属和解决路径,优先复用已有能力或正确传递合同,不额外解释请求意图、扩展工具权限或改变续接语义
|
||||
- **问题核实**:对照官方与 CPR 的实际请求、响应及状态生命周期,检查本项目是否误解、改写、遗漏或破坏合同;分别说明源码推断、代理链路复现与官方直连结果。真实上游报错不自动等于 CPR 缺陷
|
||||
- **审查官方对接**:审查 OpenAI 官方上游与 Codex 的对接时,默认二者兼容、直接对接正常。不得仅凭自行构造的异常上游数据揣测官方不兼容,据此报告缺陷、提出改进建议或要求防御分支与测试;特定版本或场景的例外须有真实响应或实际复现证据
|
||||
- **偏离依据**:新增覆盖配置、补偿逻辑或三方兼容方案前,说明官方路径为何不足、本项目实际约束、最小改动、行为代价及验证。补偿后成功、测试通过或三方采用同一做法不能单独证明必要性;依据不足时保持现有合同并报告缺口,不先加分支再倒推理由。用户明确要求的行为变更限定在授权范围内
|
||||
- **网关职责**:调度、隔离和资源管理按网关职责评估,不机械照搬单客户端实现,不因官方客户端没有同类池策略就判错,也不任意改变客户端可观察行为。新增默认限制或回收阈值须有容量依据,并验证续接、重试与延迟影响
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
|
||||
反馈 Issue 时区分症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。症状反馈不以找到官方源码或证明根因为前提;引用上游行为或提出方案时注明上述依据与缺口
|
||||
|
||||
## PR 流程
|
||||
|
||||
@@ -147,7 +162,7 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
|
||||
AI 进行前端开发或审查时,执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),覆盖页面、组件、弹窗、表单及局部文案和样式。用户指定设计技能时使用该技能;未指定时,只有任务明确需要建立或重塑视觉方向才使用 `$frontend-design`。新增页面也优先沿用 [管理端主题](docs/theme.md) 和已有设计,设计技能不构成重做页面的理由
|
||||
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
|
||||
|
||||
@@ -163,11 +178,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
|
||||
|
||||
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
|
||||
|
||||
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
|
||||
@@ -45,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
```
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use super::{
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
|
||||
UsageOverview, UsagePage,
|
||||
HealthTimeline, ObservabilityPageSize, RequestMetricPoint, TimeRange, UsageOverview,
|
||||
UsagePage,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct KeyUsageQuery {
|
||||
pub enum KeyUsageRecordKind {
|
||||
Success,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -35,7 +36,4 @@ pub struct KeyUsageOverview {
|
||||
pub health_timeline: HealthTimeline,
|
||||
}
|
||||
|
||||
pub enum KeyUsageRecords {
|
||||
Success(UsagePage),
|
||||
Error(OpsErrorPage),
|
||||
}
|
||||
pub type KeyUsageRecords = UsagePage;
|
||||
|
||||
@@ -195,6 +195,7 @@ pub struct UsageQuery {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct DashboardQuery {
|
||||
pub range: TimeRange,
|
||||
pub yesterday_range: TimeRange,
|
||||
pub granularity: Granularity,
|
||||
pub account_limit: u16,
|
||||
pub recent_request_limit: u16,
|
||||
@@ -202,10 +203,21 @@ pub struct DashboardQuery {
|
||||
}
|
||||
|
||||
impl DashboardQuery {
|
||||
#[must_use]
|
||||
pub fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
pub fn new(
|
||||
range: TimeRange,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, AdminModelError> {
|
||||
let yesterday_range = TimeRange::new(
|
||||
timezone
|
||||
.days_before(range.end, 1)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?,
|
||||
timezone
|
||||
.day_start(range.end)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?,
|
||||
)?;
|
||||
Ok(Self {
|
||||
range,
|
||||
yesterday_range,
|
||||
granularity: Granularity::for_range(range),
|
||||
account_limit: 4,
|
||||
recent_request_limit: 10,
|
||||
@@ -213,7 +225,7 @@ impl DashboardQuery {
|
||||
outcome: Some(RequestOutcome::Succeeded),
|
||||
..UsageFilter::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +665,7 @@ pub struct DashboardObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub totals: DashboardTotals,
|
||||
pub yesterday: RequestMetrics,
|
||||
pub provider_accounts: AccountPoolMetrics,
|
||||
pub runtime_slots: Option<DashboardRuntimeSlots>,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
@@ -664,6 +677,9 @@ pub struct DashboardObservation {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageListRecord {
|
||||
pub outcome: RequestOutcome,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
|
||||
@@ -14,9 +14,7 @@ use crate::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
|
||||
},
|
||||
observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
@@ -221,35 +219,21 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let result = match query.kind {
|
||||
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
|
||||
self.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
status: Default::default(),
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage records"))?,
|
||||
),
|
||||
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
|
||||
self.observations
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range: query.usage.range,
|
||||
filter: OpsErrorFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model: query.usage.model,
|
||||
..OpsErrorFilter::default()
|
||||
},
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage errors"))?,
|
||||
),
|
||||
};
|
||||
let result = self
|
||||
.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
status: match query.kind {
|
||||
KeyUsageRecordKind::Success => UsageRecordStatus::Success,
|
||||
KeyUsageRecordKind::Error => UsageRecordStatus::Error,
|
||||
KeyUsageRecordKind::All => UsageRecordStatus::All,
|
||||
},
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage records"))?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,11 @@ impl DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let observed_at = Utc::now();
|
||||
let query = DashboardQuery::new(range, self.timezone)
|
||||
.map_err(|_| AdminError::internal("时间超出支持范围"))?;
|
||||
let today_start = query.yesterday_range.end;
|
||||
let (mut observation, settings) = futures::try_join!(
|
||||
self.store
|
||||
.dashboard_summary(DashboardQuery::new(range), observed_at),
|
||||
self.store.dashboard_summary(query, observed_at),
|
||||
self.settings.load_runtime_settings(),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
@@ -126,17 +128,16 @@ impl DefaultObservabilityService {
|
||||
.providers
|
||||
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
|
||||
}
|
||||
let today_start = self
|
||||
.timezone
|
||||
.day_start(observation.range.end)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let yesterday_start = self
|
||||
.timezone
|
||||
.days_before(observation.range.end, 1)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let today =
|
||||
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
|
||||
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
|
||||
let today = dashboard_period_metrics(
|
||||
observation
|
||||
.trend
|
||||
.iter()
|
||||
.filter(|point| {
|
||||
point.bucket_start >= today_start && point.bucket_start < observation.range.end
|
||||
})
|
||||
.map(|point| &point.metrics),
|
||||
);
|
||||
let yesterday = dashboard_period_metrics(std::iter::once(&observation.yesterday));
|
||||
let total_billing_usd = observation.totals.billing_usd.clone();
|
||||
let total_cached_token_rate = rate_or_zero(
|
||||
observation.totals.cached_input_tokens,
|
||||
@@ -1051,17 +1052,11 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
}
|
||||
}
|
||||
|
||||
fn dashboard_period_metrics(
|
||||
points: &[RequestMetricPoint],
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
fn dashboard_period_metrics<'a>(
|
||||
metrics: impl IntoIterator<Item = &'a RequestMetrics>,
|
||||
) -> DashboardPeriodMetrics {
|
||||
let mut result = DashboardPeriodMetrics::default();
|
||||
for metrics in points
|
||||
.iter()
|
||||
.filter(|point| point.bucket_start >= start && point.bucket_start < end)
|
||||
.map(|point| &point.metrics)
|
||||
{
|
||||
for metrics in metrics {
|
||||
result.request_count = result.request_count.saturating_add(metrics.request_count);
|
||||
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
|
||||
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
|
||||
|
||||
@@ -147,6 +147,123 @@ async fn dashboard_summary_should_derive_first_token_average_from_trend() {
|
||||
assert_eq!(summary.average_first_token_latency_ms, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_comparison_should_follow_deployment_calendar_across_dst() {
|
||||
let timezone = "America/New_York".parse().unwrap();
|
||||
for (end, previous_start, previous_end, hours) in [
|
||||
(
|
||||
"2026-03-09T16:00:00Z",
|
||||
"2026-03-08T05:00:00Z",
|
||||
"2026-03-09T04:00:00Z",
|
||||
23,
|
||||
),
|
||||
(
|
||||
"2026-11-02T17:00:00Z",
|
||||
"2026-11-01T04:00:00Z",
|
||||
"2026-11-02T05:00:00Z",
|
||||
25,
|
||||
),
|
||||
(
|
||||
"2026-11-02T05:00:00Z",
|
||||
"2026-11-01T04:00:00Z",
|
||||
"2026-11-02T05:00:00Z",
|
||||
25,
|
||||
),
|
||||
] {
|
||||
let end = end.parse().unwrap();
|
||||
let range = TimeRange::calendar_at(
|
||||
gateway_admin::model::observability::CalendarPeriod::Today,
|
||||
end,
|
||||
timezone,
|
||||
)
|
||||
.unwrap();
|
||||
let query = DashboardQuery::new(range, timezone).unwrap();
|
||||
assert_eq!(query.range, range);
|
||||
assert_eq!(
|
||||
query.yesterday_range.start,
|
||||
previous_start.parse::<DateTime<Utc>>().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
query.yesterday_range.end,
|
||||
previous_end.parse::<DateTime<Utc>>().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
(query.yesterday_range.end - query.yesterday_range.start).num_hours(),
|
||||
hours
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_compare_separate_yesterday_metrics_with_today_only_trend() {
|
||||
let end: DateTime<Utc> = "2026-10-18T04:00:00Z".parse().unwrap();
|
||||
let range = TimeRange::calendar_at(
|
||||
gateway_admin::model::observability::CalendarPeriod::Today,
|
||||
end,
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_trend(vec![health_metric_point(
|
||||
range.start,
|
||||
RequestMetrics {
|
||||
request_count: 2,
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
total_tokens: 120,
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
*store.yesterday.lock().unwrap() = RequestMetrics {
|
||||
request_count: 4,
|
||||
input_tokens: 400,
|
||||
cached_input_tokens: 200,
|
||||
total_tokens: 500,
|
||||
..Default::default()
|
||||
};
|
||||
let result = observability_services(store.clone())
|
||||
.await
|
||||
.observability()
|
||||
.dashboard_summary(range, TrendKind::Usage)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.today.request_count, 2);
|
||||
assert_eq!(result.today.total_tokens, 120);
|
||||
assert_eq!(result.today.observed_cached_token_rate, Some(0.2));
|
||||
assert_eq!(result.yesterday.request_count, 4);
|
||||
assert_eq!(result.yesterday.total_tokens, 500);
|
||||
assert_eq!(result.yesterday.observed_cached_token_rate, Some(0.5));
|
||||
assert_eq!(result.observation.range, range);
|
||||
assert_eq!(result.observation.trend.len(), 1);
|
||||
assert_eq!(result.observation.trend[0].bucket_start, range.start);
|
||||
assert_eq!(
|
||||
store
|
||||
.summary_query
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.yesterday_range
|
||||
.end,
|
||||
range.start
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_keep_missing_yesterday_cache_rate_unknown() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let result = observability_services(store)
|
||||
.await
|
||||
.observability()
|
||||
.dashboard_summary(range, TrendKind::Usage)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.yesterday.request_count, 0);
|
||||
assert_eq!(result.yesterday.total_tokens, 0);
|
||||
assert_eq!(result.yesterday.observed_cached_token_rate, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_timeline_should_apply_status_precedence_and_thresholds() {
|
||||
let now = Utc::now();
|
||||
@@ -866,6 +983,7 @@ async fn dashboard_should_preserve_provider_plan_names() {
|
||||
}
|
||||
|
||||
struct FixtureObservabilityStore {
|
||||
yesterday: Mutex<RequestMetrics>,
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
projected_costs: Mutex<Vec<UsageProjectedCostPoint>>,
|
||||
@@ -886,6 +1004,7 @@ struct FixtureObservabilityStore {
|
||||
impl FixtureObservabilityStore {
|
||||
fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
yesterday: Mutex::new(RequestMetrics::default()),
|
||||
trend: Mutex::new(Vec::new()),
|
||||
overview: Mutex::new(UsageOverview {
|
||||
analytics: Default::default(),
|
||||
@@ -973,6 +1092,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
tokio::time::sleep(dashboard_delay).await;
|
||||
}
|
||||
Ok(DashboardObservation {
|
||||
yesterday: self.yesterday.lock().unwrap().clone(),
|
||||
analytics: self.overview.lock().expect("overview").analytics.clone(),
|
||||
range: query.range,
|
||||
totals: Default::default(),
|
||||
@@ -1219,6 +1339,9 @@ fn total_record(
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: id.to_owned(),
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
rust-version = "1.99.1"
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
|
||||
@@ -262,6 +262,8 @@ pub(crate) fn usage_list_record_view(
|
||||
.clone()
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
UsageListRecordView {
|
||||
error_code: record.error_code,
|
||||
error_message: record.error_message,
|
||||
logical_outcome: record.outcome.as_str().to_owned(),
|
||||
client_api_key_name: record.client_api_key_name,
|
||||
id: record.id,
|
||||
|
||||
@@ -86,6 +86,8 @@ pub struct ImageBillingView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageListRecordView {
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub logical_outcome: String,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
|
||||
@@ -5,7 +5,7 @@ use gateway_admin::model::{
|
||||
client_keys::ClientKeySecret,
|
||||
key_usage::{KeyUsageOverview, KeyUsageRecords},
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord,
|
||||
},
|
||||
system::SystemVersion,
|
||||
};
|
||||
@@ -228,8 +228,20 @@ struct OutputTimingView {
|
||||
first_text_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
|
||||
let token_details = Some(usage_list_token_details(&value));
|
||||
fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
|
||||
let token_details = [
|
||||
value.input_tokens,
|
||||
value.output_tokens,
|
||||
value.cached_input_tokens,
|
||||
value.cache_write_input_tokens,
|
||||
value.reasoning_output_tokens,
|
||||
value.image_input_tokens,
|
||||
value.image_output_tokens,
|
||||
value.total_tokens,
|
||||
]
|
||||
.iter()
|
||||
.any(Option::is_some)
|
||||
.then(|| usage_list_token_details(&value));
|
||||
let billing = billing_view(value.billing.as_ref());
|
||||
RecordView {
|
||||
id: value.id,
|
||||
@@ -258,58 +270,30 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
|
||||
},
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "success",
|
||||
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实
|
||||
status_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
|
||||
RecordView {
|
||||
id: value.event_id,
|
||||
created_at: value.occurred_at,
|
||||
created_at_display: time.datetime(&value.occurred_at),
|
||||
model: value.requested_model_id,
|
||||
route: value.endpoint,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: value.client_transport,
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details: None,
|
||||
billing: None,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: None,
|
||||
latency_details: OutputTimingView::default(),
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "error",
|
||||
status: match value.outcome {
|
||||
RequestOutcome::Succeeded => "success",
|
||||
RequestOutcome::Failed => "error",
|
||||
RequestOutcome::Incomplete => "incomplete",
|
||||
RequestOutcome::Cancelled => "cancelled",
|
||||
RequestOutcome::Running => "running",
|
||||
RequestOutcome::Other(_) => "unknown",
|
||||
},
|
||||
status_code: value.client_status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn records(
|
||||
value: KeyUsageRecords,
|
||||
page: KeyUsageRecords,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> PageData<RecordView> {
|
||||
match value {
|
||||
KeyUsageRecords::Success(page) => PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| success_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
KeyUsageRecords::Error(page) => PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| error_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| usage_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ enum RecordKind {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -109,6 +110,7 @@ impl RecordsQuery {
|
||||
kind: match self.kind {
|
||||
RecordKind::Success => KeyUsageRecordKind::Success,
|
||||
RecordKind::Error => KeyUsageRecordKind::Error,
|
||||
RecordKind::All => KeyUsageRecordKind::All,
|
||||
},
|
||||
current_page,
|
||||
page_size,
|
||||
|
||||
@@ -96,7 +96,7 @@ fn percent_decode(value: &str) -> String {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&output).into_owned()
|
||||
String::from_utf8_lossy_owned(output)
|
||||
}
|
||||
|
||||
async fn open_sideband(
|
||||
|
||||
@@ -25,6 +25,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
yesterday: Default::default(),
|
||||
analytics: gateway_admin::model::observability::AnalyticsMetadata {
|
||||
coverage_start: Some(now - Duration::hours(1)),
|
||||
coverage_end: Some(now),
|
||||
@@ -106,14 +107,14 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
async fn dashboard_summary_should_keep_yesterday_comparison_separate_from_today_and_lifetime() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, DashboardObservation, DashboardTotals, TimeRange,
|
||||
AccountPoolMetrics, DashboardObservation, DashboardTotals, RequestMetrics, TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
@@ -127,6 +128,13 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
yesterday: RequestMetrics {
|
||||
request_count: 7,
|
||||
input_tokens: 400,
|
||||
cached_input_tokens: 200,
|
||||
total_tokens: 500,
|
||||
..Default::default()
|
||||
},
|
||||
analytics: Default::default(),
|
||||
runtime_slots: None,
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
@@ -161,6 +169,11 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
.expect("dashboard response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("dashboard response JSON");
|
||||
assert_eq!(status, StatusCode::OK, "{value}");
|
||||
let cards = &value["data"]["cards"];
|
||||
assert_eq!(cards["traffic"]["todayRequestsValue"], 0);
|
||||
assert_eq!(cards["traffic"]["yesterdayRequestsValue"], 7);
|
||||
assert_eq!(cards["tokens"]["yesterdayTokensValue"], 500);
|
||||
assert_eq!(cards["cache"]["yesterdayHitRateValue"], 0.5);
|
||||
assert_eq!(
|
||||
value["data"]["analytics"]["coverageStartDisplay"],
|
||||
serde_json::Value::Null
|
||||
@@ -208,6 +221,7 @@ async fn dashboard_summary_should_default_to_current_deployment_day() {
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
yesterday: Default::default(),
|
||||
analytics: Default::default(),
|
||||
runtime_slots: None,
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
@@ -801,6 +815,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
.lock()
|
||||
.expect("usage records")
|
||||
.push(UsageListRecord {
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: "request_endpoint".to_owned(),
|
||||
@@ -892,12 +909,18 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
});
|
||||
records.push(image);
|
||||
}
|
||||
let mut failed = records[0].clone();
|
||||
failed.id = "request_failed".to_owned();
|
||||
failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed;
|
||||
failed.error_code = Some("server_error".to_owned());
|
||||
failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned());
|
||||
records.push(failed);
|
||||
}
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records")
|
||||
.uri("/api/admin/usage/records?status=all")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_usage_endpoint")
|
||||
.body(Body::empty())
|
||||
@@ -911,6 +934,13 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
.expect("usage response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
|
||||
|
||||
assert!(value["data"]["items"][0]["errorCode"].is_null());
|
||||
assert!(value["data"]["items"][0]["errorMessage"].is_null());
|
||||
assert_eq!(value["data"]["items"][3]["errorCode"], "server_error");
|
||||
assert_eq!(
|
||||
value["data"]["items"][3]["errorMessage"],
|
||||
"The upstream service is temporarily unavailable."
|
||||
);
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
|
||||
assert_eq!(
|
||||
@@ -1075,6 +1105,7 @@ async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor
|
||||
let fixture = AdminTestFixture::with_timezone(timezone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
|
||||
yesterday: Default::default(),
|
||||
analytics: gateway_admin::model::observability::AnalyticsMetadata {
|
||||
coverage_start: Some(range.start),
|
||||
coverage_end: Some(end),
|
||||
|
||||
@@ -5,9 +5,8 @@ use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::{
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
|
||||
UsageOverview,
|
||||
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, RequestMetricPoint,
|
||||
RequestMetrics, TimeRange, UsageBilling, UsageListRecord, UsageOverview,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
@@ -87,12 +86,14 @@ pub(super) async fn fixture() -> AdminTestFixture {
|
||||
}]);
|
||||
}
|
||||
fixture.usage_records.lock().unwrap().push(usage_record());
|
||||
fixture.ops_errors.lock().unwrap().push(error_record());
|
||||
fixture
|
||||
}
|
||||
|
||||
fn usage_record() -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
client_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: "req-visible".to_owned(),
|
||||
@@ -158,61 +159,3 @@ fn usage_record() -> UsageListRecord {
|
||||
started_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record() -> OpsError {
|
||||
OpsError {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
source: "private-sentinel".to_owned(),
|
||||
event_id: "error-visible".to_owned(),
|
||||
request_id: Some("private-sentinel".to_owned()),
|
||||
attempt_index: None,
|
||||
client_api_key_ref: Some("private-sentinel".to_owned()),
|
||||
component: "private-sentinel".to_owned(),
|
||||
operation: "private-sentinel".to_owned(),
|
||||
protocol: Some("private-sentinel".to_owned()),
|
||||
client_transport: Some("http_sse".to_owned()),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
service_tier: Some("private-sentinel".to_owned()),
|
||||
endpoint: Some("/v1/responses".to_owned()),
|
||||
provider_kind: Some("private-sentinel".to_owned()),
|
||||
provider_account_ref: Some("private-sentinel".to_owned()),
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
failure_kind: "private-sentinel".to_owned(),
|
||||
upstream_send_state: Some("private-sentinel".to_owned()),
|
||||
client_status_code: Some(502),
|
||||
upstream_status_code: Some(503),
|
||||
provider_error_code: Some("private-sentinel".to_owned()),
|
||||
client_response_id: Some("private-sentinel".to_owned()),
|
||||
upstream_request_id: Some("private-sentinel".to_owned()),
|
||||
latency_ms: Some(420),
|
||||
message: "private-sentinel".to_owned(),
|
||||
error_details: Some("private-sentinel".to_owned()),
|
||||
client_ip: Some("192.0.2.42".to_owned()),
|
||||
user_agent: Some("key-usage-test/1.0".to_owned()),
|
||||
reasoning_effort: Some("xhigh".to_owned()),
|
||||
reasoning_preset: Some("private-sentinel".to_owned()),
|
||||
request_kind: Some("private-sentinel".to_owned()),
|
||||
subagent_kind: Some("private-sentinel".to_owned()),
|
||||
compact: None,
|
||||
continuation_affinity_hash: Some("private-sentinel".to_owned()),
|
||||
continuation_previous_response_id_hash: Some("private-sentinel".to_owned()),
|
||||
continuation_unavailable_reason: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_id: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_exit_reason: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_age_ms: None,
|
||||
upstream_connection_idle_ms: None,
|
||||
recovery_request_id: Some("private-sentinel".to_owned()),
|
||||
recovered_at: None,
|
||||
recovery_attempt_count: 0,
|
||||
recovery_retry_delay_ms: None,
|
||||
recovery_total_latency_ms: None,
|
||||
occurred_at: Utc::now(),
|
||||
stable_sort_id: "private-sentinel".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::RequestMetrics;
|
||||
use gateway_admin::model::observability::{RequestMetrics, RequestOutcome, UsageRecordStatus};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -255,7 +255,16 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
for kind in ["success", "error"] {
|
||||
for (kind, outcome) in [
|
||||
("success", RequestOutcome::Succeeded),
|
||||
("error", RequestOutcome::Failed),
|
||||
("all", RequestOutcome::Incomplete),
|
||||
] {
|
||||
{
|
||||
let mut records = fixture.usage_records.lock().unwrap();
|
||||
records[0].outcome = outcome;
|
||||
records[0].client_status_code = (kind != "success").then_some(502);
|
||||
}
|
||||
let response = get(
|
||||
&app,
|
||||
"records",
|
||||
@@ -291,7 +300,10 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
"statusCode",
|
||||
],
|
||||
);
|
||||
assert_eq!(record["status"], kind);
|
||||
assert_eq!(
|
||||
record["status"],
|
||||
if kind == "all" { "incomplete" } else { kind }
|
||||
);
|
||||
assert_eq!(record["model"], "coding");
|
||||
assert_eq!(record["reasoningEffort"], "xhigh");
|
||||
assert_eq!(record["clientTransport"], "http_sse");
|
||||
@@ -323,22 +335,54 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
assert!(record["statusCode"].is_null());
|
||||
} else {
|
||||
assert_eq!(record["statusCode"], 502);
|
||||
assert!(record["billing"].is_null());
|
||||
assert!(record["tokenDetails"].is_null());
|
||||
assert_eq!(record["latencyDetails"], json!({}));
|
||||
assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235");
|
||||
assert_eq!(record["tokenDetails"]["totalTokens"], 1100);
|
||||
}
|
||||
}
|
||||
let data = fixture.observations.lock().unwrap();
|
||||
assert_eq!(
|
||||
data.records[0].filter.client_api_key_ref.as_deref(),
|
||||
Some("key-42")
|
||||
);
|
||||
assert_eq!(
|
||||
data.errors[0].filter.client_api_key_ref.as_deref(),
|
||||
Some("key-42")
|
||||
);
|
||||
assert_eq!(data.records[0].filter.model.as_deref(), Some("coding"));
|
||||
assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding"));
|
||||
assert!(data.errors.is_empty());
|
||||
assert_eq!(data.records.len(), 3);
|
||||
for (query, status) in data.records.iter().zip([
|
||||
UsageRecordStatus::Success,
|
||||
UsageRecordStatus::Error,
|
||||
UsageRecordStatus::All,
|
||||
]) {
|
||||
assert_eq!(query.status, status);
|
||||
assert_eq!(query.filter.client_api_key_ref.as_deref(), Some("key-42"));
|
||||
assert_eq!(query.filter.model.as_deref(), Some("coding"));
|
||||
assert!(query.filter.provider_account_ref.is_none());
|
||||
assert!(query.filter.provider_kind.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_records_without_usage_keep_token_and_billing_details_unknown() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
{
|
||||
let mut records = fixture.usage_records.lock().unwrap();
|
||||
let record = &mut records[0];
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
record.client_status_code = Some(502);
|
||||
record.input_tokens = None;
|
||||
record.output_tokens = None;
|
||||
record.cached_input_tokens = None;
|
||||
record.cache_write_input_tokens = None;
|
||||
record.reasoning_output_tokens = None;
|
||||
record.image_input_tokens = None;
|
||||
record.image_output_tokens = None;
|
||||
record.total_tokens = None;
|
||||
record.billing = None;
|
||||
}
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
let response = get(&app, "records", "&kind=error", &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let data = response_json(response).await;
|
||||
let record = &data["data"]["items"][0];
|
||||
assert_eq!(record["status"], "error");
|
||||
assert_eq!(record["statusCode"], 502);
|
||||
assert!(record["tokenDetails"].is_null());
|
||||
assert!(record["billing"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -392,7 +436,7 @@ async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
|
||||
"¤tPage=0",
|
||||
"&pageSize=0",
|
||||
"&pageSize=101",
|
||||
"&kind=all",
|
||||
"&kind=unknown",
|
||||
"¤tPage=-1",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
rust-version = "1.99.1"
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
|
||||
@@ -118,7 +118,7 @@ impl NestedExecutionGraph {
|
||||
pub(super) fn acquire(self: &Arc<Self>) -> Result<NestedExecutionPermit, GatewayError> {
|
||||
if self
|
||||
.active
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||
(active < MAX_CONCURRENT_NESTED_EXECUTIONS).then_some(active + 1)
|
||||
})
|
||||
.is_err()
|
||||
@@ -130,7 +130,7 @@ impl NestedExecutionGraph {
|
||||
}
|
||||
if self
|
||||
.total_started
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |total| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |total| {
|
||||
(total < MAX_NESTED_EXECUTIONS).then_some(total + 1)
|
||||
})
|
||||
.is_err()
|
||||
|
||||
@@ -100,12 +100,12 @@ pub struct AccountProbeError {
|
||||
gateway: GatewayError,
|
||||
source: AccountProbeErrorSource,
|
||||
send_state: Option<UpstreamSendState>,
|
||||
upstream_response: Option<AccountProbeUpstreamResponse>,
|
||||
upstream_response: Option<Box<AccountProbeUpstreamResponse>>,
|
||||
}
|
||||
|
||||
impl AccountProbeError {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
pub fn new(
|
||||
gateway: GatewayError,
|
||||
source: AccountProbeErrorSource,
|
||||
send_state: Option<UpstreamSendState>,
|
||||
@@ -115,7 +115,7 @@ impl AccountProbeError {
|
||||
gateway,
|
||||
source,
|
||||
send_state,
|
||||
upstream_response,
|
||||
upstream_response: upstream_response.map(Box::new),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +150,8 @@ impl AccountProbeError {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn upstream_response(&self) -> Option<&AccountProbeUpstreamResponse> {
|
||||
self.upstream_response.as_ref()
|
||||
pub fn upstream_response(&self) -> Option<&AccountProbeUpstreamResponse> {
|
||||
self.upstream_response.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ impl Budget {
|
||||
|
||||
fn reserve(self: &Arc<Self>, bytes: usize) -> Option<Reservation> {
|
||||
self.items
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |items| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |items| {
|
||||
items
|
||||
.checked_add(1)
|
||||
.filter(|items| *items <= self.maximum_items)
|
||||
@@ -45,7 +45,7 @@ impl Budget {
|
||||
.ok()?;
|
||||
if self
|
||||
.bytes
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
|
||||
used.checked_add(bytes)
|
||||
.filter(|used| *used <= self.maximum_bytes)
|
||||
})
|
||||
|
||||
@@ -428,7 +428,7 @@ async fn service_chain_preserves_onion_order_parent_scope_and_native_results_wit
|
||||
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 8);
|
||||
for round in events.chunks_exact(4) {
|
||||
for round in events.as_chunks::<4>().0 {
|
||||
let phases = round
|
||||
.iter()
|
||||
.map(|event| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -81,13 +81,13 @@ impl ExecutionBufferState {
|
||||
0
|
||||
});
|
||||
self.queued_items
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
current.checked_add(1).filter(|next| *next <= item_limit)
|
||||
})
|
||||
.map_err(|_| ReservationFailure::ItemCapacity)?;
|
||||
let bytes_reserved = self
|
||||
.queued_bytes
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
current
|
||||
.checked_add(bytes)
|
||||
.filter(|next| *next <= byte_limit)
|
||||
@@ -158,7 +158,7 @@ impl ReservationFailure {
|
||||
}
|
||||
|
||||
fn saturating_increment(counter: &AtomicU64, increment: u64) {
|
||||
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
let _ = counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
Some(current.saturating_add(increment))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ impl PgObservabilityRepository {
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> StoreResult<DashboardObservation> {
|
||||
let range = store_range(query.range)?;
|
||||
let yesterday_range = store_range(query.yesterday_range)?;
|
||||
let account_usage_query = ProviderAccountUsageQuery::recent(range, query.account_limit)?
|
||||
.with_hourly_request_buckets()?;
|
||||
let recent_query = UsageRecordQuery {
|
||||
@@ -180,7 +181,7 @@ impl PgObservabilityRepository {
|
||||
page_size: ObservabilityPageSize::new(query.recent_request_limit)
|
||||
.map_err(|_| invalid("invalid dashboard page size"))?,
|
||||
};
|
||||
let (totals, trend, analytics, account_usage) = self
|
||||
let (totals, yesterday, trend, analytics, account_usage) = self
|
||||
.query_budget
|
||||
.run(
|
||||
&self.pool,
|
||||
@@ -188,6 +189,7 @@ impl PgObservabilityRepository {
|
||||
|mut connection| async move {
|
||||
let mut snapshot = analytics::AnalyticsSnapshot::new(&mut connection).await?;
|
||||
let totals = snapshot.totals().await?;
|
||||
let yesterday = snapshot.dashboard_card_metrics(yesterday_range).await?;
|
||||
let trend = snapshot
|
||||
.trend(
|
||||
range,
|
||||
@@ -201,7 +203,7 @@ impl PgObservabilityRepository {
|
||||
provider_account_usage(&mut snapshot, account_usage_query).await?;
|
||||
let metadata = snapshot.metadata();
|
||||
snapshot.finish().await?;
|
||||
Ok((totals, trend, metadata, account_usage))
|
||||
Ok((totals, yesterday, trend, metadata, account_usage))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -222,6 +224,7 @@ impl PgObservabilityRepository {
|
||||
analytics,
|
||||
range,
|
||||
totals,
|
||||
yesterday,
|
||||
provider_accounts,
|
||||
runtime_slots,
|
||||
trend,
|
||||
|
||||
@@ -144,6 +144,36 @@ impl<'a> AnalyticsSnapshot<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn dashboard_card_metrics(
|
||||
&mut self,
|
||||
range: ObservabilityRange,
|
||||
) -> StoreResult<RequestMetrics> {
|
||||
// 日对比只读取卡片计数,复用整桶汇总及脏桶回读,不加载费用和延迟分布
|
||||
let states = self
|
||||
.load_selected(
|
||||
range,
|
||||
&UsageRecordFilter::default(),
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
ReadSelection {
|
||||
distributions: false,
|
||||
costs: false,
|
||||
keys: None,
|
||||
counters: Some(&[
|
||||
"request_count",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cached_input_tokens",
|
||||
"total_tokens",
|
||||
]),
|
||||
distribution_names: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
merge_states(states.into_values())?.request_metrics()
|
||||
}
|
||||
|
||||
pub async fn overview(
|
||||
&mut self,
|
||||
range: ObservabilityRange,
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(crate) fn admin_dashboard_observation(
|
||||
analytics,
|
||||
range,
|
||||
totals,
|
||||
yesterday,
|
||||
provider_accounts,
|
||||
runtime_slots,
|
||||
trend,
|
||||
@@ -77,6 +78,7 @@ pub(crate) fn admin_dashboard_observation(
|
||||
analytics,
|
||||
range: admin_range(range),
|
||||
totals,
|
||||
yesterday,
|
||||
provider_accounts,
|
||||
runtime_slots: Some(runtime_slots),
|
||||
trend: trend.into_iter().map(admin_request_metric_point).collect(),
|
||||
@@ -303,6 +305,9 @@ pub(crate) fn usage_list_record_from_row(
|
||||
cost_currency.as_deref(),
|
||||
)?;
|
||||
Ok(UsageListRecord {
|
||||
error_code: get(row, "error_code")?,
|
||||
error_message: get(row, "error_message")?,
|
||||
client_status_code: optional_status(row, "client_status_code")?,
|
||||
outcome: admin_observability::RequestOutcome::new(get::<String>(row, "outcome")?)
|
||||
.map_err(|_| invalid("invalid request outcome"))?,
|
||||
client_api_key_name: get(row, "client_api_key_name")?,
|
||||
|
||||
@@ -315,6 +315,7 @@ pub struct DashboardObservation {
|
||||
pub analytics: admin_observability::AnalyticsMetadata,
|
||||
pub range: ObservabilityRange,
|
||||
pub totals: DashboardTotals,
|
||||
pub yesterday: RequestMetrics,
|
||||
pub provider_accounts: ProviderAccountMetrics,
|
||||
pub runtime_slots: admin_observability::DashboardRuntimeSlots,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
"select mr.id, mr.outcome, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
|
||||
"select mr.id, mr.outcome, mr.provider_error_code as error_code, mr.error_message, mr.client_status_code, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
|
||||
mr.provider_kind, mr.provider_account_ref,
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
mr.provider_account_email_snapshot as provider_account_email,
|
||||
|
||||
@@ -417,10 +417,14 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() {
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let dashboard = store
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -1259,10 +1263,14 @@ async fn dashboard_account_metrics_should_partition_account_statuses() {
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let metrics = repository
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -1356,10 +1364,14 @@ async fn dashboard_account_metrics_with_cooldowns_should_only_reclassify_eligibl
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let metrics = repository
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -1585,10 +1597,14 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let dashboard = store
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -1955,10 +1971,14 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let dashboard = repository
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -2005,6 +2025,61 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_load_yesterday_without_widening_today() {
|
||||
let Some(database) = TestDatabase::create("dashboard_yesterday").await else {
|
||||
return;
|
||||
};
|
||||
let timezone = gateway_core::time::DeploymentTimeZone::default();
|
||||
let end = timezone.day_start(Utc::now()).unwrap() + TimeDelta::hours(12);
|
||||
seed_observability_facts(&database.pool, end).await.unwrap();
|
||||
let range = admin_observability::TimeRange::calendar_at(
|
||||
admin_observability::CalendarPeriod::Today,
|
||||
end,
|
||||
timezone,
|
||||
)
|
||||
.unwrap();
|
||||
let query = admin_observability::DashboardQuery::new(range, timezone).unwrap();
|
||||
sqlx::query("update model_requests set started_at=$1,completed_at=$1 + interval '1 minute' where id='req_observe_success'")
|
||||
.bind(query.yesterday_range.start + TimeDelta::hours(1)).execute(&database.pool).await.unwrap();
|
||||
sqlx::query("update model_requests set started_at=$1,completed_at=$1 + interval '1 minute' where id='req_observe_uncommitted'")
|
||||
.bind(query.yesterday_range.start - TimeDelta::hours(1)).execute(&database.pool).await.unwrap();
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let repository = observability_repository(&database.pool);
|
||||
let result = repository
|
||||
.dashboard_summary(query.clone(), end)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.yesterday.request_count, 1);
|
||||
assert_eq!(result.yesterday.input_tokens, 100);
|
||||
assert_eq!(result.yesterday.output_tokens, 20);
|
||||
assert_eq!(result.yesterday.cached_input_tokens, 40);
|
||||
assert_eq!(result.yesterday.total_tokens, 120);
|
||||
assert_eq!(
|
||||
result
|
||||
.trend
|
||||
.iter()
|
||||
.map(|point| point.metrics.request_count)
|
||||
.sum::<u64>(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.trend
|
||||
.iter()
|
||||
.all(|point| point.bucket_start >= range.start)
|
||||
);
|
||||
assert_eq!(result.recent_requests.len(), 0);
|
||||
|
||||
// 昨日迟到修正经过同一聚合管道更新,不能固化为跨日缓存
|
||||
sqlx::query("update model_requests set input_tokens=200,total_tokens=220 where id='req_observe_success'")
|
||||
.execute(&database.pool).await.unwrap();
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let updated = repository.dashboard_summary(query, end).await.unwrap();
|
||||
assert_eq!(updated.yesterday.input_tokens, 200);
|
||||
assert_eq!(updated.yesterday.total_tokens, 220);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observability_queries_preserve_request_account_cost_and_diagnostic_facts() {
|
||||
let Some(database) = TestDatabase::create("observability").await else {
|
||||
@@ -2022,10 +2097,14 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let dashboard = repository
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -2678,10 +2757,14 @@ async fn account_hourly_buckets_use_utc_hours_independently_of_calendar_metrics(
|
||||
let dashboard = admin_observability_store(&database.pool)
|
||||
.with_timezone(timezone)
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
end,
|
||||
)
|
||||
.await
|
||||
@@ -2804,7 +2887,9 @@ async fn dashboard_shares_account_facts_and_bounds_slow_cooldown_reads() {
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange::new(now - TimeDelta::hours(1), now).unwrap(),
|
||||
),
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -2868,7 +2953,7 @@ async fn observability_store_executes_caller_selection_without_replacing_policy(
|
||||
let range = TimeRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)).unwrap();
|
||||
super::drain_analytics(&database.pool).await;
|
||||
let store = admin_observability_store(&database.pool);
|
||||
let mut query = DashboardQuery::new(range);
|
||||
let mut query = DashboardQuery::new(range, Default::default()).expect("dashboard query");
|
||||
query.account_limit = 1;
|
||||
query.recent_request_limit = 1;
|
||||
query.recent_request_filter = UsageFilter::default();
|
||||
|
||||
@@ -159,10 +159,14 @@ async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audi
|
||||
assert_eq!(detail.request.service_tier.as_deref(), Some("auto"));
|
||||
let dashboard = repository
|
||||
.dashboard_summary(
|
||||
admin_observability::DashboardQuery::new(admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}),
|
||||
admin_observability::DashboardQuery::new(
|
||||
admin_observability::TimeRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query"),
|
||||
end,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -67,10 +67,14 @@ async fn short_retention_keeps_statistics_readable_until_each_deletion_batch_is_
|
||||
let range = ObservabilityRange::new(now - ChronoDuration::days(41), now).unwrap();
|
||||
let statistics = observability_repository(&database.pool);
|
||||
let dashboard = admin_observability_store(&database.pool);
|
||||
let dashboard_query = DashboardQuery::new(TimeRange {
|
||||
start: now - ChronoDuration::days(1),
|
||||
end: now,
|
||||
});
|
||||
let dashboard_query = DashboardQuery::new(
|
||||
TimeRange {
|
||||
start: now - ChronoDuration::days(1),
|
||||
end: now,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("dashboard query");
|
||||
let original = statistics
|
||||
.usage_summary(range, Default::default())
|
||||
.await
|
||||
|
||||
@@ -311,7 +311,7 @@ async fn request_status_pages_share_one_request_timeline() {
|
||||
.bind(id).bind(outcome).execute(&database.pool).await.unwrap();
|
||||
}
|
||||
sqlx::query(
|
||||
"update model_requests set requested_model_id='model_status_b' where id='status_b'",
|
||||
r#"update model_requests set requested_model_id='model_status_b',request_observation_json=jsonb_set(request_observation_json,'{error}','{"providerErrorCode":"server_error","message":"The upstream service is temporarily unavailable."}'::jsonb),client_status_code=502 where id='status_b'"#,
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
@@ -377,6 +377,12 @@ async fn request_status_pages_share_one_request_timeline() {
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].id, "status_b");
|
||||
assert_eq!(page.items[0].client_status_code, Some(502));
|
||||
assert_eq!(page.items[0].error_code.as_deref(), Some("server_error"));
|
||||
assert_eq!(
|
||||
page.items[0].error_message.as_deref(),
|
||||
Some("The upstream service is temporarily unavailable.")
|
||||
);
|
||||
// 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制
|
||||
let errors = super::admin_observability_store(&database.pool)
|
||||
.diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100)
|
||||
|
||||
@@ -22,7 +22,7 @@ enum CodexQuotaFetchError {
|
||||
InvalidCredential,
|
||||
Upstream {
|
||||
account: Box<ProviderAccount>,
|
||||
error: CodexClientError,
|
||||
error: Box<CodexClientError>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ impl CodexCredentialQuotaService {
|
||||
Err(CodexQuotaFetchAttemptError::Upstream(error)) => {
|
||||
Err(CodexQuotaFetchError::Upstream {
|
||||
account: Box::new(prepared.account),
|
||||
error,
|
||||
error: Box::new(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,7 +905,7 @@ impl CappedResponseBody {
|
||||
}
|
||||
|
||||
pub(super) fn into_string(self) -> String {
|
||||
String::from_utf8_lossy(&self.bytes).into_owned()
|
||||
String::from_utf8_lossy_owned(self.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ impl CodexBackendClient {
|
||||
.flatten();
|
||||
let body = read_model_catalog_body(response).await?;
|
||||
if !status.is_success() {
|
||||
let body = String::from_utf8_lossy(&body).into_owned();
|
||||
let body = String::from_utf8_lossy_owned(body);
|
||||
return Err(CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds: retry_after_seconds
|
||||
|
||||
@@ -79,7 +79,7 @@ impl CodexWebSocketPool {
|
||||
// 不限额时也跟踪 socket,后续调低或启用预算不会丢失已有连接的占用
|
||||
if capacity
|
||||
.connections
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
|
||||
if limit != 0 && used >= limit {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -429,7 +429,7 @@ impl LimitedProxy {
|
||||
peak.fetch_max(count, Ordering::SeqCst);
|
||||
let request = read_http_request(&mut stream).await;
|
||||
assert!(request.starts_with("CONNECT upstream.invalid:80 "));
|
||||
if reject_connects.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)).is_ok() {
|
||||
if reject_connects.try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)).is_ok() {
|
||||
stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n").await.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,11 +349,11 @@ impl ReqwestGrokInferenceTransport {
|
||||
async fn client_for(
|
||||
&self,
|
||||
binding: &GrokSessionBinding,
|
||||
) -> Result<(Client, GrokInferenceClientCacheStatus), GrokInferenceTransportError> {
|
||||
) -> Result<(Client, GrokInferenceClientCacheStatus), Box<GrokInferenceTransportError>> {
|
||||
let entry = self
|
||||
.clients
|
||||
.entry(binding.clone())
|
||||
.ok_or_else(inference_client_pool_unavailable_for_cache_miss)?;
|
||||
.ok_or_else(|| Box::new(inference_client_pool_unavailable_for_cache_miss()))?;
|
||||
let status = if entry.initialized() {
|
||||
GrokInferenceClientCacheStatus::Hit
|
||||
} else {
|
||||
@@ -374,7 +374,7 @@ impl ReqwestGrokInferenceTransport {
|
||||
.build_inference_client(None, binding.outbound_proxy())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| inference_client_pool_unavailable_for_cache_miss())?;
|
||||
.map_err(|_| Box::new(inference_client_pool_unavailable_for_cache_miss()))?;
|
||||
Ok((client.clone(), status))
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,10 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
|
||||
.map(|header| (header.name(), header.value().expose().as_bytes())),
|
||||
);
|
||||
trace.capture("upstream.request.body", request.body());
|
||||
let (client, client_cache_status) = self.client_for(request.binding()).await?;
|
||||
let (client, client_cache_status) = self
|
||||
.client_for(request.binding())
|
||||
.await
|
||||
.map_err(|error| *error)?;
|
||||
let mut builder = client
|
||||
.post(request.endpoint().clone())
|
||||
.body(request.body().to_vec());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.97"
|
||||
channel = "1.99.1"
|
||||
components = ["clippy", "rustfmt"]
|
||||
profile = "minimal"
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
|
||||
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
|
||||
|
||||
FROM node:26-bookworm-slim@sha256:86f07bc9c5dce4578cf37e5a418b7bfc7f817cda25cde66e2b66e95ed86c4567 AS frontend-builder
|
||||
FROM node:26.12.0-bookworm-slim@sha256:298992629d69bbd5b2b03ad57094d8e4fcb3a8eeb7c4e25d29de656519568913 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||
@@ -13,7 +13,7 @@ RUN npm install --global "$(node -p 'require("./package.json").packageManager')"
|
||||
COPY frontend ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.98-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS backend-builder
|
||||
FROM rust:1.99.1-bookworm@sha256:e12566d5abc17a0da741628782cdb504b68b8961f5bfcf677936bf3e8b32de29 AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
ARG TARGETARCH
|
||||
|
||||
+7
-4
@@ -656,13 +656,14 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期;
|
||||
| 方法 | 路由 | 查询 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/key-usage/overview` | `period?`、`asOf?` 或 `startTime?`、`endTime?`,另含 `model?` | 用量汇总、趋势、当前额度和锚点所在日期的健康时间线 |
|
||||
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
|
||||
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的请求记录 |
|
||||
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
|
||||
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
|
||||
|
||||
用量查询支持[页面时间合同](#页面时间合同),默认 `period=7d`,一次最多 31 天。模型按完整名称匹配;
|
||||
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
|
||||
`kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }`
|
||||
`kind` 为 `success`(默认)、`error` 或 `all`,沿用使用统计的请求结果筛选:错误包括失败和未完成请求,
|
||||
全部还包括取消与进行中的请求;一次请求只占一条记录。分页响应为 `{ items, currentPage, pageSize, total }`
|
||||
|
||||
overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。
|
||||
`key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限,
|
||||
@@ -677,8 +678,8 @@ overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summ
|
||||
日志只返回时间、公开请求模型、推理强度、接口、上下游传输方式、当前请求的 IP / User-Agent、
|
||||
Token 明细、费用明细、用时/首字与状态。Token 和费用复用现有展示合同;延迟仅包含当前请求的首事件、
|
||||
首推理、首文本、总耗时与上游性能指标,口径见[记录范围与统计口径](#记录范围与统计口径),不含账号容量或调度诊断。
|
||||
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
|
||||
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
|
||||
`status` 为 `success`、`error`、`incomplete`、`cancelled`、`running` 或 `unknown`,`statusCode` 只返回已记录的客户端状态码。
|
||||
各结果均保留已采集的 Token/费用明细,缺失时为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
|
||||
|
||||
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断
|
||||
|
||||
@@ -1974,6 +1975,8 @@ request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细
|
||||
重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表,
|
||||
不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询
|
||||
|
||||
请求列表的 `errorCode` / `errorMessage` 提供最终请求的错误码与错误信息,未记录时为 `null`
|
||||
|
||||
诊断 `dimension` 可取 `model`、`account`、
|
||||
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
|
||||
同请求量按维度标识稳定排列
|
||||
|
||||
@@ -1109,9 +1109,9 @@ PostgreSQL 备份恢复属于人工维护操作。当前没有部署级维护模
|
||||
后端验证从 `backend/Cargo.toml` 执行,仓库根目录没有 Cargo manifest:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
|
||||
cargo +1.99.1 fmt --all --manifest-path backend/Cargo.toml -- --check
|
||||
RUST_MIN_STACK=16777216 cargo +1.99.1 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
|
||||
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml --test main --locked
|
||||
```
|
||||
|
||||
线程栈设置与当前 CI 一致。PostgreSQL/Redis 集成测试需按
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@
|
||||
|
||||
## 环境与依赖
|
||||
|
||||
需要 Node.js 24、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
|
||||
需要 Node.js 26.12.0、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
|
||||
|
||||
各前端的 `packageManager` 固定 pnpm 版本,宿主 CI 和 Docker 从 `frontend/package.json` 读取
|
||||
|
||||
@@ -41,7 +41,7 @@ pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml up -d postgres redis
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-rs --locked
|
||||
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-rs --locked
|
||||
```
|
||||
|
||||
后端从当前目录向上查找 `deploy/config.yaml`,本机运行时数据库与 Redis 地址应指向可访问的本机端口
|
||||
@@ -130,7 +130,7 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$cpr_sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$cpr_sdk_check_dir/"
|
||||
RUST_MIN_STACK=16777216 CARGO_TARGET_DIR="$PWD/backend/target/plugin-development" \
|
||||
cargo +1.97.0 test --manifest-path "$cpr_sdk_check_dir/backend/Cargo.toml" \
|
||||
cargo +1.99.1 test --manifest-path "$cpr_sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$PWD/backend/crates/gateway-plugin/sdk\""
|
||||
)
|
||||
```
|
||||
|
||||
@@ -485,6 +485,30 @@ interface AccountExportParam {
|
||||
confirm: string
|
||||
}
|
||||
|
||||
interface AccountExportResponse {
|
||||
exportedAt: string
|
||||
fileName: string
|
||||
documents: unknown[]
|
||||
}
|
||||
|
||||
interface AccountImportTaskListResponse {
|
||||
items: AccountImportTask[]
|
||||
}
|
||||
|
||||
interface AccountDetailResponse {
|
||||
account: Account
|
||||
credentialConfiguration?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface AccountResetCreditAutomationParam extends AccountIdParam {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AccountConcurrencyParams extends AccountIdParam {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function getAccounts(data: AccountListParams, options: RequestOptions = {}) {
|
||||
return request<AccountListResponse>({
|
||||
url: '/api/admin/accounts',
|
||||
@@ -495,7 +519,7 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
|
||||
}
|
||||
|
||||
export function exportAccounts(data: AccountExportParam) {
|
||||
return request<{ exportedAt: string, fileName: string, documents: unknown[] }>({
|
||||
return request<AccountExportResponse>({
|
||||
url: '/api/admin/accounts/export',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -623,7 +647,7 @@ export function createAccountImportTask(data: CreateAccountImportTaskParam) {
|
||||
}
|
||||
|
||||
export function getAccountImportTasks(options: RequestOptions = {}) {
|
||||
return request<{ items: AccountImportTask[] }>({
|
||||
return request<AccountImportTaskListResponse>({
|
||||
url: '/api/admin/accounts/import-tasks',
|
||||
method: 'GET',
|
||||
...options,
|
||||
@@ -701,7 +725,7 @@ export interface ApiKeyConfiguration {
|
||||
}
|
||||
|
||||
export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) {
|
||||
return request<{ account: Account, credentialConfiguration?: Record<string, unknown> }>({
|
||||
return request<AccountDetailResponse>({
|
||||
url: '/api/admin/accounts/detail',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
@@ -726,7 +750,7 @@ export function getAccountResetCreditAutomation(data: AccountIdParam, options: R
|
||||
})
|
||||
}
|
||||
|
||||
export function setAccountResetCreditAutomation(data: AccountIdParam & { enabled: boolean }, options: RequestOptions = {}) {
|
||||
export function setAccountResetCreditAutomation(data: AccountResetCreditAutomationParam, options: RequestOptions = {}) {
|
||||
return request<AccountResetCreditAutomation>({
|
||||
url: '/api/admin/accounts/reset-credits/automation',
|
||||
method: 'POST',
|
||||
@@ -756,7 +780,7 @@ export interface AccountConcurrencyPage {
|
||||
observedAt: string
|
||||
}
|
||||
|
||||
export function getAccountConcurrency(data: { accountId: string, page: number, pageSize: number }, options: RequestOptions = {}) {
|
||||
export function getAccountConcurrency(data: AccountConcurrencyParams, options: RequestOptions = {}) {
|
||||
return request<AccountConcurrencyPage>({
|
||||
url: '/api/admin/accounts/concurrency',
|
||||
method: 'GET',
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface KeyUsageOverview {
|
||||
healthTimeline: DashboardHealthTimeline
|
||||
}
|
||||
|
||||
export type KeyUsageRecordKind = 'success' | 'error'
|
||||
export type KeyUsageRecordKind = 'success' | 'error' | 'all'
|
||||
|
||||
export interface KeyUsageRecord {
|
||||
id: string
|
||||
@@ -77,7 +77,7 @@ export interface KeyUsageRecord {
|
||||
latencyDetails: Pick<UsageLatencyDetails, 'firstEventMs' | 'firstReasoningMs' | 'firstTextMs'>
|
||||
clientIp: string | null
|
||||
userAgent: string | null
|
||||
status: KeyUsageRecordKind
|
||||
status: 'success' | 'error' | 'incomplete' | 'cancelled' | 'running' | 'unknown'
|
||||
statusCode: number | null
|
||||
}
|
||||
|
||||
@@ -94,11 +94,17 @@ export interface KeyUsageQuery {
|
||||
model?: string
|
||||
}
|
||||
|
||||
export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptions = {}) {
|
||||
interface KeyUsageRecordsQuery extends KeyUsageQuery {
|
||||
kind: KeyUsageRecordKind
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function getKeyUsageOverview(data: KeyUsageQuery, options: RequestOptions = {}) {
|
||||
return request<KeyUsageOverview>({
|
||||
url: '/api/key-usage/overview',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -119,14 +125,11 @@ export function getKeyUsageConfig(options: RequestOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getKeyUsageRecords(
|
||||
params: KeyUsageQuery & { kind: KeyUsageRecordKind, currentPage: number, pageSize: number },
|
||||
options: RequestOptions = {},
|
||||
) {
|
||||
export function getKeyUsageRecords(data: KeyUsageRecordsQuery, options: RequestOptions = {}) {
|
||||
return request<KeyUsagePage>({
|
||||
url: '/api/key-usage/records',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ interface ApiKeyIdParam {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ApiKeyResetBudgetParam extends ApiKeyIdParam {
|
||||
period: ApiKeyBudgetPeriod
|
||||
}
|
||||
|
||||
export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {}) {
|
||||
return request<ApiKeyListResponse>({
|
||||
url: '/api/admin/client-keys',
|
||||
@@ -133,7 +137,7 @@ export function deleteApiKey(data: ApiKeyIdParam) {
|
||||
})
|
||||
}
|
||||
|
||||
export function resetApiKeyBudget(data: ApiKeyIdParam & { period: ApiKeyBudgetPeriod }) {
|
||||
export function resetApiKeyBudget(data: ApiKeyResetBudgetParam) {
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/reset-budget',
|
||||
method: 'POST',
|
||||
|
||||
@@ -18,6 +18,15 @@ export interface LogoutResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ChangeAdminPasswordParam {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
interface ChangeAdminPasswordResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
// Cookie 写入由浏览器完成,跨标签页串行认证操作以免晚到的续期覆盖登录或退出
|
||||
@@ -34,8 +43,8 @@ export function login(data: LoginParam) {
|
||||
return withSessionLock(() => request<AuthSession>({
|
||||
url: '/api/auth/login',
|
||||
method: 'POST',
|
||||
skipSessionRecovery: true,
|
||||
data,
|
||||
skipSessionRecovery: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -68,11 +77,11 @@ export function logout() {
|
||||
}))
|
||||
}
|
||||
|
||||
export function changeAdminPassword(data: { currentPassword: string, newPassword: string }) {
|
||||
return withSessionLock(() => request<{ message: string }>({
|
||||
export function changeAdminPassword(data: ChangeAdminPasswordParam) {
|
||||
return withSessionLock(() => request<ChangeAdminPasswordResponse>({
|
||||
url: '/api/auth/password',
|
||||
method: 'POST',
|
||||
skipSessionRecovery: true,
|
||||
data,
|
||||
skipSessionRecovery: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ export interface PluginManagementCallbackTicket {
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
interface PluginManagementCallbackTicketParam {
|
||||
path: string
|
||||
ttlSeconds: number
|
||||
}
|
||||
|
||||
export function getPluginManagementViews(options: RequestOptions = {}) {
|
||||
return request<PluginManagementView[]>({
|
||||
url: '/api/admin/plugins/extensions',
|
||||
@@ -122,7 +127,7 @@ export function callPluginModelResponses(
|
||||
|
||||
export function createPluginManagementCallbackTicket(
|
||||
target: PluginManagementTarget,
|
||||
data: { path: string, ttlSeconds: number },
|
||||
data: PluginManagementCallbackTicketParam,
|
||||
options: RequestOptions = {},
|
||||
) {
|
||||
return request<PluginManagementCallbackTicket>({
|
||||
|
||||
@@ -271,6 +271,38 @@ export interface PluginRollbackPlan {
|
||||
targets: { artifactSha256: string, version: string, platforms: string[] }[]
|
||||
}
|
||||
|
||||
interface PluginArtifactParam {
|
||||
sha256: string
|
||||
}
|
||||
|
||||
interface PluginSourceCredentialIdParam {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface PluginUpdateCheckParam {
|
||||
pluginId: string
|
||||
credentialIds: string[]
|
||||
}
|
||||
|
||||
interface PluginInstanceIdParam {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface PluginInstanceUpdateParam extends PluginInstanceIdParam {
|
||||
instance: ConfigurePluginInstanceRequest
|
||||
}
|
||||
|
||||
interface PluginVersionPlanParams extends PluginInstanceIdParam {
|
||||
artifactSha256: string
|
||||
}
|
||||
|
||||
interface PluginVersionChangeParam extends PluginInstanceIdParam {
|
||||
target: {
|
||||
artifactSha256: string
|
||||
expectedRevision: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginArtifacts(options: RequestOptions = {}) {
|
||||
return request<PluginArtifact[]>({
|
||||
url: '/api/admin/plugins/artifacts',
|
||||
@@ -297,11 +329,11 @@ export function uploadPluginArtifact(file: File, sha256: string, options: Reques
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyUploadedPlugin(file: File, options: RequestOptions = {}) {
|
||||
export function verifyUploadedPlugin(data: File, options: RequestOptions = {}) {
|
||||
return request<VerifiedPluginArtifact>({
|
||||
url: '/api/admin/plugins/artifacts/upload/verify',
|
||||
method: 'POST',
|
||||
data: file,
|
||||
data,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
@@ -320,7 +352,7 @@ export function installRemotePlugin(data: InstallRemotePluginRequest, options: R
|
||||
})
|
||||
}
|
||||
|
||||
export function acceptPluginArtifact(data: { sha256: string }, options: RequestOptions = {}) {
|
||||
export function acceptPluginArtifact(data: PluginArtifactParam, options: RequestOptions = {}) {
|
||||
return request<PluginArtifactMutationResponse>({
|
||||
url: '/api/admin/plugins/artifacts/accept',
|
||||
method: 'POST',
|
||||
@@ -339,7 +371,7 @@ export function verifyRemotePlugin(data: VerifyRemotePluginRequest, options: Req
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePluginArtifact(data: { sha256: string }, options: RequestOptions = {}) {
|
||||
export function deletePluginArtifact(data: PluginArtifactParam, options: RequestOptions = {}) {
|
||||
return request<void>({
|
||||
url: '/api/admin/plugins/artifacts/delete',
|
||||
method: 'POST',
|
||||
@@ -375,7 +407,7 @@ export function createPluginSourceCredential(data: CreatePluginSourceCredentialR
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePluginSourceCredential(data: { id: string }, options: RequestOptions = {}) {
|
||||
export function deletePluginSourceCredential(data: PluginSourceCredentialIdParam, options: RequestOptions = {}) {
|
||||
return request<PluginMutationResponse>({
|
||||
url: '/api/admin/plugins/source-credentials/delete',
|
||||
method: 'POST',
|
||||
@@ -401,7 +433,7 @@ export function updatePluginSource(data: PluginUpdateSourceBinding, options: Req
|
||||
})
|
||||
}
|
||||
|
||||
export function checkPluginUpdate(data: { pluginId: string, credentialIds: string[] }, options: RequestOptions = {}) {
|
||||
export function checkPluginUpdate(data: PluginUpdateCheckParam, options: RequestOptions = {}) {
|
||||
return request<PluginUpdateCheck>({
|
||||
url: '/api/admin/plugins/updates/check',
|
||||
method: 'POST',
|
||||
@@ -418,7 +450,7 @@ export function getPluginInstances(options: RequestOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePluginInstance(data: { id: string, instance: ConfigurePluginInstanceRequest }, options: RequestOptions = {}) {
|
||||
export function updatePluginInstance(data: PluginInstanceUpdateParam, options: RequestOptions = {}) {
|
||||
return request<PluginInstanceMutationResponse>({
|
||||
url: '/api/admin/plugins/instances/update',
|
||||
method: 'POST',
|
||||
@@ -427,16 +459,16 @@ export function updatePluginInstance(data: { id: string, instance: ConfigurePlug
|
||||
})
|
||||
}
|
||||
|
||||
export function getPluginRollbackPlan(id: string, options: RequestOptions = {}) {
|
||||
export function getPluginRollbackPlan(data: PluginInstanceIdParam, options: RequestOptions = {}) {
|
||||
return request<PluginRollbackPlan>({
|
||||
url: '/api/admin/plugins/instances/rollback-plan',
|
||||
method: 'GET',
|
||||
params: { id },
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function rollbackPluginInstance(data: { id: string, target: { artifactSha256: string, expectedRevision: number } }, options: RequestOptions = {}) {
|
||||
export function rollbackPluginInstance(data: PluginVersionChangeParam, options: RequestOptions = {}) {
|
||||
return request<PluginInstanceMutationResponse>({
|
||||
url: '/api/admin/plugins/instances/rollback',
|
||||
method: 'POST',
|
||||
@@ -445,7 +477,7 @@ export function rollbackPluginInstance(data: { id: string, target: { artifactSha
|
||||
})
|
||||
}
|
||||
|
||||
export function disablePluginInstance(data: { id: string }, options: RequestOptions = {}) {
|
||||
export function disablePluginInstance(data: PluginInstanceIdParam, options: RequestOptions = {}) {
|
||||
return request<PluginMutationResponse>({
|
||||
url: '/api/admin/plugins/instances/disable',
|
||||
method: 'POST',
|
||||
@@ -454,7 +486,7 @@ export function disablePluginInstance(data: { id: string }, options: RequestOpti
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePluginInstance(data: { id: string }, options: RequestOptions = {}) {
|
||||
export function deletePluginInstance(data: PluginInstanceIdParam, options: RequestOptions = {}) {
|
||||
return request<PluginMutationResponse>({
|
||||
url: '/api/admin/plugins/instances/delete',
|
||||
method: 'POST',
|
||||
@@ -472,16 +504,16 @@ export interface PluginVersionPlan {
|
||||
restored: boolean
|
||||
}
|
||||
|
||||
export function getPluginVersionPlan(id: string, artifactSha256: string, options: RequestOptions = {}) {
|
||||
export function getPluginVersionPlan(data: PluginVersionPlanParams, options: RequestOptions = {}) {
|
||||
return request<PluginVersionPlan>({
|
||||
url: '/api/admin/plugins/instances/version-plan',
|
||||
method: 'GET',
|
||||
params: { id, artifactSha256 },
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function switchPluginVersion(data: { id: string, target: { artifactSha256: string, expectedRevision: number } }, options: RequestOptions = {}) {
|
||||
export function switchPluginVersion(data: PluginVersionChangeParam, options: RequestOptions = {}) {
|
||||
return request<PluginInstanceMutationResponse>({
|
||||
url: '/api/admin/plugins/instances/switch-version',
|
||||
method: 'POST',
|
||||
|
||||
@@ -69,7 +69,57 @@ interface ProxyAccountPage {
|
||||
page: ProxyPage['page']
|
||||
}
|
||||
|
||||
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
interface ProxyListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
interface ProxyAccountListParams extends ProxyListParams {
|
||||
proxyId: string
|
||||
}
|
||||
|
||||
interface ProxyAccountRemoveParam {
|
||||
proxyId: string
|
||||
accountId: string
|
||||
}
|
||||
|
||||
interface ProxyCreateParam {
|
||||
name: string
|
||||
proxyUrl: string
|
||||
maxWebsocketConnections?: number
|
||||
autoLocation?: boolean
|
||||
location?: RequestLocation | null
|
||||
}
|
||||
|
||||
interface ProxyIdParam {
|
||||
id: string
|
||||
revision: number
|
||||
}
|
||||
|
||||
interface ProxyUpdateParam extends ProxyIdParam {
|
||||
name: string
|
||||
enabled?: boolean
|
||||
maxWebsocketConnections?: number
|
||||
proxyUrl?: string
|
||||
autoLocation?: boolean
|
||||
location?: RequestLocation | null
|
||||
}
|
||||
|
||||
interface ProxyTestParam extends ProxyIdParam {
|
||||
detectLocation?: boolean
|
||||
}
|
||||
|
||||
interface ProxyProbeParam {
|
||||
proxyUrl: string
|
||||
detectLocation?: boolean
|
||||
}
|
||||
|
||||
interface ProxyMutationResponse {
|
||||
configRevision: number
|
||||
}
|
||||
|
||||
export function getProxyAccounts(data: ProxyAccountListParams, options: RequestOptions = {}) {
|
||||
return request<ProxyAccountPage>({
|
||||
url: '/api/admin/proxies/accounts',
|
||||
method: 'GET',
|
||||
@@ -78,8 +128,8 @@ export function getProxyAccounts(data: { proxyId: string, page: number, pageSize
|
||||
})
|
||||
}
|
||||
|
||||
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
|
||||
return request<{ configRevision: number }>({
|
||||
export function removeProxyAccount(data: ProxyAccountRemoveParam) {
|
||||
return request<ProxyMutationResponse>({
|
||||
url: '/api/admin/proxies/accounts/remove',
|
||||
method: 'POST',
|
||||
data,
|
||||
@@ -91,12 +141,11 @@ export type ProxyBatchResult<T>
|
||||
| { status: 'failed', httpStatus: number, code: number, message: string }
|
||||
| { status: 'skipped' }
|
||||
|
||||
interface ProxyMutation {
|
||||
interface ProxyMutation extends ProxyMutationResponse {
|
||||
record: OutboundProxyRecord
|
||||
configRevision: number
|
||||
}
|
||||
|
||||
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
export function getProxies(data: ProxyListParams, options: RequestOptions = {}) {
|
||||
return request<ProxyPage>({
|
||||
url: '/api/admin/proxies',
|
||||
method: 'GET',
|
||||
@@ -105,34 +154,34 @@ export function getProxies(data: { page: number, pageSize: number, search?: stri
|
||||
})
|
||||
}
|
||||
|
||||
export function createProxy(data: { name: string, proxyUrl: string, maxWebsocketConnections?: number, autoLocation?: boolean, location?: RequestLocation | null }[], options: RequestOptions = {}) {
|
||||
export function createProxy(data: ProxyCreateParam[], options: RequestOptions = {}) {
|
||||
return request<ProxyBatchResult<ProxyMutation>[]>({
|
||||
url: '/api/admin/proxies/create',
|
||||
timeout: 25000 * Math.max(1, data.length),
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000 * Math.max(1, data.length),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProxy(data: { id: string, revision: number, name: string, enabled?: boolean, maxWebsocketConnections?: number, proxyUrl?: string, autoLocation?: boolean, location?: RequestLocation | null }) {
|
||||
export function updateProxy(data: ProxyUpdateParam) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/update',
|
||||
timeout: 25000,
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProxy(data: { id: string, revision: number }) {
|
||||
return request<{ configRevision: number }>({
|
||||
export function deleteProxy(data: ProxyIdParam) {
|
||||
return request<ProxyMutationResponse>({
|
||||
url: '/api/admin/proxies/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function testProxy(data: { id: string, revision: number, detectLocation?: boolean }[], options: RequestOptions = {}) {
|
||||
export function testProxy(data: ProxyTestParam[], options: RequestOptions = {}) {
|
||||
return request<ProxyBatchResult<OutboundProxyRecord>[]>({
|
||||
url: '/api/admin/proxies/test',
|
||||
method: 'POST',
|
||||
@@ -142,7 +191,7 @@ export function testProxy(data: { id: string, revision: number, detectLocation?:
|
||||
})
|
||||
}
|
||||
|
||||
export function probeProxy(data: { proxyUrl: string, detectLocation?: boolean }) {
|
||||
export function probeProxy(data: ProxyProbeParam) {
|
||||
return request<OutboundProxyTest>({
|
||||
url: '/api/admin/proxies/probe',
|
||||
method: 'POST',
|
||||
|
||||
@@ -103,6 +103,10 @@ export interface DownloadUrlResult {
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
interface BackupIdParam {
|
||||
backupId: string
|
||||
}
|
||||
|
||||
export function getBackupSettings() {
|
||||
return request<BackupSettingsView>({
|
||||
url: '/api/admin/settings/backups',
|
||||
@@ -149,7 +153,7 @@ export function createBackup() {
|
||||
})
|
||||
}
|
||||
|
||||
export function getBackupDownloadUrl(data: { backupId: string }) {
|
||||
export function getBackupDownloadUrl(data: BackupIdParam) {
|
||||
return request<DownloadUrlResult>({
|
||||
url: '/api/admin/settings/backups/download-url',
|
||||
method: 'POST',
|
||||
@@ -157,7 +161,7 @@ export function getBackupDownloadUrl(data: { backupId: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteBackup(data: { backupId: string }) {
|
||||
export function deleteBackup(data: BackupIdParam) {
|
||||
return request<BackupRecord>({
|
||||
url: '/api/admin/settings/backups/delete',
|
||||
method: 'POST',
|
||||
|
||||
@@ -157,7 +157,11 @@ export function deleteAdminApiKey() {
|
||||
})
|
||||
}
|
||||
|
||||
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
|
||||
interface CodexDesktopWindowsDownloadsParams {
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
export function getCodexDesktopWindowsDownloads(data: CodexDesktopWindowsDownloadsParams = {}) {
|
||||
return request<CodexDesktopWindowsDownloads>({
|
||||
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
|
||||
method: 'GET',
|
||||
|
||||
@@ -40,7 +40,11 @@ export interface PrivacyPreviewResult extends PrivacySample {
|
||||
outcomes: { ruleId: string, matches: number, status: string, reason: string | null }[]
|
||||
}
|
||||
|
||||
export function previewPrivacyPolicy(data: PrivacySample & { policy: CodexPrivacyPolicy }, options: RequestOptions = {}) {
|
||||
interface PrivacyPreviewParam extends PrivacySample {
|
||||
policy: CodexPrivacyPolicy
|
||||
}
|
||||
|
||||
export function previewPrivacyPolicy(data: PrivacyPreviewParam, options: RequestOptions = {}) {
|
||||
return request<PrivacyPreviewResult>({
|
||||
url: '/api/admin/settings/privacy/preview',
|
||||
method: 'POST',
|
||||
|
||||
@@ -73,11 +73,15 @@ export function getClientProfileOptions() {
|
||||
})
|
||||
}
|
||||
|
||||
export function previewClientProfile(configuration: ClientProfileSelection | null) {
|
||||
interface ClientProfilePreviewParam {
|
||||
configuration: ClientProfileSelection | null
|
||||
}
|
||||
|
||||
export function previewClientProfile(data: ClientProfilePreviewParam) {
|
||||
return request<ClientProfilePreview>({
|
||||
url: '/api/admin/settings/client-profiles/openai/preview',
|
||||
method: 'POST',
|
||||
data: { configuration },
|
||||
data,
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
@@ -103,19 +107,28 @@ export interface XaiClientProfilePreview extends Omit<XaiClientProfileSelection,
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface XaiClientProfileOptions {
|
||||
defaults: XaiClientProfileSelection
|
||||
globalConfiguration: XaiClientProfileSelection
|
||||
}
|
||||
|
||||
interface XaiClientProfilePreviewParam {
|
||||
configuration: XaiClientProfileSelection | null
|
||||
}
|
||||
|
||||
export function getXaiClientProfileOptions() {
|
||||
return request<{ defaults: XaiClientProfileSelection, globalConfiguration: XaiClientProfileSelection }>({
|
||||
return request<XaiClientProfileOptions>({
|
||||
url: '/api/admin/settings/client-profiles/xai',
|
||||
method: 'GET',
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function previewXaiClientProfile(configuration: XaiClientProfileSelection | null) {
|
||||
export function previewXaiClientProfile(data: XaiClientProfilePreviewParam) {
|
||||
return request<XaiClientProfilePreview>({
|
||||
url: '/api/admin/settings/client-profiles/xai/preview',
|
||||
method: 'POST',
|
||||
data: { configuration },
|
||||
data,
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,14 +111,21 @@ export interface SystemRestartPlan {
|
||||
}
|
||||
|
||||
export function checkSystemRestart() {
|
||||
return request<SystemRestartPlan>({ url: '/api/admin/system/restart/check', method: 'GET' })
|
||||
return request<SystemRestartPlan>({
|
||||
url: '/api/admin/system/restart/check',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export function restartSystem(confirmation?: SystemRestartPlan, options: RequestOptions = {}) {
|
||||
interface SystemRestartParam {
|
||||
confirmation?: SystemRestartPlan
|
||||
}
|
||||
|
||||
export function restartSystem(data: SystemRestartParam = {}, options: RequestOptions = {}) {
|
||||
return request<SystemRestartAccepted>({
|
||||
url: '/api/admin/system/restart',
|
||||
method: 'POST',
|
||||
data: { confirmation },
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export type UsageRecordStatus = 'success' | 'error' | 'all'
|
||||
|
||||
export interface UsageListRecord {
|
||||
logicalOutcome: string
|
||||
errorCode: string | null
|
||||
errorMessage: string | null
|
||||
clientApiKeyName: string | null
|
||||
id: string
|
||||
provider: string | null
|
||||
@@ -394,6 +396,8 @@ interface UsageDetailQuery {
|
||||
|
||||
type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string }
|
||||
|
||||
type DiagnosticsDownloadParams = { requestId: string } | { errorId: string }
|
||||
|
||||
export function getUsageRecords(data: UsagePageQuery, options: RequestOptions = {}) {
|
||||
return request<UsageRecordsResponse>({
|
||||
url: '/api/admin/usage/records',
|
||||
@@ -439,7 +443,7 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery, o
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadDiagnostics(data: { requestId: string } | { errorId: string }, options: RequestOptions = {}) {
|
||||
export function downloadDiagnostics(data: DiagnosticsDownloadParams, options: RequestOptions = {}) {
|
||||
return requestRaw({
|
||||
url: '/api/admin/diagnostics/export',
|
||||
method: 'GET',
|
||||
|
||||
@@ -70,7 +70,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
|
||||
return
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await previewClientProfile(configuration)
|
||||
const result = await previewClientProfile({ configuration })
|
||||
if (!cancelled)
|
||||
preview.value = result
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
|
||||
return
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await previewXaiClientProfile(configuration)
|
||||
const result = await previewXaiClientProfile({ configuration })
|
||||
if (!cancelled)
|
||||
preview.value = result
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { UsageListRecord } from '@/api'
|
||||
import type { UsageModelRecord } from './shared/presenter'
|
||||
|
||||
import { CornerDownRight } from '@lucide/vue'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed } from 'vue'
|
||||
import { usageModelDisplay } from './shared/presenter'
|
||||
|
||||
const props = defineProps<{
|
||||
record: UsageListRecord
|
||||
record: UsageModelRecord
|
||||
}>()
|
||||
|
||||
const modelDisplay = computed(() => usageModelDisplay(props.record))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { UsageListRecord } from '@/api'
|
||||
import type { UsageReasoningRecord } from './shared/presenter'
|
||||
|
||||
import { Bot, SquareDashedBottomCode } from '@lucide/vue'
|
||||
import { usageReasoningEffort } from './shared/presenter'
|
||||
|
||||
defineProps<{
|
||||
record: UsageListRecord
|
||||
record: UsageReasoningRecord
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { UsageRecordStatus } from '@/api/modules/usage'
|
||||
import { ZSegmented } from '@codex-proxy/ui'
|
||||
import { CloudRain, List, Sun } from '@lucide/vue'
|
||||
|
||||
const model = defineModel<UsageRecordStatus>({ required: true })
|
||||
const options = [
|
||||
{ label: '成功', value: 'success', icon: Sun },
|
||||
{ label: '错误', value: 'error', icon: CloudRain },
|
||||
{ label: '全部', value: 'all', icon: List },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZSegmented v-model="model" aria-label="请求明细类型" :options="options" display="icon" class="w-30 shrink-0" />
|
||||
</template>
|
||||
@@ -3,7 +3,6 @@ import type { TableColumn } from '@codex-proxy/ui'
|
||||
import type { UsageListRecord } from '@/api'
|
||||
|
||||
import { ZTable } from '@codex-proxy/ui'
|
||||
import { Minimize2 } from '@lucide/vue'
|
||||
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
|
||||
@@ -12,6 +11,8 @@ import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
|
||||
import UsageModelCell from '@/components/usage/UsageModelCell.vue'
|
||||
import UsagePerformanceCell from '@/components/usage/UsagePerformanceCell.vue'
|
||||
import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue'
|
||||
import UsageRouteCell from '@/components/usage/UsageRouteCell.vue'
|
||||
import UsageTimeCell from '@/components/usage/UsageTimeCell.vue'
|
||||
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
|
||||
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
|
||||
import {
|
||||
@@ -83,6 +84,10 @@ withDefaults(
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
<template #createdAtDisplay="{ row }">
|
||||
<UsageTimeCell :value="row.createdAtDisplay" />
|
||||
</template>
|
||||
|
||||
<template #clientIp="{ row }">
|
||||
<UsageClientIpCell :record="row" />
|
||||
</template>
|
||||
@@ -102,16 +107,17 @@ withDefaults(
|
||||
</template>
|
||||
|
||||
<template #route="{ row }">
|
||||
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
|
||||
<code class="font-mono text-cp-sm font-emphasis">{{ row.route || '—' }}</code>
|
||||
<span
|
||||
v-if="row.compact"
|
||||
class="inline-flex shrink-0 text-cp-orange-text"
|
||||
title="压缩请求"
|
||||
aria-label="压缩请求"
|
||||
>
|
||||
<Minimize2 class="size-3.5" stroke-width="2.4" />
|
||||
</span>
|
||||
<UsageRouteCell :record="row" />
|
||||
</template>
|
||||
|
||||
<template #errorMessage="{ row }">
|
||||
<div class="min-w-0 py-0.5" :title="row.errorMessage || row.errorCode || undefined">
|
||||
<code class="block truncate font-mono text-cp-sm font-bold text-cp-error-text">
|
||||
{{ row.errorCode || (row.logicalOutcome === 'incomplete' ? '响应未完成' : '请求失败') }}
|
||||
</code>
|
||||
<p v-if="row.errorMessage" class="mt-1 mb-0 truncate text-cp-xs leading-[1.45] font-emphasis text-cp-text-secondary">
|
||||
{{ row.errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Minimize2 } from '@lucide/vue'
|
||||
|
||||
defineProps<{ record: { route: string | null, compact?: boolean } }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
|
||||
<code class="font-mono text-cp-sm font-emphasis">{{ record.route || '—' }}</code>
|
||||
<span
|
||||
v-if="record.compact"
|
||||
class="inline-flex shrink-0 text-cp-orange-text"
|
||||
title="压缩请求"
|
||||
aria-label="压缩请求"
|
||||
>
|
||||
<Minimize2 class="size-3.5" stroke-width="2.4" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ value: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="block truncate font-mono text-cp-sm leading-none font-bold text-cp-text-secondary" :title="value">
|
||||
{{ value || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { TableColumn } from '@codex-proxy/ui'
|
||||
import type { UsageListRecord } from '@/api'
|
||||
import { defineTableColumns } from '@codex-proxy/ui'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
export const usageDataColumns = [
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
|
||||
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
|
||||
{ key: 'route', label: '端点', kind: 'mono' },
|
||||
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
|
||||
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
|
||||
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
|
||||
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
|
||||
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
|
||||
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
|
||||
] satisfies TableColumn[]
|
||||
|
||||
export const usageRecordColumns = defineTableColumns<UsageListRecord>([
|
||||
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
|
||||
{
|
||||
@@ -20,17 +36,6 @@ export const usageRecordColumns = defineTableColumns<UsageListRecord>([
|
||||
fixedWidth: true,
|
||||
formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
|
||||
},
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
|
||||
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
|
||||
{ key: 'route', label: '端点', kind: 'mono' },
|
||||
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
|
||||
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
|
||||
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
|
||||
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
|
||||
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
|
||||
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
|
||||
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
|
||||
...usageDataColumns,
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false },
|
||||
])
|
||||
|
||||
@@ -9,6 +9,9 @@ export type UsagePerformanceRecord = Pick<UsageCommonRecord, 'latencyDetails' |
|
||||
tokenDetails: Pick<UsageListRecord['tokenDetails'], 'outputTokens'> | null
|
||||
}
|
||||
|
||||
export type UsageModelRecord = Pick<UsageCommonRecord, 'model'> & Partial<Pick<UsageCommonRecord, 'requestedModel' | 'upstreamModel' | 'upstreamResponseModel'>>
|
||||
export type UsageReasoningRecord = Pick<UsageCommonRecord, 'reasoningEffort'> & Partial<Pick<UsageCommonRecord, 'reasoningPreset' | 'subagentKind'>>
|
||||
|
||||
interface UsageTimingItem {
|
||||
label: string
|
||||
value: string
|
||||
@@ -58,7 +61,7 @@ export function usageTransportTypeClass(transport?: string | null) {
|
||||
return 'bg-cp-fill-tertiary text-cp-text-secondary'
|
||||
}
|
||||
|
||||
export function usageAccountText(record: UsageCommonRecord) {
|
||||
export function usageAccountText(record: Pick<UsageCommonRecord, 'accountEmail' | 'accountName' | 'accountId'>) {
|
||||
return record.accountEmail || record.accountName || record.accountId || '—'
|
||||
}
|
||||
|
||||
@@ -70,14 +73,14 @@ export function usageUserAgent(record: { userAgent?: string | null }) {
|
||||
return record.userAgent || '—'
|
||||
}
|
||||
|
||||
export function usageReasoningEffort(record: UsageCommonRecord) {
|
||||
export function usageReasoningEffort(record: UsageReasoningRecord) {
|
||||
const reasoningEffort = record.reasoningEffort || '—'
|
||||
if (record.subagentKind)
|
||||
return reasoningEffort
|
||||
return record.reasoningPreset || reasoningEffort
|
||||
}
|
||||
|
||||
export function usageModelDisplay(record: UsageCommonRecord) {
|
||||
export function usageModelDisplay(record: UsageModelRecord) {
|
||||
const requestedModel = record.requestedModel || ''
|
||||
const upstreamModel = record.upstreamModel || ''
|
||||
const storedModel = record.model || ''
|
||||
|
||||
@@ -14,6 +14,13 @@ interface PageResult {
|
||||
}
|
||||
}
|
||||
|
||||
export interface PagedQueryExecution {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
silent?: boolean
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
export function usePagedQuery<Result extends PageResult>(options: {
|
||||
initialPageSize: number
|
||||
load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise<Result>
|
||||
@@ -25,34 +32,52 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
const total = shallowRef(0)
|
||||
const items = shallowRef<Result['items'][number][]>([])
|
||||
const request = useRequestState(options.onError)
|
||||
const { loading, error, invalidate } = request
|
||||
const { loading, error } = request
|
||||
let confirmedPagination = { page: page.value, pageSize: pageSize.value }
|
||||
|
||||
async function execute(execution: { silent?: boolean, background?: boolean } = {}) {
|
||||
function restorePagination() {
|
||||
page.value = confirmedPagination.page
|
||||
pageSize.value = confirmedPagination.pageSize
|
||||
}
|
||||
|
||||
function invalidate(options?: Parameters<typeof request.invalidate>[0]) {
|
||||
request.invalidate(options)
|
||||
restorePagination()
|
||||
}
|
||||
|
||||
async function execute(execution: PagedQueryExecution = {}): Promise<boolean> {
|
||||
// 保留当前列表的刷新不一定静默;手动刷新仍需由请求层提示失败。
|
||||
const background = execution.background || execution.silent
|
||||
const requestId = request.start(background)
|
||||
const pagination = {
|
||||
page: execution.page ?? page.value,
|
||||
pageSize: execution.pageSize ?? pageSize.value,
|
||||
}
|
||||
// 同步控件的目标值,失败时再回退;只在成功时写入会让控件内部模型无法恢复。
|
||||
// 轮询或操作后的回读也沿用当前目标页,避免把尚未完成的翻页拉回旧页。
|
||||
page.value = pagination.page
|
||||
pageSize.value = pagination.pageSize
|
||||
|
||||
try {
|
||||
const result = await options.load({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
}, { silent: execution.silent, signal: request.signal })
|
||||
const result = await options.load(pagination, { silent: execution.silent, signal: request.signal })
|
||||
if (!request.isCurrent(requestId))
|
||||
return false
|
||||
|
||||
if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) {
|
||||
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
|
||||
return execute(execution)
|
||||
return execute({ ...execution, page: clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY) })
|
||||
}
|
||||
|
||||
items.value = result.items
|
||||
page.value = result.page.page
|
||||
pageSize.value = result.page.pageSize
|
||||
total.value = result.page.total
|
||||
confirmedPagination = { page: page.value, pageSize: pageSize.value }
|
||||
options.onSuccess?.(result)
|
||||
return true
|
||||
}
|
||||
catch (cause: unknown) {
|
||||
if (request.isCurrent(requestId))
|
||||
restorePagination()
|
||||
request.fail(requestId, cause, background)
|
||||
return false
|
||||
}
|
||||
@@ -61,6 +86,14 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFromStart() {
|
||||
// 筛选条件已改变,旧结果不能继续对应当前查询,也不能在失败时恢复旧页。
|
||||
items.value = []
|
||||
total.value = 0
|
||||
confirmedPagination = { page: 1, pageSize: confirmedPagination.pageSize }
|
||||
return execute(confirmedPagination)
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -69,6 +102,7 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
loading,
|
||||
error,
|
||||
execute,
|
||||
reloadFromStart,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +577,7 @@ onBeforeUnmount(() => {
|
||||
:size="isCollapsed ? 'default' : 'small'"
|
||||
aria-label="退出登录"
|
||||
type="danger"
|
||||
:disabled="authStore.loading || !authStore.isAuthenticated"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="isCollapsed ? 19 : 18" />
|
||||
|
||||
@@ -8,6 +8,8 @@ import { resetUnauthorizedHandling } from '@/api/request'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const session = shallowRef<AuthSession | null>(null)
|
||||
// 登录身份变化使关联数据失效,正常续期只更新会话有效期。
|
||||
const sessionGeneration = shallowRef(0)
|
||||
const isAuthenticated = computed(() => session.value !== null)
|
||||
const isAdmin = computed(() => session.value?.role === 'admin')
|
||||
const sessionChecked = shallowRef(false)
|
||||
@@ -41,7 +43,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// 登录或退出之后到达的旧状态响应,不覆盖新会话。
|
||||
if (currentRevision === revision) {
|
||||
const wasAuthenticated = isAuthenticated.value
|
||||
const previousRole = session.value?.role
|
||||
session.value = status.session
|
||||
if (previousRole !== status.session?.role)
|
||||
sessionGeneration.value += 1
|
||||
sessionChecked.value = true
|
||||
if (status.authenticated && !wasAuthenticated)
|
||||
resetUnauthorizedHandling()
|
||||
@@ -61,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
revision += 1
|
||||
pendingCheck.value = undefined
|
||||
session.value = result
|
||||
sessionGeneration.value += 1
|
||||
sessionChecked.value = true
|
||||
resetUnauthorizedHandling()
|
||||
return result
|
||||
@@ -97,9 +103,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
revision += 1
|
||||
pendingCheck.value = undefined
|
||||
session.value = null
|
||||
sessionGeneration.value += 1
|
||||
sessionChecked.value = true
|
||||
resetUnauthorizedHandling()
|
||||
}
|
||||
|
||||
return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
|
||||
return { session, sessionGeneration, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
|
||||
})
|
||||
|
||||
@@ -70,10 +70,7 @@ export const usePluginViewsStore = defineStore('plugin-views', () => {
|
||||
return pending ?? refresh()
|
||||
}
|
||||
|
||||
watch(() => authStore.session, (session, previous) => {
|
||||
if (session !== previous)
|
||||
reset()
|
||||
})
|
||||
watch(() => authStore.sessionGeneration, reset, { flush: 'sync' })
|
||||
|
||||
return {
|
||||
views,
|
||||
|
||||
@@ -471,7 +471,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
|
||||
try {
|
||||
// 进程可能在返回响应前退出,由下面的目标版本探测判定是否完成。
|
||||
await restartSystem(confirmation, { silent: true })
|
||||
await restartSystem({ confirmation }, { silent: true })
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error instanceof ApiError && error.status > 0) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user