mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26bc36db5 | ||
|
|
96d5ab674a | ||
|
|
84c0db2613 | ||
|
|
d819315d8b | ||
|
|
06e1d8f0ac | ||
|
|
bdc8274b36 | ||
|
|
3f6607209b | ||
|
|
3c05bc8f07 | ||
|
|
9ccd84f1a0 | ||
|
|
a8f183707e | ||
|
|
3f20af61ff | ||
|
|
efaed4c3a6 | ||
|
|
a36137bafb | ||
|
|
2e7a39e1e8 | ||
|
|
6648198df4 | ||
|
|
2eed9b20ed | ||
|
|
16aa08091c | ||
|
|
abae4a631b | ||
|
|
8983eb4ce9 | ||
|
|
cd605b850e | ||
|
|
7beebb2340 | ||
|
|
31de0bd83c | ||
|
|
5cfc6dee10 | ||
|
|
ef5b4d7dbd | ||
|
|
31974bc289 | ||
|
|
e033e21994 | ||
|
|
2818edb9fc | ||
|
|
1d1f34ba7e | ||
|
|
76291f448c | ||
|
|
c10efea12c | ||
|
|
806f7ad9ee | ||
|
|
632edee7dc | ||
|
|
22518d064c | ||
|
|
64c1ec2afd | ||
|
|
a3f0c1b028 | ||
|
|
605354c861 | ||
|
|
60e6bc0125 | ||
|
|
bfea3e1d6f | ||
|
|
6b4b8faaf4 | ||
|
|
f195de38f5 | ||
|
|
07bdd425b6 | ||
|
|
d48982eb04 | ||
|
|
39b35d46a0 | ||
|
|
8fc8c6228d | ||
|
|
7da79b82fd | ||
|
|
a23afbf2f5 | ||
|
|
b4a5210e44 | ||
|
|
f7f59103a3 | ||
|
|
5b77a6b8de | ||
|
|
3f95f8b0c4 | ||
|
|
28fa655c26 | ||
|
|
7a60bc6ded | ||
|
|
a5b767e69b | ||
|
|
78bc57c5cf | ||
|
|
ef11b72d8a | ||
|
|
52fb1e7180 | ||
|
|
7a7a7ed6ad | ||
|
|
b8479b59db | ||
|
|
2480f07f2d | ||
|
|
b2dc9f493d | ||
|
|
d92d310e45 | ||
|
|
3caf83131a | ||
|
|
af35cde374 | ||
|
|
5c115fb38f | ||
|
|
ed558e5396 | ||
|
|
929f781a63 | ||
|
|
e0326ecba4 | ||
|
|
6057088300 | ||
|
|
e3a49a6217 | ||
|
|
b32f87fa8a | ||
|
|
91e8105e5d | ||
|
|
c4d9576639 | ||
|
|
015cd60be1 | ||
|
|
bad2a245ab | ||
|
|
b49b4528e7 | ||
|
|
d678f350c1 | ||
|
|
17b05d4671 | ||
|
|
7148303ad6 | ||
|
|
f6538d433d | ||
|
|
406256bcc0 | ||
|
|
d912fa0c59 | ||
|
|
a166580aed | ||
|
|
d1aec7311c | ||
|
|
16d8a3f873 | ||
|
|
ac6033f6fa | ||
|
|
cef9f0b21e | ||
|
|
591f95cc18 | ||
|
|
99ba5b1120 | ||
|
|
2221f400da | ||
|
|
38bcfb9da1 | ||
|
|
10f122aac8 | ||
|
|
598d56baed | ||
|
|
5f61309941 | ||
|
|
ab8c61b118 | ||
|
|
c3146dc10a | ||
|
|
72bdc234ca | ||
|
|
733e9aa8a0 | ||
|
|
743a3aba70 | ||
|
|
349e983eb7 |
@@ -1,59 +1,57 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
|
||||
description: Codex Proxy RS 开发、修复、排障、代码审查和文档修改入口;页面、样式、文案、接口、调度、数据库及宿主 SDK、Runtime、插件接入均适用;PR、Issue、独立网关插件与发版叠加对应技能
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
## 先确定领域与必读规范
|
||||
|
||||
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
|
||||
|
||||
职责边界决定问题由谁修复;即使代码位于账号、调度或 API 模块,只要影响插件接入或为插件增加处理,也命中插件边界。确定方案时说明相关业务事实的归属及已有实现的复用依据,不能以功能恢复或测试通过替代职责判断
|
||||
|
||||
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
|
||||
| --- | --- |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
|
||||
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 |
|
||||
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 代码审查,包括 PR 审查 | [审查标准](../../../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} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
**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 约定执行 |
|
||||
| 模块或调用链变更 | [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) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
|
||||
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
|
||||
开发、排障和代码审查先按[问题与方案依据](../../../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#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
|
||||
## 测试维护
|
||||
|
||||
- 测试随当前功能维护,名称、夹具和断言描述当前输入、行为与结果;功能变更时优先更新所属模块的现有用例,不为每次改动叠加一组记录变更经过的测试
|
||||
- 清理只证明旧字段、旧路由或旧实现已经消失的用例与附带断言;未知字段校验使用合法请求基线加无业务含义的未知字段,避免因缺少必填项等其他错误而误通过
|
||||
- 同一层对同一行为的重复覆盖合并为清晰的场景矩阵,复用相邻测试夹具;跨层测试确有不同职责或失败路径时保留,不为统一而扩大测试辅助抽象
|
||||
- 断言可观察的行为与业务不变量,避免复述结构体赋值、罗列表列或字段清单、匹配 SQL 文本与源码布局;项目明确要求的架构边界检查继续维护
|
||||
- 当前协议兼容、数据读取兼容、安全边界、并发和资源生命周期仍需覆盖,不按 `old`、`legacy` 等名称机械删除,也不能用改名代替无效用例清理
|
||||
- 不在仓库中新增或保留迁移 SQL 专项测试,包括迁移文本、schema 演变与指定版本升级用例;需要验证迁移执行、数据保留或回滚时,在临时目录编写一次性验证并使用隔离数据库,测试通过即可,临时脚本不纳入维护测试集
|
||||
- 持久化功能测试使用当前完整 schema,验证现行读写、事务与数据约束;插件运行时状态迁移等业务能力按当前功能测试,不归为迁移 SQL 专项测试
|
||||
|
||||
## 后端注释
|
||||
|
||||
- 每个 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 或任务报告
|
||||
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对。涉及合同或职责说明时,补读所属架构、API 或 SDK 的对应条款,核实事实;不因文档提及实现而执行无关的开发与构建流程
|
||||
|
||||
## 逐段验收
|
||||
|
||||
@@ -30,9 +24,8 @@
|
||||
|
||||
## 交付检查
|
||||
|
||||
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
|
||||
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
|
||||
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
|
||||
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
|
||||
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
|
||||
|
||||
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
|
||||
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 前端开发与审查
|
||||
|
||||
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
|
||||
|
||||
## 领域必读
|
||||
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
|
||||
## 按改动补齐规范
|
||||
|
||||
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
|
||||
|
||||
按改动补读,不把“未修改主题算法”当作跳过页面样式规范的理由:
|
||||
|
||||
| 改动 | 规范入口 |
|
||||
| --- | --- |
|
||||
| Vue 组件、逻辑、请求或状态 | 使用 `$vue-best-practices`,读取[项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目;请求与接口合同同时执行开发入口的 API 分支 |
|
||||
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
|
||||
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
|
||||
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
|
||||
|
||||
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
|
||||
|
||||
## 交付前
|
||||
|
||||
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
|
||||
|
||||
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
|
||||
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
|
||||
- **样式与状态**:是否使用对应 Token 与 utility,局部样式是否符合适用边界,受影响的主题、窄屏及交互状态是否正确
|
||||
- **查询与动作**:区分首次加载、刷新、分页、单行与批量操作,核对 loading、禁用状态的影响范围;沿用请求层错误处理,不在页面叠加同一错误的提示
|
||||
- **运行证据**:按界面验证核对截图与实际交互,证据对应当前实现;页面 PR 的可访问图片和 Draft 条件按该节执行
|
||||
|
||||
交付简述所复用的页面或组件、实际验证范围及缺口;环境不足时如实说明,不能仅凭构建、源码或截图推断未验证的行为
|
||||
@@ -1,11 +1,15 @@
|
||||
# 插件职责边界
|
||||
|
||||
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
|
||||
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
|
||||
开发、排查或审查插件接入、SDK、Runtime、宿主扩展接口及独立网关插件时完整读取,不以文件位于插件目录或正在选择能力为条件
|
||||
确定方案前读取[职责与源码入口](../../../../docs/architecture.md#职责与源码入口)、[发布与执行边界](../../../../docs/architecture.md#发布与执行边界)及[信任与资源边界](../../../../docs/architecture.md#信任与资源边界),明确问题归属、必要宿主保护与插件责任
|
||||
|
||||
**宿主可以暴露底层能力,不能替插件完成业务**
|
||||
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
|
||||
|
||||
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
|
||||
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
|
||||
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
|
||||
- 插件行为不可预测,只保留宿主安全解码、权限、资源限制及一致性所需的检查,不为插件业务正确性增加语义校验、未知字段拒绝或重复检查;失败按已有调用、会话、实例边界及能力策略拒绝、隔离和回收,不把单次业务错误扩大为全局停用
|
||||
- 不靠猜测插件意图、补造业务字段、修正业务结果、重放操作或增加插件专属兼容分支让错误插件继续工作;问题归插件时在插件侧修复,不把补偿搬进宿主 SDK 或 Runtime
|
||||
- 通用协议与会话辅助、公开合同规定的默认值和失败恢复仍按既有合同执行,不能因拒绝业务兜底而删掉必要的宿主保护;故障隔离按[信任与资源边界](../../../../docs/architecture.md#信任与资源边界)评估,不假定完整信任的插件具有安全沙箱
|
||||
- 开放已有能力时复用宿主服务与数据模型,不为插件的导入、修正、重算等编排新增表、字段、账本或任务系统;确有宿主能力缺口时,界定可独立使用的基础接口及输入输出、权限、事务、失败和重复调用语义,不夹带插件私有模型与工作流
|
||||
- 新增防御、兼容、重试、缓存或状态前,说明要保护的宿主不变量、触发依据和最小处理范围;仅为迁就某个插件或猜测未来行为时不加入宿主
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合;验证失败与取消后的资源释放、状态一致性和其他调用受影响范围,不以插件恢复成功代替宿主边界验证
|
||||
|
||||
@@ -13,14 +13,16 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
|
||||
|
||||
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
|
||||
|
||||
## 按需取材
|
||||
## 按动作读取与核实
|
||||
|
||||
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
目标、授权、事实与脱敏要求适用于草拟、提交和回复;按下列动作读取必需规则与证据,再组织正文。症状反馈可保留未知根因,进入技术排查或方案判断时补齐对应规范
|
||||
|
||||
- 新建 Issue 时,读取本仓库的[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
|
||||
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
|
||||
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
|
||||
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级);注明适用版本、来源与缺口,不把上游报错直接定性为 CPR 缺陷,也不把第三方补偿方案当成预期行为;症状反馈仍不以证明根因为前提
|
||||
- 需要技术排查、判断实现方案或修改时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读,不能仅凭症状建议跨职责补偿;提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
|
||||
|
||||
## 正文与证据
|
||||
|
||||
@@ -36,16 +38,13 @@ 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,38 +15,33 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
|
||||
|
||||
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
|
||||
|
||||
涉及 Codex 行为时,优先执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级),先判断是否保持官方合同、偏离是否必要,再评价实现;引用源码和验证通过不能替代方案依据
|
||||
涉及上游行为的判断按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行
|
||||
|
||||
## 按模式读取
|
||||
|
||||
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
|
||||
先完成上述范围判断,再按当前动作与差异涉及的领域叠加下表。命中的流程引用完整读取,长文档读取指定章节;只复用已实际读取且仍适用的内容,单纯整理正文或回复不扩展为完整实现审查
|
||||
|
||||
| 当前动作 | 读取与执行 |
|
||||
| --- | --- |
|
||||
| 只整理标题或描述 | 当前 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](../cpr-dev-guide/SKILL.md)的领域必读与对应检查,包括后端、插件、测试和 API 等命中项;只读审查同样执行,不能仅核对 PR 正文与 CI |
|
||||
| 含前端改动 | [前端流程](../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`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-pr"
|
||||
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,实现自审与审查叠加开发指南的领域规范,核实问题、方案、验证、页面截图和 AI 模型披露。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -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,19 @@
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;按入口的官方行为优先约束核对偏离的必要性、最小范围、行为代价与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
真实复现上游错误或补偿后成功,也不能单独证明 CPR 应改写请求;缺少必要性依据的行为偏离须在解决方向中明确指出,
|
||||
不因测试通过而给出方案合理的结论。网关资源策略单独核对容量依据与客户端影响,不以官方客户端没有同类池策略就判错。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
按实际行为与文件执行 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)的领域必读,再判断方案与实现;只读审查也核对职责边界与生命周期。
|
||||
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 的批准或请求修改状态。
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
---
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
|
||||
description: 开发、修复、排查、审查或打包 Codex Proxy RS 独立网关插件,含处理器、清单与插件页面;仅改宿主能力时使用 cpr-dev-guide 的插件边界,不适用于 Codex 插件或 Vue 插件
|
||||
---
|
||||
|
||||
# 网关插件开发
|
||||
|
||||
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
|
||||
## 先读职责边界
|
||||
|
||||
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
|
||||
插件在独立项目中通过公开 SDK 实现业务,插件业务错误在插件侧修复;宿主维护自身合同、资源与稳定性,不猜测插件意图、不补造业务数据,不在宿主、SDK 或 Runtime 中增加插件专属补偿
|
||||
|
||||
开发、排查和审查先完整读取[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md),再选择实现步骤;已有处理器修复和页面改动也执行,不以是否新增能力为条件。需要新增宿主能力时,先界定可独立使用的基础接口及其职责;必要的协议保护与资源回收仍由宿主负责
|
||||
|
||||
同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与收尾要求;已实际读取且仍适用的内容复用,不递归重读入口。仅构建、打包或分发已有插件时按对应操作读取,若需诊断或修改行为,再补齐开发分支
|
||||
|
||||
## 范围与合同
|
||||
|
||||
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 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 为准
|
||||
|
||||
## 按当前步骤读取
|
||||
|
||||
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
|
||||
完成上述适用边界后,按当前步骤读取下表;同时涉及多项时叠加。命中的实现或页面参考完整读取,长文档读取指定章节,示例、前端和打包资料在对应步骤加载
|
||||
|
||||
| 当前工作 | 入口 |
|
||||
| --- | --- |
|
||||
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
|
||||
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 选择、使用或审查能力 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,不以已有调用能编译代替合同核对 |
|
||||
| 开发、排查或审查清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;涉及清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建、修改或审查页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
|
||||
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
|
||||
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
|
||||
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md) |
|
||||
|
||||
## 交付
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-plugin-dev"
|
||||
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
|
||||
short_description: "独立网关插件开发、排障与审查,先核对宿主边界再实现和验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查、审查或打包独立网关插件,先读取适用职责边界,插件业务错误在插件侧修复,再按步骤读取实现与验证资料。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -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)的页面分支
|
||||
创建、修改或审查插件页面时完整读取,先完成[插件入口](../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 实例
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 清单与处理器实现
|
||||
|
||||
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
|
||||
开发、排查或审查插件清单、Rust 处理器时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断,涉及 Rust 时执行[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
使用能力前读取 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,已有实现的修复与审查也核对合同
|
||||
|
||||
## 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- `contributes` 每种能力最多一项,派生 ID 和固定阶段可省略;能力版本与中间件阶段按[扩展项简写](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)声明。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
|
||||
@@ -15,25 +15,25 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
|
||||
|
||||
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
|
||||
|
||||
## 必须保持的边界
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
## 按任务读取
|
||||
|
||||
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
|
||||
先落实上述范围与边界,再读取当前阶段的完整指定章节;实际发布包含准备与验收,不能只读执行命令。阶段变化时补齐读取,准备文案不加载执行流程,实际发布前须核对发布入口的完整副作用
|
||||
|
||||
| 任务 | 读取范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
|
||||
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
|
||||
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
|
||||
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
|
||||
| 实际发布 | 先完成[基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明)、[发行准备检查](references/preparation.md#发行准备检查),再执行[通道核对](references/operations.md#发行通道核对)、[执行发版](references/operations.md#执行发版)与[恢复与验收](references/operations.md#恢复与验收) |
|
||||
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md);实际发布必须覆盖分支与版本、发布能力、文案和执行与验收 |
|
||||
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
|
||||
## 必须保持的边界
|
||||
|
||||
- 发行范围涉及 Codex 兼容性变化时,按[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级)核对已有方案依据和实际验证范围;缺少依据如实列为发行风险,不以 CI 通过代替,不把网关自定义策略写成官方要求
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
|
||||
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
实际发布由 `release/publish` 提交说明、版本文件及[插件接口弃用清单](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)的必要更新,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
|
||||
|
||||
## 发行准备检查
|
||||
|
||||
@@ -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
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
@@ -33,7 +38,7 @@ runs:
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload-artifact == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
|
||||
@@ -242,7 +242,7 @@ runs:
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
|
||||
@@ -52,7 +52,7 @@ updates:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
directory: /frontend
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
|
||||
|
||||
@@ -24,17 +24,17 @@ permissions:
|
||||
jobs:
|
||||
backend:
|
||||
if: inputs.run_backend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
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
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
|
||||
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
env:
|
||||
POSTGRES_DB: codex_proxy
|
||||
POSTGRES_USER: codex_proxy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
|
||||
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -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
|
||||
@@ -117,7 +122,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -151,6 +156,20 @@ jobs:
|
||||
echo "CPR_TEST_REDIS_URL=redis://:${redis_password}@127.0.0.1:6379"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Expose matching PostgreSQL backup and restore tools
|
||||
shell: bash
|
||||
env:
|
||||
CPR_TEST_POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
client_dir="$RUNNER_TEMP/pg-client"
|
||||
mkdir -p "$client_dir"
|
||||
for tool in pg_dump psql; do
|
||||
printf '#!/bin/sh\nexec docker exec -i --env PGPASSWORD "%s" %s "$@"\n' "$CPR_TEST_POSTGRES_CONTAINER" "$tool" > "$client_dir/$tool"
|
||||
chmod +x "$client_dir/$tool"
|
||||
done
|
||||
echo "$client_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
@@ -180,7 +199,7 @@ jobs:
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -194,7 +213,7 @@ jobs:
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
workflow-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -202,6 +221,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
|
||||
# https://github.com/rhysd/actionlint/issues/657
|
||||
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
|
||||
# https://github.com/actions/runner-images#available-images
|
||||
- name: Lint GitHub Actions workflows
|
||||
run: >-
|
||||
docker run --rm
|
||||
@@ -209,6 +232,8 @@ 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"$'
|
||||
-ignore '^label "ubuntu-26\.04" is unknown\.'
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check workflow script syntax
|
||||
@@ -216,3 +241,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
|
||||
|
||||
+22
-15
@@ -20,8 +20,11 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
@@ -42,18 +45,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'
|
||||
@@ -94,7 +105,7 @@ jobs:
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
@@ -106,22 +117,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
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ steps.toolchains.outputs.rust }}
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
|
||||
+62
-354
@@ -21,7 +21,7 @@ env:
|
||||
|
||||
jobs:
|
||||
release-info:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
@@ -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,154 +46,18 @@ 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
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -207,7 +73,7 @@ jobs:
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -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,18 +131,19 @@ 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 }}
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
@@ -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 }}-
|
||||
|
||||
@@ -328,7 +196,7 @@ jobs:
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
@@ -337,7 +205,7 @@ jobs:
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -351,19 +219,19 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/frontend
|
||||
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: codex-proxy-rs-*
|
||||
path: dist/binaries
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -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
|
||||
@@ -475,7 +250,7 @@ jobs:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload release assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
@@ -508,19 +283,19 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/docker/web/dist
|
||||
|
||||
- name: Download Docker binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -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
|
||||
@@ -636,7 +362,7 @@ jobs:
|
||||
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
|
||||
|
||||
- name: Upload verified image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
@@ -644,9 +370,12 @@ 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
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -661,13 +390,13 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
|
||||
- name: Download verified image digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: image-digest-*
|
||||
path: dist/digests
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
@@ -33,14 +33,23 @@ jobs:
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
// 按 PR 作者识别官方机器人,维护者重开或编辑 PR 时仍使用同一判断
|
||||
const isDependabot = pr.user.login === 'dependabot[bot]'
|
||||
&& pr.user.type === 'Bot'
|
||||
&& pr.head.repo?.full_name === `${owner}/${repo}`;
|
||||
const isDependencyUpdate = file => isDependabot
|
||||
&& file.status === 'modified'
|
||||
&& (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)
|
||||
|| /^\.github\/actions\/.+\/(?:action\.ya?ml|Dockerfile)$/.test(file.filename));
|
||||
const isProtectedRule = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|
||||
|| path === 'CONTRIBUTING.md'
|
||||
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|
||||
|| path === '.github' || path.startsWith('.github/'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
|
||||
// 依赖更新只豁免已有文件的修改,重命名前后的受保护路径仍参与检查
|
||||
if (!files.some(file => !isDependencyUpdate(file)
|
||||
&& (isProtectedRule(file.filename) || isProtectedRule(file.previous_filename)))) {
|
||||
core.info('No protected contribution rule changes detected.');
|
||||
return;
|
||||
}
|
||||
@@ -56,7 +65,7 @@ jobs:
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
backend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cargo-audit audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -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
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [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 |
|
||||
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [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 |
|
||||
| 写、修或审查独立网关插件,排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide;仅改宿主能力时由 cpr-dev-guide 加载插件边界 |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
|
||||
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [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 +36,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#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
|
||||
+37
-6
@@ -19,15 +19,35 @@
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 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 流程
|
||||
|
||||
@@ -69,7 +89,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
|
||||
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
|
||||
|
||||
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
官方 `dependabot[bot]` 从本仓库分支发起的依赖更新可修改 `.github/workflows/` 中已有的 YAML 工作流,以及 `.github/actions/` 下已有的 `action.yml`、`action.yaml` 和 `Dockerfile`,继续执行正常审查与 CI;其他受保护文件以及新增、删除、重命名不在例外范围内
|
||||
|
||||
除此之外,调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
|
||||
|
||||
@@ -127,6 +149,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
|
||||
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
|
||||
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
|
||||
| [共用组件](modules/ui/README.md)及其组件参考 | 如何安装、使用和开发独立 UI 库 | 组件公开 API、主题能力与库内开发;宿主页面约定归管理端主题,跨仓库联调归开发文档 |
|
||||
| [官方插件示例](modules/plugins/README.md)及其示例说明 | 如何构建、使用和维护示例插件 | 示例自身的功能、配置与验证;通用协议归 SDK,宿主安装操作归插件使用文档 |
|
||||
| [AGENTS.md](AGENTS.md) 与[项目技能](.agents/skills/cpr-dev-guide/SKILL.md) | AI 如何选择并执行任务流程 | 任务匹配、执行步骤和规范入口,不另建模块合同、版本表或协作标准 |
|
||||
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
|
||||
|
||||
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
|
||||
@@ -147,7 +172,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 +188,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
|
||||
|
||||
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
|
||||
|
||||
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -80,7 +80,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
|
||||
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CC Switch
|
||||
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
@@ -93,7 +93,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,合并配置后重启 Codex;也可通过「导入到」把服务连接与密钥添加到 CC Switch
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
@@ -118,7 +118,8 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](modules/plugins/README.md) |
|
||||
| 开发共用组件 | [组件库说明](modules/ui/README.md) · [组件参考](modules/ui/playground/components/index.md) |
|
||||
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
|
||||
|
||||
|
||||
Generated
+42
-35
@@ -198,7 +198,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -791,9 +791,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -856,7 +856,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -910,7 +910,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1129,9 +1129,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -1189,9 +1189,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -1657,7 +1657,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1720,7 +1720,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1759,6 +1759,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
@@ -1903,6 +1904,7 @@ dependencies = [
|
||||
name = "gateway-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-credential-types",
|
||||
@@ -1915,6 +1917,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"redis",
|
||||
"secrecy",
|
||||
"serde",
|
||||
@@ -2250,8 +2253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -2259,6 +2263,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
@@ -3266,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3626,7 +3631,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3831,9 +3836,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -3999,7 +4004,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4014,10 +4019,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -4098,9 +4104,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -4480,9 +4486,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4580,7 +4586,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4649,9 +4655,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
version = "1.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4734,9 +4740,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
version = "0.7.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5051,9 +5057,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -5220,9 +5226,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.5"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -5468,13 +5474,14 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wnaf"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
|
||||
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
"primefield",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-4
@@ -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"
|
||||
@@ -43,7 +43,7 @@ opt-level = 2
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."sha2@0.11.0"]
|
||||
[profile.dev.package."sha2@0.11.1"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."miniz_oxide@0.9.1"]
|
||||
@@ -57,7 +57,5 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
|
||||
@@ -96,6 +96,10 @@ async fn execute(
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
|
||||
@@ -31,6 +31,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
|
||||
@@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
)
|
||||
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
|
||||
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
|
||||
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
|
||||
.env(
|
||||
"CPR_DATABASE_PASSWORD",
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh",
|
||||
)
|
||||
.env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh")
|
||||
.status()
|
||||
.expect("run isolated environment override test");
|
||||
assert!(status.success());
|
||||
@@ -152,14 +155,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["tls"] = serde_json::json!({});
|
||||
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
|
||||
parse_config(&document.to_string()).expect("removed provider settings are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
|
||||
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
|
||||
@@ -189,7 +184,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
document["openai"]["unused_setting"] = serde_json::json!(UNUSED_SECRET);
|
||||
} else if case == "missing" {
|
||||
document["host"]["listen"]
|
||||
.as_object_mut()
|
||||
@@ -229,7 +224,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
|
||||
stderr.contains("openai.unused_setting") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
@@ -253,19 +248,7 @@ fn config_loader_should_allow_missing_provider_configuration() {
|
||||
let mut document = valid_config_document();
|
||||
document.as_object_mut().unwrap().remove("openai");
|
||||
document.as_object_mut().unwrap().remove("xai");
|
||||
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_provider_identity_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
|
||||
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("removed identities ignored");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("legacy-invalid"));
|
||||
assert!(!debug.contains("legacy-client"));
|
||||
parse_config(&document.to_string()).expect("providers use defaults when omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -282,13 +265,53 @@ fn config_loader_should_reject_embedded_database_password() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_non_hex_postgres_password() {
|
||||
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
|
||||
fn config_loader_should_accept_managed_store_passwords() {
|
||||
for password in [
|
||||
"RdsTest9!abcdefGh".to_owned(),
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(),
|
||||
POSTGRES_PASSWORD.to_owned(),
|
||||
"x".repeat(128),
|
||||
"Managed@:/?#%&+Password9!".to_owned(),
|
||||
"数据库密码".repeat(4),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("managed store password");
|
||||
assert!(!format!("{config:?}").contains(&password));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_wrong_length_redis_password() {
|
||||
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
|
||||
fn config_loader_should_reject_invalid_store_passwords() {
|
||||
for password in [
|
||||
String::new(),
|
||||
"x".repeat(15),
|
||||
"x".repeat(129),
|
||||
" ".repeat(32),
|
||||
"\u{3000}".repeat(6),
|
||||
"数据库密码".repeat(9),
|
||||
"RdsTest9!abcdefGh\u{0000}".to_owned(),
|
||||
"RdsTest9!abcdefGh\n".to_owned(),
|
||||
"RdsTest9!abcdefGh\t".to_owned(),
|
||||
"RdsTest9!abcdefGh\u{007f}".to_owned(),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_embedded_redis_password() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"redis://127.0.0.1:6379/",
|
||||
"redis://default:embedded@127.0.0.1:6379/",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
|
||||
@@ -26,10 +26,15 @@ cpr-plugin package \
|
||||
--output-dir /path/to/output
|
||||
```
|
||||
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
|
||||
`--resource-map web=web/dist` 将包内 `web/` 前缀映射到作者清单所在目录下的 `web/dist/`。
|
||||
可重复传入不同前缀,源目录必须位于项目内;没有对应资源时省略该参数,未使用的映射会被拒绝。
|
||||
未映射的资源按清单中的相对路径读取,只收集 `resources` 声明的文件
|
||||
|
||||
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
|
||||
输出为 `<插件 ID>-<版本>-<目标 triple>.tar.gz` 及同名 `.tar.gz.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和声明资源。
|
||||
输入清单及生成后的清单均不得超过 64 KiB,压缩包上限为 32 MiB,展开归档上限为 128 MiB
|
||||
|
||||
作者清单不填写 `package`;字段省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
|
||||
CLI 生成规范化后的 `plugin.json`,补入协议版本、目标平台和文件摘要。打包成功只证明输入符合包合同,实际启动与能力仍需在目标宿主验证
|
||||
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
|
||||
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
|
||||
@@ -45,6 +50,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
|
||||
```
|
||||
|
||||
@@ -250,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"middleware":{"version":6,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{"version":2}
|
||||
},
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
task::{DaemonTask, WorkerDaemonContext, WorkerTaskError},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -75,18 +75,20 @@ impl BackupTask {
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(cancellation).await })
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(context).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
async fn run_daemon(&self, cancellation: CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
async fn run_daemon(&self, context: WorkerDaemonContext) -> Result<(), WorkerTaskError> {
|
||||
let cancellation = context.cancellation();
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle(&cancellation).await?;
|
||||
self.run_cycle(cancellation).await?;
|
||||
context.succeeded();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return Ok(()),
|
||||
@@ -647,7 +649,7 @@ impl BackupTask {
|
||||
for record in expired {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&record.id, now)
|
||||
.transition_to_deleting(&record.id, now, None)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
@@ -671,7 +673,7 @@ impl BackupTask {
|
||||
for decision in decisions.into_iter().take(RETENTION_BATCH_SIZE) {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&decision.record_id, now)
|
||||
.transition_to_deleting(&decision.record_id, now, None)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
@@ -706,6 +708,10 @@ impl BackupTask {
|
||||
fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
match error.code() {
|
||||
code::CANCELLED => (code::CANCELLED, "备份已取消"),
|
||||
code::ANALYTICS_NOT_READY => (
|
||||
code::ANALYTICS_NOT_READY,
|
||||
"用量统计正在重建,请在统计就绪后重试备份",
|
||||
),
|
||||
code::STAGING_SPACE_EXHAUSTED => (code::STAGING_SPACE_EXHAUSTED, "暂存磁盘空间不足"),
|
||||
_ => (code::PG_DUMP_FAILED, "数据库导出失败"),
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ pub struct AccountRecord {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy_enabled: bool,
|
||||
pub credential_state: CredentialState,
|
||||
|
||||
@@ -19,6 +19,10 @@ pub enum MutationAuditOperation {
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
BackupCreate,
|
||||
BackupDelete,
|
||||
BackupConnectionTest,
|
||||
BackupDownloadUrl,
|
||||
ProviderAccountAdaptConcurrency,
|
||||
ProviderAccountRecover,
|
||||
ProviderAccountBatchUpdate,
|
||||
@@ -76,6 +80,10 @@ impl MutationAuditOperation {
|
||||
Self::ProviderAccountUpdate => ("update", "provider_account"),
|
||||
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
|
||||
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
|
||||
Self::BackupCreate => ("backup.created", "backup"),
|
||||
Self::BackupDelete => ("backup.delete_requested", "backup"),
|
||||
Self::BackupConnectionTest => ("backup.s3_connection_tested", "backup_settings"),
|
||||
Self::BackupDownloadUrl => ("backup.download_url_created", "backup"),
|
||||
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
|
||||
Self::ProviderAccountRecover => ("recover", "provider_account"),
|
||||
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
|
||||
|
||||
@@ -15,6 +15,8 @@ pub const STATE_CONFLICT: &str = "backup.state_conflict";
|
||||
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
|
||||
/// `pg_dump` 非零退出或失败
|
||||
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
|
||||
/// 用量汇总尚未完整发布
|
||||
pub const ANALYTICS_NOT_READY: &str = "backup.analytics_not_ready";
|
||||
/// 暂存磁盘空间不足
|
||||
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
|
||||
/// S3 认证失败
|
||||
|
||||
@@ -499,7 +499,7 @@ impl BackupError {
|
||||
/// 构造稳定错误码常量
|
||||
pub mod code;
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.sql.gz`
|
||||
///
|
||||
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
|
||||
/// 输出统一使用 `/` 分隔且不含空段
|
||||
@@ -510,7 +510,7 @@ pub fn build_object_key(
|
||||
) -> Result<String, BackupError> {
|
||||
let prefix = normalize_prefix(prefix)?;
|
||||
Ok(format!(
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.dump",
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.sql.gz",
|
||||
at.format("%Y"),
|
||||
at.format("%m"),
|
||||
at.format("%d"),
|
||||
@@ -519,10 +519,10 @@ pub fn build_object_key(
|
||||
))
|
||||
}
|
||||
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.sql.gz`,与对象 key 的文件名一致
|
||||
#[must_use]
|
||||
pub fn build_download_file_name(backup_id: &str) -> String {
|
||||
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
|
||||
format!("codex-proxy-rs_{}.sql.gz", short_backup_id(backup_id))
|
||||
}
|
||||
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -157,12 +157,15 @@ impl OtherRequestOutcome {
|
||||
/// 用量记录过滤条件
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct UsageFilter {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub operation: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub model_contains: Option<String>,
|
||||
pub outcome: Option<RequestOutcome>,
|
||||
pub status_code: Option<u16>,
|
||||
pub transport: Option<String>,
|
||||
@@ -195,6 +198,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 +206,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 +228,7 @@ impl DashboardQuery {
|
||||
outcome: Some(RequestOutcome::Succeeded),
|
||||
..UsageFilter::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +509,46 @@ pub struct RequestMetrics {
|
||||
pub cache_hit_request_count: u64,
|
||||
}
|
||||
|
||||
/// 摘要实际消费的请求计数与延迟样本,不承诺分位数或分布
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestSummaryMetrics {
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub caller_error_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub cache_write_input_tokens: u64,
|
||||
pub reasoning_output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub latency_sum_ms: u64,
|
||||
pub latency_count: u64,
|
||||
}
|
||||
|
||||
impl From<&RequestMetrics> for RequestSummaryMetrics {
|
||||
fn from(metrics: &RequestMetrics) -> Self {
|
||||
Self {
|
||||
request_count: metrics.request_count,
|
||||
success_count: metrics.success_count,
|
||||
failure_count: metrics.failure_count,
|
||||
cancelled_count: metrics.cancelled_count,
|
||||
incomplete_count: metrics.incomplete_count,
|
||||
caller_error_count: metrics.caller_error_count,
|
||||
input_tokens: metrics.input_tokens,
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_input_tokens: metrics.cached_input_tokens,
|
||||
cache_write_input_tokens: metrics.cache_write_input_tokens,
|
||||
reasoning_output_tokens: metrics.reasoning_output_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
latency_sum_ms: metrics.latency_sum_ms,
|
||||
latency_count: metrics.latency_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上游 attempt 聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AttemptMetrics {
|
||||
@@ -653,6 +708,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 +720,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,
|
||||
@@ -681,7 +740,6 @@ pub struct UsageListRecord {
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
@@ -706,10 +764,6 @@ pub struct UsageListRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -747,7 +801,6 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
|
||||
pub provider_metadata_json: Option<String>,
|
||||
@@ -787,10 +840,6 @@ pub struct UsageRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -885,10 +934,19 @@ pub struct UsageOverview {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
/// 用量摘要所需的窄聚合事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSummaryObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub requests: RequestSummaryMetrics,
|
||||
pub attempts: AttemptMetrics,
|
||||
}
|
||||
|
||||
/// 用量摘要的用例结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSummary {
|
||||
pub overview: UsageOverview,
|
||||
pub observation: UsageSummaryObservation,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -1130,10 +1188,10 @@ pub struct DashboardWireProfile {
|
||||
pub release: Option<DashboardDesktopRelease>,
|
||||
}
|
||||
|
||||
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
|
||||
/// Provider 画像的差异字段,以稳定标识传递语义,名称由前端本地化
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireAttribute {
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -820,6 +820,13 @@ pub struct ProviderQuotaRequest {
|
||||
pub rolling_usage: Option<AccountUsage>,
|
||||
}
|
||||
|
||||
/// 当前目录页已读取的账号事实;批量投影不触发上游刷新
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProviderQuotaReadRequest<'a> {
|
||||
pub account: &'a AccountRecord,
|
||||
pub rolling_usage: Option<&'a AccountUsage>,
|
||||
}
|
||||
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
|
||||
@@ -66,8 +66,12 @@ pub trait BackupRepository: Send + Sync {
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
/// 同事务插入人工 queued 任务与审计;存在活跃任务时返回 `backup.active_task_conflict`
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务
|
||||
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`
|
||||
@@ -111,11 +115,12 @@ pub trait BackupRepository: Send + Sync {
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用
|
||||
/// 迁移到删除流程(completed/failed → deleting);人工操作同事务记录审计,保留策略传 None
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 远端删除成功后硬删除记录行
|
||||
@@ -313,6 +318,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
@@ -363,6 +369,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
&self,
|
||||
_id: &str,
|
||||
_now: DateTime<Utc>,
|
||||
_context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaReadRequest,
|
||||
ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
|
||||
explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -289,6 +290,25 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 只读投影当前页额度,返回值与输入逐项对应,单个失败不影响其他账号
|
||||
/// 没有批量存储能力的 Provider 继续使用原有单账号语义
|
||||
async fn read_quotas(
|
||||
&self,
|
||||
requests: &[ProviderQuotaReadRequest<'_>],
|
||||
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
|
||||
futures::future::join_all(requests.iter().map(|request| async {
|
||||
let account_id = ProviderAccountId::new(request.account.id.clone())
|
||||
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid))?;
|
||||
self.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: request.rolling_usage.cloned(),
|
||||
})
|
||||
.await
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::model::{
|
||||
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
|
||||
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageBillingProjection, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageInsightsObservation, UsageOverview, UsagePage, UsageQuery,
|
||||
UsageInsightsObservation, UsagePage, UsageQuery, UsageSummaryObservation,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
@@ -485,7 +485,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview>;
|
||||
) -> AdminStoreResult<UsageSummaryObservation>;
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
|
||||
@@ -114,10 +114,14 @@ impl DefaultAccountGroupService {
|
||||
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let mut result = result.map_err(|error| map_store_error(error, "account group"))?;
|
||||
if let Some(record) = result.record.as_mut() {
|
||||
self.enrich_records(std::slice::from_mut(record)).await?;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
if let Some(record) = result.record.as_mut()
|
||||
&& let Err(error) = self.enrich_records(std::slice::from_mut(record)).await
|
||||
{
|
||||
tracing::warn!(group_id = %result.id.as_str(), error = %error, "committed account group runtime projection is unavailable");
|
||||
// record 可缺省;不要用未完成的容量投影冒充有效展示数据。
|
||||
result.record = None;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ use crate::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
|
||||
PrepareCredentialRefresh, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
ProviderProfileAvatar, ProviderQuota, ProviderQuotaReadRequest, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast::{
|
||||
AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window,
|
||||
@@ -236,6 +237,78 @@ impl DefaultAccountsService {
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_quotas(
|
||||
providers: &ProviderAdminRegistry,
|
||||
items: &[AccountPageItem],
|
||||
rolling_usage: &BTreeMap<String, AccountUsage>,
|
||||
) -> Vec<ProviderQuota> {
|
||||
let mut groups = BTreeMap::<_, Vec<usize>>::new();
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
groups
|
||||
.entry(&item.account.provider_kind)
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
let batches = groups.into_iter().map(|(kind, indices)| async move {
|
||||
let provider = match providers.require(kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = %kind,
|
||||
error = %error,
|
||||
"account directory provider is not registered; showing empty quota"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let requests = indices
|
||||
.iter()
|
||||
.map(|&index| {
|
||||
let account = &items[index].account;
|
||||
ProviderQuotaReadRequest {
|
||||
account,
|
||||
rolling_usage: rolling_usage.get(&account.id),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = provider.read_quotas(&requests).await;
|
||||
if results.len() != indices.len() {
|
||||
tracing::warn!(
|
||||
provider = %kind,
|
||||
expected = indices.len(),
|
||||
actual = results.len(),
|
||||
"account directory quota batch has invalid length; showing empty quota"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
indices
|
||||
.into_iter()
|
||||
.zip(results)
|
||||
.map(|(index, result)| {
|
||||
let quota = result.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
account_id = %items[index].account.id,
|
||||
error = %error,
|
||||
"account directory quota projection failed; showing empty quota"
|
||||
);
|
||||
empty_quota()
|
||||
});
|
||||
(index, quota)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
// Provider 可独立完成,回填原页顺序并保留未注册或失败账号的位置
|
||||
let mut quotas = vec![empty_quota(); items.len()];
|
||||
for (index, quota) in futures::future::join_all(batches)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
quotas[index] = quota;
|
||||
}
|
||||
quotas
|
||||
}
|
||||
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -487,45 +560,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
|
||||
let account = &item.account;
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回
|
||||
let provider = match providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory provider is not registered; showing empty quota"
|
||||
);
|
||||
return Ok(empty_quota());
|
||||
}
|
||||
};
|
||||
match provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: rolling_usage.get(&account.id).cloned(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(quota) => Ok(quota),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory quota projection failed; showing empty quota"
|
||||
);
|
||||
Ok(empty_quota())
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
let mut quotas = Self::read_quotas(providers, &page.items, &rolling_usage).await;
|
||||
self.attach_quota_local_usage(&page.items, &mut quotas)
|
||||
.await?;
|
||||
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
|
||||
|
||||
@@ -7,11 +7,10 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
audit::{MutationAuditIntent, MutationAuditOperation},
|
||||
backup::{
|
||||
BackupError, BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupSettings,
|
||||
BackupStatus, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
|
||||
@@ -180,8 +179,8 @@ impl BackupService for DefaultBackupService {
|
||||
}
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.s3_connection_tested",
|
||||
"backup_settings",
|
||||
MutationAuditOperation::BackupConnectionTest,
|
||||
"1",
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
@@ -219,11 +218,9 @@ impl BackupService for DefaultBackupService {
|
||||
.map_err(map_backup_error)?;
|
||||
let record = self
|
||||
.repository
|
||||
.insert_backup_record(seed)
|
||||
.insert_backup_record(seed, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?;
|
||||
self.append_audit(context, "backup.created", "backup", vec![record.id.clone()])
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -264,9 +261,9 @@ impl BackupService for DefaultBackupService {
|
||||
.map_err(map_backup_error)?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.download_url_created",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
MutationAuditOperation::BackupDownloadUrl,
|
||||
backup_id,
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
Ok(DownloadUrlResult {
|
||||
@@ -290,17 +287,10 @@ impl BackupService for DefaultBackupService {
|
||||
let now = Utc::now();
|
||||
let deleting = self
|
||||
.repository
|
||||
.transition_to_deleting(backup_id, now)
|
||||
.transition_to_deleting(backup_id, now, Some(context))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.delete_requested",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
Ok(deleting)
|
||||
}
|
||||
}
|
||||
@@ -317,41 +307,15 @@ impl DefaultBackupService {
|
||||
async fn append_audit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
operation: MutationAuditOperation,
|
||||
entity_ref: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> Result<(), AdminError> {
|
||||
let actor_kind = match context.actor {
|
||||
crate::model::MutationActor::AdminSession { .. } => AuditActorKind::AdminSession,
|
||||
crate::model::MutationActor::AdminApiKey => AuditActorKind::AdminApiKey,
|
||||
crate::model::MutationActor::System => AuditActorKind::System,
|
||||
};
|
||||
let actor_ref = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
crate::model::auth::admin_session_actor_ref(admin_user_id)
|
||||
}
|
||||
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
|
||||
crate::model::MutationActor::System => "system".to_owned(),
|
||||
};
|
||||
let actor_admin_user_id = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
Some(admin_user_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let event = AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: "backup".to_owned(),
|
||||
entity_ref: entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: Utc::now(),
|
||||
};
|
||||
let event = MutationAuditIntent {
|
||||
operation,
|
||||
entity_ref,
|
||||
}
|
||||
.event(context, changed_fields);
|
||||
self.auth
|
||||
.append_audit_event(event)
|
||||
.await
|
||||
|
||||
@@ -9,10 +9,7 @@ use std::{
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -332,8 +329,9 @@ impl ImportTasksService for DefaultImportTasksService {
|
||||
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
|
||||
|
||||
impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = context.cancellation();
|
||||
let mut active = FuturesUnordered::new();
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter
|
||||
let cancelled = cancellation.cancelled();
|
||||
@@ -354,6 +352,7 @@ impl DaemonTask for ImportTaskWorker {
|
||||
};
|
||||
active.push(self.0.execute(id, index, input));
|
||||
}
|
||||
context.succeeded();
|
||||
tokio::select! {
|
||||
() = &mut cancelled => {},
|
||||
_ = active.next(), if !active.is_empty() => {},
|
||||
|
||||
@@ -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},
|
||||
@@ -108,7 +106,7 @@ impl DefaultKeyUsageService {
|
||||
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
UsageFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model,
|
||||
model_contains: model,
|
||||
..UsageFilter::default()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 集中归纳已采集证据的缺口,不从缺失事件推断业务结果
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model::diagnostics::DiagnosticBundle;
|
||||
|
||||
use super::gap;
|
||||
|
||||
pub(super) fn trace_gaps(bundle: &mut DiagnosticBundle, section: &str, trace: &Value) {
|
||||
let trace_section = format!("{section}.trace");
|
||||
for (field, reason) in [
|
||||
("droppedEvents", "events_evicted"),
|
||||
("droppedEvidenceEvents", "critical_evidence_evicted"),
|
||||
("stageCountOverflow", "stage_coverage_incomplete"),
|
||||
] {
|
||||
if trace[field].as_u64().unwrap_or(0) > 0 {
|
||||
gap(bundle, &trace_section, reason);
|
||||
}
|
||||
}
|
||||
if !trace["stageCounts"].is_object() {
|
||||
gap(bundle, &trace_section, "stage_coverage_not_recorded");
|
||||
}
|
||||
let events = trace["events"].as_array();
|
||||
if !events.is_some_and(|events| {
|
||||
events.iter().any(|event| {
|
||||
event["stage"] == "request.routed" && event["data"]["accountPolicy"].is_object()
|
||||
})
|
||||
}) {
|
||||
gap(bundle, section, "incident_policy_not_recorded");
|
||||
}
|
||||
for (index, event) in events.into_iter().flatten().enumerate() {
|
||||
let event_section = format!("{trace_section}.events[{index}]");
|
||||
let data = &event["data"];
|
||||
for (missing, reason) in [
|
||||
(
|
||||
event["stage"] == "provider.response.parse_gap",
|
||||
"response_parse_failed",
|
||||
),
|
||||
(data["jsonValid"] == false, "json_parse_failed"),
|
||||
(data["truncated"] == true, "event_data_truncated"),
|
||||
(
|
||||
data["input"]["omittedItems"].as_u64().unwrap_or(0) > 0,
|
||||
"input_prefix_sample_limited",
|
||||
),
|
||||
(
|
||||
data["correlation"].as_object().is_some_and(|headers| {
|
||||
headers
|
||||
.values()
|
||||
.any(|header| header["omittedValues"].as_u64().unwrap_or(0) > 0)
|
||||
}),
|
||||
"correlation_header_sample_limited",
|
||||
),
|
||||
] {
|
||||
if missing {
|
||||
gap(bundle, &event_section, reason);
|
||||
}
|
||||
}
|
||||
if event["stage"] == "capture.gap" || data["truncated"] == true {
|
||||
gap(bundle, &trace_section, "capture_gap_or_truncation");
|
||||
}
|
||||
if event["stage"] == "retry.decided" && data["phase"].is_null() {
|
||||
gap(bundle, &trace_section, "legacy_retry_facts_not_recorded");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-32
@@ -7,6 +7,8 @@ use std::{
|
||||
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
mod availability;
|
||||
|
||||
use super::{DefaultObservabilityService, map_store_error};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind,
|
||||
@@ -150,38 +152,7 @@ impl DefaultObservabilityService {
|
||||
&format!("{section}.trace"),
|
||||
"not_recorded_or_retained",
|
||||
),
|
||||
Some(trace) => {
|
||||
if trace["droppedEvents"].as_u64().unwrap_or(0) > 0 {
|
||||
gap(&mut bundle, &format!("{section}.trace"), "events_evicted");
|
||||
}
|
||||
let events = trace["events"].as_array();
|
||||
if !events.is_some_and(|events| {
|
||||
events.iter().any(|event| {
|
||||
event["stage"] == "request.routed"
|
||||
&& event["data"]["accountPolicy"].is_object()
|
||||
})
|
||||
}) {
|
||||
gap(&mut bundle, §ion, "incident_policy_not_recorded");
|
||||
}
|
||||
for event in events.into_iter().flatten() {
|
||||
if event["stage"] == "capture.gap" || event["data"]["truncated"] == true
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"capture_gap_or_truncation",
|
||||
);
|
||||
}
|
||||
if event["stage"] == "retry.decided" && event["data"]["phase"].is_null()
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"legacy_retry_facts_not_recorded",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(trace) => availability::trace_gaps(&mut bundle, §ion, trace),
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
@@ -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,
|
||||
@@ -266,17 +267,17 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageSummary, AdminError> {
|
||||
let overview = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_summary(range, filter)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage summary"))?;
|
||||
let average_latency_ms = average(
|
||||
overview.requests.latency_sum_ms,
|
||||
overview.requests.latency_count,
|
||||
observation.requests.latency_sum_ms,
|
||||
observation.requests.latency_count,
|
||||
);
|
||||
Ok(UsageSummary {
|
||||
overview,
|
||||
observation,
|
||||
average_latency_ms,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -41,7 +41,7 @@ impl AnalyticsValue for DashboardObservation {
|
||||
&mut self.analytics
|
||||
}
|
||||
}
|
||||
impl AnalyticsValue for UsageOverview {
|
||||
impl AnalyticsValue for UsageSummaryObservation {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.analytics
|
||||
}
|
||||
@@ -177,7 +177,7 @@ type UsageKey = (TimeRange, UsageFilter);
|
||||
pub(crate) struct CachedObservabilityStore {
|
||||
inner: Arc<dyn ObservabilityStore>,
|
||||
dashboard: Cache<DashboardQuery, DashboardObservation>,
|
||||
summaries: Cache<UsageKey, UsageOverview>,
|
||||
summaries: Cache<UsageKey, UsageSummaryObservation>,
|
||||
insights: Cache<(UsageKey, Granularity), UsageInsightsObservation>,
|
||||
diagnostics: Cache<(UsageKey, DiagnosticDimension, u16), DiagnosticsObservation>,
|
||||
}
|
||||
@@ -254,7 +254,7 @@ impl ObservabilityStore for CachedObservabilityStore {
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview> {
|
||||
) -> AdminStoreResult<UsageSummaryObservation> {
|
||||
self.summaries
|
||||
.get((range, filter.clone()), self.inner.as_ref(), TTL, || {
|
||||
self.inner.usage_summary(range, filter)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! 供 backup::task 与 use_case::backup 的测试复用
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -91,7 +92,9 @@ pub(crate) fn queued_record(seed: &BackupRecordSeed) -> BackupRecord {
|
||||
|
||||
/// 内存版备份仓储
|
||||
pub(crate) struct FakeBackupRepository {
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
settings: Mutex<BackupSettings>,
|
||||
settings_unavailable: AtomicBool,
|
||||
records: Mutex<Vec<BackupRecord>>,
|
||||
}
|
||||
|
||||
@@ -99,10 +102,21 @@ impl FakeBackupRepository {
|
||||
pub(crate) fn new(settings: BackupSettings) -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(settings),
|
||||
audits: Mutex::new(Vec::new()),
|
||||
settings_unavailable: AtomicBool::new(false),
|
||||
records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn audit_events(&self) -> Vec<AdminAuditEvent> {
|
||||
self.audits.lock().expect("audits").clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_settings_unavailable(&self, unavailable: bool) {
|
||||
self.settings_unavailable
|
||||
.store(unavailable, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 测试辅助:读取当前全部记录
|
||||
pub(crate) fn all_records(&self) -> Vec<BackupRecord> {
|
||||
self.records.lock().expect("records").clone()
|
||||
@@ -134,6 +148,13 @@ impl FakeBackupRepository {
|
||||
#[async_trait]
|
||||
impl BackupRepository for FakeBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
if self.settings_unavailable.load(Ordering::SeqCst) {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"backup",
|
||||
"test store unavailable",
|
||||
));
|
||||
}
|
||||
Ok(self.settings.lock().expect("settings").clone())
|
||||
}
|
||||
|
||||
@@ -192,7 +213,11 @@ impl BackupRepository for FakeBackupRepository {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord> {
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active()) {
|
||||
return Err(AdminStoreError::new(
|
||||
@@ -203,6 +228,13 @@ impl BackupRepository for FakeBackupRepository {
|
||||
}
|
||||
let record = queued_record(&seed);
|
||||
records.push(record.clone());
|
||||
self.audits.lock().expect("audits").push(
|
||||
gateway_admin::model::audit::MutationAuditIntent {
|
||||
operation: gateway_admin::model::audit::MutationAuditOperation::BackupCreate,
|
||||
entity_ref: &record.id,
|
||||
}
|
||||
.event(context, vec!["status".to_owned()]),
|
||||
);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -386,6 +418,7 @@ impl BackupRepository for FakeBackupRepository {
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
@@ -396,6 +429,15 @@ impl BackupRepository for FakeBackupRepository {
|
||||
};
|
||||
record.status = BackupStatus::Deleting;
|
||||
record.updated_at = now;
|
||||
if let Some(context) = context {
|
||||
self.audits.lock().expect("audits").push(
|
||||
gateway_admin::model::audit::MutationAuditIntent {
|
||||
operation: gateway_admin::model::audit::MutationAuditOperation::BackupDelete,
|
||||
entity_ref: id,
|
||||
}
|
||||
.event(context, vec!["status".to_owned()]),
|
||||
);
|
||||
}
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use gateway_admin::{
|
||||
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
|
||||
|
||||
use super::support::{
|
||||
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
|
||||
@@ -31,7 +32,7 @@ async fn backup_task_runs_full_dump_upload_verify_pipeline() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
@@ -72,7 +73,7 @@ async fn backup_task_marks_failed_when_dump_port_fails() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
@@ -104,7 +105,7 @@ async fn backup_task_marks_failed_with_the_upload_error() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
@@ -137,7 +138,7 @@ async fn backup_task_recovery_marks_failed_with_the_upload_error() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
let cancellation = CancellationToken::new();
|
||||
@@ -184,14 +185,14 @@ async fn retention_cleans_expired_scheduled_backups() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(old_seed.clone())
|
||||
.insert_backup_record(old_seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert old");
|
||||
// 旧备份 40 天前完成(释放活跃名额),再插入新备份
|
||||
let now = Utc::now();
|
||||
repository.set_completed(&backup_id("old"), now - Duration::days(40));
|
||||
repository
|
||||
.insert_backup_record(new_seed.clone())
|
||||
.insert_backup_record(new_seed.clone(), &system_context())
|
||||
.await
|
||||
.expect("insert new");
|
||||
// 新备份 1 天前完成
|
||||
@@ -278,7 +279,10 @@ async fn completed_recovery_removes_staging_for_dumping_and_uploading_tasks() {
|
||||
object_key: "recovery.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository.insert_backup_record(seed.clone()).await.unwrap();
|
||||
repository
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.unwrap();
|
||||
let artifact = dump
|
||||
.dump(DumpRequest {
|
||||
backup_id: seed.id.clone(),
|
||||
@@ -343,7 +347,10 @@ async fn manual_backup_preserves_primary_and_final_delete_failures() {
|
||||
object_key: "codex/cleanup-failure.dump".into(),
|
||||
expires_at: expiring.then(|| Utc::now() + Duration::hours(1)),
|
||||
};
|
||||
repository.insert_backup_record(seed.clone()).await.unwrap();
|
||||
repository
|
||||
.insert_backup_record(seed.clone(), &system_context())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), store.clone());
|
||||
task.run_cycle(&CancellationToken::new()).await.unwrap();
|
||||
let records = repository.all_records();
|
||||
@@ -379,3 +386,40 @@ async fn manual_backup_preserves_primary_and_final_delete_failures() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn daemon_reports_success_only_after_a_complete_cycle_and_recovers_after_store_failure() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let task = Arc::new(BackupTask::new(
|
||||
repository.clone(),
|
||||
Arc::new(FakeDumpPort::new()),
|
||||
Arc::new(FakeObjectStore::new()),
|
||||
));
|
||||
let context = WorkerDaemonContext::new(CancellationToken::new());
|
||||
repository.set_settings_unavailable(true);
|
||||
let error = task.run(context.clone()).await.unwrap_err();
|
||||
assert_eq!(error.as_safe_str(), "backup store failed");
|
||||
assert_eq!(context.completed_cycles(), 0);
|
||||
|
||||
repository.set_settings_unavailable(false);
|
||||
let recovered = WorkerDaemonContext::new(CancellationToken::new());
|
||||
let worker = tokio::spawn({
|
||||
let task = task.clone();
|
||||
let context = recovered.clone();
|
||||
async move { task.run(context).await }
|
||||
});
|
||||
recovered.progress_since(0).await;
|
||||
assert_eq!(recovered.completed_cycles(), 1);
|
||||
repository.set_settings_unavailable(true);
|
||||
tokio::time::advance(std::time::Duration::from_secs(30)).await;
|
||||
assert_eq!(
|
||||
worker.await.unwrap().unwrap_err().as_safe_str(),
|
||||
"backup store failed"
|
||||
);
|
||||
assert_eq!(recovered.completed_cycles(), 1);
|
||||
|
||||
let cancelled = WorkerDaemonContext::new(CancellationToken::new());
|
||||
cancelled.cancellation().cancel();
|
||||
task.run(cancelled.clone()).await.unwrap();
|
||||
assert_eq!(cancelled.completed_cycles(), 0);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,16 @@ use std::str::FromStr as _;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::account::OpaqueProviderData;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
use gateway_admin::model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
ProviderDocument, ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
},
|
||||
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
|
||||
settings::AdminApiKey,
|
||||
};
|
||||
|
||||
mod backup;
|
||||
@@ -99,20 +95,6 @@ fn login_command_debug_should_redact_password() {
|
||||
assert!(!format!("{command:?}").contains("secret-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_store_capability_traits_should_be_object_safe() {
|
||||
fn assert_object_safe<T: ?Sized + Send + Sync>() {}
|
||||
|
||||
assert_object_safe::<dyn AccountStore>();
|
||||
assert_object_safe::<dyn AuthStore>();
|
||||
assert_object_safe::<dyn ClientKeyStore>();
|
||||
assert_object_safe::<dyn ObservabilityStore>();
|
||||
assert_object_safe::<dyn SettingsStore>();
|
||||
|
||||
fn assert_send_object_safe<T: ?Sized + Send>() {}
|
||||
assert_send_object_safe::<dyn CredentialCommitGuard>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
let document = ProviderDocument::new(OpaqueProviderData::new(Default::default()));
|
||||
|
||||
@@ -58,7 +58,7 @@ fn object_key_shape() {
|
||||
let key = build_object_key("production/backups", "backup_abc", at).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.dump"
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.sql.gz"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,9 @@ async fn group_options_query_should_preserve_store_failures() {
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeGroupStore {
|
||||
projection_failure: bool,
|
||||
missing_record: bool,
|
||||
published_revision: Option<Arc<std::sync::atomic::AtomicU64>>,
|
||||
requested_groups: Mutex<Vec<String>>,
|
||||
members: Option<Vec<AccountGroupMemberFact>>,
|
||||
}
|
||||
@@ -223,6 +226,16 @@ impl AccountGroupStore for FakeGroupStore {
|
||||
&self,
|
||||
group_ids: &[AccountGroupId],
|
||||
) -> AdminStoreResult<Vec<AccountGroupMemberFact>> {
|
||||
if self.projection_failure {
|
||||
assert_eq!(
|
||||
self.published_revision
|
||||
.as_ref()
|
||||
.expect("publication counter")
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
2
|
||||
);
|
||||
return Err(unused());
|
||||
}
|
||||
*self.requested_groups.lock().expect("requested groups") = group_ids
|
||||
.iter()
|
||||
.map(|group_id| group_id.as_str().to_owned())
|
||||
@@ -251,10 +264,16 @@ impl AccountGroupStore for FakeGroupStore {
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
_: SetAccountGroupEnabled,
|
||||
command: SetAccountGroupEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unused())
|
||||
let mut record = group_record();
|
||||
record.enabled = command.enabled;
|
||||
Ok(AccountGroupMutation {
|
||||
config_revision: Revision::new(2).expect("committed revision"),
|
||||
id: record.id.clone(),
|
||||
record: (!self.missing_record).then_some(record),
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_account_group(
|
||||
@@ -431,3 +450,50 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
|
||||
assert_eq!(created.kind(), AdminErrorKind::Invalid);
|
||||
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
struct RecordingPublication(Arc<std::sync::atomic::AtomicU64>);
|
||||
|
||||
impl gateway_core::runtime::SnapshotControl for RecordingPublication {
|
||||
fn publish_committed(
|
||||
&self,
|
||||
revision: gateway_core::routing::ConfigRevision,
|
||||
) -> futures::future::BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
self.0
|
||||
.store(revision.get(), std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_group_mutations_publish_even_when_the_display_projection_is_unavailable() {
|
||||
for missing_record in [false, true] {
|
||||
let published = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let groups = Arc::new(FakeGroupStore {
|
||||
projection_failure: true,
|
||||
missing_record,
|
||||
published_revision: Some(published.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
let services = AdminHarness::new()
|
||||
.account_groups(groups)
|
||||
.snapshot(Arc::new(RecordingPublication(published.clone())))
|
||||
.build()
|
||||
.await;
|
||||
let mutation = services
|
||||
.account_groups()
|
||||
.set_enabled(
|
||||
&crate::backup::support::system_context(),
|
||||
SetAccountGroupEnabled {
|
||||
id: group_id(),
|
||||
enabled: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("committed mutation remains successful");
|
||||
assert_eq!(mutation.config_revision.get(), 2);
|
||||
assert_eq!(mutation.id, group_id());
|
||||
assert!(mutation.record.is_none());
|
||||
assert_eq!(published.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2098,11 +2098,23 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
provider_data: None,
|
||||
});
|
||||
let failing = FakeProviderAdmin::new("xai", events.clone());
|
||||
let mut xai_quota = failing.quota.lock().expect("provider quota").clone();
|
||||
xai_quota.observed_at = Some(Utc::now());
|
||||
failing.set_quota(xai_quota);
|
||||
failing.fail_next_quota(ProviderAdminErrorKind::Invalid);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let mut xai_account = account_record("xai");
|
||||
xai_account.id = "acct_bad_xai".to_owned();
|
||||
store.set_accounts(vec![account_record("openai"), xai_account]);
|
||||
let mut healthy_xai = xai_account.clone();
|
||||
healthy_xai.id = "acct_healthy_xai".to_owned();
|
||||
let mut second_openai = account_record("openai");
|
||||
second_openai.id = "acct_second_openai".to_owned();
|
||||
store.set_accounts(vec![
|
||||
account_record("openai"),
|
||||
xai_account,
|
||||
second_openai,
|
||||
healthy_xai,
|
||||
]);
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store.clone())
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
@@ -2126,7 +2138,18 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
.await
|
||||
.expect("one failing quota must not fail the directory page");
|
||||
|
||||
assert_eq!(page.items.len(), 2);
|
||||
assert_eq!(
|
||||
page.items
|
||||
.iter()
|
||||
.map(|item| item.account.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"acct_test",
|
||||
"acct_bad_xai",
|
||||
"acct_second_openai",
|
||||
"acct_healthy_xai"
|
||||
]
|
||||
);
|
||||
let by_id = page
|
||||
.items
|
||||
.iter()
|
||||
@@ -2139,7 +2162,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
assert!(degraded.quota.windows.is_empty());
|
||||
assert!(degraded.quota.observed_at.is_none());
|
||||
assert!(degraded.quota.provider_data.is_none());
|
||||
assert_eq!(page.summary.total, 2);
|
||||
assert!(by_id["acct_healthy_xai"].quota.observed_at.is_some());
|
||||
assert_eq!(by_id["acct_second_openai"].quota.windows.len(), 1);
|
||||
assert_eq!(page.summary.total, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3194,6 +3219,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
|
||||
@@ -38,9 +38,12 @@ async fn backup_service_creates_downloads_and_deletes_with_audit() {
|
||||
assert_eq!(created.status, BackupStatus::Queued);
|
||||
assert!(created.object_key.starts_with("codex/"));
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
repository
|
||||
.audit_events()
|
||||
.iter()
|
||||
.any(|action| action == "backup.created")
|
||||
.any(|event| event.action == "backup.created"
|
||||
&& event.entity_ref == created.id
|
||||
&& event.request_id.as_deref() == Some(system_context().request_id.as_str()))
|
||||
);
|
||||
|
||||
// 下载地址要求 completed 记录
|
||||
@@ -65,9 +68,12 @@ async fn backup_service_creates_downloads_and_deletes_with_audit() {
|
||||
.expect("delete backup");
|
||||
assert_eq!(deleting.status, BackupStatus::Deleting);
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
repository
|
||||
.audit_events()
|
||||
.iter()
|
||||
.any(|action| action == "backup.delete_requested")
|
||||
.any(|event| event.action == "backup.delete_requested"
|
||||
&& event.entity_ref == created.id
|
||||
&& event.changed_fields == ["status"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use gateway_admin::{
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
routing::ProviderKind,
|
||||
task::{WorkerContribution, WorkerKind, WorkerRunnable},
|
||||
task::{WorkerContribution, WorkerDaemonContext, WorkerKind, WorkerRunnable},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -75,7 +75,9 @@ fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandl
|
||||
let cancellation = CancellationToken::new();
|
||||
let token = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
task.run(token).await.expect("import daemon exits cleanly");
|
||||
task.run(WorkerDaemonContext::new(token))
|
||||
.await
|
||||
.expect("import daemon exits cleanly");
|
||||
});
|
||||
(cancellation, handle)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ use gateway_admin::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardQuery, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticsObservation, Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint,
|
||||
TimeRange, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
TimeRange, UsageDetail, UsageFilter, UsagePage, UsageQuery, UsageSummaryObservation,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
@@ -99,6 +99,7 @@ pub(super) struct AdminHarness {
|
||||
proxy_probe: Arc<dyn gateway_admin::ports::proxy::ProxyProbe>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
@@ -125,6 +126,7 @@ impl AdminHarness {
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
account_runtime: unavailable.clone(),
|
||||
account_groups: Arc::new(UnavailableAccountGroupStore),
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
auth: Arc::new(BootstrapAuthStore::default()),
|
||||
client_keys: unavailable.clone(),
|
||||
observability: unavailable.clone(),
|
||||
@@ -168,6 +170,11 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(mut self, snapshot: Arc<dyn SnapshotControl>) -> Self {
|
||||
self.snapshot = snapshot;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn auth(mut self, store: Arc<dyn AuthStore>) -> Self {
|
||||
self.auth = store;
|
||||
self
|
||||
@@ -291,7 +298,7 @@ impl AdminHarness {
|
||||
self.providers,
|
||||
)
|
||||
.unwrap(),
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
snapshot: self.snapshot,
|
||||
account_probe: self.probe,
|
||||
proxy_probe: self.proxy_probe,
|
||||
client_distribution: Arc::new(NoopClientDistribution),
|
||||
@@ -826,7 +833,11 @@ impl ObservabilityStore for UnavailableStore {
|
||||
Err(unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<UsageSummaryObservation> {
|
||||
Err(unavailable("usage summary"))
|
||||
}
|
||||
|
||||
@@ -1033,7 +1044,7 @@ pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
|
||||
},
|
||||
user_agent: "gateway-admin-test".to_owned(),
|
||||
attributes: vec![DashboardWireAttribute {
|
||||
label: "Core".to_owned(),
|
||||
key: "core".to_owned(),
|
||||
value: "test".to_owned(),
|
||||
}],
|
||||
verified_at: Some(chrono::Utc::now()),
|
||||
|
||||
@@ -23,7 +23,7 @@ use gateway_admin::{
|
||||
HealthStatus, LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
|
||||
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
|
||||
UsageBilling, UsageDetail, UsageFilter, UsageInsightsObservation, UsageListRecord,
|
||||
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery,
|
||||
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -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(),
|
||||
@@ -1046,7 +1166,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview> {
|
||||
) -> AdminStoreResult<UsageSummaryObservation> {
|
||||
self.usage_summary_filters
|
||||
.lock()
|
||||
.expect("summary filters")
|
||||
@@ -1058,9 +1178,12 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
if self.summary_fail.load(Ordering::SeqCst) {
|
||||
return Err(super::unavailable("projection summary failed"));
|
||||
}
|
||||
Ok(UsageOverview {
|
||||
let overview = self.overview.lock().expect("overview").clone();
|
||||
Ok(UsageSummaryObservation {
|
||||
range,
|
||||
..self.overview.lock().expect("overview").clone()
|
||||
analytics: overview.analytics,
|
||||
requests: (&overview.requests).into(),
|
||||
attempts: overview.attempts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1219,6 +1342,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(),
|
||||
@@ -1235,7 +1361,6 @@ fn total_record(
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
upstream_response_model: None,
|
||||
service_tier: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
@@ -1267,10 +1392,6 @@ fn total_record(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(100),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
@@ -1385,8 +1506,8 @@ async fn usage_summary_cache_should_coalesce_identical_queries_and_invalidate_de
|
||||
.usage_summary(range, filter)
|
||||
.await
|
||||
.expect("generation refresh");
|
||||
assert_eq!(refreshed.overview.analytics.generation, 1);
|
||||
assert_eq!(refreshed.overview.requests.request_count, 7);
|
||||
assert_eq!(refreshed.observation.analytics.generation, 1);
|
||||
assert_eq!(refreshed.observation.requests.request_count, 7);
|
||||
assert_eq!(store.usage_summary_filters.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
@@ -1406,13 +1527,17 @@ async fn usage_summary_cache_should_coalesce_when_generation_advances_during_the
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first.expect("first summary").overview.analytics.generation,
|
||||
first
|
||||
.expect("first summary")
|
||||
.observation
|
||||
.analytics
|
||||
.generation,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
second
|
||||
.expect("coalesced summary")
|
||||
.overview
|
||||
.observation
|
||||
.analytics
|
||||
.generation,
|
||||
1
|
||||
|
||||
@@ -21,7 +21,7 @@ fn counters_preserve_integer_precision_and_reject_json_numbers() {
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_absent() {
|
||||
async fn usage_mutations_require_a_trusted_actor() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let denied = registry
|
||||
@@ -38,21 +38,6 @@ async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_a
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let ops = registry
|
||||
.operations()
|
||||
.filter(|op| op.starts_with("usage.") || *op == "pricing.evaluate")
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ops,
|
||||
std::collections::BTreeSet::from([
|
||||
"usage.records.get",
|
||||
"usage.records.list",
|
||||
"usage.records.write",
|
||||
"usage.aggregates.list",
|
||||
"usage.aggregates.write",
|
||||
"pricing.evaluate"
|
||||
])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sdk_wire_types_match_host_contracts() {
|
||||
|
||||
@@ -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]]
|
||||
@@ -38,6 +38,7 @@ tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
zstd = "=0.13.3"
|
||||
zip = { version = "9.0.4", default-features = false, features = ["deflate-flate2"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(super) fn account_view(
|
||||
let usage_period = quota.usage_window().map(|(_, period)| period);
|
||||
let mut usage = account_usage_view(usage, usage_period, now, time);
|
||||
if account.authentication_kind == "api_key" {
|
||||
usage.window_label_display = "通用额度".to_owned();
|
||||
usage.window_kind = AccountUsageWindowKind::General;
|
||||
}
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
|
||||
AccountView {
|
||||
@@ -120,6 +120,7 @@ pub(super) fn account_view(
|
||||
},
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_id: account.outbound_proxy_id,
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
@@ -240,6 +241,10 @@ pub(super) fn account_quota_view(
|
||||
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
|
||||
(
|
||||
AccountQuotaView {
|
||||
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
|
||||
refreshed_at_relative: quota
|
||||
.observed_at
|
||||
.map(|value| time.relative_view(value, now)),
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
@@ -343,12 +348,11 @@ pub(super) fn account_usage_view(
|
||||
"known"
|
||||
};
|
||||
AccountUsageView {
|
||||
window_label_display: match period {
|
||||
Some(AccountUsagePeriod::Weekly) => "周额度窗口",
|
||||
Some(AccountUsagePeriod::Monthly) => "月额度窗口",
|
||||
None => "周/月额度窗口",
|
||||
}
|
||||
.to_owned(),
|
||||
window_kind: match period {
|
||||
Some(AccountUsagePeriod::Weekly) => AccountUsageWindowKind::Weekly,
|
||||
Some(AccountUsagePeriod::Monthly) => AccountUsageWindowKind::Monthly,
|
||||
None => AccountUsageWindowKind::Quota,
|
||||
},
|
||||
request_count: Some(usage.request_count),
|
||||
request_count_display: format_number(usage.request_count),
|
||||
input_tokens: usage.input_tokens,
|
||||
@@ -469,7 +473,7 @@ pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
|
||||
|
||||
pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
AccountUsageView {
|
||||
window_label_display: "周/月额度窗口".to_owned(),
|
||||
window_kind: AccountUsageWindowKind::Quota,
|
||||
request_count: None,
|
||||
request_count_display: "—".to_owned(),
|
||||
input_tokens: None,
|
||||
|
||||
@@ -267,6 +267,7 @@ pub struct AccountSummaryView {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -384,6 +385,8 @@ pub use crate::admin::account_groups::AccountGroupRefView;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub refreshed_at: Option<String>,
|
||||
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub refreshed_at_display: String,
|
||||
pub limit_reached: bool,
|
||||
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
|
||||
@@ -424,11 +427,21 @@ pub struct AccountQuotaWindowView {
|
||||
pub reset_at_display: String,
|
||||
}
|
||||
|
||||
/// 账号用量窗口的语义标识,展示名称由管理端本地化
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountUsageWindowKind {
|
||||
General,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Quota,
|
||||
}
|
||||
|
||||
/// 账号观测用量
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountUsageView {
|
||||
pub window_label_display: String,
|
||||
pub window_kind: AccountUsageWindowKind,
|
||||
pub request_count: Option<u64>,
|
||||
pub request_count_display: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -1033,8 +1046,7 @@ impl AccountExportData {
|
||||
time: crate::time::TimePresenter,
|
||||
) -> Self {
|
||||
let filename = format!(
|
||||
"cpr-accounts-selected-{}-{}.json",
|
||||
bundle.documents.len(),
|
||||
"cpr-accounts-{}.json",
|
||||
time.label(bundle.exported_at, "%Y-%m-%d")
|
||||
);
|
||||
let documents = bundle
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 只打包已经脱敏的诊断文档,摘要与正文使用同一份证据
|
||||
|
||||
use std::io::{self, Cursor, Write};
|
||||
|
||||
use serde_json::Value;
|
||||
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
pub(super) fn build(document: &Value) -> io::Result<Vec<u8>> {
|
||||
let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(CompressionMethod::Deflated)
|
||||
.compression_level(Some(1))
|
||||
.unix_permissions(0o600);
|
||||
archive.start_file("diagnostics.json", options)?;
|
||||
serde_json::to_writer_pretty(&mut archive, document)?;
|
||||
archive.start_file("summary.txt", options)?;
|
||||
write!(
|
||||
archive,
|
||||
"CPR 诊断摘要\n导出时间:{}\n目标:{}\n导出截断:{}\n环境:{}\n",
|
||||
document["collectedAt"],
|
||||
document["target"],
|
||||
document["exportTruncated"],
|
||||
document["environment"]
|
||||
)?;
|
||||
for request in document["requests"].as_array().into_iter().flatten() {
|
||||
let evidence = &request["evidence"];
|
||||
let trace = &evidence["trace"];
|
||||
let detail = &evidence["detail"]["request"];
|
||||
writeln!(
|
||||
archive,
|
||||
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={}\n传输:{} → {}\n时延:{}",
|
||||
detail["logicalOutcome"],
|
||||
detail["statusCode"],
|
||||
detail["attemptCount"],
|
||||
detail["requestedModel"],
|
||||
detail["upstreamModel"],
|
||||
detail["clientTransport"],
|
||||
detail["upstreamTransport"],
|
||||
detail["latencyDetails"]
|
||||
)?;
|
||||
writeln!(
|
||||
archive,
|
||||
"\n请求:{}\n总事件:{};淘汰事件:{};关键证据淘汰:{}\n采集阶段:{}",
|
||||
request["requestId"],
|
||||
trace["totalEvents"],
|
||||
trace["droppedEvents"],
|
||||
trace["droppedEvidenceEvents"],
|
||||
trace["stageCounts"]
|
||||
)?;
|
||||
for event in trace["events"].as_array().into_iter().flatten() {
|
||||
if matches!(
|
||||
event["stage"].as_str(),
|
||||
Some(
|
||||
"request.finished"
|
||||
| "attempt.failed"
|
||||
| "retry.decided"
|
||||
| "provider.response.usage"
|
||||
| "request.usage"
|
||||
| "capture.gap"
|
||||
| "provider.response.parse_gap"
|
||||
)
|
||||
) {
|
||||
writeln!(
|
||||
archive,
|
||||
"attempt={} t={}ms {}:{}",
|
||||
event["attemptIndex"], event["elapsedMs"], event["stage"], event["data"]
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !document["error"].is_null() {
|
||||
writeln!(archive, "\n目标错误:{}", document["error"])?;
|
||||
}
|
||||
writeln!(archive, "\n证据缺口:")?;
|
||||
for gap in document["availability"]["gaps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
writeln!(archive, "{}:{}", gap["section"], gap["reason"])?;
|
||||
}
|
||||
archive.start_file("README.txt", options)?;
|
||||
archive.write_all("diagnostics.json 为完整结构化证据,summary.txt 为同一证据的摘要\n先检查 availability.gaps、exportTruncated 与 trace.stageCounts\n缺失、字段无效、上游明确返回 0、采集截断和证据淘汰含义不同\nprovider.response.usage 为上游终态观测及共享解析结果,request.usage 为网关最终归集结果\n输入指纹来自已解析对象;outbound 为准备发送的对象,成功发送需结合发送事实与响应判断\n指纹只能比较内容与顺序,不能证明上游缓存节点或缓存是否仍存在\nenvironment 是导出时环境;事发配置见 incidentConfiguration 与 request.routed\n历史记录不会补生成当时未采集的信息,未知业务内容不属于自动脱敏保证范围\n".as_bytes())?;
|
||||
Ok(archive.finish()?.into_inner())
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::{auth::SessionState, time::TimePresenter};
|
||||
|
||||
mod archive;
|
||||
mod privacy;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -46,26 +47,43 @@ where
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let time = TimePresenter::new(state.admin_services().timezone());
|
||||
let id = match &bundle.target {
|
||||
DiagnosticTarget::Request(id) | DiagnosticTarget::Operation(id) => id,
|
||||
};
|
||||
let suffix = id
|
||||
.chars()
|
||||
.take(128)
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let disposition = format!("attachment; filename=cpr-diagnostics-{suffix}.zip");
|
||||
let document = privacy::redact(document(bundle, time));
|
||||
let body = serde_json::to_vec_pretty(&document).map_err(|_| {
|
||||
map_service_error(gateway_admin::model::AdminError::internal(
|
||||
"诊断文件生成失败",
|
||||
))
|
||||
})?;
|
||||
let body = tokio::task::spawn_blocking(move || archive::build(&document))
|
||||
.await
|
||||
.map_err(archive_error)?
|
||||
.map_err(archive_error)?;
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=diagnostics.json",
|
||||
),
|
||||
(header::CACHE_CONTROL, "private, no-store"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
(header::CONTENT_TYPE, "application/zip".to_owned()),
|
||||
(header::CONTENT_DISPOSITION, disposition),
|
||||
(header::CACHE_CONTROL, "private, no-store".to_owned()),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()),
|
||||
],
|
||||
body,
|
||||
))
|
||||
}
|
||||
|
||||
fn archive_error(source: impl Into<gateway_core::error::ErrorSource>) -> AdminError {
|
||||
map_service_error(
|
||||
gateway_admin::model::AdminError::internal("诊断文件生成失败").with_source(source),
|
||||
)
|
||||
}
|
||||
|
||||
fn document(bundle: DiagnosticBundle, time: TimePresenter) -> Value {
|
||||
let target = match &bundle.target {
|
||||
DiagnosticTarget::Request(id) => json!({"requestId": id}),
|
||||
@@ -101,6 +119,7 @@ fn document(bundle: DiagnosticBundle, time: TimePresenter) -> Value {
|
||||
}).collect::<Vec<_>>();
|
||||
json!({
|
||||
"schemaVersion": 5,
|
||||
"exportTruncated": false,
|
||||
"source": "gateway.diagnostics",
|
||||
"exportPolicy": "support_credentials_redacted",
|
||||
"target": target,
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
|
||||
outcome.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn request_metrics_view(metrics: &domain::RequestMetrics) -> RequestMetricsView {
|
||||
fn request_summary_metrics_view(metrics: &domain::RequestSummaryMetrics) -> RequestMetricsView {
|
||||
RequestMetricsView {
|
||||
request_count: metrics.request_count,
|
||||
success_count: metrics.success_count,
|
||||
@@ -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,
|
||||
@@ -277,7 +279,6 @@ pub(crate) fn usage_list_record_view(
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
upstream_model: record.upstream_model_id,
|
||||
upstream_response_model: record.upstream_response_model,
|
||||
service_tier: record.service_tier,
|
||||
client_transport: record.client_transport,
|
||||
upstream_transport: record.upstream_transport,
|
||||
@@ -302,10 +303,6 @@ pub(crate) fn usage_list_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
first_token_latency_ms: record.first_token_ms,
|
||||
latency_ms: record.latency_ms,
|
||||
@@ -379,7 +376,6 @@ pub(crate) fn usage_record_view(
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
upstream_model: record.upstream_model_id,
|
||||
upstream_response_model: record.upstream_response_model,
|
||||
service_tier: record.service_tier,
|
||||
status_code,
|
||||
client_transport: record.client_transport,
|
||||
@@ -409,10 +405,6 @@ pub(crate) fn usage_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
attempt_index: None,
|
||||
attempt_count: u64::from(record.attempt_count),
|
||||
@@ -692,20 +684,10 @@ pub(crate) fn trend_point_view(
|
||||
tokens_value: point.metrics.total_tokens,
|
||||
errors: format_compact_number(point.service_failure_count),
|
||||
errors_value: point.service_failure_count,
|
||||
latency: display_duration(point.average_latency_ms),
|
||||
latency_value: point.average_latency_ms,
|
||||
max_latency: display_duration(point.metrics.max_latency_ms),
|
||||
max_latency_value: point.metrics.max_latency_ms,
|
||||
min_latency: display_duration(point.metrics.min_latency_ms),
|
||||
min_latency_value: point.metrics.min_latency_ms,
|
||||
success_rate: success_rate_value
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
success_rate_value,
|
||||
first_token_p50_ms: point
|
||||
.metrics
|
||||
.first_token_latency_percentiles
|
||||
.p50_ms
|
||||
.map(|value| value.as_f64()),
|
||||
first_token_p95_ms: point
|
||||
.metrics
|
||||
.first_token_latency_percentiles
|
||||
@@ -717,20 +699,6 @@ pub(crate) fn trend_point_view(
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
output_throughput_p50: point.metrics.output_throughput_p50,
|
||||
admission_decision_p95_ms: point
|
||||
.metrics
|
||||
.admission_decision_percentiles
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
account_selection_wait_p95_ms: point
|
||||
.metrics
|
||||
.account_selection_wait_percentiles
|
||||
.p95_ms
|
||||
.map(|value| value.as_f64()),
|
||||
capacity_utilization: point
|
||||
.metrics
|
||||
.capacity_utilization_p95_basis_points
|
||||
.map(basis_points_rate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,34 +709,34 @@ pub(crate) fn trend_summary_view(
|
||||
match kind {
|
||||
TrendKind::Usage => vec![
|
||||
TrendSummaryView {
|
||||
label: "输入".to_owned(),
|
||||
metric: TrendMetric::InputTokens,
|
||||
value: format_compact_number(summary.input_tokens),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "输出".to_owned(),
|
||||
metric: TrendMetric::OutputTokens,
|
||||
value: format_compact_number(summary.output_tokens),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "缓存".to_owned(),
|
||||
metric: TrendMetric::CachedInputTokens,
|
||||
value: format_compact_number(summary.cached_input_tokens),
|
||||
ratio: None,
|
||||
},
|
||||
],
|
||||
TrendKind::Latency => vec![
|
||||
TrendSummaryView {
|
||||
label: "首字 P95".to_owned(),
|
||||
metric: TrendMetric::FirstTokenP95Ms,
|
||||
value: display_duration_f64(summary.peak_first_token_p95_ms),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "总耗时 P95".to_owned(),
|
||||
metric: TrendMetric::LatencyP95Ms,
|
||||
value: display_duration_f64(summary.peak_latency_p95_ms),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "速率 P50".to_owned(),
|
||||
metric: TrendMetric::OutputThroughputP50,
|
||||
value: summary
|
||||
.minimum_output_throughput_p50
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
|
||||
@@ -777,19 +745,19 @@ pub(crate) fn trend_summary_view(
|
||||
],
|
||||
TrendKind::Errors => vec![
|
||||
TrendSummaryView {
|
||||
label: "错误数".to_owned(),
|
||||
metric: TrendMetric::Errors,
|
||||
value: format_compact_number(summary.service_failure_count),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "成功率".to_owned(),
|
||||
metric: TrendMetric::SuccessRate,
|
||||
value: "—".to_owned(),
|
||||
ratio: summary
|
||||
.success_rate
|
||||
.map(|value| format!("{:.1}%", value * 100.0)),
|
||||
},
|
||||
TrendSummaryView {
|
||||
label: "总请求".to_owned(),
|
||||
metric: TrendMetric::Requests,
|
||||
value: format_compact_number(summary.request_count),
|
||||
ratio: None,
|
||||
},
|
||||
@@ -881,7 +849,7 @@ pub(crate) fn wire_profile_view(
|
||||
.attributes
|
||||
.into_iter()
|
||||
.map(|attribute| DashboardWireAttributeView {
|
||||
label: attribute.label,
|
||||
key: attribute.key,
|
||||
value: attribute.value,
|
||||
})
|
||||
.collect(),
|
||||
@@ -934,8 +902,10 @@ pub(crate) fn dashboard_view(
|
||||
.filter(|window| window.used_percent.is_none())
|
||||
.and_then(|window| window.local_usage.as_ref())
|
||||
.map(|usage| usage.request_count);
|
||||
let (metric_label, metric_value) = match local_requests {
|
||||
let (metric, metric_value_raw, metric_label, metric_value) = match local_requests {
|
||||
Some(count) => (
|
||||
DashboardAccountMetric::Requests,
|
||||
Some(count),
|
||||
"次数".to_owned(),
|
||||
if count > 0 {
|
||||
format_compact_number(count)
|
||||
@@ -944,6 +914,8 @@ pub(crate) fn dashboard_view(
|
||||
},
|
||||
),
|
||||
None => (
|
||||
DashboardAccountMetric::Tokens,
|
||||
credential.total_tokens,
|
||||
"今日 Token".to_owned(),
|
||||
credential
|
||||
.total_tokens
|
||||
@@ -977,8 +949,14 @@ pub(crate) fn dashboard_view(
|
||||
usage_window: credential
|
||||
.quota_window
|
||||
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
|
||||
metric,
|
||||
metric_value_raw,
|
||||
metric_label,
|
||||
metric_value,
|
||||
last_used_at: credential.last_used_at,
|
||||
last_used_relative: credential
|
||||
.last_used_at
|
||||
.map(|value| time.relative_view(value, range.end)),
|
||||
last_used: time.relative_optional(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
@@ -1056,7 +1034,7 @@ pub(crate) fn usage_summary_view(
|
||||
summary: domain::UsageSummary,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageSummaryView {
|
||||
let overview = summary.overview;
|
||||
let overview = summary.observation;
|
||||
UsageSummaryView {
|
||||
analytics: analytics_view(overview.analytics, time),
|
||||
total_requests: format_compact_number(overview.requests.request_count),
|
||||
@@ -1066,7 +1044,8 @@ pub(crate) fn usage_summary_view(
|
||||
cache_write_input_tokens: format_compact_number(overview.requests.cache_write_input_tokens),
|
||||
total_tokens: format_compact_number(overview.requests.total_tokens),
|
||||
average_latency_ms: display_duration(summary.average_latency_ms),
|
||||
logical_requests: request_metrics_view(&overview.requests),
|
||||
average_latency_ms_value: summary.average_latency_ms,
|
||||
logical_requests: request_summary_metrics_view(&overview.requests),
|
||||
attempts: attempt_metrics_view(&overview.attempts),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,27 +25,28 @@ impl DashboardQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// 逻辑请求列表、汇总和洞察查询参数
|
||||
/// 请求明细的字段筛选、时间范围与分页参数
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageQuery {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub current_page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub kind: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
pub attempt_index: Option<i64>,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量汇总与洞察只按时间和 Provider 查询
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageAnalyticsQuery {
|
||||
pub provider: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
@@ -258,16 +259,6 @@ pub(crate) fn domain_diagnostic_dimension(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_outcome(
|
||||
value: Option<String>,
|
||||
) -> Result<Option<domain::RequestOutcome>, WireValidationError> {
|
||||
non_empty(value)
|
||||
.map(|value| {
|
||||
domain::RequestOutcome::new(value).map_err(|_| WireValidationError::new("outcome"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn usage_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
@@ -288,39 +279,14 @@ pub(crate) fn dashboard_today_range(
|
||||
crate::time::query_range(start, end, period, as_of, timezone, "today")
|
||||
}
|
||||
|
||||
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
|
||||
let outcome = query.outcome.clone().or_else(|| {
|
||||
non_empty(query.kind.clone()).filter(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"running" | "succeeded" | "failed" | "cancelled" | "incomplete"
|
||||
)
|
||||
})
|
||||
});
|
||||
Ok(domain::UsageFilter {
|
||||
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
|
||||
request_id: non_empty(query.request_id.clone()),
|
||||
provider_account_ref: non_empty(query.account_id.clone()),
|
||||
operation: non_empty(query.route.clone()),
|
||||
pub(crate) fn usage_filter(query: &UsageQuery) -> domain::UsageFilter {
|
||||
domain::UsageFilter {
|
||||
client_api_key_name: non_empty(query.client_api_key_name.clone()),
|
||||
provider_account_name: non_empty(query.account_name.clone()),
|
||||
provider_kind: non_empty(query.provider.clone()),
|
||||
model: non_empty(query.model.clone()),
|
||||
outcome: request_outcome(outcome)?,
|
||||
status_code: parse_status(query.status_code)?,
|
||||
transport: non_empty(query.transport.clone()),
|
||||
attempt_index: parse_attempt_index(query.attempt_index)?,
|
||||
response_id: non_empty(query.response_id.clone()),
|
||||
upstream_request_id: non_empty(query.upstream_request_id.clone()),
|
||||
search: non_empty(query.search.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn analytics_filter(
|
||||
query: &UsageQuery,
|
||||
) -> Result<domain::UsageFilter, WireValidationError> {
|
||||
if query.search.is_some() {
|
||||
return Err(WireValidationError::new("search"));
|
||||
model_contains: non_empty(query.model.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
usage_filter(query)
|
||||
}
|
||||
|
||||
pub(crate) fn usage_command(
|
||||
@@ -344,7 +310,7 @@ pub(crate) fn usage_command(
|
||||
query.as_of,
|
||||
timezone,
|
||||
)?,
|
||||
filter: usage_filter(query)?,
|
||||
filter: usage_filter(query),
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ where
|
||||
pub(crate) async fn usage_records_summary<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
AdminQuery(query): AdminQuery<UsageAnalyticsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
@@ -155,7 +155,10 @@ where
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = analytics_filter(&query).map_err(map_wire_error)?;
|
||||
let filter = domain::UsageFilter {
|
||||
provider_kind: non_empty(query.provider),
|
||||
..Default::default()
|
||||
};
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
@@ -174,7 +177,7 @@ where
|
||||
pub(crate) async fn usage_insights_overview<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
AdminQuery(query): AdminQuery<UsageAnalyticsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
@@ -188,7 +191,10 @@ where
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = analytics_filter(&query).map_err(map_wire_error)?;
|
||||
let filter = domain::UsageFilter {
|
||||
provider_kind: non_empty(query.provider),
|
||||
..Default::default()
|
||||
};
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
|
||||
@@ -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,
|
||||
@@ -101,7 +103,6 @@ pub struct UsageListRecordView {
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub client_transport: String,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -140,7 +141,6 @@ pub struct UsageRecordView {
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub client_transport: String,
|
||||
@@ -228,14 +228,6 @@ pub struct UsageLatencyDetailsView {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示
|
||||
@@ -312,27 +304,33 @@ pub struct TrendPointView {
|
||||
pub tokens_value: u64,
|
||||
pub errors: String,
|
||||
pub errors_value: u64,
|
||||
pub latency: String,
|
||||
pub latency_value: Option<u64>,
|
||||
pub max_latency: String,
|
||||
pub max_latency_value: Option<u64>,
|
||||
pub min_latency: String,
|
||||
pub min_latency_value: Option<u64>,
|
||||
pub success_rate: String,
|
||||
pub success_rate_value: Option<f64>,
|
||||
pub first_token_p50_ms: Option<f64>,
|
||||
pub first_token_p95_ms: Option<f64>,
|
||||
pub latency_p95_ms: Option<f64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub admission_decision_p95_ms: Option<f64>,
|
||||
pub account_selection_wait_p95_ms: Option<f64>,
|
||||
pub capacity_utilization: Option<f64>,
|
||||
}
|
||||
|
||||
/// 趋势指标的稳定身份,展示名称由管理端本地化
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TrendMetric {
|
||||
InputTokens,
|
||||
OutputTokens,
|
||||
CachedInputTokens,
|
||||
FirstTokenP95Ms,
|
||||
LatencyP95Ms,
|
||||
OutputThroughputP50,
|
||||
Errors,
|
||||
SuccessRate,
|
||||
Requests,
|
||||
}
|
||||
|
||||
/// Dashboard 趋势摘要
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrendSummaryView {
|
||||
pub label: String,
|
||||
pub metric: TrendMetric,
|
||||
pub value: String,
|
||||
pub ratio: Option<String>,
|
||||
}
|
||||
@@ -392,7 +390,15 @@ pub struct DashboardCacheCardView {
|
||||
pub average_first_token_latency_ms: String,
|
||||
}
|
||||
|
||||
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影
|
||||
/// Dashboard 账号概览指标的稳定标识
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DashboardAccountMetric {
|
||||
Requests,
|
||||
Tokens,
|
||||
}
|
||||
|
||||
/// Dashboard 账号概览卡片所需的 Provider 安全投影
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardAccountUsageView {
|
||||
@@ -407,8 +413,12 @@ pub struct DashboardAccountUsageView {
|
||||
pub request_buckets: Vec<crate::time::RequestBucketView>,
|
||||
pub quota_used_percent: Option<f64>,
|
||||
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
|
||||
pub metric: DashboardAccountMetric,
|
||||
pub metric_value_raw: Option<u64>,
|
||||
pub metric_label: String,
|
||||
pub metric_value: String,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub last_used_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
@@ -521,7 +531,7 @@ pub struct DashboardWireProfileView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardWireAttributeView {
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
@@ -601,6 +611,7 @@ pub struct UsageSummaryView {
|
||||
pub cache_write_input_tokens: String,
|
||||
pub total_tokens: String,
|
||||
pub average_latency_ms: String,
|
||||
pub average_latency_ms_value: Option<u64>,
|
||||
pub logical_requests: RequestMetricsView,
|
||||
pub attempts: AttemptMetricsView,
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ use crate::{
|
||||
|
||||
const MAXIMUM_BODY_BYTES: usize = 1024 * 1024;
|
||||
const MAXIMUM_MODEL_BODY_BYTES: usize = 8 * 1024 * 1024;
|
||||
// 传输归属由宿主写入,不能用插件可覆盖的安全响应头识别管理错误
|
||||
const PLUGIN_RESPONSE_HEADER: &str = "x-cpr-plugin-response";
|
||||
// HTML/SVG 即使被直接导航打开也不能获得管理端 origin;执行入口由隔离 iframe 的消息桥承载
|
||||
const RESOURCE_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
|
||||
|
||||
@@ -124,17 +126,17 @@ async fn model_responses(
|
||||
match decode_request_with_body(&body, &headers, MAXIMUM_MODEL_BODY_BYTES) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => {
|
||||
return Ok(protocol_error_response(
|
||||
return Ok(plugin_response(protocol_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
error.protocol_body(),
|
||||
));
|
||||
)));
|
||||
}
|
||||
};
|
||||
let service = state.openai().clone();
|
||||
let execution = service.execution();
|
||||
let prepared = match execution.prepare_plugin_execution(&client_key_id).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return Ok(gateway_error_response(&error)),
|
||||
Err(error) => return Ok(plugin_response(gateway_error_response(&error))),
|
||||
};
|
||||
let authorization: Arc<dyn ResponseAuthorization> =
|
||||
Arc::new(PluginModelsAuthorization { state, target });
|
||||
@@ -158,7 +160,7 @@ async fn model_responses(
|
||||
Some(authorization),
|
||||
)
|
||||
.await;
|
||||
Ok(response)
|
||||
Ok(plugin_response(response))
|
||||
}
|
||||
|
||||
struct PluginModelsAuthorization {
|
||||
@@ -431,5 +433,12 @@ fn raw_response(result: PluginManagementResponse) -> Result<Response, AdminError
|
||||
);
|
||||
// 宿主提供默认值,插件显式字段随后覆盖;保留同名多值,只验证 HTTP 语法
|
||||
headers.extend(decode_headers(result.headers).map_err(|_| AdminError::bad_gateway())?);
|
||||
Ok(response)
|
||||
Ok(plugin_response(response))
|
||||
}
|
||||
|
||||
fn plugin_response(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(PLUGIN_RESPONSE_HEADER, HeaderValue::from_static("1"));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use gateway_admin::model::{
|
||||
client_keys::ClientKeySecret,
|
||||
key_usage::{KeyUsageOverview, KeyUsageRecords},
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord,
|
||||
},
|
||||
system::SystemVersion,
|
||||
};
|
||||
@@ -213,14 +213,6 @@ struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
@@ -228,8 +220,20 @@ struct OutputTimingView {
|
||||
first_text_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
|
||||
let token_details = Some(usage_list_token_details(&value));
|
||||
fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
|
||||
let token_details = [
|
||||
value.input_tokens,
|
||||
value.output_tokens,
|
||||
value.cached_input_tokens,
|
||||
value.cache_write_input_tokens,
|
||||
value.reasoning_output_tokens,
|
||||
value.image_input_tokens,
|
||||
value.image_output_tokens,
|
||||
value.total_tokens,
|
||||
]
|
||||
.iter()
|
||||
.any(Option::is_some)
|
||||
.then(|| usage_list_token_details(&value));
|
||||
let billing = billing_view(value.billing.as_ref());
|
||||
RecordView {
|
||||
id: value.id,
|
||||
@@ -248,68 +252,36 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
|
||||
upstream_response_ms: value.upstream_response_ms,
|
||||
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: value.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
},
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "success",
|
||||
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实
|
||||
status_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
|
||||
RecordView {
|
||||
id: value.event_id,
|
||||
created_at: value.occurred_at,
|
||||
created_at_display: time.datetime(&value.occurred_at),
|
||||
model: value.requested_model_id,
|
||||
route: value.endpoint,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: value.client_transport,
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details: None,
|
||||
billing: None,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: None,
|
||||
latency_details: OutputTimingView::default(),
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "error",
|
||||
status: match value.outcome {
|
||||
RequestOutcome::Succeeded => "success",
|
||||
RequestOutcome::Failed => "error",
|
||||
RequestOutcome::Incomplete => "incomplete",
|
||||
RequestOutcome::Cancelled => "cancelled",
|
||||
RequestOutcome::Running => "running",
|
||||
RequestOutcome::Other(_) => "unknown",
|
||||
},
|
||||
status_code: value.client_status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn records(
|
||||
value: KeyUsageRecords,
|
||||
page: KeyUsageRecords,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> PageData<RecordView> {
|
||||
match value {
|
||||
KeyUsageRecords::Success(page) => PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| success_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
KeyUsageRecords::Error(page) => PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| error_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
PageData {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| usage_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ enum RecordKind {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -109,6 +110,7 @@ impl RecordsQuery {
|
||||
kind: match self.kind {
|
||||
RecordKind::Success => KeyUsageRecordKind::Success,
|
||||
RecordKind::Error => KeyUsageRecordKind::Error,
|
||||
RecordKind::All => KeyUsageRecordKind::All,
|
||||
},
|
||||
current_page,
|
||||
page_size,
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(crate) async fn hangup(
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let protocol_headers = protocol_header_pairs(&headers);
|
||||
let protocol_headers = filter_protocol_headers(&headers);
|
||||
let outcome = gateway
|
||||
.hangup(LiveHangupRequest {
|
||||
call_id: &call_id,
|
||||
@@ -141,13 +141,8 @@ pub(crate) async fn hangup(
|
||||
let status = StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::OK);
|
||||
let mut response = Response::new(axum::body::Body::from(outcome.body));
|
||||
*response.status_mut() = status;
|
||||
for (name, value) in outcome.headers {
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
header::HeaderName::from_bytes(name.as_bytes()),
|
||||
header::HeaderValue::from_bytes(value.as_bytes()),
|
||||
) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
for (name, value) in live_forwarded_response_headers(&outcome.headers) {
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -262,20 +257,7 @@ async fn read_call_body(
|
||||
}
|
||||
}
|
||||
|
||||
/// realtime 协议头的 `(name, value)` 形态;名称小写化以稳定上游顺序。
|
||||
pub(crate) fn protocol_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
super::filter_protocol_headers(headers)
|
||||
.into_iter()
|
||||
.map(|header| {
|
||||
(
|
||||
header.name().to_ascii_lowercase(),
|
||||
String::from_utf8_lossy(header.value()).into_owned(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 汇集引导响应;只放行白名单响应头,正文按字节透传。
|
||||
/// 汇集引导响应;普通响应头与正文按字节透传
|
||||
async fn collect_live_call_response(started: StartedExecution, path: String) -> Response {
|
||||
let request_id = started.request_id;
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
|
||||
@@ -34,14 +34,6 @@ pub(crate) const MAX_LIVE_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub(crate) const LIVE_CALLS_ENDPOINT: &str = "realtime-calls";
|
||||
/// realtime calls 的官方查询参数;上游按 `architecture=avas` 返回 WebRTC answer。
|
||||
pub(crate) const LIVE_CALLS_QUERY: &str = "intent=quicksilver&architecture=avas";
|
||||
/// 客户端协议头允许清单;语音会话语义字段,转发给上游引导请求。
|
||||
pub(crate) const LIVE_PROTOCOL_HEADERS: [&str; 5] = [
|
||||
"openai-alpha",
|
||||
"x-session-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"openai-safety-identifier",
|
||||
];
|
||||
|
||||
pub(crate) fn router() -> Router<crate::ApiState> {
|
||||
Router::new()
|
||||
@@ -136,22 +128,12 @@ pub(crate) fn realtime_unsupported_response(capability: &str) -> Response {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// 请求侧协议头过滤;语音语义字段按允许清单转发,其余一律不透传。
|
||||
/// 共用 OpenAI 入站头边界;Content-Type 由正文适配结果单独提供
|
||||
pub(crate) fn filter_protocol_headers(headers: &HeaderMap) -> Vec<ProviderHttpHeader> {
|
||||
LIVE_PROTOCOL_HEADERS
|
||||
.iter()
|
||||
.flat_map(|name| {
|
||||
headers
|
||||
.get_all(*name)
|
||||
.into_iter()
|
||||
.map(|value| (*name, value))
|
||||
})
|
||||
.filter_map(|(name, value)| {
|
||||
let value = value.to_str().ok()?;
|
||||
Some(ProviderHttpHeader::new(
|
||||
name,
|
||||
Bytes::copy_from_slice(value.as_bytes()),
|
||||
))
|
||||
super::responses::passthrough_request_headers(headers)
|
||||
.filter(|(name, _)| *name != header::CONTENT_TYPE)
|
||||
.map(|(name, value)| {
|
||||
ProviderHttpHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -281,20 +263,20 @@ pub(crate) fn encode_call_request(
|
||||
.map_err(|_| invalid("Codex live call request is invalid"))
|
||||
}
|
||||
|
||||
/// 响应头白名单;`Location` 是客户端获知 call id 的合同。
|
||||
/// 共用响应身份与传输边界;Live 原始正文另保留上游 Content-Type
|
||||
pub(crate) fn live_forwarded_response_headers(
|
||||
headers: &[ProviderResponseHeader],
|
||||
) -> Vec<(HeaderName, HeaderValue)> {
|
||||
const FORWARDABLE: [&str; 5] = [
|
||||
"location",
|
||||
"content-type",
|
||||
"retry-after",
|
||||
"x-request-id",
|
||||
"openai-request-id",
|
||||
];
|
||||
let connection_options = super::responses::response_connection_options(headers);
|
||||
headers
|
||||
.iter()
|
||||
.filter(|header| FORWARDABLE.contains(&header.name()))
|
||||
.filter(|header| {
|
||||
header.name().eq_ignore_ascii_case("content-type")
|
||||
|| super::responses::response_header_is_forwardable(
|
||||
header.name(),
|
||||
&connection_options,
|
||||
)
|
||||
})
|
||||
.filter_map(|header| {
|
||||
Some((
|
||||
HeaderName::from_bytes(header.name().as_bytes()).ok()?,
|
||||
|
||||
@@ -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(
|
||||
@@ -128,15 +128,7 @@ async fn open_sideband(
|
||||
client_api_key_id: client.policy().key_id(),
|
||||
account_scope: client.policy().account_scope(),
|
||||
style,
|
||||
protocol_headers: filter_protocol_headers(&headers)
|
||||
.into_iter()
|
||||
.map(|header| {
|
||||
(
|
||||
header.name().to_ascii_lowercase(),
|
||||
String::from_utf8_lossy(header.value()).into_owned(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
protocol_headers: filter_protocol_headers(&headers),
|
||||
subprotocols: offered_subprotocols(&headers),
|
||||
});
|
||||
let result = tokio::select! {
|
||||
|
||||
@@ -321,24 +321,36 @@ impl AuthorizedResponseBody {
|
||||
impl MiddlewareBody for AuthorizedResponseBody {
|
||||
fn next_frame(&mut self) -> BoxFuture<'_, Result<Option<MiddlewareFrame>, MiddlewareError>> {
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
let elapsed = self.last_authorized_at.elapsed();
|
||||
if elapsed >= AUTHORIZATION_RECHECK_INTERVAL {
|
||||
if let Err(error) = self.authorization.authorize().await {
|
||||
return Err(self.revoke(error).await);
|
||||
}
|
||||
self.last_authorized_at = Instant::now();
|
||||
continue;
|
||||
if self.last_authorized_at.elapsed() >= AUTHORIZATION_RECHECK_INTERVAL {
|
||||
if let Err(error) = self.authorization.authorize().await {
|
||||
return Err(self.revoke(error).await);
|
||||
}
|
||||
self.last_authorized_at = Instant::now();
|
||||
}
|
||||
let error = {
|
||||
let Some(inner) = self.inner.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let wait = AUTHORIZATION_RECHECK_INTERVAL - elapsed;
|
||||
tokio::select! {
|
||||
frame = inner.next_frame() => return frame,
|
||||
() = tokio::time::sleep(wait) => {}
|
||||
// 复核身份只暂停同一次读取,不能丢弃已经消费上游事件的 future
|
||||
let frame = inner.next_frame();
|
||||
tokio::pin!(frame);
|
||||
loop {
|
||||
let elapsed = self.last_authorized_at.elapsed();
|
||||
if elapsed >= AUTHORIZATION_RECHECK_INTERVAL {
|
||||
if let Err(error) = self.authorization.authorize().await {
|
||||
break error;
|
||||
}
|
||||
self.last_authorized_at = Instant::now();
|
||||
continue;
|
||||
}
|
||||
let wait = AUTHORIZATION_RECHECK_INTERVAL - elapsed;
|
||||
tokio::select! {
|
||||
result = &mut frame => return result,
|
||||
() = tokio::time::sleep(wait) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Err(self.revoke(error).await)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -879,7 +891,7 @@ impl StreamingExecutionBody {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 已提交的流在确认 Core 终结后才交付成功终态,不能先输出 completed 再报结算失败
|
||||
// 已提交的流先确认 Core 终结,避免后续 EOF 验证推翻已交付的成功终态
|
||||
if self.awaiting_terminal_eof && self.committed {
|
||||
self.verify_terminal_eof().await?;
|
||||
encoded.push_back(done_frame());
|
||||
|
||||
@@ -14,11 +14,11 @@ pub(crate) use http::{
|
||||
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
|
||||
};
|
||||
pub use http::{collect_execution_response, stream_execution_response};
|
||||
pub(crate) use request::decode_request_with_body;
|
||||
pub use request::{
|
||||
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
|
||||
decode_request_with_headers,
|
||||
};
|
||||
pub(crate) use request::{decode_request_with_body, passthrough_request_headers};
|
||||
pub use response::OpenAiResponsesEncoder;
|
||||
pub(crate) use websocket::responses_websocket;
|
||||
pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::{borrow::Cow, fmt, net::IpAddr};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use bytes::Bytes;
|
||||
use gateway_core::operation::{
|
||||
@@ -567,6 +567,20 @@ fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
|
||||
passthrough_request_headers(headers)
|
||||
.map(|(name, value)| {
|
||||
Value::Array(vec![
|
||||
Value::String(name.as_str().to_owned()),
|
||||
Value::String(STANDARD.encode(value.as_bytes())),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// HTTP 与 Live 入站共用的身份、传输边界;保留业务头原始多值字节
|
||||
pub(crate) fn passthrough_request_headers(
|
||||
headers: &HeaderMap,
|
||||
) -> impl Iterator<Item = (&HeaderName, &HeaderValue)> {
|
||||
let connection_headers = headers
|
||||
.get_all("connection")
|
||||
.iter()
|
||||
@@ -579,14 +593,7 @@ fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
|
||||
|
||||
headers
|
||||
.iter()
|
||||
.filter(|(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
|
||||
.map(|(name, value)| {
|
||||
Value::Array(vec![
|
||||
Value::String(name.as_str().to_owned()),
|
||||
Value::String(STANDARD.encode(value.as_bytes())),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
.filter(move |(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
|
||||
}
|
||||
|
||||
fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
pump::run_pump,
|
||||
state::{ConnectionCommand, ConnectionStats},
|
||||
};
|
||||
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
|
||||
use axum::extract::ws::{CloseFrame, Message, Utf8Bytes, WebSocket, close_code};
|
||||
use futures::{Sink, Stream};
|
||||
use gateway_core::{
|
||||
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader},
|
||||
@@ -142,7 +142,7 @@ impl ResponsesWebSocketConnection {
|
||||
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误
|
||||
pub async fn send_text(
|
||||
&mut self,
|
||||
payload: String,
|
||||
payload: impl Into<Utf8Bytes>,
|
||||
context: WriteContext,
|
||||
) -> Result<WriteOutcome, ConnectionWriteError> {
|
||||
self.send(Message::Text(payload.into()), context).await
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::{extract::ws::Utf8Bytes, http::StatusCode};
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::CommitRequirement;
|
||||
@@ -268,7 +268,7 @@ pub(super) async fn forward_response(
|
||||
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
|
||||
}
|
||||
let terminal = frame.terminal();
|
||||
let Ok(message) = String::from_utf8(frame.into_bytes().to_vec()) else {
|
||||
let Ok(message) = Utf8Bytes::try_from(frame.into_bytes()) else {
|
||||
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
|
||||
};
|
||||
let phase = match (first_frame_written, terminal) {
|
||||
|
||||
@@ -58,7 +58,8 @@ fn decode_response_create_inner(
|
||||
else {
|
||||
return Err(ResponseCreateFrameError::ExpectedObject);
|
||||
};
|
||||
match body.remove("type") {
|
||||
// 保持剩余字段顺序,避免末尾元数据交换到模型等路由字段之前
|
||||
match body.shift_remove("type") {
|
||||
Some(Value::String(message_type)) if message_type == "response.create" => {}
|
||||
_ => return Err(ResponseCreateFrameError::UnsupportedType),
|
||||
}
|
||||
|
||||
@@ -101,21 +101,51 @@ impl TimePresenter {
|
||||
self.timezone.local(*value).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
pub(crate) fn relative_view(
|
||||
self,
|
||||
value: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
) -> RelativeTimeView {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return self.datetime(&value);
|
||||
return RelativeTimeView::Absolute {
|
||||
value: self.datetime(&value),
|
||||
};
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
return RelativeTimeView::JustNow;
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_minutes(),
|
||||
unit: RelativeTimeUnit::Minute,
|
||||
};
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
return RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_hours(),
|
||||
unit: RelativeTimeUnit::Hour,
|
||||
};
|
||||
}
|
||||
RelativeTimeView::Elapsed {
|
||||
value: elapsed.num_days(),
|
||||
unit: RelativeTimeUnit::Day,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
match self.relative_view(value, now) {
|
||||
RelativeTimeView::JustNow => "刚刚".to_owned(),
|
||||
RelativeTimeView::Elapsed { value, unit } => {
|
||||
let unit = match unit {
|
||||
RelativeTimeUnit::Minute => "分钟",
|
||||
RelativeTimeUnit::Hour => "小时",
|
||||
RelativeTimeUnit::Day => "天",
|
||||
};
|
||||
format!("{value} {unit}前")
|
||||
}
|
||||
RelativeTimeView::Absolute { value } => value,
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(crate) fn relative_optional(
|
||||
@@ -127,6 +157,24 @@ impl TimePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 后端按查询锚点与部署时区决定展示方式,前端只本地化分类和单位
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RelativeTimeView {
|
||||
JustNow,
|
||||
Elapsed { value: i64, unit: RelativeTimeUnit },
|
||||
Absolute { value: String },
|
||||
}
|
||||
|
||||
/// 相对时间保持分钟、小时、天的固定精度,不引入月或年的日历推算
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelativeTimeUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestBucketView {
|
||||
|
||||
@@ -46,7 +46,6 @@ async fn list_route_should_keep_camel_case_group_and_page_wire() {
|
||||
value["data"]["items"][0]["usage"]["retainedTotalUsd"],
|
||||
"5.5"
|
||||
);
|
||||
assert!(value["data"]["items"][0].get("member_count").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -9,6 +9,67 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() {
|
||||
use chrono::Utc;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, OutboundProxy, QuotaState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (proxy_id, proxy_url, enabled) in [
|
||||
(
|
||||
Some("proxy-a"),
|
||||
Some("socks5h://alice:secret@127.0.0.1:50101"),
|
||||
true,
|
||||
),
|
||||
(
|
||||
Some("proxy-b"),
|
||||
Some("socks5h://bob:secret@127.0.0.1:50101"),
|
||||
false,
|
||||
),
|
||||
(None, Some("socks5h://alice:secret@127.0.0.1:50101"), true),
|
||||
(None, None, true),
|
||||
] {
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
};
|
||||
let mut item = account_page_item(facts, now, "oauth");
|
||||
item.account.outbound_proxy_id = proxy_id.map(str::to_owned);
|
||||
item.account.outbound_proxy = proxy_url.map(|url| OutboundProxy::parse(url).unwrap());
|
||||
item.account.outbound_proxy_enabled = enabled;
|
||||
*fixture.account.lock().unwrap() = Some(item);
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_proxy_binding")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let account = &value["data"]["items"][0];
|
||||
assert_eq!(account["outboundProxyId"], serde_json::json!(proxy_id));
|
||||
assert_eq!(
|
||||
account["outboundProxyEndpoint"],
|
||||
serde_json::json!(proxy_url.map(|_| "socks5h://127.0.0.1:50101"))
|
||||
);
|
||||
assert!(!String::from_utf8(body.to_vec()).unwrap().contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
@@ -99,6 +160,90 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::provider_credentials::ProviderQuota;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
|
||||
use serde_json::json;
|
||||
|
||||
for zone in ["UTC", "Asia/Shanghai", "Asia/Kathmandu", "America/New_York"] {
|
||||
let zone = zone
|
||||
.parse::<gateway_core::time::DeploymentTimeZone>()
|
||||
.unwrap();
|
||||
let fixture = AdminTestFixture::with_timezone(zone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
now,
|
||||
"oauth",
|
||||
));
|
||||
let future = now + Duration::days(1);
|
||||
let future_display = zone.local(future).format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
for (observed_at, expected, legacy) in [
|
||||
(None, json!(null), "—"),
|
||||
(Some(now), json!({ "kind": "just_now" }), "刚刚"),
|
||||
(
|
||||
Some(now - Duration::seconds(90)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
|
||||
"1 分钟前",
|
||||
),
|
||||
(
|
||||
Some(now - Duration::hours(25)),
|
||||
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
|
||||
"1 天前",
|
||||
),
|
||||
(
|
||||
Some(future),
|
||||
json!({ "kind": "absolute", "value": future_display }),
|
||||
future_display.as_str(),
|
||||
),
|
||||
] {
|
||||
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
credits: None,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
});
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_quota_relative_time")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::ACCEPT_LANGUAGE, "en")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let quota = &value["data"]["items"][0]["quota"];
|
||||
assert_eq!(quota["refreshedAtRelative"], expected);
|
||||
assert_eq!(quota["refreshedAtDisplay"], legacy);
|
||||
let refreshed_at = quota["refreshedAt"]
|
||||
.as_str()
|
||||
.map(|value| DateTime::parse_from_rfc3339(value).unwrap().to_utc());
|
||||
assert_eq!(refreshed_at, observed_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_page_item(
|
||||
facts: gateway_core::account::AccountStatusFacts,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
@@ -112,6 +257,7 @@ fn account_page_item(
|
||||
|
||||
AccountPageItem {
|
||||
account: AccountRecord {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
id: "acct_cooldown".to_owned(),
|
||||
provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
@@ -149,7 +295,7 @@ fn account_page_item(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_aliases() {
|
||||
async fn account_usage_preserves_cache_read_and_write_counts_and_missing_values() {
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::accounts::AccountUsage;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
|
||||
@@ -215,37 +361,9 @@ async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_alia
|
||||
usage["cacheWriteInputTokensDisplay"],
|
||||
if write.is_some() { "7" } else { "—" }
|
||||
);
|
||||
for alias in [
|
||||
"createdTokens",
|
||||
"createdTokensDisplay",
|
||||
"readTokens",
|
||||
"readTokensDisplay",
|
||||
] {
|
||||
assert!(usage.get(alias).is_none(), "obsolete token alias: {alias}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_credential_rotation_route_is_not_exposed() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/rotate")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_update_requires_admin_and_validates_before_calling_the_service() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
|
||||
@@ -181,7 +181,13 @@ mod query {
|
||||
|
||||
#[test]
|
||||
fn account_query_should_reject_unknown_fields() {
|
||||
assert!(serde_json::from_value::<ListQuery>(json!({"id": "cred_1"})).is_err());
|
||||
let mut query = json!({"page": 1, "pageSize": 20});
|
||||
serde_json::from_value::<ListQuery>(query.clone())
|
||||
.unwrap()
|
||||
.validate()
|
||||
.expect("valid account query");
|
||||
query["unknownField"] = json!(true);
|
||||
assert!(serde_json::from_value::<ListQuery>(query).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +254,8 @@ mod profile_statistics {
|
||||
value["activityInsights"]["invocations"][0]["usageCount"],
|
||||
29
|
||||
);
|
||||
assert!(value.get("cycle").is_none());
|
||||
assert!(value.get("models").is_none());
|
||||
assert!(value.get("estimatedCost").is_none());
|
||||
assert_eq!(value["activityInsights"]["fastModePercent"], 0.0);
|
||||
assert!(value["activityInsights"]["invocations"][0]["skillId"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -375,7 +380,7 @@ mod batch_update {
|
||||
assert!(request.concurrency_limit.is_none());
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"], "enabled": true, "legacy": true
|
||||
"accountIds": ["acct_test"], "enabled": true, "unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -471,13 +476,13 @@ fn single_update_should_reject_oversized_notes_and_control_characters() {
|
||||
}
|
||||
|
||||
mod response {
|
||||
use gateway_api::admin::accounts::AccountUsageView;
|
||||
use gateway_api::admin::accounts::{AccountUsageView, AccountUsageWindowKind};
|
||||
use gateway_api::admin::presenter::format_decimal_currency;
|
||||
|
||||
#[test]
|
||||
fn account_usage_view_should_keep_unobserved_numbers_null() {
|
||||
let view = AccountUsageView {
|
||||
window_label_display: "周/月额度窗口".to_owned(),
|
||||
window_kind: AccountUsageWindowKind::Quota,
|
||||
request_count: None,
|
||||
request_count_display: "-".to_owned(),
|
||||
input_tokens: None,
|
||||
@@ -511,7 +516,7 @@ mod response {
|
||||
models: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(view).expect("serialize account usage");
|
||||
assert_eq!(value["windowLabelDisplay"], "周/月额度窗口");
|
||||
assert_eq!(value["windowKind"], "quota");
|
||||
assert!(value["inputTokens"].is_null());
|
||||
assert!(value["totalTokens"].is_null());
|
||||
assert!(value["reasoningOutputTokens"].is_null());
|
||||
@@ -583,12 +588,12 @@ mod actions {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
|
||||
fn account_actions_should_validate_account_ids_and_reject_unknown_fields() {
|
||||
let id: AccountIdQuery =
|
||||
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
|
||||
assert!(id.validate().is_ok());
|
||||
let action: AccountActionRequest =
|
||||
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
|
||||
serde_json::from_value(json!({ "accountId": "invalid-id" })).expect("decode action");
|
||||
assert_eq!(action.validate().unwrap_err().field(), "accountId");
|
||||
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1"
|
||||
@@ -598,7 +603,7 @@ mod actions {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountRefreshRequest>(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
"unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -772,15 +777,14 @@ mod actions {
|
||||
assert_eq!(invalid.validate().unwrap_err().field(), "data");
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"document": {}
|
||||
"provider": "openai"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"unknownField": true,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
|
||||
@@ -11,7 +11,7 @@ use gateway_api::admin::accounts::{
|
||||
use gateway_core::operation::RawJsonPayload;
|
||||
|
||||
#[test]
|
||||
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
fn quota_forecast_projection_formats_capacity_and_preserves_null_zero() {
|
||||
let now = "2026-09-12T00:00:00Z".parse().unwrap();
|
||||
let forecast = AccountQuotaForecast {
|
||||
period: AccountUsagePeriod::Weekly,
|
||||
@@ -54,16 +54,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(week["estimatedTokensDisplay"], "1M");
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
for forecast in value["forecasts"].as_array().unwrap() {
|
||||
for field in [
|
||||
"remainingTokens",
|
||||
"remainingTokensDisplay",
|
||||
"remainingUsd",
|
||||
"remainingUsdDisplay",
|
||||
] {
|
||||
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
@@ -77,9 +67,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
"usdDisplay": "$0.1234"
|
||||
})
|
||||
);
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
|
||||
@@ -87,7 +74,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
assert!(value.get("account").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -375,18 +375,17 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateClientKeyRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0,
|
||||
"tokensPerMinute": 0
|
||||
"unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"unknownField": true,
|
||||
"name": "terminal key",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 2,
|
||||
@@ -458,7 +457,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
));
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["items"][0]["id"], "key_visible");
|
||||
assert_eq!(
|
||||
@@ -468,12 +466,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
assert_eq!(list["items"][0]["routingScope"], "all");
|
||||
assert_eq!(list["items"][0]["maxConcurrency"], 2);
|
||||
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
|
||||
assert!(list["items"][0].get("tokensPerMinute").is_none());
|
||||
assert!(list["items"][0].get("policyJson").is_none());
|
||||
assert_eq!(list["items"][0]["dailyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["weeklyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["dailyUsedUsd"], "0");
|
||||
assert!(list["items"][0].get("unresolvedRequests").is_none());
|
||||
assert_eq!(
|
||||
DateTime::parse_from_rfc3339(
|
||||
list["items"][0]["lastUsedAt"]
|
||||
|
||||
@@ -511,6 +511,7 @@ mod provider {
|
||||
total_slots: None,
|
||||
},
|
||||
account: AccountRecord {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user