mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2221f400da | ||
|
|
38bcfb9da1 | ||
|
|
10f122aac8 | ||
|
|
598d56baed | ||
|
|
5f61309941 | ||
|
|
ab8c61b118 | ||
|
|
c3146dc10a | ||
|
|
72bdc234ca | ||
|
|
733e9aa8a0 | ||
|
|
743a3aba70 | ||
|
|
349e983eb7 | ||
|
|
45954899e5 | ||
|
|
78f533d9d3 | ||
|
|
376305fed4 | ||
|
|
a82bcdb8f8 | ||
|
|
3d072fedcf | ||
|
|
a0e708071a | ||
|
|
e9ad1811dd | ||
|
|
615e8d66f4 | ||
|
|
d79c76a105 | ||
|
|
1bf47f71b6 | ||
|
|
2f3f2975ef | ||
|
|
759935b75d | ||
|
|
fcf4af0ddd | ||
|
|
8fb48dd6c3 | ||
|
|
5a81d61979 | ||
|
|
1fadc75ba2 | ||
|
|
83f5d71853 | ||
|
|
f4eceb076b | ||
|
|
0b02d0d98a | ||
|
|
a014da6916 | ||
|
|
9d4c58f967 | ||
|
|
c67b081ec1 | ||
|
|
1ce9710afa | ||
|
|
447869860a | ||
|
|
815c93a715 | ||
|
|
5843091969 | ||
|
|
1280c437b4 | ||
|
|
d965bc9050 | ||
|
|
bd9b017ed3 | ||
|
|
338cde1505 | ||
|
|
b25f96559a | ||
|
|
a8a96d8919 | ||
|
|
7b8339ede3 | ||
|
|
f439a30612 | ||
|
|
8572873c5f | ||
|
|
57c8ef358f | ||
|
|
70ab8ebc8d | ||
|
|
14ea5234ce | ||
|
|
f8480f3bdc | ||
|
|
52b8103bc9 | ||
|
|
dacdd3f0e2 | ||
|
|
ac36f5bc13 | ||
|
|
e0a022518c | ||
|
|
37f7a55427 | ||
|
|
f6085206eb | ||
|
|
9813c86055 | ||
|
|
260f775039 | ||
|
|
1424cea3a9 | ||
|
|
787ad28424 | ||
|
|
db23560b0e | ||
|
|
d700df8a4f | ||
|
|
c9adfd9a2b | ||
|
|
654b6baf89 |
@@ -1,55 +1,42 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
|
||||
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
## 加载边界
|
||||
## 先匹配任务与文件
|
||||
|
||||
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
|
||||
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
|
||||
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
|
||||
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
|
||||
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 当前任务 | 必读的相关部分 |
|
||||
| 命中条件,操作或文件满足其一即可 | 必须执行 |
|
||||
| --- | --- |
|
||||
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
|
||||
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 页面开发 | `$frontend-design`、[前端职责](../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
|
||||
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
|
||||
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
|
||||
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
|
||||
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
|
||||
|
||||
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
|
||||
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
|
||||
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
|
||||
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
|
||||
开发、排障和代码审查先按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实问题与方案。涉及 Codex 行为时执行其中的官方行为优先约束:对应版本的官方源码优先,以透明转发和最小必要适配为默认方案,先判断偏离是否必要再评价实现
|
||||
|
||||
## 执行与收尾
|
||||
|
||||
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
|
||||
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
|
||||
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
|
||||
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
|
||||
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
|
||||
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
|
||||
## 后端注释
|
||||
|
||||
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
|
||||
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
|
||||
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
|
||||
|
||||
## 文档硬约束
|
||||
|
||||
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
|
||||
|
||||
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
|
||||
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
|
||||
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-dev-guide"
|
||||
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
|
||||
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
|
||||
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
# 文档检查
|
||||
|
||||
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
|
||||
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
|
||||
|
||||
## 写之前
|
||||
|
||||
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
|
||||
|
||||
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
|
||||
|
||||
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
|
||||
|
||||
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
|
||||
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
|
||||
|
||||
## 逐段验收
|
||||
|
||||
@@ -30,9 +24,8 @@
|
||||
|
||||
## 交付检查
|
||||
|
||||
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
|
||||
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
|
||||
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
|
||||
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
|
||||
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
|
||||
|
||||
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
|
||||
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 前端开发与审查
|
||||
|
||||
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
|
||||
|
||||
## 开始前
|
||||
|
||||
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
|
||||
|
||||
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
|
||||
|
||||
按改动补读,不把“未修改主题算法”当作跳过页面样式规范的理由:
|
||||
|
||||
| 改动 | 规范入口 |
|
||||
| --- | --- |
|
||||
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
|
||||
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
|
||||
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
|
||||
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
|
||||
|
||||
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
|
||||
|
||||
## 交付前
|
||||
|
||||
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
|
||||
|
||||
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
|
||||
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
|
||||
- **样式与状态**:是否使用对应 Token 与 utility,局部样式是否符合适用边界,受影响的主题、窄屏及交互状态是否正确
|
||||
- **查询与动作**:区分首次加载、刷新、分页、单行与批量操作,核对 loading、禁用状态的影响范围;沿用请求层错误处理,不在页面叠加同一错误的提示
|
||||
- **运行证据**:按界面验证核对截图与实际交互,证据对应当前实现;页面 PR 的可访问图片和 Draft 条件按该节执行
|
||||
|
||||
交付简述所复用的页面或组件、实际验证范围及缺口;环境不足时如实说明,不能仅凭构建、源码或截图推断未验证的行为
|
||||
@@ -3,9 +3,13 @@
|
||||
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
|
||||
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
|
||||
|
||||
**宿主可以暴露底层能力,不能替插件完成业务**
|
||||
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
|
||||
|
||||
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
|
||||
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
|
||||
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
|
||||
- 插件行为不可预测,只保留宿主安全解码、权限、资源限制及一致性所需的检查,不为插件业务正确性增加语义校验、未知字段拒绝或重复检查;失败按已有调用、会话、实例边界及能力策略拒绝、隔离和回收,不把单次业务错误扩大为全局停用
|
||||
- 不靠猜测插件意图、补造业务字段、修正业务结果、重放操作或增加插件专属兼容分支让错误插件继续工作;问题归插件时在插件侧修复,不把补偿搬进宿主 SDK 或 Runtime
|
||||
- 通用协议与会话辅助、公开合同规定的默认值和失败恢复仍按既有合同执行,不能因拒绝业务兜底而删掉必要的宿主保护;故障隔离按[信任与资源边界](../../../../docs/architecture.md#信任与资源边界)评估,不假定完整信任的插件具有安全沙箱
|
||||
- 开放已有能力时复用宿主服务与数据模型,不为插件的导入、修正、重算等编排新增表、字段、账本或任务系统;确有宿主能力缺口时,界定可独立使用的基础接口及输入输出、权限、事务、失败和重复调用语义,不夹带插件私有模型与工作流
|
||||
- 新增防御、兼容、重试、缓存或状态前,说明要保护的宿主不变量、触发依据和最小处理范围;仅为迁就某个插件或猜测未来行为时不加入宿主
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合;验证失败与取消后的资源释放、状态一致性和其他调用受影响范围,不以插件恢复成功代替宿主边界验证
|
||||
|
||||
@@ -20,7 +20,7 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
|
||||
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
|
||||
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
|
||||
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
|
||||
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
|
||||
|
||||
## 正文与证据
|
||||
|
||||
@@ -36,16 +36,11 @@ YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必
|
||||
|
||||
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
|
||||
|
||||
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
Issue 可记录复现、排查和变更经过;涉及常规文档修改时执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 Issue 回复时遵守以下约定:
|
||||
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
|
||||
草拟和发布 Issue 回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
|
||||
|
||||
## 发布与收尾
|
||||
|
||||
|
||||
@@ -15,36 +15,33 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
|
||||
|
||||
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
|
||||
|
||||
涉及上游行为的判断按[问题与方案依据](../../../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/references/frontend.md),核对适用规范与当前实现的运行证据 |
|
||||
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
|
||||
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
|
||||
草拟和发布 PR 讨论、审查意见的回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
|
||||
|
||||
## 创建与审查的公共核对
|
||||
|
||||
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
|
||||
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状,分别判断问题是否成立、是否属于目标项目职责及方案是否必要
|
||||
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
|
||||
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
|
||||
- 自审或审查覆盖完整差异与调用链,前端与文档按模式表检查
|
||||
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
|
||||
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
|
||||
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只核对现有说明是否失真
|
||||
|
||||
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
|
||||
|
||||
|
||||
@@ -6,19 +6,13 @@
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
3. 已完成的改动也须按入口公共核对复核问题与方案。原问题不成立时不包装成已修复的缺陷,证据不足时保留结论。
|
||||
实现与自审按 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)执行,包括适用的后端生命周期和前端检查;需要修正时只修改已授权范围。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查,记录对应提交或工作区状态;文档、页面证据与模型披露按入口核对。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及上游行为时,按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)写明官方源码位置、版本、处理方式及本次方案关系。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
@@ -32,4 +26,4 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
创建完成返回链接、目标分支和验证摘要,不称为已合并。
|
||||
|
||||
@@ -4,28 +4,18 @@
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
|
||||
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
5. 前端改动执行[前端流程](../../cpr-dev-guide/references/frontend.md),浏览器可用时检查相关交互;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
文档差异按[文档检查](../../cpr-dev-guide/references/documentation.md)核对最终 diff。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
7. 按审查标准用中文输出问题是否成立、方案是否合理及对应依据,再列实现问题与验证缺口;复用建议指出具体已有实现。
|
||||
没有问题的类别可省略,未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
|
||||
@@ -14,7 +14,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
|
||||
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
|
||||
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
|
||||
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
|
||||
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
|
||||
- Codex 兼容性适配按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行,不因位于插件中扩大改写或补偿范围;业务改写限于用户授权,宿主能力以目标 SDK 为准
|
||||
|
||||
## 按当前步骤读取
|
||||
|
||||
@@ -29,7 +29,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
|
||||
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
|
||||
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
|
||||
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md) |
|
||||
|
||||
## 交付
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ
|
||||
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
```
|
||||
|
||||
直接打包的示意命令:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# 插件页面
|
||||
|
||||
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.md)的页面分支
|
||||
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
|
||||
|
||||
- 沿用已有插件页面与 [管理端主题](../../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../../CONTRIBUTING.md#界面验证) 提供实际截图
|
||||
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
|
||||
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
|
||||
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
|
||||
|
||||
@@ -31,8 +31,9 @@ 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/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -12,10 +12,15 @@ runs:
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
shell: bash
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
|
||||
@@ -42,8 +42,11 @@ else
|
||||
mkdir -p "$(dirname "$directory")"
|
||||
temporary="$(mktemp -d "${directory}.XXXXXX")"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
mkdir "$temporary/context"
|
||||
"${command[@]}" --output "type=local,dest=$temporary/snapshot" "$temporary/context"
|
||||
mkdir "$temporary"/{context,snapshot}
|
||||
# tar 流避开 local exporter 的逐文件同步阻塞,直接解包以免额外落盘整份归档。
|
||||
# https://github.com/moby/buildkit/issues/2950
|
||||
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
|
||||
| tar -xf - -C "$temporary/snapshot"
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
|
||||
@@ -154,6 +154,21 @@ runs:
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
- name: Verify application restart after installer permissions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" stop --timeout 30 codex-proxy-rs
|
||||
# 使用安装器原函数,验证已有备份暂存目录与日志不会因所有权变更而阻止启动
|
||||
(
|
||||
export INSTALL_DIR="$PWD"
|
||||
source deploy/install.sh
|
||||
fix_runtime_permissions
|
||||
)
|
||||
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
|
||||
|
||||
- name: Verify clean logs and graceful shutdown
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
require 'digest'
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
|
||||
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
|
||||
module ReleasePackage
|
||||
APP_NAME = 'codex-proxy-rs'
|
||||
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
|
||||
|
||||
def self.install_manifest(directory)
|
||||
manifest = JSON.parse(File.read(MANIFEST))
|
||||
unless manifest['schema_version'] == 1 && manifest['sealed'] == true && manifest['plugins'] == []
|
||||
raise 'Expected a sealed host manifest without bundled plugins'
|
||||
end
|
||||
FileUtils.mkdir_p(directory)
|
||||
destination = File.join(directory, File.basename(MANIFEST))
|
||||
FileUtils.cp(MANIFEST, destination)
|
||||
File.chmod(0o444, destination)
|
||||
File.chmod(0o555, directory)
|
||||
end
|
||||
|
||||
def self.archives
|
||||
platforms = JSON.parse(ENV.fetch('PLATFORM_MATRIX')).fetch('include')
|
||||
release_dir = 'dist/release'
|
||||
FileUtils.rm_rf(release_dir)
|
||||
FileUtils.mkdir_p(release_dir)
|
||||
# 上一次打包生成的只读清单目录需要先恢复写权限
|
||||
FileUtils.chmod_R('u+w', 'package') if File.directory?('package')
|
||||
FileUtils.rm_rf('package')
|
||||
config = 'deploy/config.example.yaml'
|
||||
FileUtils.cp(config, release_dir)
|
||||
compose = File.read('deploy/compose.yaml')
|
||||
image = "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}"
|
||||
default_image = "${CPR_IMAGE:-#{image}:latest}"
|
||||
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
|
||||
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
|
||||
FileUtils.cp(MANIFEST, release_dir)
|
||||
|
||||
platforms.each do |platform|
|
||||
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
|
||||
name = "#{platform.fetch('os')}-#{platform.fetch('arch')}"
|
||||
binary = platform.fetch('binary')
|
||||
root = "package/#{name}/#{APP_NAME}"
|
||||
FileUtils.mkdir_p(["#{root}/web/dist", "#{root}/deploy"])
|
||||
FileUtils.cp("dist/binaries/#{APP_NAME}-#{name}/#{binary}", "#{root}/#{binary}")
|
||||
File.chmod(0o755, "#{root}/#{binary}")
|
||||
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
|
||||
FileUtils.cp(config, "#{root}/deploy")
|
||||
install_manifest("#{root}/plugins/official")
|
||||
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
|
||||
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
|
||||
end
|
||||
|
||||
checksums = Dir.glob("#{release_dir}/*").sort.map { |path| "#{Digest::SHA256.file(path).hexdigest} #{File.basename(path)}\n" }
|
||||
File.write("#{release_dir}/checksums.txt", checksums.join)
|
||||
end
|
||||
|
||||
def self.image
|
||||
platform = "#{ENV.fetch('MATRIX_OS')}-#{ENV.fetch('MATRIX_ARCH')}"
|
||||
target = "dist/docker/#{platform}"
|
||||
FileUtils.mkdir_p(target)
|
||||
FileUtils.cp("dist/docker-artifacts/#{APP_NAME}-#{platform}/#{ENV.fetch('MATRIX_BINARY')}", "#{target}/#{APP_NAME}")
|
||||
File.chmod(0o755, "#{target}/#{APP_NAME}")
|
||||
install_manifest("#{target}/plugins/official")
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
case ARGV
|
||||
when ['archives'] then ReleasePackage.archives
|
||||
when ['image'] then ReleasePackage.image
|
||||
else raise 'usage: package-release.rb archives|image'
|
||||
end
|
||||
rescue StandardError => error
|
||||
warn "error: #{error.message}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 构建和验证可以并行;调用方必须在仓库级发布锁内执行本脚本
|
||||
module ReleasePublication
|
||||
def self.run(*command)
|
||||
output, error, status = Open3.capture3(*command)
|
||||
raise "#{command.first} failed: #{error.strip}" unless status.success?
|
||||
output.strip
|
||||
end
|
||||
|
||||
def self.latest_release(repository)
|
||||
output, error, status = Open3.capture3('gh', 'api', '--include', "repos/#{repository}/releases/latest")
|
||||
code = output.lines.first.to_s.split[1]
|
||||
return nil if code == '404'
|
||||
raise "Cannot read latest GitHub release: #{error.strip}" unless status.success? && code == '200'
|
||||
JSON.parse(output.split(/\r?\n\r?\n/, 2).fetch(1)).fetch('tag_name')
|
||||
end
|
||||
|
||||
def self.latest_image(image)
|
||||
ref = "#{image}:latest"
|
||||
output, error, status = Open3.capture3('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{json .Image}}')
|
||||
# 只把明确的缺失标签当作首次发布,鉴权和网络错误必须终止提升
|
||||
return nil if !status.success? && error.include?("#{ref}: not found")
|
||||
raise "Cannot read latest image: #{error.strip}" unless status.success?
|
||||
versions = JSON.parse(output).values.map do |config|
|
||||
config.fetch('config').fetch('Labels').fetch('org.opencontainers.image.version')
|
||||
end.uniq
|
||||
raise 'Latest image must have one consistent version across platforms' unless versions.length == 1
|
||||
versions.first
|
||||
end
|
||||
|
||||
def self.can_promote?(version, current_versions)
|
||||
return false if ReleaseMetadata.prerelease?(version)
|
||||
current_versions.compact.all? do |current|
|
||||
raise "Latest points to a prerelease: #{current}" if ReleaseMetadata.prerelease?(current)
|
||||
comparison = ReleaseMetadata.sort_key(version) <=> ReleaseMetadata.sort_key(current)
|
||||
# 构建元数据不决定版本先后;同优先级只允许相同版本重试
|
||||
comparison.positive? || (comparison.zero? && ReleaseMetadata.version(current) == version)
|
||||
end
|
||||
end
|
||||
|
||||
def self.tag(image, digest, name)
|
||||
ref = "#{image}:#{name}"
|
||||
run('docker', 'buildx', 'imagetools', 'create', '-t', ref, "#{image}@#{digest}")
|
||||
actual = run('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{.Manifest.Digest}}')
|
||||
raise "Promoted image digest changed: #{ref}" unless actual == digest
|
||||
end
|
||||
|
||||
def self.publish(version:, image:, digest:, repository:, release_id:)
|
||||
version = ReleaseMetadata.version(version)
|
||||
raise 'Invalid image digest' unless digest.match?(/\Asha256:[a-f0-9]{64}\z/)
|
||||
raise 'Invalid GitHub release ID' unless release_id.match?(/\A\d+\z/)
|
||||
endpoint = "repos/#{repository}/releases/#{release_id}"
|
||||
|
||||
# 附件上传及镜像证明、签名已完成,再公开版本入口;失败可从本 job 重跑
|
||||
tag(image, digest, version.tr('+', '-'))
|
||||
run('gh', 'api', '--method', 'PATCH', endpoint, '-F', 'draft=false', '-f', 'make_latest=false')
|
||||
return if ReleaseMetadata.prerelease?(version)
|
||||
|
||||
current_versions = [latest_release(repository), latest_image(image)]
|
||||
unless can_promote?(version, current_versions)
|
||||
puts "Published #{version}; latest remains at #{current_versions.compact.join(', ')}"
|
||||
return
|
||||
end
|
||||
|
||||
# 两个服务不能原子更新;同时检查两侧版本,让失败重试可以补齐而旧版不能回退
|
||||
tag(image, digest, 'latest')
|
||||
run('gh', 'api', '--method', 'PATCH', endpoint, '-f', 'make_latest=true')
|
||||
puts "Published #{version} and promoted latest"
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
ReleasePublication.publish(
|
||||
version: ENV.fetch('VERSION'), image: "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}",
|
||||
digest: ENV.fetch('DIGEST'), repository: ENV.fetch('GITHUB_REPOSITORY'),
|
||||
release_id: ENV.fetch('RELEASE_ID')
|
||||
)
|
||||
rescue StandardError => error
|
||||
warn "error: #{error.message}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require 'open3'
|
||||
require 'rubygems'
|
||||
require 'time'
|
||||
require 'yaml'
|
||||
|
||||
# 本地入口与发行工作流共用版本、说明和平台合同
|
||||
module ReleaseMetadata
|
||||
APP_NAME = 'codex-proxy-rs'
|
||||
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
|
||||
|
||||
def self.version(raw)
|
||||
value = raw.delete_prefix('v')
|
||||
raise "Invalid release version: #{raw}" unless value.match?(SEMVER)
|
||||
value
|
||||
end
|
||||
|
||||
def self.prerelease?(value)
|
||||
version(value).split('+', 2).first.include?('-')
|
||||
end
|
||||
|
||||
def self.sort_key(value)
|
||||
Gem::Version.new(version(value).split('+', 2).first)
|
||||
end
|
||||
|
||||
def self.validate_notes(value)
|
||||
tag = "v#{version(value)}"
|
||||
lines = File.readlines('release/notes.md')
|
||||
unless lines.first&.strip == "# #{tag}" && !lines.drop(1).join.strip.empty?
|
||||
raise "release/notes.md: expected non-empty notes starting with '# #{tag}'"
|
||||
end
|
||||
end
|
||||
|
||||
def self.git(*arguments)
|
||||
output, status = Open3.capture2('git', *arguments)
|
||||
raise "git #{arguments.join(' ')} failed" unless status.success?
|
||||
output.strip
|
||||
end
|
||||
|
||||
def self.platforms
|
||||
entries = YAML.load_file('release/platforms.yaml').fetch('platforms')
|
||||
raise 'release/platforms.yaml: platforms must be a non-empty array' unless entries.is_a?(Array) && !entries.empty?
|
||||
seen = {}
|
||||
entries.each do |entry|
|
||||
raise 'Invalid release platform' unless entry.is_a?(Hash)
|
||||
%w[os arch runner target binary archive].each do |key|
|
||||
raise "Invalid platform #{key}" unless entry[key].is_a?(String) && !entry[key].strip.empty?
|
||||
end
|
||||
key = "#{entry['os']}-#{entry['arch']}"
|
||||
raise "Duplicate release platform: #{key}" if seen[key]
|
||||
seen[key] = true
|
||||
raise "Unsupported archive: #{entry['archive']}" unless entry['archive'] == 'tar.gz'
|
||||
if entry['docker'] == true
|
||||
platform = entry['dockerPlatform']
|
||||
raise "Missing Docker platform: #{key}" unless platform.is_a?(String) && !platform.strip.empty?
|
||||
entry['dockerPlatform'] = platform.strip
|
||||
end
|
||||
entry['artifact'] = "#{APP_NAME}-#{key}"
|
||||
end
|
||||
raise 'At least one Docker platform is required' unless entries.any? { |entry| entry['docker'] == true }
|
||||
entries
|
||||
end
|
||||
|
||||
def self.outputs(tag)
|
||||
value = version(YAML.load_file('release/version.yaml').fetch('version'))
|
||||
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
|
||||
validate_notes(value)
|
||||
entries = platforms
|
||||
docker, native = entries.partition { |entry| entry['docker'] == true }
|
||||
previous = git('tag', '--list', 'v*', '--merged', 'HEAD').lines.map(&:strip).filter_map do |candidate|
|
||||
next unless candidate.delete_prefix('v').match?(SEMVER)
|
||||
key = sort_key(candidate)
|
||||
[key, candidate] if key < sort_key(value)
|
||||
end.max_by(&:first)&.last
|
||||
{
|
||||
'tag' => tag,
|
||||
'version' => value,
|
||||
'docker_version' => value.tr('+', '-'),
|
||||
'build_time' => Time.now.utc.iso8601,
|
||||
'git_sha' => git('rev-parse', 'HEAD'),
|
||||
'platform_matrix' => JSON.generate('include' => entries),
|
||||
'docker_matrix' => JSON.generate('include' => docker),
|
||||
'non_docker_matrix' => JSON.generate('include' => native),
|
||||
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
|
||||
'previous_tag' => previous,
|
||||
'prerelease' => prerelease?(value),
|
||||
'build_type' => value.split('+', 2).first.include?('-exp.') ? 'experimental' : 'release'
|
||||
}
|
||||
end
|
||||
|
||||
def self.seal_manifest(metadata)
|
||||
path = 'backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json'
|
||||
host = JSON.parse(File.read(path))
|
||||
raise "Invalid plugin host compatibility declaration: #{path}" unless host['schema_version'] == 2
|
||||
manifest = {
|
||||
'schema_version' => 1,
|
||||
'sealed' => true,
|
||||
'gateway_version' => metadata.fetch('version'),
|
||||
'gateway_git_sha' => metadata.fetch('git_sha'),
|
||||
'plugin_host' => host,
|
||||
'plugins' => []
|
||||
}
|
||||
FileUtils.mkdir_p('dist/plugin-release-assets')
|
||||
File.write('dist/plugin-release-assets/plugin-release-manifest.json', JSON.pretty_generate(manifest) + "\n")
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
command, value = ARGV
|
||||
raise 'usage: release-metadata.rb validate VERSION | outputs TAG' unless ARGV.length == 2
|
||||
case command
|
||||
when 'validate'
|
||||
value = ReleaseMetadata.version(value)
|
||||
ReleaseMetadata.validate_notes(value)
|
||||
puts value
|
||||
when 'outputs'
|
||||
metadata = ReleaseMetadata.outputs(value)
|
||||
ReleaseMetadata.seal_manifest(metadata)
|
||||
metadata.each { |key, entry| puts "#{key}=#{entry}" }
|
||||
else
|
||||
raise "Unknown command: #{command}"
|
||||
end
|
||||
rescue StandardError => error
|
||||
warn "error: #{error.message}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# 从构建声明读取 CI 工具链,阻止 Docker 与本地 Rust 版本漂移
|
||||
module BuildToolchains
|
||||
def self.read(root = '.')
|
||||
declaration = File.read(File.join(root, 'backend/rust-toolchain.toml'))
|
||||
rust = declaration[/^\s*channel\s*=\s*["'](\d+\.\d+(?:\.\d+)?)["']\s*(?:#.*)?$/, 1]
|
||||
raise 'Expected a pinned Rust channel in backend/rust-toolchain.toml' unless rust
|
||||
dockerfile = File.read(File.join(root, 'deploy/Dockerfile'))
|
||||
image = lambda do |stage, name|
|
||||
match = dockerfile.match(/^FROM (#{name}:([^\s@]+)@sha256:[a-f0-9]{64}) AS #{stage}$/)
|
||||
raise "Missing pinned #{stage} image" unless match
|
||||
[match[1], match[2].split('-', 2).first]
|
||||
end
|
||||
rust_image, image_rust = image.call('backend-builder', 'rust')
|
||||
_, node = image.call('frontend-builder', 'node')
|
||||
raise "Rust toolchain #{rust} differs from Docker builder #{image_rust}" unless rust == image_rust
|
||||
{ 'rust' => rust, 'rust_image' => rust_image, 'node' => node }
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
BuildToolchains.read.each { |key, value| puts "#{key}=#{value}" }
|
||||
rescue StandardError => error
|
||||
warn "error: #{error.message}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
|
||||
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
|
||||
RUST_MIN_STACK: 16777216
|
||||
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
|
||||
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
|
||||
@@ -97,9 +97,14 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ steps.toolchains.outputs.rust }}
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
@@ -202,6 +207,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
|
||||
# https://github.com/rhysd/actionlint/issues/657
|
||||
- name: Lint GitHub Actions workflows
|
||||
run: >-
|
||||
docker run --rm
|
||||
@@ -209,6 +216,7 @@ jobs:
|
||||
--workdir /repo
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check workflow script syntax
|
||||
@@ -216,3 +224,6 @@ jobs:
|
||||
bash -n release/publish
|
||||
shopt -s globstar nullglob
|
||||
for script in .github/**/*.sh; do bash -n "$script"; done
|
||||
for script in .github/scripts/*.rb; do ruby -c "$script"; done
|
||||
ruby .github/scripts/toolchains.rb
|
||||
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
|
||||
|
||||
@@ -42,18 +42,26 @@ jobs:
|
||||
predicate-quantifier: some-with-excludes
|
||||
filters: |
|
||||
backend:
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- 'deploy/Dockerfile'
|
||||
- 'backend/**'
|
||||
- 'release/version.yaml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
frontend:
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'deploy/Dockerfile'
|
||||
- 'frontend/**'
|
||||
- '.github/actions/build-frontend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
source:
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'deploy/Dockerfile'
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
@@ -106,12 +114,18 @@ jobs:
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ steps.toolchains.outputs.rust }}
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
|
||||
+45
-337
@@ -36,6 +36,8 @@ jobs:
|
||||
previous_tag: ${{ steps.version.outputs.previous_tag }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
build_type: ${{ steps.version.outputs.build_type }}
|
||||
rust: ${{ steps.toolchains.outputs.rust }}
|
||||
rust_image: ${{ steps.toolchains.outputs.rust_image }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -44,151 +46,15 @@ jobs:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read release metadata
|
||||
- name: Read release metadata and seal plugin manifest
|
||||
id: version
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "rubygems"
|
||||
require "time"
|
||||
require "yaml"
|
||||
run: ruby .github/scripts/release-metadata.rb outputs "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
|
||||
|
||||
def fail(message)
|
||||
warn(message)
|
||||
exit(1)
|
||||
end
|
||||
|
||||
def require_string(data, key, path)
|
||||
value = data[key]
|
||||
fail("#{path}: #{key} must be a non-empty string") unless value.is_a?(String) && !value.strip.empty?
|
||||
value.strip
|
||||
end
|
||||
|
||||
def version_sort_key(version)
|
||||
Gem::Version.new(version.split("+", 2).first)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
version_path = "release/version.yaml"
|
||||
platforms_path = "release/platforms.yaml"
|
||||
version_data = YAML.load_file(version_path)
|
||||
platforms_data = YAML.load_file(platforms_path)
|
||||
|
||||
version = require_string(version_data, "version", version_path).delete_prefix("v")
|
||||
tag_prefix = "v"
|
||||
expected_tag = "#{tag_prefix}#{version}"
|
||||
release_tag = ENV.fetch("RELEASE_TAG")
|
||||
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
|
||||
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
|
||||
|
||||
notes_path = "release/notes.md"
|
||||
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
|
||||
notes = File.readlines(notes_path)
|
||||
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
|
||||
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
|
||||
end
|
||||
|
||||
platforms = platforms_data["platforms"]
|
||||
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
|
||||
|
||||
required_platform_keys = %w[os arch runner target binary archive]
|
||||
seen_platforms = {}
|
||||
docker_platform_jobs = []
|
||||
non_docker_platform_jobs = []
|
||||
docker_platforms = []
|
||||
platforms.each_with_index do |platform, index|
|
||||
fail("#{platforms_path}: platforms[#{index}] must be a table") unless platform.is_a?(Hash)
|
||||
required_platform_keys.each do |key|
|
||||
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
|
||||
end
|
||||
archive = platform["archive"]
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
|
||||
platform_key = "#{platform["os"]}-#{platform["arch"]}"
|
||||
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
|
||||
seen_platforms[platform_key] = true
|
||||
platform["artifact"] = "#{APP_NAME}-#{platform_key}"
|
||||
if platform["docker"] == true
|
||||
docker_platform = require_string(platform, "dockerPlatform", "#{platforms_path}: platforms[#{index}]")
|
||||
platform["dockerPlatform"] = docker_platform
|
||||
docker_platform_jobs << platform
|
||||
docker_platforms << docker_platform
|
||||
else
|
||||
non_docker_platform_jobs << platform
|
||||
end
|
||||
end
|
||||
|
||||
fail("#{platforms_path}: at least one platform must set docker = true") if docker_platforms.empty?
|
||||
|
||||
git_sha, status = Open3.capture2("git", "rev-parse", "HEAD")
|
||||
fail("failed to resolve git sha") unless status.success?
|
||||
matrix = JSON.generate({ "include" => platforms })
|
||||
docker_matrix = JSON.generate({ "include" => docker_platform_jobs })
|
||||
non_docker_matrix = JSON.generate({ "include" => non_docker_platform_jobs })
|
||||
current_sort_key = version_sort_key(version)
|
||||
tags, status = Open3.capture2("git", "tag", "--list", "#{tag_prefix}*", "--merged", "HEAD")
|
||||
fail("failed to list git tags") unless status.success?
|
||||
previous_tag = tags
|
||||
.lines
|
||||
.map(&:strip)
|
||||
.reject(&:empty?)
|
||||
.reject { |tag| tag == release_tag }
|
||||
.filter_map do |tag|
|
||||
tag_version = tag.delete_prefix(tag_prefix)
|
||||
next unless tag_version.match?(SEMVER)
|
||||
sort_key = version_sort_key(tag_version)
|
||||
next unless sort_key && current_sort_key && sort_key < current_sort_key
|
||||
[sort_key, tag]
|
||||
end
|
||||
.max_by(&:first)
|
||||
&.last
|
||||
|
||||
puts "tag=#{release_tag}"
|
||||
puts "version=#{version}"
|
||||
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
|
||||
puts "docker_version=#{version.tr("+", "-")}"
|
||||
puts "build_time=#{Time.now.utc.iso8601}"
|
||||
puts "git_sha=#{git_sha.strip}"
|
||||
puts "platform_matrix=#{matrix}"
|
||||
puts "docker_matrix=#{docker_matrix}"
|
||||
puts "non_docker_matrix=#{non_docker_matrix}"
|
||||
puts "docker_platforms=#{docker_platforms.join(",")}"
|
||||
puts "previous_tag=#{previous_tag}"
|
||||
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
|
||||
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
|
||||
RUBY
|
||||
|
||||
- name: Seal plugin release manifest
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
GIT_SHA: ${{ steps.version.outputs.git_sha }}
|
||||
run: |
|
||||
ruby <<'RUBY'
|
||||
require "fileutils"
|
||||
require "json"
|
||||
|
||||
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
|
||||
host = JSON.parse(File.read(host_path))
|
||||
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
|
||||
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
||||
manifest = {
|
||||
"schema_version" => 1,
|
||||
"sealed" => true,
|
||||
"gateway_version" => ENV.fetch("VERSION"),
|
||||
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
|
||||
"plugin_host" => host,
|
||||
"plugins" => [],
|
||||
}
|
||||
FileUtils.mkdir_p("dist/plugin-release-assets")
|
||||
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
|
||||
RUBY
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -245,7 +111,7 @@ jobs:
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
@@ -265,11 +131,12 @@ jobs:
|
||||
-v "$HOME/.cargo:/cargo" \
|
||||
-v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend \
|
||||
rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
|
||||
"${RUST_IMAGE}" \
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
env:
|
||||
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
@@ -299,8 +166,9 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo
|
||||
@@ -310,7 +178,7 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
@@ -373,101 +241,8 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
require "json"
|
||||
require "yaml"
|
||||
|
||||
print JSON.generate(YAML.load_file("release/platforms.yaml"))
|
||||
RUBY
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
|
||||
def sha256(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
version = os.environ["VERSION"]
|
||||
platforms = json.loads(pathlib.Path("release-platforms.json").read_text())["platforms"]
|
||||
frontend_dir = pathlib.Path("dist/frontend")
|
||||
binary_artifacts_dir = pathlib.Path("dist/binaries")
|
||||
release_dir = pathlib.Path("dist/release")
|
||||
package_dir = pathlib.Path("package")
|
||||
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
release_dir.mkdir(parents=True)
|
||||
config_template = pathlib.Path("deploy/config.example.yaml")
|
||||
shutil.copy2(config_template, release_dir / config_template.name)
|
||||
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
||||
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
||||
if compose.count(default_image) != 1:
|
||||
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
||||
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
||||
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
shutil.rmtree(package_dir, ignore_errors=True)
|
||||
release_packages = []
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
artifact_name = f"{APP_NAME}-{platform_name}"
|
||||
binary_src = binary_artifacts_dir / artifact_name / platform["binary"]
|
||||
if not binary_src.is_file():
|
||||
raise SystemExit(f"Missing binary artifact: {binary_src}")
|
||||
|
||||
package_root = package_dir / platform_name / APP_NAME
|
||||
web_dist = package_root / "web" / "dist"
|
||||
web_dist.mkdir(parents=True)
|
||||
|
||||
binary_dest = package_root / platform["binary"]
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
deploy_dir = package_root / "deploy"
|
||||
deploy_dir.mkdir()
|
||||
shutil.copy2(config_template, deploy_dir / config_template.name)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
if platform["archive"] != "tar.gz":
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
release_packages.append((platform, package_root, archive_path))
|
||||
|
||||
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
|
||||
assets.append(plugin_manifest_path)
|
||||
|
||||
# 保留运行时清单目录合同,宿主包不再携带教学示例。
|
||||
for platform, package_root, archive_path in release_packages:
|
||||
official_dir = package_root / "plugins" / "official"
|
||||
official_dir.mkdir(parents=True)
|
||||
bundled_manifest = official_dir / plugin_manifest_path.name
|
||||
shutil.copy2(plugin_manifest_path, bundled_manifest)
|
||||
bundled_manifest.chmod(0o444)
|
||||
official_dir.chmod(0o555)
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
with checksum_path.open("w", encoding="utf-8") as file:
|
||||
for asset in assets:
|
||||
file.write(f"{sha256(asset)} {asset.name}\n")
|
||||
PY
|
||||
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
|
||||
run: ruby .github/scripts/package-release.rb archives
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
@@ -531,56 +306,7 @@ jobs:
|
||||
MATRIX_OS: ${{ matrix.os }}
|
||||
MATRIX_ARCH: ${{ matrix.arch }}
|
||||
MATRIX_BINARY: ${{ matrix.binary }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
app_name = "codex-proxy-rs"
|
||||
platform_name = f"{os.environ['MATRIX_OS']}-{os.environ['MATRIX_ARCH']}"
|
||||
binary_src = pathlib.Path("dist/docker-artifacts") / f"{app_name}-{platform_name}" / os.environ["MATRIX_BINARY"]
|
||||
if not binary_src.is_file():
|
||||
raise SystemExit(f"Missing Docker binary artifact: {binary_src}")
|
||||
|
||||
target_dir = pathlib.Path("dist/docker") / platform_name
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
binary_dest = target_dir / app_name
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
|
||||
release_dir = pathlib.Path("dist/plugin-release-assets")
|
||||
manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
|
||||
raise SystemExit("Invalid sealed plugin release manifest")
|
||||
official_dir = target_dir / "plugins" / "official"
|
||||
official_dir.mkdir(parents=True)
|
||||
bundled_manifest = official_dir / manifest_path.name
|
||||
shutil.copy2(manifest_path, bundled_manifest)
|
||||
bundled_manifest.chmod(0o444)
|
||||
selected = 0
|
||||
for plugin in manifest.get("plugins", []):
|
||||
artifacts = [
|
||||
artifact for artifact in plugin.get("artifacts", [])
|
||||
if artifact.get("release_os") == os.environ["MATRIX_OS"]
|
||||
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
|
||||
]
|
||||
if len(artifacts) != 1:
|
||||
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
|
||||
archive = release_dir / artifacts[0]["archive"]
|
||||
if not archive.is_file():
|
||||
raise SystemExit(f"Missing official plugin archive: {archive.name}")
|
||||
bundled_archive = official_dir / archive.name
|
||||
shutil.copy2(archive, bundled_archive)
|
||||
bundled_archive.chmod(0o444)
|
||||
selected += 1
|
||||
if selected != len(manifest.get("plugins", [])):
|
||||
raise SystemExit("Official plugin release bundle is incomplete")
|
||||
official_dir.chmod(0o555)
|
||||
PY
|
||||
run: ruby .github/scripts/package-release.rb image
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
@@ -644,6 +370,9 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
concurrency:
|
||||
group: release-publication
|
||||
queue: max
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -686,14 +415,12 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create app image manifest
|
||||
- name: Assemble candidate image manifest
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -717,16 +444,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
target_args=(
|
||||
-t "${image}:${DOCKER_VERSION}"
|
||||
-t "${image}:sha-${GIT_SHA}"
|
||||
)
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
target_args+=(-t "${image}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
|
||||
candidate="${image}:sha-${GIT_SHA}"
|
||||
docker buildx imagetools create -t "$candidate" "${sources[@]}"
|
||||
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
|
||||
inspect_output="$(docker buildx imagetools inspect "$candidate")"
|
||||
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
|
||||
for platform in "${platforms[@]}"; do
|
||||
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
|
||||
@@ -736,7 +457,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
digest="$(docker buildx imagetools inspect "$candidate" --format '{{ .Manifest.Digest }}')"
|
||||
if [ -z "${digest}" ]; then
|
||||
echo "missing image digest"
|
||||
exit 1
|
||||
@@ -750,31 +471,10 @@ jobs:
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Sign app image
|
||||
shell: bash
|
||||
- name: Sign app image digest
|
||||
env:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DIGEST}" ]; then
|
||||
echo "missing image digest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
tags+=("${image}:latest")
|
||||
fi
|
||||
images=()
|
||||
for tag in "${tags[@]}"; do
|
||||
images+=("${tag}@${DIGEST}")
|
||||
done
|
||||
|
||||
cosign sign --yes "${images[@]}"
|
||||
run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}"
|
||||
|
||||
- name: Prepare release notes
|
||||
shell: bash
|
||||
@@ -803,25 +503,33 @@ jobs:
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
ruby <<'RUBY'
|
||||
notes = File.readlines('release/notes.md').drop(1).join.strip
|
||||
installation = ENV.fetch('INSTALLATION_NOTES').strip
|
||||
output = File.join(ENV.fetch('RUNNER_TEMP'), 'release-notes.md')
|
||||
File.write(output, "#{notes}\n\n#{installation}\n")
|
||||
RUBY
|
||||
|
||||
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
|
||||
installation = os.environ["INSTALLATION_NOTES"].strip()
|
||||
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
|
||||
output.write_text(f"{notes}\n\n{installation}\n")
|
||||
PY
|
||||
|
||||
- name: Publish GitHub Release
|
||||
- name: Upload GitHub Release assets
|
||||
id: release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
|
||||
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
# action 保留已公开 Release 的状态,新 Release 上传完成后仍保持草稿
|
||||
draft: true
|
||||
make_latest: 'false'
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
dist/release/*
|
||||
|
||||
- name: Publish version and advance latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
run: ruby .github/scripts/publish-release.rb
|
||||
|
||||
@@ -51,10 +51,14 @@ jobs:
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
|
||||
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md),按实际操作和文件匹配规范,跨前后端同时执行 |
|
||||
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
|
||||
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
|
||||
Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复口吻)执行
|
||||
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
@@ -34,10 +34,10 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题及方案必要性;涉及 Codex 行为、协议或兼容性时,以官方对应版本源码为第一手资料,三方仓库仅作参考
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
|
||||
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 前端开发与审查执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),先复用已有页面设计与组件,不因个人偏好重排页面或另建交互;页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
|
||||
+26
-5
@@ -19,15 +19,30 @@
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
|
||||
|
||||
### GitHub 回复口吻
|
||||
|
||||
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
|
||||
|
||||
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
|
||||
涉及上游协议、客户端行为或兼容性时,遵守以下依据与行为约束;无上游依赖的内部变更以本项目代码、合同和运行证据为依据
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
|
||||
- **资料来源**:以对应版本的官方源码与调用链为首要资料,Codex 对照 [openai/codex](https://github.com/openai/codex)。先查用户提供或本机已有的仓库,核对来源、版本或提交;不足时再查官方对应版本源码,结合测试、文档、发布说明和运行证据。三方资料只作线索或实现参考,不能替代官方依据
|
||||
- **证据表达**:引用具体文件、符号或行号,对外优先使用固定提交的源码链接;注明适用版本,区分官方事实、三方参考与本项目推断。源码不可得或版本不匹配时报告缺口,不把最新实现当作部署行为,也不把自定义策略写成官方要求
|
||||
- **默认方案**:Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案。沿用官方协议语义、职责归属和解决路径,优先复用已有能力或正确传递合同,不额外解释请求意图、扩展工具权限或改变续接语义
|
||||
- **问题核实**:对照官方与 CPR 的实际请求、响应及状态生命周期,检查本项目是否误解、改写、遗漏或破坏合同;分别说明源码推断、代理链路复现与官方直连结果。真实上游报错不自动等于 CPR 缺陷
|
||||
- **审查官方对接**:审查 OpenAI 官方上游与 Codex 的对接时,默认二者兼容、直接对接正常。不得仅凭自行构造的异常上游数据揣测官方不兼容,据此报告缺陷、提出改进建议或要求防御分支与测试;特定版本或场景的例外须有真实响应或实际复现证据
|
||||
- **偏离依据**:新增覆盖配置、补偿逻辑或三方兼容方案前,说明官方路径为何不足、本项目实际约束、最小改动、行为代价及验证。补偿后成功、测试通过或三方采用同一做法不能单独证明必要性;依据不足时保持现有合同并报告缺口,不先加分支再倒推理由。用户明确要求的行为变更限定在授权范围内
|
||||
- **网关职责**:调度、隔离和资源管理按网关职责评估,不机械照搬单客户端实现,不因官方客户端没有同类池策略就判错,也不任意改变客户端可观察行为。新增默认限制或回收阈值须有容量依据,并验证续接、重试与延迟影响
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
|
||||
反馈 Issue 时区分症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。症状反馈不以找到官方源码或证明根因为前提;引用上游行为或提出方案时注明上述依据与缺口
|
||||
|
||||
## PR 流程
|
||||
|
||||
@@ -147,7 +162,7 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
|
||||
AI 进行前端开发或审查时,执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),覆盖页面、组件、弹窗、表单及局部文案和样式。用户指定设计技能时使用该技能;未指定时,只有任务明确需要建立或重塑视觉方向才使用 `$frontend-design`。新增页面也优先沿用 [管理端主题](docs/theme.md) 和已有设计,设计技能不构成重做页面的理由
|
||||
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
|
||||
|
||||
@@ -163,11 +178,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
|
||||
|
||||
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
|
||||
|
||||
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
|
||||
Generated
+49
-4
@@ -145,6 +145,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
@@ -931,6 +943,23 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.15.27"
|
||||
@@ -1717,6 +1746,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"regex",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1848,6 +1878,7 @@ dependencies = [
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2749,6 +2780,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3750,9 +3793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -4743,6 +4786,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5600,14 +5644,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "9.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
rust-version = "1.99.1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
warnings = "deny"
|
||||
|
||||
@@ -67,6 +67,7 @@ async fn execute(
|
||||
let Environment {
|
||||
host,
|
||||
mut store,
|
||||
timezone,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
@@ -107,7 +108,7 @@ async fn execute(
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
providers.admin,
|
||||
providers.admin.clone(),
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::public_service::Registry::new(service_middleware(
|
||||
@@ -115,6 +116,22 @@ async fn execute(
|
||||
&plugin_runtime,
|
||||
));
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let accounts = gateway_admin::initialize_accounts(
|
||||
store.admin_ports().accounts(),
|
||||
store.admin_ports().account_runtime(),
|
||||
store.admin_ports().settings(),
|
||||
timezone,
|
||||
providers.admin.clone(),
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, services.register_accounts(&accounts));
|
||||
let usage_data = gateway_admin::initialize_usage_data(
|
||||
store.admin_ports().usage_data(),
|
||||
store.admin_ports().settings(),
|
||||
providers.admin,
|
||||
);
|
||||
or_shutdown!(plugin_runtime, services.register_usage_data(&usage_data));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use syn::{Item, visit::Visit};
|
||||
@@ -26,22 +27,24 @@ pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
|
||||
#[test]
|
||||
fn workspace_member_list_matches_the_frozen_dag_scope() {
|
||||
let manifest =
|
||||
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
assert!(
|
||||
manifest.contains(&format!("\"{member}\"")),
|
||||
"workspace must include {member}"
|
||||
);
|
||||
}
|
||||
let member_count = manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
|
||||
.count();
|
||||
let metadata = cargo_metadata_json();
|
||||
let root = backend_root();
|
||||
let actual: BTreeSet<_> = metadata["packages"]
|
||||
.as_array()
|
||||
.expect("Cargo packages")
|
||||
.iter()
|
||||
.map(|package| {
|
||||
Path::new(package["manifest_path"].as_str().expect("package manifest"))
|
||||
.parent()
|
||||
.expect("package directory")
|
||||
.strip_prefix(&root)
|
||||
.expect("workspace member path")
|
||||
.to_path_buf()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
member_count,
|
||||
WORKSPACE_MEMBERS.len(),
|
||||
actual,
|
||||
WORKSPACE_MEMBERS.iter().map(PathBuf::from).collect(),
|
||||
"new workspace members must extend the dependency DAG rules"
|
||||
);
|
||||
}
|
||||
@@ -378,7 +381,7 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
#[test]
|
||||
fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
let metadata = cargo_metadata_json();
|
||||
let actual = workspace_runtime_dependency_edges(&metadata);
|
||||
let actual = workspace_runtime_dependency_edges(metadata);
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
@@ -474,24 +477,6 @@ fn adapter_public_module_surfaces_match_allowlist() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let src = backend_root().join(member).join("src");
|
||||
let sources = super::rust_files(&src);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = src.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_modules_follow_conventional_file_layout() {
|
||||
let targets = test_source_roots();
|
||||
@@ -748,40 +733,46 @@ pub(super) fn backend_root() -> PathBuf {
|
||||
.expect("backend workspace root")
|
||||
}
|
||||
|
||||
fn cargo_metadata_json() -> serde_json::Value {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
fn cargo_metadata_json() -> &'static serde_json::Value {
|
||||
static METADATA: OnceLock<serde_json::Value> = OnceLock::new();
|
||||
METADATA.get_or_init(|| {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
})
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束
|
||||
/// 复用 Cargo 解析后的实际包名,覆盖重命名依赖与平台条件,排除测试和构建依赖
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
let mut in_dependencies = false;
|
||||
let mut names = Vec::new();
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_dependencies = line == "[dependencies]";
|
||||
continue;
|
||||
}
|
||||
if !in_dependencies || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, _)) = line.split_once('=') {
|
||||
names.push(name.trim().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
let manifest = backend_root().join(member).join("Cargo.toml");
|
||||
let package = cargo_metadata_json()["packages"]
|
||||
.as_array()
|
||||
.expect("Cargo packages")
|
||||
.iter()
|
||||
.find(|package| {
|
||||
Path::new(package["manifest_path"].as_str().expect("package manifest")) == manifest
|
||||
})
|
||||
.expect("workspace member");
|
||||
package["dependencies"]
|
||||
.as_array()
|
||||
.expect("package dependencies")
|
||||
.iter()
|
||||
.filter(|dependency| dependency["kind"].is_null())
|
||||
.map(|dependency| {
|
||||
dependency["name"]
|
||||
.as_str()
|
||||
.expect("dependency name")
|
||||
.to_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
|
||||
|
||||
@@ -78,7 +78,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy, false).await;
|
||||
let result = probe.test(Some(&proxy), false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
|
||||
@@ -59,9 +59,16 @@ fn cargo_library_root_is_conventional_lib() {
|
||||
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
for member in architecture::WORKSPACE_MEMBERS {
|
||||
let root = architecture::backend_root().join(member).join("src");
|
||||
for relative in rust_files(&root) {
|
||||
let sources = rust_files(&root);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
|
||||
@@ -45,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
|
||||
|
||||
```bash
|
||||
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
|
||||
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
|
||||
```
|
||||
|
||||
@@ -250,8 +250,8 @@ fn source_manifest() -> &'static [u8] {
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"version":4,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{}
|
||||
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{"version":2}
|
||||
},
|
||||
|
||||
"icon":"assets/icon.png",
|
||||
|
||||
@@ -31,7 +31,7 @@ sha2 = "0.11"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
cron = "0.17"
|
||||
chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
pub mod public_service;
|
||||
mod reset_credit_automation;
|
||||
mod use_case;
|
||||
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
|
||||
|
||||
@@ -39,6 +40,7 @@ pub use use_case::{
|
||||
proxies::ProxiesService,
|
||||
settings::SettingsService,
|
||||
system::SystemService,
|
||||
usage_data::UsageDataService,
|
||||
};
|
||||
|
||||
use model::AdminError;
|
||||
@@ -217,6 +219,7 @@ pub struct AdminServices {
|
||||
client_distribution: Arc<dyn ClientDistributionService>,
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
usage_data: Arc<dyn UsageDataService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
credentials: Arc<CredentialsService>,
|
||||
// Runtime 只持有 Weak,这个强引用保障账号端口与 Admin 服务同寿命
|
||||
@@ -285,6 +288,11 @@ impl AdminServices {
|
||||
self.client_distribution.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn usage_data(&self) -> &dyn UsageDataService {
|
||||
self.usage_data.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> &dyn ObservabilityService {
|
||||
self.observability.as_ref()
|
||||
@@ -413,13 +421,15 @@ async fn initialize_inner(
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
|
||||
let accounts = Arc::new(DefaultAccountsService::new(
|
||||
let accounts = initialize_accounts(
|
||||
store.accounts(),
|
||||
store.account_runtime(),
|
||||
store.settings(),
|
||||
timezone,
|
||||
registry.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
));
|
||||
);
|
||||
let backup_ports = store.backup();
|
||||
let backups = Arc::new(DefaultBackupService::new(
|
||||
backup_ports.repository(),
|
||||
@@ -467,8 +477,11 @@ async fn initialize_inner(
|
||||
registry.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone());
|
||||
let mut public_services = public_service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
public_services.register_accounts(&accounts)?;
|
||||
public_services.register_usage_data(&usage_data)?;
|
||||
let services = AdminServices {
|
||||
timezone,
|
||||
public_services: Arc::new(public_services),
|
||||
@@ -512,8 +525,10 @@ async fn initialize_inner(
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
timezone,
|
||||
system.clone(),
|
||||
)),
|
||||
settings,
|
||||
usage_data,
|
||||
system,
|
||||
credentials,
|
||||
_plugin_accounts: plugin_accounts,
|
||||
@@ -542,6 +557,33 @@ async fn initialize_inner(
|
||||
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(registration));
|
||||
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
|
||||
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(90),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(
|
||||
WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
|
||||
accounts,
|
||||
store: store.accounts(),
|
||||
settings: store.settings(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
|
||||
));
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
@@ -630,6 +672,28 @@ fn freeze_recovery_worker_contribution(
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
|
||||
#[must_use]
|
||||
pub fn initialize_accounts(
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> Arc<dyn AccountsService> {
|
||||
Arc::new(DefaultAccountsService::new(
|
||||
accounts,
|
||||
account_runtime,
|
||||
settings,
|
||||
timezone,
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
))
|
||||
}
|
||||
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
|
||||
pub fn initialize_settings(
|
||||
store: Arc<dyn ports::store::SettingsStore>,
|
||||
@@ -644,3 +708,15 @@ pub fn initialize_settings(
|
||||
pricing_source,
|
||||
))
|
||||
}
|
||||
|
||||
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
|
||||
#[must_use]
|
||||
pub fn initialize_usage_data(
|
||||
store: Arc<dyn ports::usage_data::UsageDataStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Arc<dyn UsageDataService> {
|
||||
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
|
||||
store, settings, providers,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ pub struct AccountGroupListQuery {
|
||||
/// 完整账号分组摘要
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRecord {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
@@ -139,6 +140,7 @@ pub struct AccountGroupOptionsPage {
|
||||
/// 创建账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -148,6 +150,7 @@ pub struct CreateAccountGroup {
|
||||
/// 已生成稳定标识、可直接提交存储的创建命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAccountGroup {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
@@ -158,6 +161,7 @@ pub struct NewAccountGroup {
|
||||
/// 更新账号分组的描述字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccountGroup {
|
||||
pub allow_image_generation: Option<bool>,
|
||||
pub fast_mode: Option<FastMode>,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
|
||||
@@ -124,6 +124,7 @@ pub struct AccountRecord {
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy_enabled: bool,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
@@ -133,6 +134,16 @@ pub struct AccountRecord {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AccountRecord {
|
||||
/// 配置保留绑定,执行请求时禁用代理按直连处理
|
||||
#[must_use]
|
||||
pub fn effective_outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy
|
||||
.as_ref()
|
||||
.filter(|_| self.outbound_proxy_enabled)
|
||||
}
|
||||
}
|
||||
|
||||
pub use super::observability::{
|
||||
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
|
||||
CurrencyCost as AccountCost,
|
||||
@@ -283,3 +294,24 @@ pub enum AccountConnectionTestEvent {
|
||||
/// 每次连接测试独占的有限事件流
|
||||
pub type AccountConnectionTestEventStream =
|
||||
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
|
||||
|
||||
/// 单个账号普通并发池的实时租约明细,不关联请求历史
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountConcurrencyEntry {
|
||||
pub lease_id: String,
|
||||
pub request_id: Option<String>,
|
||||
pub client_key_id: Option<String>,
|
||||
pub client_key_name: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub client_ip: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub acquired_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 同一次 Redis 读取中的当前占用数和分页明细
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountConcurrencyPage {
|
||||
pub entries: Vec<AccountConcurrencyEntry>,
|
||||
pub total: u64,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 故障诊断导出的目标、已采集证据与缺口
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{
|
||||
observability::{OpsError, UsageDetail},
|
||||
system::SystemVersion,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DiagnosticTarget {
|
||||
Request(String),
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
impl DiagnosticTarget {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Request(id) | Self::Operation(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiagnosticGap {
|
||||
pub section: String,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub struct DiagnosticRequest {
|
||||
pub request_id: String,
|
||||
pub detail: Option<UsageDetail>,
|
||||
pub errors: Vec<OpsError>,
|
||||
}
|
||||
|
||||
/// 原始受控详情只能经导出投影脱敏后交付,不参与普通日志或 Debug
|
||||
pub struct DiagnosticBundle {
|
||||
pub target: DiagnosticTarget,
|
||||
pub collected_at: DateTime<Utc>,
|
||||
pub environment: Option<SystemVersion>,
|
||||
pub error: Option<OpsError>,
|
||||
pub requests: Vec<DiagnosticRequest>,
|
||||
pub gaps: Vec<DiagnosticGap>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod diagnostics;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
@@ -21,9 +22,11 @@ pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
pub mod quota_forecast_sampling;
|
||||
pub mod reset_credit_automation;
|
||||
pub mod retention;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod usage_data;
|
||||
|
||||
/// 管理用例对外返回的稳定错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -172,9 +172,19 @@ pub struct UsageFilter {
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// 请求列表的结果筛选,不改变汇总和计量口径
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum UsageRecordStatus {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
/// 用量记录分页查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageQuery {
|
||||
pub status: UsageRecordStatus,
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub current_page: u32,
|
||||
@@ -185,6 +195,7 @@ pub struct UsageQuery {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct DashboardQuery {
|
||||
pub range: TimeRange,
|
||||
pub yesterday_range: TimeRange,
|
||||
pub granularity: Granularity,
|
||||
pub account_limit: u16,
|
||||
pub recent_request_limit: u16,
|
||||
@@ -192,10 +203,21 @@ pub struct DashboardQuery {
|
||||
}
|
||||
|
||||
impl DashboardQuery {
|
||||
#[must_use]
|
||||
pub fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
pub fn new(
|
||||
range: TimeRange,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, AdminModelError> {
|
||||
let yesterday_range = TimeRange::new(
|
||||
timezone
|
||||
.days_before(range.end, 1)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?,
|
||||
timezone
|
||||
.day_start(range.end)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?,
|
||||
)?;
|
||||
Ok(Self {
|
||||
range,
|
||||
yesterday_range,
|
||||
granularity: Granularity::for_range(range),
|
||||
account_limit: 4,
|
||||
recent_request_limit: 10,
|
||||
@@ -203,7 +225,7 @@ impl DashboardQuery {
|
||||
outcome: Some(RequestOutcome::Succeeded),
|
||||
..UsageFilter::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +665,7 @@ pub struct DashboardObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub totals: DashboardTotals,
|
||||
pub yesterday: RequestMetrics,
|
||||
pub provider_accounts: AccountPoolMetrics,
|
||||
pub runtime_slots: Option<DashboardRuntimeSlots>,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
@@ -653,6 +676,10 @@ 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,
|
||||
@@ -845,7 +872,7 @@ pub struct UsageAttempt {
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 一条请求及其全部尝试
|
||||
/// 一条请求及其保留的尝试与诊断轨迹
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageDetail {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
|
||||
@@ -76,6 +76,7 @@ pub struct PluginManagementResource {
|
||||
|
||||
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志
|
||||
pub struct PluginManagementRequest {
|
||||
pub request_context: Option<std::sync::Arc<gateway_core::engine::extensions::RequestContext>>,
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 插件私有状态的归属、代次隔离与迁移数据合同
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
use std::{collections::BTreeMap, fmt, sync::Arc};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -173,6 +173,8 @@ pub struct PluginStateTransition {
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub namespaces: Vec<PluginStateMigrationNamespace>,
|
||||
/// Store 持有的迁移所有权;最后一个调用持有者释放后允许回收遗留 staging
|
||||
pub guard: Arc<dyn Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -69,6 +69,7 @@ pub struct ProxyTestResult {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub enabled: bool,
|
||||
pub auto_location: bool,
|
||||
pub detected_location: Option<DetectedProxyLocation>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
@@ -103,6 +104,9 @@ pub struct NewProxy {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
/// 省略保留配置,零值取消限制
|
||||
pub max_websocket_connections: Option<u32>,
|
||||
pub enabled: Option<bool>,
|
||||
pub auto_location: Option<bool>,
|
||||
pub test: Option<ProxyTestResult>,
|
||||
/// 外层为空保留配置,内层为空恢复全局继承
|
||||
@@ -119,6 +123,21 @@ pub struct ProxyMutation {
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
|
||||
pub const MAX_PROXY_BATCH_SIZE: usize = 100;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TestProxy {
|
||||
pub id: String,
|
||||
pub revision: Revision,
|
||||
pub detect_location: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProxyBatchResult<T> {
|
||||
Completed(Result<T, super::AdminError>),
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 自动重置卡的持久计划与查询投影,不保存上游凭据或完整卡片
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlannedResetCredit {
|
||||
pub id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PendingResetCredit {
|
||||
pub credit: PlannedResetCredit,
|
||||
pub redeem_request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResetCreditAutomationState {
|
||||
pub next_discovery_at: DateTime<Utc>,
|
||||
pub credits: Vec<PlannedResetCredit>,
|
||||
pub pending: Option<PendingResetCredit>,
|
||||
pub retry_at: Option<DateTime<Utc>>,
|
||||
pub failures: u32,
|
||||
pub last_result: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResetCreditAutomation {
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
/// 零表示尚未创建;每次持久状态写入都递增版本
|
||||
pub revision: u64,
|
||||
pub next_run_at: DateTime<Utc>,
|
||||
pub state: ResetCreditAutomationState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResetCreditAutomationStatus {
|
||||
pub enabled: bool,
|
||||
pub lead_minutes: u32,
|
||||
pub next_discovery_at: Option<DateTime<Utc>>,
|
||||
pub next_consume_at: Option<DateTime<Utc>>,
|
||||
pub last_result: Option<String>,
|
||||
}
|
||||
@@ -24,7 +24,7 @@ impl RetentionPolicy {
|
||||
audit_days: u32,
|
||||
) -> Result<(), &'static str> {
|
||||
for (valid, field) in [
|
||||
(usage_days >= 31, "usage_retention_days"),
|
||||
((1..=31).contains(&usage_days), "usage_retention_days"),
|
||||
(ops_days > 0, "ops_event_retention_days"),
|
||||
(audit_days > 0, "audit_retention_days"),
|
||||
] {
|
||||
@@ -38,7 +38,7 @@ impl RetentionPolicy {
|
||||
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
|
||||
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
|
||||
return Err(AdminError::invalid(
|
||||
"请求保留期至少 31 天,事件与审计保留期必须为正数",
|
||||
"请求保留期必须为 1~31 天,事件与审计保留期必须为正数",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
|
||||
@@ -75,6 +75,8 @@ pub struct RuntimeSettingsValues {
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
/// 完整运行设置事实
|
||||
@@ -189,6 +191,15 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
impl RuntimeSettingsValues {
|
||||
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if !(1..=1440).contains(&self.reset_credit_lead_minutes) {
|
||||
return Err("reset_credit_lead_minutes");
|
||||
}
|
||||
if self
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.is_some_and(|percent| percent > 100)
|
||||
{
|
||||
return Err("reset_credit_max_weekly_remaining_percent");
|
||||
}
|
||||
self.request_location
|
||||
.validate()
|
||||
.map_err(|_| "request_location")?;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 由宿主传入的可信写入主体,不属于插件请求载荷
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageActor {
|
||||
Admin {
|
||||
subject: String,
|
||||
},
|
||||
Plugin {
|
||||
publisher: String,
|
||||
name: String,
|
||||
instance_id: String,
|
||||
},
|
||||
System,
|
||||
}
|
||||
|
||||
impl super::UsageRecordChange {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Put { id, .. } | Self::Update { id, .. } | Self::Delete { id } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 按归属时间和维度维护统计贡献,业务来源与调整历史由调用方保存
|
||||
|
||||
use super::{UsageDelta, UsageRecordFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageMetric {
|
||||
RequestCount,
|
||||
SuccessCount,
|
||||
FailureCount,
|
||||
CancelledCount,
|
||||
IncompleteCount,
|
||||
CompletedRequestCount,
|
||||
InputTokens,
|
||||
OutputTokens,
|
||||
CachedInputTokens,
|
||||
CacheWriteInputTokens,
|
||||
ReasoningOutputTokens,
|
||||
ImageInputTokens,
|
||||
ImageOutputTokens,
|
||||
TotalTokens,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageDimensions {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub client_key_ref: Option<String>,
|
||||
pub operation: Option<String>,
|
||||
pub transport: Option<String>,
|
||||
}
|
||||
|
||||
/// 整个键决定覆盖目标;调用方可将来源标识编码进 key,重试相同键不会累计
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregateKey {
|
||||
pub bucket_start_ms: i64,
|
||||
pub dimensions: UsageDimensions,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// 值按 bucket_start_ms 归属,不自动摊分;负值用于相对原生统计的修正
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregate {
|
||||
pub identity: UsageAggregateKey,
|
||||
pub counts: BTreeMap<UsageMetric, UsageDelta>,
|
||||
pub costs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageAggregateChange {
|
||||
Put { aggregate: UsageAggregate },
|
||||
Delete { identity: UsageAggregateKey },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteUsageAggregates {
|
||||
pub changes: Vec<UsageAggregateChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregateWriteResult {
|
||||
pub changed: Vec<UsageAggregateKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListUsageAggregates {
|
||||
pub filter: UsageRecordFilter,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregatePage {
|
||||
pub items: Vec<UsageAggregate>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 用量基础操作的错误分类,不保存失败或重试状态
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageErrorKind {
|
||||
InvalidInput,
|
||||
NotFound,
|
||||
Conflict,
|
||||
CapacityExceeded,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageDataError {
|
||||
pub kind: UsageErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
impl UsageDataError {
|
||||
#[must_use]
|
||||
pub fn new(kind: UsageErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(UsageErrorKind::InvalidInput, message)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(UsageErrorKind::Unavailable, message)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UsageDataError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for UsageDataError {}
|
||||
pub type UsageDataResult<T> = Result<T, UsageDataError>;
|
||||
@@ -0,0 +1,22 @@
|
||||
//! 用量明细、聚合维护与纯计价的公开合同
|
||||
|
||||
mod value;
|
||||
pub use value::*;
|
||||
mod record;
|
||||
pub use record::*;
|
||||
mod query;
|
||||
pub use query::*;
|
||||
mod pricing;
|
||||
pub use pricing::*;
|
||||
mod error;
|
||||
pub use error::*;
|
||||
|
||||
pub const USAGE_DATA_MAX_TARGETS: usize = 500;
|
||||
pub const USAGE_DATA_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub const USAGE_DATA_MAX_PAGE_SIZE: u16 = 200;
|
||||
|
||||
mod actor;
|
||||
pub use actor::*;
|
||||
|
||||
mod aggregate;
|
||||
pub use aggregate::*;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 纯 Token 计价合同,费用是否采用与额外费用合并由调用方决定
|
||||
|
||||
use super::{UsageCost, UsageTokens};
|
||||
use gateway_core::metering::ModelPriceOverride;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsagePriceSelection {
|
||||
Current,
|
||||
Override { price: Box<ModelPriceOverride> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingInput {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub tokens: UsageTokens,
|
||||
pub service_tier: Option<String>,
|
||||
pub price: UsagePriceSelection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EvaluateUsagePricing {
|
||||
pub items: Vec<UsagePricingInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingBreakdown {
|
||||
pub input_amount: String,
|
||||
pub output_amount: String,
|
||||
pub cache_read_amount: String,
|
||||
pub cache_write_amount: String,
|
||||
pub standard_amount: String,
|
||||
pub long_context_billing_applied: bool,
|
||||
pub service_tier: Option<String>,
|
||||
pub custom_multiplier_bps: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsagePricingResult {
|
||||
Estimated {
|
||||
id: String,
|
||||
cost: UsageCost,
|
||||
breakdown: UsagePricingBreakdown,
|
||||
},
|
||||
NotEstimable {
|
||||
id: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingEvaluation {
|
||||
pub items: Vec<UsagePricingResult>,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! 请求明细的有界筛选与稳定顺序分页
|
||||
|
||||
use super::UsageRecord;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GetUsageRecord {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordFilter {
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub client_key_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListUsageRecords {
|
||||
pub filter: UsageRecordFilter,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordPage {
|
||||
pub items: Vec<UsageRecord>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 既有请求明细的用量读写合同,不承载插件任务或来源状态
|
||||
|
||||
use super::UsageCount;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Token 使用宿主口径:缓存包含在输入中,推理包含在输出中,未知值为 null
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageTokens {
|
||||
pub input_tokens: Option<UsageCount>,
|
||||
pub output_tokens: Option<UsageCount>,
|
||||
pub cached_input_tokens: Option<UsageCount>,
|
||||
pub cache_write_input_tokens: Option<UsageCount>,
|
||||
pub reasoning_output_tokens: Option<UsageCount>,
|
||||
pub image_input_tokens: Option<UsageCount>,
|
||||
pub image_output_tokens: Option<UsageCount>,
|
||||
pub total_tokens: Option<UsageCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageCostSource {
|
||||
ProviderReported,
|
||||
Calculated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageCost {
|
||||
#[default]
|
||||
Unavailable,
|
||||
Known {
|
||||
source: UsageCostSource,
|
||||
amount: String,
|
||||
currency: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageOutcome {
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordData {
|
||||
pub client_key_ref: String,
|
||||
pub provider: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub protocol: String,
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub started_at_ms: i64,
|
||||
pub completed_at_ms: Option<i64>,
|
||||
pub outcome: UsageOutcome,
|
||||
pub tokens: UsageTokens,
|
||||
pub cost: UsageCost,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecord {
|
||||
pub id: String,
|
||||
pub data: UsageRecordData,
|
||||
pub billing_snapshot: Option<serde_json::Value>,
|
||||
pub provider_observation: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageRecordChange {
|
||||
Put {
|
||||
id: String,
|
||||
data: Box<UsageRecordData>,
|
||||
},
|
||||
/// tokens 替换全部 Token 字段;cost 的 unavailable 明确清空费用
|
||||
Update {
|
||||
id: String,
|
||||
tokens: Option<UsageTokens>,
|
||||
cost: Option<UsageCost>,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteUsageRecords {
|
||||
pub changes: Vec<UsageRecordChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageWriteResult {
|
||||
pub changed_ids: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 用量计数使用十进制字符串,避免插件前端丢失整数精度
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UsageCount(pub u64);
|
||||
impl UsageCount {
|
||||
pub const ZERO: Self = Self(0);
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
impl From<u64> for UsageCount {
|
||||
fn from(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
impl fmt::Display for UsageCount {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
impl Serialize for UsageCount {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0.to_string())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for UsageCount {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(D::Error::custom("count must be an unsigned decimal string"));
|
||||
}
|
||||
value.parse().map(Self).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// 有符号统计贡献受 PostgreSQL bigint 范围限制,JSON 使用十进制字符串
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UsageDelta(pub i64);
|
||||
impl UsageDelta {
|
||||
#[must_use]
|
||||
pub const fn get(self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
impl Serialize for UsageDelta {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0.to_string())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for UsageDelta {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
let digits = value.strip_prefix('-').unwrap_or(&value);
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(D::Error::custom("delta must be a signed decimal string"));
|
||||
}
|
||||
value.parse().map(Self).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod reset_credit_automation;
|
||||
pub mod retention;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
pub mod usage_data;
|
||||
|
||||
@@ -26,6 +26,14 @@ use crate::model::{
|
||||
quota_forecast_sampling::QuotaForecastObservation,
|
||||
};
|
||||
|
||||
/// 按原生 Token 口径计算的报价输入,额外费用由调用方处理
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderUsagePricingInput {
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub usage: gateway_core::metering::Usage,
|
||||
}
|
||||
|
||||
/// Provider 管理失败的稳定分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdminErrorKind {
|
||||
@@ -161,6 +169,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn evaluate_usage_pricing(
|
||||
&self,
|
||||
_input: &ProviderUsagePricingInput,
|
||||
_pricing: Option<&gateway_core::metering::ModelPriceOverride>,
|
||||
) -> Result<Option<gateway_core::metering::CalculatedCostBreakdown>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
|
||||
|
||||
@@ -64,5 +64,6 @@ pub struct ProxyImportReservation {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
|
||||
/// 未指定代理时探测服务器直连出口,不继承进程代理环境变量
|
||||
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! 自动重置卡计划的持久化端口,配置变更沿用管理事务与审计
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{MutationContext, reset_credit_automation::ResetCreditAutomation};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ResetCreditAutomationStore: Send + Sync {
|
||||
async fn reset_credit_automation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> AdminStoreResult<Option<ResetCreditAutomation>>;
|
||||
|
||||
/// 只读取到期的本地计划,按到期时间稳定排序
|
||||
async fn due_reset_credit_automations(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<String>>;
|
||||
|
||||
/// 按 record.revision 比较写入,成功返回新版本;配置变更传入审计上下文
|
||||
async fn save_reset_credit_automation(
|
||||
&self,
|
||||
record: &ResetCreditAutomation,
|
||||
context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<u64>;
|
||||
}
|
||||
@@ -109,7 +109,9 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
|
||||
/// 账号目录与公共账号写操作
|
||||
#[async_trait]
|
||||
pub trait AccountStore: Send + Sync {
|
||||
pub trait AccountStore:
|
||||
super::reset_credit_automation::ResetCreditAutomationStore + Send + Sync
|
||||
{
|
||||
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
@@ -128,6 +130,8 @@ pub trait AccountStore: Send + Sync {
|
||||
runtime: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<Option<AccountPageItem>>;
|
||||
|
||||
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
@@ -241,6 +245,13 @@ pub trait AccountStore: Send + Sync {
|
||||
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有
|
||||
#[async_trait]
|
||||
pub trait AccountRuntimeStore: Send + Sync {
|
||||
async fn account_concurrency(
|
||||
&self,
|
||||
account_id: &str,
|
||||
offset: u64,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<crate::model::accounts::AccountConcurrencyPage>;
|
||||
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
async fn account_runtime(
|
||||
@@ -485,6 +496,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
|
||||
/// 按精确目标读取受控错误,不受列表时间窗口与当前页面影响
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
target: &crate::model::diagnostics::DiagnosticTarget,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<Vec<crate::model::observability::OpsError>>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 写入
|
||||
@@ -565,9 +583,15 @@ pub struct AdminStorePorts {
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
usage_data: Arc<dyn super::usage_data::UsageDataStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn usage_data(&self) -> Arc<dyn super::usage_data::UsageDataStore> {
|
||||
self.usage_data.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
|
||||
self.plugin_resources.clone()
|
||||
@@ -598,6 +622,7 @@ impl AdminStorePorts {
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
usage_data: Arc<dyn super::usage_data::UsageDataStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -609,6 +634,7 @@ impl AdminStorePorts {
|
||||
plugins,
|
||||
plugin_state,
|
||||
plugin_resources,
|
||||
usage_data,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! 原生请求用量的有界读取和事务写入端口
|
||||
|
||||
use crate::model::{auth::AdminAuditEvent, usage_data::*};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct StoredUsageWrite {
|
||||
pub changes: Vec<(UsageRecordChange, AdminAuditEvent)>,
|
||||
}
|
||||
|
||||
pub struct StoredAggregateWrite {
|
||||
pub changes: Vec<(UsageAggregateChange, AdminAuditEvent)>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageDataStore: Send + Sync {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage>;
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
command: StoredAggregateWrite,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult>;
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord>;
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage>;
|
||||
async fn write_records(&self, command: StoredUsageWrite) -> UsageDataResult<UsageWriteResult>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 账号自动重置的公开操作声明,复用账号用例及宿主签发的审计上下文
|
||||
|
||||
use crate::{AccountsService, model::AdminError};
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn AccountsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
macro_rules! register {
|
||||
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
|
||||
let service = service.clone();
|
||||
registry.register(concat!("accounts.", stringify!($method)), move |$pattern: $input| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move { service.$method($($argument),*).await })
|
||||
})?;
|
||||
}};
|
||||
}
|
||||
register!(
|
||||
GetResetCreditAutomation,
|
||||
reset_credit_automation_status,
|
||||
String,
|
||||
account_id,
|
||||
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?
|
||||
);
|
||||
register!(
|
||||
SetResetCreditAutomation,
|
||||
set_reset_credit_automation,
|
||||
(String, bool),
|
||||
(account_id, enabled),
|
||||
&super::actor::mutation_context()?,
|
||||
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?,
|
||||
enabled
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 公开服务写入主体由受信宿主边界设置,插件输入不能覆盖
|
||||
|
||||
use crate::model::usage_data::UsageActor;
|
||||
use crate::model::{AdminError, MutationActor, MutationContext};
|
||||
use std::future::Future;
|
||||
|
||||
tokio::task_local! {
|
||||
static ACTOR: Option<UsageActor>;
|
||||
}
|
||||
|
||||
/// 与 middleware Origin 独立传递,嵌套服务不会丢失真实调用主体
|
||||
pub async fn scope_actor<F: Future>(actor: UsageActor, future: F) -> F::Output {
|
||||
scope_captured(Some(actor), future).await
|
||||
}
|
||||
|
||||
pub(super) async fn scope_captured<F: Future>(actor: Option<UsageActor>, future: F) -> F::Output {
|
||||
ACTOR.scope(actor, future).await
|
||||
}
|
||||
|
||||
pub(super) fn current_actor() -> Result<UsageActor, crate::model::usage_data::UsageDataError> {
|
||||
ACTOR.try_with(Clone::clone).ok().flatten().ok_or_else(|| {
|
||||
crate::model::usage_data::UsageDataError::invalid(
|
||||
"usage mutation requires a trusted caller identity",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn mutation_context() -> Result<MutationContext, AdminError> {
|
||||
let actor = ACTOR
|
||||
.try_with(Clone::clone)
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| AdminError::invalid("服务写入缺少可信调用主体"))?;
|
||||
super::CURRENT
|
||||
.try_with(|origin| {
|
||||
let (actor, request_id) = match actor {
|
||||
UsageActor::Plugin { instance_id, .. } => (
|
||||
MutationActor::System,
|
||||
format!(
|
||||
"plugin:{instance_id}:request:{}:call:{}",
|
||||
origin.request_id, origin.call_id
|
||||
),
|
||||
),
|
||||
UsageActor::Admin { subject } => (
|
||||
MutationActor::AdminSession {
|
||||
admin_user_id: subject,
|
||||
},
|
||||
origin.request_id.clone(),
|
||||
),
|
||||
UsageActor::System => (MutationActor::System, origin.request_id.clone()),
|
||||
};
|
||||
MutationContext { actor, request_id }
|
||||
})
|
||||
.map_err(|_| AdminError::invalid("服务写入缺少调用上下文"))
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
|
||||
|
||||
mod accounts;
|
||||
mod actor;
|
||||
mod registry;
|
||||
mod settings;
|
||||
mod usage_data;
|
||||
pub use actor::scope_actor;
|
||||
pub use registry::Registry;
|
||||
|
||||
use std::{future::Future, sync::Arc};
|
||||
@@ -98,8 +102,10 @@ pub(crate) async fn invoke(
|
||||
plan: plan.clone(),
|
||||
};
|
||||
let inner = context.clone();
|
||||
// next 可在另一回调任务中执行,终端必须保留发起主体及其缺席状态
|
||||
let actor = actor::current_actor().ok();
|
||||
let next = compose(Vec::new(), move |input| {
|
||||
Box::pin(scope(inner, terminal(input)))
|
||||
Box::pin(actor::scope_captured(actor, scope(inner, terminal(input))))
|
||||
});
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
@@ -29,6 +29,13 @@ impl Registry {
|
||||
super::settings::register(self, service)
|
||||
}
|
||||
|
||||
pub fn register_accounts(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::AccountsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
super::accounts::register(self, service)
|
||||
}
|
||||
|
||||
pub(crate) fn register<I, O>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
@@ -54,6 +61,57 @@ impl Registry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_usage_data(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::UsageDataService>,
|
||||
) -> Result<(), AdminError> {
|
||||
super::usage_data::register(self, service)
|
||||
}
|
||||
|
||||
pub(crate) fn register_usage<I, O>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
handler: impl Fn(I) -> BoxFuture<'static, crate::model::usage_data::UsageDataResult<O>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Result<(), AdminError>
|
||||
where
|
||||
I: DeserializeOwned + Send + 'static,
|
||||
O: Serialize + Send + 'static,
|
||||
{
|
||||
let std::collections::btree_map::Entry::Vacant(entry) = self.operations.entry(name) else {
|
||||
return Err(AdminError::internal(format!("公开服务重复登记:{name}")));
|
||||
};
|
||||
let handler = Arc::new(handler);
|
||||
entry.insert(Arc::new(move |input| {
|
||||
let handler = handler.clone();
|
||||
Box::pin(async move {
|
||||
// 在反序列化之前限制编码后的完整请求,避免单条记录绕过批量行数约束
|
||||
let bytes =
|
||||
serde_json::to_vec(&input).map_err(|_| Error::invalid("服务输入编码无效"))?;
|
||||
if bytes.len() > crate::model::usage_data::USAGE_DATA_MAX_REQUEST_BYTES {
|
||||
return Err(super::usage_data::encode_error(
|
||||
crate::model::usage_data::UsageDataError::new(
|
||||
crate::model::usage_data::UsageErrorKind::CapacityExceeded,
|
||||
"usage input exceeds 2 MiB",
|
||||
),
|
||||
));
|
||||
}
|
||||
let input = serde_json::from_value(input).map_err(|_| {
|
||||
super::usage_data::encode_error(
|
||||
crate::model::usage_data::UsageDataError::invalid("服务输入类型无效"),
|
||||
)
|
||||
})?;
|
||||
let output = handler(input)
|
||||
.await
|
||||
.map_err(super::usage_data::encode_error)?;
|
||||
serde_json::to_value(output).map_err(|_| Error::unavailable("服务结果编码失败"))
|
||||
})
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn call(&self, operation: &str, input: Value) -> Result<Value, Error> {
|
||||
let (operation, handler) =
|
||||
self.operations
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 原生用量能力的公开服务登记
|
||||
|
||||
use crate::{
|
||||
UsageDataService,
|
||||
model::{AdminError, usage_data::*},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn UsageDataService>,
|
||||
) -> Result<(), AdminError> {
|
||||
macro_rules! read {
|
||||
($name:literal, $method:ident, $input:ty) => {{
|
||||
let service = service.clone();
|
||||
registry.register_usage($name, move |request: $input| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move { service.$method(request).await })
|
||||
})?;
|
||||
}};
|
||||
}
|
||||
read!("usage.records.get", get_record, GetUsageRecord);
|
||||
read!("usage.records.list", list_records, ListUsageRecords);
|
||||
read!(
|
||||
"usage.aggregates.list",
|
||||
list_aggregates,
|
||||
ListUsageAggregates
|
||||
);
|
||||
read!("pricing.evaluate", evaluate_pricing, EvaluateUsagePricing);
|
||||
let aggregates = service.clone();
|
||||
registry.register_usage(
|
||||
"usage.aggregates.write",
|
||||
move |request: WriteUsageAggregates| {
|
||||
let service = aggregates.clone();
|
||||
Box::pin(async move {
|
||||
let actor = super::actor::current_actor()?;
|
||||
service.write_aggregates(&actor, request).await
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let service = service.clone();
|
||||
registry.register_usage("usage.records.write", move |request: WriteUsageRecords| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move {
|
||||
let actor = super::actor::current_actor()?;
|
||||
service.write_records(&actor, request).await
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn encode_error(
|
||||
error: UsageDataError,
|
||||
) -> gateway_core::engine::middleware::service::Error {
|
||||
gateway_core::engine::middleware::service::Error {
|
||||
kind: serde_json::to_value(error.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "unavailable".into()),
|
||||
message: error.message.clone(),
|
||||
details: serde_json::to_value(error).ok(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
|
||||
|
||||
use crate::{
|
||||
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
|
||||
ports::store::{AccountStore, SettingsStore},
|
||||
use_case::accounts::AccountsService,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
|
||||
time::DeploymentTimeZone,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
|
||||
pub(crate) fn next_discovery(
|
||||
now: DateTime<Utc>,
|
||||
timezone: DeploymentTimeZone,
|
||||
random: u64,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let start = timezone.days_after(now, 1)?;
|
||||
let end = timezone.days_after(now, 2)?;
|
||||
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
|
||||
if seconds == 0 {
|
||||
return None;
|
||||
}
|
||||
start.checked_add_signed(chrono::Duration::seconds(
|
||||
i64::try_from(random % seconds).ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
|
||||
pub(crate) fn consume_at(
|
||||
account_id: &str,
|
||||
credit: &PlannedResetCredit,
|
||||
lead_minutes: u32,
|
||||
) -> DateTime<Utc> {
|
||||
let lead_seconds = i64::from(lead_minutes) * 60;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(account_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(credit.id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let offset =
|
||||
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
|
||||
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
|
||||
}
|
||||
|
||||
pub(crate) fn next_consumption(
|
||||
record: &ResetCreditAutomation,
|
||||
lead_minutes: u32,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
|
||||
.min()
|
||||
}
|
||||
|
||||
pub(crate) fn reschedule(
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead_minutes: u32,
|
||||
now: DateTime<Utc>,
|
||||
) {
|
||||
record.next_run_at = if let Some(retry) = record.state.retry_at {
|
||||
retry
|
||||
} else if record.state.pending.is_some() {
|
||||
now
|
||||
} else {
|
||||
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
|
||||
due.min(record.state.next_discovery_at)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) struct ResetCreditAutomationTask {
|
||||
pub(crate) accounts: Arc<dyn AccountsService>,
|
||||
pub(crate) store: Arc<dyn AccountStore>,
|
||||
pub(crate) settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
impl ScheduledTask for ResetCreditAutomationTask {
|
||||
fn run_cycle(
|
||||
&self,
|
||||
context: WorkerCycleContext,
|
||||
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let settings = self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
|
||||
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
|
||||
let due = self
|
||||
.store
|
||||
.due_reset_credit_automations(Utc::now(), 100)
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
|
||||
let work = futures::stream::iter(due)
|
||||
.map(|account_id| async move {
|
||||
let id = ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
|
||||
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
error_kind = error.kind().as_str(),
|
||||
"自动重置卡任务失败"
|
||||
);
|
||||
}
|
||||
Ok::<_, WorkerTaskError>(())
|
||||
})
|
||||
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
tokio::select! {
|
||||
() = context.cancellation().cancelled() => {},
|
||||
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,7 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
self.store
|
||||
.create_account_group(
|
||||
NewAccountGroup {
|
||||
allow_image_generation: command.allow_image_generation,
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
|
||||
+117
-34
@@ -1,5 +1,7 @@
|
||||
//! 统一账号目录与原生 Provider 分派
|
||||
|
||||
mod reset_credit_automation;
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
@@ -52,6 +54,13 @@ const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
|
||||
/// 统一账号页消费的服务
|
||||
#[async_trait]
|
||||
pub trait AccountsService: Send + Sync {
|
||||
async fn concurrency(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError>;
|
||||
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
|
||||
|
||||
async fn export(
|
||||
@@ -123,6 +132,29 @@ pub trait AccountsService: Send + Sync {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
|
||||
}
|
||||
|
||||
async fn reset_credit_automation_status(
|
||||
&self,
|
||||
_account_id: ProviderAccountId,
|
||||
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
|
||||
{
|
||||
Err(AdminError::invalid("账号不支持自动重置卡"))
|
||||
}
|
||||
async fn set_reset_credit_automation(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
_account_id: ProviderAccountId,
|
||||
_enabled: bool,
|
||||
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
|
||||
{
|
||||
Err(AdminError::invalid("账号不支持自动重置卡"))
|
||||
}
|
||||
async fn reconcile_reset_credit_automation(
|
||||
&self,
|
||||
_account_id: ProviderAccountId,
|
||||
) -> Result<(), AdminError> {
|
||||
Err(AdminError::invalid("账号不支持自动重置卡"))
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
@@ -160,6 +192,8 @@ pub trait AccountsService: Send + Sync {
|
||||
pub(crate) struct DefaultAccountsService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
settings: Arc<dyn crate::ports::store::SettingsStore>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
@@ -172,6 +206,8 @@ impl DefaultAccountsService {
|
||||
pub(crate) fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
settings: Arc<dyn crate::ports::store::SettingsStore>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
@@ -179,6 +215,8 @@ impl DefaultAccountsService {
|
||||
Self {
|
||||
accounts,
|
||||
account_runtime,
|
||||
settings,
|
||||
timezone,
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
@@ -381,6 +419,31 @@ impl DefaultAccountsService {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountsService for DefaultAccountsService {
|
||||
async fn concurrency(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError> {
|
||||
let offset = page
|
||||
.checked_sub(1)
|
||||
.and_then(|page| page.checked_mul(page_size))
|
||||
.filter(|_| (1..=100).contains(&page_size))
|
||||
.ok_or_else(|| AdminError::invalid("并发明细分页参数无效"))?;
|
||||
if !self
|
||||
.accounts
|
||||
.account_exists(account_id.as_str())
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account concurrency"))?
|
||||
{
|
||||
return Err(AdminError::not_found("账号不存在"));
|
||||
}
|
||||
self.account_runtime
|
||||
.account_concurrency(account_id.as_str(), u64::from(offset), page_size)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account concurrency"))
|
||||
}
|
||||
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
|
||||
let providers = &self.providers;
|
||||
let runtime = self
|
||||
@@ -891,27 +954,48 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "provider profile avatar"))
|
||||
}
|
||||
|
||||
async fn reset_credit_automation_status(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
|
||||
{
|
||||
self.automation_status(&account_id).await
|
||||
}
|
||||
async fn set_reset_credit_automation(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
enabled: bool,
|
||||
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
|
||||
{
|
||||
self.configure_automation(context, &account_id, enabled)
|
||||
.await
|
||||
}
|
||||
async fn reconcile_reset_credit_automation(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<(), AdminError> {
|
||||
self.reconcile_automation(&account_id).await
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.reset_credits(&account_id).await {
|
||||
Ok(credits) => Ok(credits),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.reset_credits(&account_id)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset credits")),
|
||||
let lock = self.reset_credit_lock(&account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let credits = self
|
||||
.reset_credits_upstream(context, account_id.clone())
|
||||
.await?;
|
||||
if let Err(error) = self.remember_reset_credits(&account_id, &credits).await {
|
||||
tracing::warn!(
|
||||
account_id = account_id.as_str(),
|
||||
error_kind = error.kind().as_str(),
|
||||
"保存重置卡计划失败"
|
||||
);
|
||||
}
|
||||
Ok(credits)
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
@@ -919,27 +1003,26 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
let account_id = command.account_id.clone();
|
||||
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
|
||||
// 之间插入另一笔不可逆消费
|
||||
let lock = self.reset_credit_lock(&account_id).await;
|
||||
let lock = self.reset_credit_lock(&command.account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.consume_reset_credit(command.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.consume_reset_credit(command)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
|
||||
if self
|
||||
.accounts
|
||||
.reset_credit_automation(command.account_id.as_str())
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?
|
||||
.is_some_and(|record| {
|
||||
record
|
||||
.state
|
||||
.pending
|
||||
.is_some_and(|pending| pending.credit.expires_at > Utc::now())
|
||||
})
|
||||
{
|
||||
return Err(AdminError::new(
|
||||
crate::model::AdminErrorKind::Conflict,
|
||||
"自动重置结果待确认,请稍后刷新",
|
||||
));
|
||||
}
|
||||
self.consume_reset_credit_upstream(context, command).await
|
||||
}
|
||||
|
||||
async fn models(
|
||||
@@ -0,0 +1,513 @@
|
||||
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
|
||||
|
||||
use super::*;
|
||||
|
||||
impl DefaultAccountsService {
|
||||
pub(super) async fn reset_credits_upstream(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.reset_credits(&account_id).await {
|
||||
Ok(credits) => Ok(credits),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.reset_credits(&account_id)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset credits")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn consume_reset_credit_upstream(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
let account_id = command.account_id.clone();
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
match provider.consume_reset_credit(command.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(error)
|
||||
if error.kind()
|
||||
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
|
||||
{
|
||||
self.refresh(context, account_id.clone()).await?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
provider
|
||||
.consume_reset_credit(command)
|
||||
.await
|
||||
.map_err(map_reset_credits_error_after_refresh)
|
||||
}
|
||||
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
model::{MutationActor, reset_credit_automation::*},
|
||||
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
impl DefaultAccountsService {
|
||||
async fn automation_settings(
|
||||
&self,
|
||||
) -> Result<crate::model::settings::RuntimeSettingsValues, AdminError> {
|
||||
Ok(self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "runtime settings"))?
|
||||
.values)
|
||||
}
|
||||
|
||||
pub(super) async fn automation_status(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ResetCreditAutomationStatus, AdminError> {
|
||||
self.require_reset_credit_automation(account_id).await?;
|
||||
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
let record = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
|
||||
Ok(ResetCreditAutomationStatus {
|
||||
enabled: record.as_ref().is_some_and(|r| r.enabled),
|
||||
lead_minutes,
|
||||
next_discovery_at: record
|
||||
.as_ref()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.state.next_discovery_at),
|
||||
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
|
||||
next_consumption(r, lead_minutes)
|
||||
.or_else(|| {
|
||||
r.state
|
||||
.pending
|
||||
.as_ref()
|
||||
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
|
||||
})
|
||||
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
|
||||
}),
|
||||
last_result: record.and_then(|r| {
|
||||
if r.state
|
||||
.pending
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
|
||||
{
|
||||
Some("expired_unknown".to_owned())
|
||||
} else {
|
||||
r.state.last_result
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn require_reset_credit_automation(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<bool, AdminError> {
|
||||
let (stored, provider) = self.provider_for_account(account_id).await?;
|
||||
let capabilities =
|
||||
provider.account_capabilities(account_id, &stored.account.authentication_kind);
|
||||
if !capabilities.reset_credits || !capabilities.consume_reset_credit {
|
||||
return Err(AdminError::invalid("当前账号不支持自动重置卡"));
|
||||
}
|
||||
Ok(stored.account.enabled)
|
||||
}
|
||||
|
||||
pub(super) async fn configure_automation(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: &ProviderAccountId,
|
||||
enabled: bool,
|
||||
) -> Result<ResetCreditAutomationStatus, AdminError> {
|
||||
let lock = self.reset_credit_lock(account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
self.require_reset_credit_automation(account_id).await?;
|
||||
let now = Utc::now();
|
||||
let mut record = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?
|
||||
.unwrap_or_else(|| ResetCreditAutomation {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
enabled: false,
|
||||
revision: 0,
|
||||
next_run_at: now,
|
||||
state: ResetCreditAutomationState {
|
||||
next_discovery_at: now,
|
||||
credits: vec![],
|
||||
pending: None,
|
||||
retry_at: None,
|
||||
failures: 0,
|
||||
last_result: None,
|
||||
},
|
||||
});
|
||||
if record.enabled != enabled {
|
||||
record.enabled = enabled;
|
||||
// 首次开启错开一分钟;重新开启保留已知到期时间及未决幂等键
|
||||
if enabled && record.revision == 0 {
|
||||
record.state.next_discovery_at =
|
||||
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
|
||||
}
|
||||
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
reschedule(&mut record, lead, now);
|
||||
self.save_automation(&mut record, Some(context)).await?;
|
||||
let settings = self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "runtime settings"))?;
|
||||
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
|
||||
}
|
||||
self.automation_status(account_id).await
|
||||
}
|
||||
|
||||
async fn save_automation(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
context: Option<&MutationContext>,
|
||||
) -> Result<(), AdminError> {
|
||||
record.revision = self
|
||||
.accounts
|
||||
.save_reset_credit_automation(record, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observe_reset_credits(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
credits: &ProviderResetCredits,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(), AdminError> {
|
||||
record.state.credits = credits
|
||||
.credits
|
||||
.iter()
|
||||
.filter(|credit| credit.status.as_deref() == Some("available"))
|
||||
.filter_map(|credit| {
|
||||
credit
|
||||
.expires_at
|
||||
.filter(|expiry| *expiry > now)
|
||||
.map(|expires_at| PlannedResetCredit {
|
||||
id: credit.id.clone(),
|
||||
expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
record.state.credits.sort_by_key(|credit| credit.expires_at);
|
||||
record.state.credits.dedup_by(|a, b| a.id == b.id);
|
||||
if record.state.next_discovery_at <= now
|
||||
|| self
|
||||
.timezone
|
||||
.local(record.state.next_discovery_at)
|
||||
.date_naive()
|
||||
== self.timezone.local(now).date_naive()
|
||||
{
|
||||
record.state.next_discovery_at =
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
}
|
||||
if record.state.pending.is_none() {
|
||||
record.state.retry_at = None;
|
||||
record.state.failures = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn remember_reset_credits(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
credits: &ProviderResetCredits,
|
||||
) -> Result<(), AdminError> {
|
||||
let Some(mut record) = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !record.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let now = Utc::now();
|
||||
self.observe_reset_credits(&mut record, credits, now)?;
|
||||
reschedule(
|
||||
&mut record,
|
||||
self.automation_settings().await?.reset_credit_lead_minutes,
|
||||
now,
|
||||
);
|
||||
self.save_automation(&mut record, None).await
|
||||
}
|
||||
|
||||
async fn automation_failed(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(), AdminError> {
|
||||
if record.state.pending.is_none()
|
||||
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
|
||||
{
|
||||
record.state.next_discovery_at =
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("discovery_failed".to_owned());
|
||||
reschedule(record, lead, now);
|
||||
return self.save_automation(record, None).await;
|
||||
}
|
||||
record.state.failures = record.state.failures.saturating_add(1);
|
||||
let delay = (30_i64 * 2_i64.pow(record.state.failures.min(4) - 1)).min(300)
|
||||
+ i64::from(OsRng.next_u32() % 30);
|
||||
record.state.retry_at = Some(now + chrono::Duration::seconds(delay));
|
||||
record.state.last_result = Some(
|
||||
if record.state.pending.is_some() {
|
||||
"pending"
|
||||
} else {
|
||||
"discovery_failed"
|
||||
}
|
||||
.to_owned(),
|
||||
);
|
||||
reschedule(record, lead, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
/// 暂不满足条件不丢弃卡片,也不创建未决消费,允许到期前再次判断
|
||||
async fn defer_automation(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
reason: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let now = Utc::now();
|
||||
let retry = now + chrono::Duration::seconds(30 + i64::from(OsRng.next_u32() % 30));
|
||||
record.state.retry_at = Some(
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.map(|credit| credit.expires_at)
|
||||
.min()
|
||||
.map_or(retry, |expiry| retry.min(expiry)),
|
||||
);
|
||||
record.state.last_result = Some(reason.to_owned());
|
||||
record.state.failures = 0;
|
||||
reschedule(record, lead, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
async fn weekly_reset_credit_gate(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
max_remaining: u32,
|
||||
) -> Result<Option<&'static str>, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
|
||||
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
|
||||
let remaining = quota
|
||||
.usage_windows()
|
||||
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0));
|
||||
Ok(match remaining {
|
||||
Some(remaining) if remaining > f64::from(max_remaining) => {
|
||||
Some("weekly_quota_above_threshold")
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_automation(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<(), AdminError> {
|
||||
let lock = self.reset_credit_lock(account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let Some(mut record) = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "reset-credit automation"))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !record.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
match self.require_reset_credit_automation(account_id).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(()),
|
||||
Err(error) => {
|
||||
record.next_run_at = next_discovery(Utc::now(), self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
self.save_automation(&mut record, None).await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let now = Utc::now();
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead = settings.reset_credit_lead_minutes;
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.expires_at > now);
|
||||
if record
|
||||
.state
|
||||
.pending
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.credit.expires_at <= now)
|
||||
{
|
||||
record.state.pending = None;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("expired_unknown".to_owned());
|
||||
}
|
||||
if record.state.retry_at.is_some_and(|retry| retry > now) {
|
||||
reschedule(&mut record, lead, now);
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "reset-credit-automation".to_owned(),
|
||||
};
|
||||
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
|
||||
if record.state.pending.is_none()
|
||||
&& (due_credit
|
||||
|| record.state.next_discovery_at <= now
|
||||
|| record.state.retry_at.is_some())
|
||||
{
|
||||
// 先保存下一次发现,查询被取消或进程重启也不会重复刷普通发现接口
|
||||
if record.state.next_discovery_at <= now {
|
||||
record.state.next_discovery_at =
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
}
|
||||
if due_credit || record.state.retry_at.is_some() {
|
||||
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
|
||||
}
|
||||
reschedule(&mut record, lead, now);
|
||||
self.save_automation(&mut record, None).await?;
|
||||
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
|
||||
match self
|
||||
.reset_credits_upstream(&context, account_id.clone())
|
||||
.await
|
||||
{
|
||||
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if let Some(credit) = record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
|
||||
.cloned()
|
||||
{
|
||||
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
|
||||
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
|
||||
match self
|
||||
.weekly_reset_credit_gate(account_id, max_remaining)
|
||||
.await
|
||||
{
|
||||
Ok(Some(reason)) => {
|
||||
return self.defer_automation(&mut record, lead, reason).await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
|
||||
if credit.expires_at <= Utc::now() {
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.expires_at > Utc::now());
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
credit,
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
record.state.last_result = Some("pending".to_owned());
|
||||
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
|
||||
self.save_automation(&mut record, None).await?;
|
||||
}
|
||||
}
|
||||
let Some(pending) = record.state.pending.clone() else {
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
};
|
||||
let command = ConsumeProviderResetCredit {
|
||||
account_id: account_id.clone(),
|
||||
credit_id: Some(pending.credit.id.clone()),
|
||||
redeem_request_id: uuid::Uuid::parse_str(&pending.redeem_request_id)
|
||||
.map_err(|_| AdminError::internal("重置卡幂等键不合法"))?,
|
||||
};
|
||||
match self.consume_reset_credit_upstream(&context, command).await {
|
||||
Ok(result) => {
|
||||
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
|
||||
record.state.pending = None;
|
||||
if result.code == "nothing_to_reset" {
|
||||
return self
|
||||
.defer_automation(&mut record, lead, "nothing_to_reset")
|
||||
.await;
|
||||
}
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.id != pending.credit.id);
|
||||
record.state.retry_at = None;
|
||||
record.state.failures = 0;
|
||||
record.state.last_result = Some(if confirmed {
|
||||
result.code
|
||||
} else {
|
||||
"not_redeemed".to_owned()
|
||||
});
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
|
||||
self.save_automation(&mut record, None).await?;
|
||||
if confirmed {
|
||||
let _ = self.quota(account_id, true).await;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,8 @@ pub(super) async fn pending_authorization(
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
.effective_outbound_proxy()
|
||||
.cloned();
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
@@ -121,6 +122,9 @@ pub(super) async fn pending_authorization(
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
proxy_id = Some(record.id);
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
|
||||
}
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
|
||||
@@ -14,9 +14,7 @@ use crate::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
|
||||
},
|
||||
observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
@@ -221,34 +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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod plugins;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod usage_data;
|
||||
|
||||
mod credential_mutation;
|
||||
mod error;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! 按故障目标收集诊断证据,补充读取失败不丢弃已取得的记录
|
||||
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
use super::{DefaultObservabilityService, map_store_error};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind,
|
||||
diagnostics::{DiagnosticBundle, DiagnosticGap, DiagnosticRequest, DiagnosticTarget},
|
||||
observability::OpsError,
|
||||
};
|
||||
|
||||
const MAX_REQUESTS: usize = 8;
|
||||
const MAX_ERRORS: u16 = 128;
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
pub(super) async fn collect_diagnostics(
|
||||
&self,
|
||||
target: DiagnosticTarget,
|
||||
) -> Result<DiagnosticBundle, AdminError> {
|
||||
if target.id().trim().is_empty() || target.id().len() > 256 {
|
||||
return Err(AdminError::invalid("诊断目标 ID 不合法"));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let (collected, environment) = tokio::join!(
|
||||
self.collect_records(target, deadline),
|
||||
timeout_at(deadline, self.system.version()),
|
||||
);
|
||||
let mut bundle = collected?;
|
||||
match environment {
|
||||
Ok(Ok(version)) => bundle.environment = Some(version),
|
||||
_ => gap(&mut bundle, "environment", "collection_failed"),
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn collect_records(
|
||||
&self,
|
||||
target: DiagnosticTarget,
|
||||
deadline: Instant,
|
||||
) -> Result<DiagnosticBundle, AdminError> {
|
||||
let mut bundle = DiagnosticBundle {
|
||||
target,
|
||||
collected_at: chrono::Utc::now(),
|
||||
environment: None,
|
||||
error: None,
|
||||
requests: Vec::new(),
|
||||
gaps: Vec::new(),
|
||||
};
|
||||
let mut remaining_errors = MAX_ERRORS;
|
||||
let first_id = match &bundle.target {
|
||||
DiagnosticTarget::Request(id) => Some(id.clone()),
|
||||
DiagnosticTarget::Operation(_) => {
|
||||
let mut errors = self.export_errors(&bundle.target, 1, deadline).await?;
|
||||
let error = errors
|
||||
.pop()
|
||||
.ok_or_else(|| AdminError::not_found("错误记录不存在或已过期"))?;
|
||||
let id = error.request_id.clone();
|
||||
bundle.error = Some(error);
|
||||
remaining_errors -= 1;
|
||||
id
|
||||
}
|
||||
};
|
||||
let mut pending = VecDeque::from_iter(first_id);
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(id) = pending.pop_front() {
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
if bundle.requests.len() >= MAX_REQUESTS {
|
||||
gap(&mut bundle, "requests", "related_request_limit");
|
||||
break;
|
||||
}
|
||||
let section = format!("requests.{id}");
|
||||
let target = DiagnosticTarget::Request(id.clone());
|
||||
let selected = bundle
|
||||
.error
|
||||
.as_ref()
|
||||
.filter(|error| error.request_id.as_deref() == Some(id.as_str()));
|
||||
// 事件导出已经持有选中记录,额外读取一个槽位用于去重后判断是否超限
|
||||
let read_limit = remaining_errors + 1 + u16::from(selected.is_some());
|
||||
let (detail, errors) = tokio::join!(
|
||||
timeout_at(deadline, self.store.usage_record_detail(&id)),
|
||||
self.export_errors(&target, read_limit, deadline),
|
||||
);
|
||||
let (detail, detail_error) = match detail {
|
||||
Ok(Ok(detail)) => (Some(detail), None),
|
||||
Ok(Err(error)) => (None, Some(map_store_error(error, "diagnostic request"))),
|
||||
Err(_) => (
|
||||
None,
|
||||
Some(AdminError::new(AdminErrorKind::Unavailable, "诊断采集超时")),
|
||||
),
|
||||
};
|
||||
let mut errors = match errors {
|
||||
Ok(mut errors) => {
|
||||
if let Some(selected) = selected {
|
||||
errors.retain(|error| {
|
||||
error.source != selected.source || error.event_id != selected.event_id
|
||||
});
|
||||
}
|
||||
errors
|
||||
}
|
||||
Err(error) => {
|
||||
if detail.is_none() && bundle.requests.is_empty() && bundle.error.is_none() {
|
||||
return Err(error);
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.errors"),
|
||||
"collection_failed",
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if detail.is_none()
|
||||
&& errors.is_empty()
|
||||
&& bundle.requests.is_empty()
|
||||
&& bundle.error.is_none()
|
||||
{
|
||||
return Err(
|
||||
detail_error.unwrap_or_else(|| AdminError::not_found("请求记录不存在或已过期"))
|
||||
);
|
||||
}
|
||||
if errors.len() > usize::from(remaining_errors) {
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.errors"),
|
||||
"error_record_limit",
|
||||
);
|
||||
errors.truncate(usize::from(remaining_errors));
|
||||
}
|
||||
remaining_errors =
|
||||
remaining_errors.saturating_sub(u16::try_from(errors.len()).unwrap_or(MAX_ERRORS));
|
||||
if let Some(record) = &detail {
|
||||
for related in &record.related_requests {
|
||||
if let Some(id) = related.get("requestId").and_then(serde_json::Value::as_str) {
|
||||
pending.push_back(id.to_owned());
|
||||
}
|
||||
}
|
||||
if record.request.outcome == crate::model::observability::RequestOutcome::Running {
|
||||
gap(&mut bundle, §ion, "request_not_finalized");
|
||||
}
|
||||
match &record.trace {
|
||||
None => gap(
|
||||
&mut bundle,
|
||||
&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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.attempts"),
|
||||
"best_effort_persistence",
|
||||
);
|
||||
} else {
|
||||
gap(&mut bundle, §ion, "request_detail_unavailable");
|
||||
}
|
||||
bundle.requests.push(DiagnosticRequest {
|
||||
request_id: id,
|
||||
detail,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn export_errors(
|
||||
&self,
|
||||
target: &DiagnosticTarget,
|
||||
limit: u16,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<OpsError>, AdminError> {
|
||||
timeout_at(deadline, self.store.diagnostic_errors(target, limit))
|
||||
.await
|
||||
.map_err(|_| AdminError::new(AdminErrorKind::Unavailable, "诊断采集超时"))?
|
||||
.map_err(|error| map_store_error(error, "diagnostic errors"))
|
||||
}
|
||||
}
|
||||
|
||||
fn gap(bundle: &mut DiagnosticBundle, section: &str, reason: &'static str) {
|
||||
if !bundle
|
||||
.gaps
|
||||
.iter()
|
||||
.any(|gap| gap.section == section && gap.reason == reason)
|
||||
{
|
||||
bundle.gaps.push(DiagnosticGap {
|
||||
section: section.to_owned(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
|
||||
mod export;
|
||||
mod store_cache;
|
||||
pub(crate) use store_cache::CachedObservabilityStore;
|
||||
|
||||
@@ -70,6 +71,10 @@ pub trait ObservabilityService: Send + Sync {
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError>;
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError>;
|
||||
async fn export_diagnostics(
|
||||
&self,
|
||||
target: crate::model::diagnostics::DiagnosticTarget,
|
||||
) -> Result<crate::model::diagnostics::DiagnosticBundle, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
@@ -78,6 +83,7 @@ pub(crate) struct DefaultObservabilityService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
@@ -88,6 +94,7 @@ impl DefaultObservabilityService {
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
@@ -95,6 +102,7 @@ impl DefaultObservabilityService {
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
system,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,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"))?;
|
||||
@@ -118,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,
|
||||
@@ -192,6 +201,13 @@ impl DefaultObservabilityService {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityService for DefaultObservabilityService {
|
||||
async fn export_diagnostics(
|
||||
&self,
|
||||
target: crate::model::diagnostics::DiagnosticTarget,
|
||||
) -> Result<crate::model::diagnostics::DiagnosticBundle, AdminError> {
|
||||
self.collect_diagnostics(target).await
|
||||
}
|
||||
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
@@ -1036,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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 有界统计缓存:身份和完整筛选参与键,删除版本阻止旧数据复活
|
||||
//! 有界统计缓存:身份和完整筛选参与键,统计发布代次使旧快照失效
|
||||
|
||||
use crate::model::observability::*;
|
||||
use crate::ports::store::{
|
||||
@@ -121,7 +121,7 @@ impl<K: Eq + Hash, T: AnalyticsValue> Cache<K, T> {
|
||||
};
|
||||
// 同键刷新只执行一次;失败不会缓存成成功,也不延长旧结果寿命
|
||||
let mut cached = slot.lock().await;
|
||||
// 等锁期间代次可能推进;取得同键锁后再核对,失败时也不能回用已删除事实
|
||||
// 等锁期间代次可能推进;取得同键锁后再核对,失败时也不能回用已被新统计替代的快照
|
||||
let generation = store.analytics_cache_generation().await?;
|
||||
if let Some(entry) = cached.as_mut()
|
||||
&& entry.value.metadata().generation == generation
|
||||
@@ -289,4 +289,12 @@ impl ObservabilityStore for CachedObservabilityStore {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
self.inner.list_ops_errors(query).await
|
||||
}
|
||||
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
target: &crate::model::diagnostics::DiagnosticTarget,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<Vec<crate::model::observability::OpsError>> {
|
||||
self.inner.diagnostic_errors(target, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess {
|
||||
owner,
|
||||
resource_key,
|
||||
NewAccountGroup {
|
||||
allow_image_generation: command.allow_image_generation,
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
|
||||
@@ -305,6 +305,9 @@ impl PluginsService {
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("下载代理已禁用,请启用或改为直连"));
|
||||
}
|
||||
Ok(Some(PluginDistributionEgress::new(
|
||||
record.id,
|
||||
record.revision,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 账号出站代理的管理、账号解绑与连通性测试用例
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::{StreamExt, stream};
|
||||
@@ -27,6 +30,16 @@ use crate::{
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxiesService: Send + Sync {
|
||||
async fn create_batch(
|
||||
&self,
|
||||
commands: Vec<Result<NewProxy, AdminError>>,
|
||||
context: &MutationContext,
|
||||
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError>;
|
||||
async fn test_batch(
|
||||
&self,
|
||||
commands: Vec<Result<TestProxy, AdminError>>,
|
||||
context: &MutationContext,
|
||||
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError>;
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
@@ -61,10 +74,10 @@ pub trait ProxiesService: Send + Sync {
|
||||
detect_location: bool,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定
|
||||
/// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定
|
||||
async fn probe(
|
||||
&self,
|
||||
proxy: &OutboundProxy,
|
||||
proxy: Option<&OutboundProxy>,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
@@ -96,6 +109,75 @@ impl DefaultProxiesService {
|
||||
|
||||
#[async_trait]
|
||||
impl ProxiesService for DefaultProxiesService {
|
||||
async fn create_batch(
|
||||
&self,
|
||||
commands: Vec<Result<NewProxy, AdminError>>,
|
||||
context: &MutationContext,
|
||||
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError> {
|
||||
validate_batch_size(commands.len())?;
|
||||
let mut stopped = false;
|
||||
let mut results = Vec::with_capacity(commands.len());
|
||||
for command in commands {
|
||||
if stopped {
|
||||
results.push(ProxyBatchResult::Skipped);
|
||||
continue;
|
||||
}
|
||||
let result = match command {
|
||||
Ok(command) => self.create(command, context).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
stopped = result.as_ref().is_err_and(batch_should_stop);
|
||||
results.push(ProxyBatchResult::Completed(result));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn test_batch(
|
||||
&self,
|
||||
commands: Vec<Result<TestProxy, AdminError>>,
|
||||
context: &MutationContext,
|
||||
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError> {
|
||||
validate_batch_size(commands.len())?;
|
||||
let mut ids = std::collections::HashSet::with_capacity(commands.len());
|
||||
if commands
|
||||
.iter()
|
||||
.filter_map(|command| command.as_ref().ok())
|
||||
.any(|command| !ids.insert(&command.id))
|
||||
{
|
||||
return Err(AdminError::invalid("批量测试不能包含重复代理"));
|
||||
}
|
||||
let stopped = AtomicBool::new(false);
|
||||
// 与单条测试共用全局容量,批次最多占用两槽,保留交互操作的容量
|
||||
Ok(stream::iter(commands)
|
||||
.map(|command| {
|
||||
let stopped = &stopped;
|
||||
async move {
|
||||
if stopped.load(Ordering::Relaxed) {
|
||||
return ProxyBatchResult::Skipped;
|
||||
}
|
||||
let result = match command {
|
||||
Ok(command) => {
|
||||
self.test(
|
||||
&command.id,
|
||||
command.revision,
|
||||
command.detect_location,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
if result.as_ref().is_err_and(batch_should_stop) {
|
||||
stopped.store(true, Ordering::Relaxed);
|
||||
}
|
||||
ProxyBatchResult::Completed(result)
|
||||
}
|
||||
})
|
||||
.buffered(2)
|
||||
.collect()
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
@@ -194,7 +276,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
command.test = if command.auto_location {
|
||||
Some(self.probe(&command.proxy, true).await?)
|
||||
Some(self.probe(Some(&command.proxy), true).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -233,7 +315,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
if command.auto_location.unwrap_or(current.auto_location)
|
||||
&& (!current.auto_location || proxy != ¤t.proxy)
|
||||
{
|
||||
command.test = Some(self.probe(proxy, true).await?);
|
||||
command.test = Some(self.probe(Some(proxy), true).await?);
|
||||
}
|
||||
}
|
||||
let result = self
|
||||
@@ -262,7 +344,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
|
||||
async fn probe(
|
||||
&self,
|
||||
proxy: &OutboundProxy,
|
||||
proxy: Option<&OutboundProxy>,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
@@ -292,7 +374,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置
|
||||
let result = self
|
||||
.probe
|
||||
.test(&record.proxy, record.auto_location || detect_location)
|
||||
.test(Some(&record.proxy), record.auto_location || detect_location)
|
||||
.await;
|
||||
let mutation = self
|
||||
.store
|
||||
@@ -313,3 +395,22 @@ fn validate_name(value: &str) -> Result<String, AdminError> {
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
fn validate_batch_size(size: usize) -> Result<(), AdminError> {
|
||||
if size == 0 || size > MAX_PROXY_BATCH_SIZE {
|
||||
return Err(AdminError::invalid("一次需要提交 1 至 100 条代理"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn batch_should_stop(error: &AdminError) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::Unauthorized
|
||||
| AdminErrorKind::Forbidden
|
||||
| AdminErrorKind::BadGateway
|
||||
| AdminErrorKind::UpstreamResultUnknown
|
||||
| AdminErrorKind::Unavailable
|
||||
| AdminErrorKind::Internal
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 公开原生用量读写与纯计价,插件拥有跨调用流程和恢复策略
|
||||
|
||||
mod pricing;
|
||||
mod validation;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
usage_data::*,
|
||||
},
|
||||
ports::{provider::ProviderAdminRegistry, store::SettingsStore, usage_data::*},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageDataService: Send + Sync {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage>;
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult>;
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord>;
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage>;
|
||||
async fn write_records(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageRecords,
|
||||
) -> UsageDataResult<UsageWriteResult>;
|
||||
async fn evaluate_pricing(
|
||||
&self,
|
||||
request: EvaluateUsagePricing,
|
||||
) -> UsageDataResult<UsagePricingEvaluation>;
|
||||
}
|
||||
|
||||
pub struct DefaultUsageDataService {
|
||||
store: Arc<dyn UsageDataStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
}
|
||||
impl DefaultUsageDataService {
|
||||
pub fn new(
|
||||
store: Arc<dyn UsageDataStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
settings,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageDataService for DefaultUsageDataService {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage> {
|
||||
validation::query(&request.filter, request.cursor.as_deref(), request.limit)?;
|
||||
self.store.list_aggregates(request).await
|
||||
}
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult> {
|
||||
validation::bounded(&request, request.changes.len())?;
|
||||
let mut changes = Vec::with_capacity(request.changes.len());
|
||||
for change in request.changes {
|
||||
validation::aggregate(&change)?;
|
||||
let identity = match &change {
|
||||
UsageAggregateChange::Put { aggregate } => &aggregate.identity,
|
||||
UsageAggregateChange::Delete { identity } => identity,
|
||||
};
|
||||
let action = match &change {
|
||||
UsageAggregateChange::Put { .. } => "usage.aggregate.put",
|
||||
UsageAggregateChange::Delete { .. } => "usage.aggregate.delete",
|
||||
};
|
||||
let audit = audit_event(
|
||||
actor,
|
||||
action,
|
||||
"usage_aggregate",
|
||||
&identity.key,
|
||||
vec!["aggregate".into()],
|
||||
);
|
||||
changes.push((change, audit));
|
||||
}
|
||||
self.store
|
||||
.write_aggregates(StoredAggregateWrite { changes })
|
||||
.await
|
||||
}
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord> {
|
||||
validation::text(&request.id, 128)?;
|
||||
self.store.get_record(request).await
|
||||
}
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage> {
|
||||
validation::query(&request.filter, request.cursor.as_deref(), request.limit)?;
|
||||
self.store.list_records(request).await
|
||||
}
|
||||
async fn write_records(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageRecords,
|
||||
) -> UsageDataResult<UsageWriteResult> {
|
||||
validation::bounded(&request, request.changes.len())?;
|
||||
let mut changes = Vec::with_capacity(request.changes.len());
|
||||
for change in request.changes {
|
||||
validation::change(&change)?;
|
||||
let action = match &change {
|
||||
UsageRecordChange::Put { .. } => "usage.put",
|
||||
UsageRecordChange::Update { .. } => "usage.update",
|
||||
UsageRecordChange::Delete { .. } => "usage.delete",
|
||||
};
|
||||
let fields = match &change {
|
||||
UsageRecordChange::Update { tokens, cost, .. } => tokens
|
||||
.as_ref()
|
||||
.map(|_| "tokens".to_owned())
|
||||
.into_iter()
|
||||
.chain(cost.as_ref().map(|_| "cost".to_owned()))
|
||||
.collect(),
|
||||
_ => vec!["record".into()],
|
||||
};
|
||||
let audit = audit_event(actor, action, "model_request", change.id(), fields);
|
||||
changes.push((change, audit));
|
||||
}
|
||||
self.store.write_records(StoredUsageWrite { changes }).await
|
||||
}
|
||||
async fn evaluate_pricing(
|
||||
&self,
|
||||
request: EvaluateUsagePricing,
|
||||
) -> UsageDataResult<UsagePricingEvaluation> {
|
||||
validation::bounded(&request, request.items.len())?;
|
||||
let stored = self
|
||||
.settings
|
||||
.load_pricing()
|
||||
.await
|
||||
.map_err(|_| UsageDataError::unavailable("读取价目失败"))?;
|
||||
let prices = gateway_core::metering::merge_pricing(stored.synced, &stored.overrides);
|
||||
let mut items = Vec::with_capacity(request.items.len());
|
||||
for input in request.items {
|
||||
validation::text(&input.id, 128)?;
|
||||
items.push(self.evaluate_one(input, &prices)?);
|
||||
}
|
||||
Ok(UsagePricingEvaluation { items })
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_event(
|
||||
actor: &UsageActor,
|
||||
action: &str,
|
||||
entity_kind: &str,
|
||||
id: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> AdminAuditEvent {
|
||||
let actor_ref = match actor {
|
||||
UsageActor::Plugin {
|
||||
instance_id,
|
||||
publisher,
|
||||
name,
|
||||
..
|
||||
} => format!("plugin:{publisher}/{name}:{instance_id}"),
|
||||
UsageActor::Admin { subject } => format!("admin:{subject}"),
|
||||
UsageActor::System => "system".into(),
|
||||
};
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::System,
|
||||
actor_admin_user_id: None,
|
||||
actor_ref,
|
||||
request_id: None,
|
||||
action: action.into(),
|
||||
entity_kind: entity_kind.into(),
|
||||
entity_ref: id.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 复用 Provider 的单请求 Token 计价规则,不写入历史或账本
|
||||
|
||||
use super::{DefaultUsageDataService, validation};
|
||||
use crate::{
|
||||
model::usage_data::*,
|
||||
ports::provider::{ProviderAdminErrorKind, ProviderUsagePricingInput},
|
||||
};
|
||||
use gateway_core::{
|
||||
metering::{PricingOverrides, Usage},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
|
||||
impl DefaultUsageDataService {
|
||||
pub(super) fn evaluate_one(
|
||||
&self,
|
||||
input: UsagePricingInput,
|
||||
prices: &PricingOverrides,
|
||||
) -> UsageDataResult<UsagePricingResult> {
|
||||
validation::text(&input.provider, 128)?;
|
||||
validation::text(&input.model, 128)?;
|
||||
validation::tokens(&input.tokens)?;
|
||||
let unavailable = |reason: &str| UsagePricingResult::NotEstimable {
|
||||
id: input.id.clone(),
|
||||
reason: reason.into(),
|
||||
};
|
||||
let kind = ProviderKind::new(input.provider.clone())
|
||||
.map_err(|_| UsageDataError::invalid("Provider 无效"))?;
|
||||
let Ok(provider) = self.providers.require(&kind) else {
|
||||
return Ok(unavailable("Provider 没有可用的计价规则"));
|
||||
};
|
||||
let tokens = &input.tokens;
|
||||
let price = match &input.price {
|
||||
UsagePriceSelection::Current => prices
|
||||
.get(&input.provider)
|
||||
.and_then(|models| models.get(&input.model)),
|
||||
UsagePriceSelection::Override { price } => {
|
||||
price.validate().map_err(UsageDataError::invalid)?;
|
||||
Some(price.as_ref())
|
||||
}
|
||||
};
|
||||
let request = ProviderUsagePricingInput {
|
||||
upstream_model_id: input.model.clone(),
|
||||
service_tier: input.service_tier.clone(),
|
||||
usage: Usage {
|
||||
input_tokens: tokens.input_tokens.map(UsageCount::get),
|
||||
output_tokens: tokens.output_tokens.map(UsageCount::get),
|
||||
cached_input_tokens: tokens.cached_input_tokens.map(UsageCount::get),
|
||||
cache_write_input_tokens: tokens.cache_write_input_tokens.map(UsageCount::get),
|
||||
reasoning_output_tokens: tokens.reasoning_output_tokens.map(UsageCount::get),
|
||||
image_input_tokens: tokens.image_input_tokens.map(UsageCount::get),
|
||||
image_output_tokens: tokens.image_output_tokens.map(UsageCount::get),
|
||||
total_tokens: tokens.total_tokens.map(UsageCount::get),
|
||||
},
|
||||
};
|
||||
let calculated = match provider.evaluate_usage_pricing(&request, price) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => return Ok(unavailable("用量不完整、包含图像或价格不可用")),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ProviderAdminErrorKind::Unsupported | ProviderAdminErrorKind::Invalid
|
||||
) =>
|
||||
{
|
||||
return Ok(unavailable("Provider 不支持该计价输入"));
|
||||
}
|
||||
Err(_) => return Err(UsageDataError::unavailable("Provider 计价失败")),
|
||||
};
|
||||
Ok(UsagePricingResult::Estimated {
|
||||
id: input.id,
|
||||
cost: UsageCost::Known {
|
||||
source: UsageCostSource::Calculated,
|
||||
amount: calculated.total_amount().amount().canonical(),
|
||||
currency: calculated.total_amount().currency().as_str().into(),
|
||||
},
|
||||
breakdown: UsagePricingBreakdown {
|
||||
input_amount: calculated.input_amount().amount().canonical(),
|
||||
output_amount: calculated.output_amount().amount().canonical(),
|
||||
cache_read_amount: calculated.cache_read_amount().amount().canonical(),
|
||||
cache_write_amount: calculated.cache_write_amount().amount().canonical(),
|
||||
standard_amount: calculated.standard_amount().amount().canonical(),
|
||||
long_context_billing_applied: calculated.long_context_billing_applied(),
|
||||
service_tier: calculated.service_tier().map(str::to_owned),
|
||||
custom_multiplier_bps: calculated.custom_multiplier_bps(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 用量输入沿用原生记录的数值、时间和资源边界
|
||||
|
||||
use crate::model::usage_data::*;
|
||||
use gateway_core::metering::Decimal;
|
||||
use serde::Serialize;
|
||||
|
||||
pub(super) fn text(value: &str, max: usize) -> UsageDataResult<()> {
|
||||
if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
|
||||
return Err(UsageDataError::invalid("文本为空、过长或包含控制字符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn bounded(value: &impl Serialize, count: usize) -> UsageDataResult<()> {
|
||||
if count == 0
|
||||
|| count > USAGE_DATA_MAX_TARGETS
|
||||
|| serde_json::to_vec(value)
|
||||
.map_err(|_| UsageDataError::invalid("请求编码失败"))?
|
||||
.len()
|
||||
> USAGE_DATA_MAX_REQUEST_BYTES
|
||||
{
|
||||
return Err(UsageDataError::new(
|
||||
UsageErrorKind::CapacityExceeded,
|
||||
"每次操作接受 1~500 项且不超过 2 MiB",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn tokens(value: &UsageTokens) -> UsageDataResult<()> {
|
||||
if [
|
||||
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,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.get() > i64::MAX as u64)
|
||||
{
|
||||
return Err(UsageDataError::invalid("Token 超出原生存储范围"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cost(value: &UsageCost) -> UsageDataResult<()> {
|
||||
if let UsageCost::Known {
|
||||
amount, currency, ..
|
||||
} = value
|
||||
{
|
||||
let _ = amount
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| UsageDataError::invalid("费用不是有效十进制数"))?;
|
||||
if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
|
||||
return Err(UsageDataError::invalid("费用或币种不符合原生存储范围"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn change(value: &UsageRecordChange) -> UsageDataResult<()> {
|
||||
text(value.id(), 128)?;
|
||||
match value {
|
||||
UsageRecordChange::Put { data, .. } => {
|
||||
text(&data.client_key_ref, 128)?;
|
||||
for v in [
|
||||
&data.provider,
|
||||
&data.account_ref,
|
||||
&data.requested_model,
|
||||
&data.model,
|
||||
&data.service_tier,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(v, 128)?;
|
||||
}
|
||||
for v in [
|
||||
&data.protocol,
|
||||
&data.operation,
|
||||
&data.endpoint,
|
||||
&data.client_transport,
|
||||
] {
|
||||
text(v, 256)?;
|
||||
}
|
||||
if data.outcome == UsageOutcome::Running
|
||||
|| data
|
||||
.completed_at_ms
|
||||
.is_none_or(|end| end < data.started_at_ms)
|
||||
{
|
||||
return Err(UsageDataError::invalid("只允许写入已结束的请求明细"));
|
||||
}
|
||||
for at in [Some(data.started_at_ms), data.completed_at_ms]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if chrono::DateTime::from_timestamp_millis(at).is_none() {
|
||||
return Err(UsageDataError::invalid("请求时间无效"));
|
||||
}
|
||||
}
|
||||
tokens(&data.tokens)?;
|
||||
cost(&data.cost)?;
|
||||
}
|
||||
UsageRecordChange::Update {
|
||||
tokens: new_tokens,
|
||||
cost: new_cost,
|
||||
..
|
||||
} => {
|
||||
if new_tokens.is_none() && new_cost.is_none() {
|
||||
return Err(UsageDataError::invalid("修改必须指定 Token 或费用"));
|
||||
}
|
||||
if let Some(value) = new_tokens {
|
||||
tokens(value)?;
|
||||
}
|
||||
if let Some(value) = new_cost {
|
||||
cost(value)?;
|
||||
}
|
||||
}
|
||||
UsageRecordChange::Delete { .. } => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn query(
|
||||
filter: &UsageRecordFilter,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u16>,
|
||||
) -> UsageDataResult<()> {
|
||||
if limit.is_some_and(|v| v == 0 || v > USAGE_DATA_MAX_PAGE_SIZE)
|
||||
|| filter.start_ms >= filter.end_ms
|
||||
|| chrono::DateTime::from_timestamp_millis(filter.start_ms).is_none()
|
||||
|| chrono::DateTime::from_timestamp_millis(filter.end_ms).is_none()
|
||||
{
|
||||
return Err(UsageDataError::invalid("查询时间范围或分页大小无效"));
|
||||
}
|
||||
for v in [
|
||||
&filter.provider,
|
||||
&filter.model,
|
||||
&filter.account_ref,
|
||||
&filter.client_key_ref,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(v, 128)?;
|
||||
}
|
||||
if cursor.is_some_and(|v| v.len() > 4096) {
|
||||
return Err(UsageDataError::invalid("游标过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn aggregate(value: &UsageAggregateChange) -> UsageDataResult<()> {
|
||||
let identity = match value {
|
||||
UsageAggregateChange::Put { aggregate } => &aggregate.identity,
|
||||
UsageAggregateChange::Delete { identity } => identity,
|
||||
};
|
||||
text(&identity.key, 128)?;
|
||||
if chrono::DateTime::from_timestamp_millis(identity.bucket_start_ms).is_none() {
|
||||
return Err(UsageDataError::invalid("聚合归属时间无效"));
|
||||
}
|
||||
let d = &identity.dimensions;
|
||||
for value in [
|
||||
&d.provider,
|
||||
&d.model,
|
||||
&d.account_ref,
|
||||
&d.client_key_ref,
|
||||
&d.operation,
|
||||
&d.transport,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(value, 128)?;
|
||||
}
|
||||
if let UsageAggregateChange::Put { aggregate } = value {
|
||||
if aggregate.costs.len() > 32 {
|
||||
return Err(UsageDataError::invalid("费用币种数量超过限制"));
|
||||
}
|
||||
for (currency, amount) in &aggregate.costs {
|
||||
if currency.len() != 3
|
||||
|| !currency.bytes().all(|b| b.is_ascii_uppercase())
|
||||
|| amount.len() > 128
|
||||
{
|
||||
return Err(UsageDataError::invalid("聚合费用格式无效"));
|
||||
}
|
||||
amount
|
||||
.strip_prefix('-')
|
||||
.unwrap_or(amount)
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| UsageDataError::invalid("聚合费用不是有效十进制数"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,13 @@ use gateway_admin::model::retention::{RetentionPolicy, RetentionTarget};
|
||||
|
||||
#[test]
|
||||
fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows() {
|
||||
for (usage, ops, audit) in [(30, 1, 1), (31, 0, 1), (31, 1, 0)] {
|
||||
for (usage, ops, audit) in [
|
||||
(0, 1, 1),
|
||||
(32, 1, 1),
|
||||
(u32::MAX, 1, 1),
|
||||
(1, 0, 1),
|
||||
(1, 1, 0),
|
||||
] {
|
||||
assert!(RetentionPolicy::try_new(usage, ops, audit).is_err());
|
||||
}
|
||||
let policy = RetentionPolicy::try_new(31, 7, 90).unwrap();
|
||||
@@ -12,3 +18,13 @@ fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows()
|
||||
assert_eq!(policy.days(RetentionTarget::OpsEvents), 7);
|
||||
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_retention_accepts_short_windows_without_changing_other_targets() {
|
||||
for days in [1, 3, 7, 15, 30, 31] {
|
||||
let policy = RetentionPolicy::try_new(days, 30, 90).unwrap();
|
||||
assert_eq!(policy.days(RetentionTarget::ModelRequests), days);
|
||||
assert_eq!(policy.days(RetentionTarget::OpsEvents), 30);
|
||||
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +273,19 @@ struct FakeRuntimeStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for FakeRuntimeStore {
|
||||
async fn account_concurrency(
|
||||
&self,
|
||||
_: &str,
|
||||
_: u64,
|
||||
_: u32,
|
||||
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"account concurrency",
|
||||
"not configured in this fixture",
|
||||
))
|
||||
}
|
||||
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
@@ -317,6 +330,7 @@ impl AccountRuntimeStore for FakeRuntimeStore {
|
||||
fn group_record() -> AccountGroupRecord {
|
||||
let now = Utc::now();
|
||||
AccountGroupRecord {
|
||||
allow_image_generation: true,
|
||||
fast_mode: FastMode::Default,
|
||||
id: group_id(),
|
||||
name: "Primary".to_owned(),
|
||||
@@ -390,6 +404,7 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
|
||||
.create(
|
||||
&context,
|
||||
CreateAccountGroup {
|
||||
allow_image_generation: true,
|
||||
fast_mode: FastMode::Default,
|
||||
name: " invalid".to_owned(),
|
||||
description: None,
|
||||
@@ -403,6 +418,7 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
|
||||
.update(
|
||||
&context,
|
||||
UpdateAccountGroup {
|
||||
allow_image_generation: None,
|
||||
fast_mode: None,
|
||||
id: record.id,
|
||||
name: "valid".to_owned(),
|
||||
|
||||
+130
-6
@@ -1,5 +1,7 @@
|
||||
//! 账号管理用例测试及共享 Provider、存储替身
|
||||
|
||||
mod reset_credit_automation;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
@@ -87,6 +89,11 @@ pub(super) struct FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
reset_credits: Mutex<Vec<gateway_admin::model::provider_credentials::ProviderResetCredit>>,
|
||||
reset_credit_failure: Mutex<Option<ProviderAdminErrorKind>>,
|
||||
reset_credit_result: Mutex<String>,
|
||||
reset_credit_query_started: tokio::sync::Notify,
|
||||
reset_credit_query_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
|
||||
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
|
||||
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
|
||||
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
|
||||
@@ -112,6 +119,11 @@ impl FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
reset_credits: Mutex::default(),
|
||||
reset_credit_failure: Mutex::default(),
|
||||
reset_credit_result: Mutex::new("reset".into()),
|
||||
reset_credit_query_started: tokio::sync::Notify::new(),
|
||||
reset_credit_query_gate: Mutex::new(None),
|
||||
profile_result: Mutex::new(Ok(empty_profile_statistics())),
|
||||
subscription_result: Mutex::new(Ok(None)),
|
||||
personal_info_barrier: Mutex::new(None),
|
||||
@@ -498,6 +510,28 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
Ok(self.quota.lock().expect("provider quota").clone())
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<gateway_admin::model::provider_credentials::ProviderResetCredits, ProviderAdminError>
|
||||
{
|
||||
self.record("provider.reset_credits");
|
||||
self.require_available()?;
|
||||
self.reset_credit_query_started.notify_one();
|
||||
let gate = self.reset_credit_query_gate.lock().unwrap().take();
|
||||
if let Some(gate) = gate {
|
||||
let _ = gate.await;
|
||||
}
|
||||
|
||||
let credits = self.reset_credits.lock().unwrap().clone();
|
||||
Ok(
|
||||
gateway_admin::model::provider_credentials::ProviderResetCredits {
|
||||
available_count: credits.len() as u64,
|
||||
credits,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
&self,
|
||||
command: ConsumeProviderResetCredit,
|
||||
@@ -508,8 +542,11 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
.expect("provider reset-credit commands")
|
||||
.push(command);
|
||||
self.require_available()?;
|
||||
if let Some(kind) = self.reset_credit_failure.lock().unwrap().take() {
|
||||
return Err(ProviderAdminError::new(kind));
|
||||
}
|
||||
Ok(ProviderResetCreditResult {
|
||||
code: "reset".to_owned(),
|
||||
code: self.reset_credit_result.lock().unwrap().clone(),
|
||||
credit: None,
|
||||
})
|
||||
}
|
||||
@@ -577,6 +614,9 @@ pub(super) struct FakeAccountStore {
|
||||
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
|
||||
quota_forecast_history: Mutex<QuotaForecastHistory>,
|
||||
update_commands: Mutex<Vec<UpdateAccount>>,
|
||||
automations: Mutex<
|
||||
BTreeMap<String, gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
|
||||
>,
|
||||
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
|
||||
}
|
||||
|
||||
@@ -599,6 +639,7 @@ impl FakeAccountStore {
|
||||
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
|
||||
update_commands: Mutex::new(Vec::new()),
|
||||
lowered_limits: Mutex::new(Vec::new()),
|
||||
automations: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,6 +734,16 @@ impl FakeAccountStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for FakeAccountStore {
|
||||
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
|
||||
self.record("store.account_exists");
|
||||
Ok(self
|
||||
.accounts
|
||||
.lock()
|
||||
.expect("accounts")
|
||||
.iter()
|
||||
.any(|account| account.id == account_id))
|
||||
}
|
||||
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
|
||||
@@ -1086,7 +1137,10 @@ impl AccountStore for FakeAccountStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticSettingsStore;
|
||||
#[derive(Default)]
|
||||
struct StaticSettingsStore {
|
||||
reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for StaticSettingsStore {
|
||||
@@ -1145,6 +1199,9 @@ impl SettingsStore for StaticSettingsStore {
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1840,7 +1897,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
store.set_accounts(vec![openai_account, xai_account]);
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(openai)
|
||||
.provider(xai)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
@@ -2048,7 +2105,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
store.set_accounts(vec![account_record("openai"), xai_account]);
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(openai)
|
||||
.provider(failing)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
@@ -3137,6 +3194,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_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
@@ -3404,7 +3462,7 @@ async fn accounts_service_with_probe(
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(provider)
|
||||
.probe(probe)
|
||||
.build()
|
||||
@@ -3573,6 +3631,19 @@ struct CapacityRuntime {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for CapacityRuntime {
|
||||
async fn account_concurrency(
|
||||
&self,
|
||||
_: &str,
|
||||
_: u64,
|
||||
_: u32,
|
||||
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"account concurrency",
|
||||
"not configured in this fixture",
|
||||
))
|
||||
}
|
||||
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
@@ -3635,7 +3706,7 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.account_runtime(runtime.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.settings(Arc::new(StaticSettingsStore::default()))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.build()
|
||||
.await;
|
||||
@@ -3666,3 +3737,56 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
|
||||
for FakeAccountStore
|
||||
{
|
||||
async fn reset_credit_automation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> AdminStoreResult<
|
||||
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
|
||||
> {
|
||||
Ok(self.automations.lock().unwrap().get(account_id).cloned())
|
||||
}
|
||||
async fn due_reset_credit_automations(
|
||||
&self,
|
||||
now: chrono::DateTime<Utc>,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<String>> {
|
||||
let records = self.automations.lock().unwrap();
|
||||
let mut due = records
|
||||
.values()
|
||||
.filter(|r| r.enabled && r.next_run_at <= now)
|
||||
.collect::<Vec<_>>();
|
||||
due.sort_by_key(|r| r.next_run_at);
|
||||
Ok(due
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.map(|r| r.account_id.clone())
|
||||
.collect())
|
||||
}
|
||||
async fn save_reset_credit_automation(
|
||||
&self,
|
||||
record: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
|
||||
context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<u64> {
|
||||
let mut records = self.automations.lock().unwrap();
|
||||
if records.get(&record.account_id).map_or(0, |r| r.revision) != record.revision {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Conflict,
|
||||
"automation",
|
||||
"stale revision",
|
||||
));
|
||||
}
|
||||
let mut next = record.clone();
|
||||
next.revision += 1;
|
||||
let revision = next.revision;
|
||||
records.insert(next.account_id.clone(), next);
|
||||
if let Some(context) = context {
|
||||
self.record_context(context);
|
||||
}
|
||||
Ok(revision)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
//! 自动重置的持久幂等恢复、低频发现与账号独立错峰
|
||||
|
||||
use super::*;
|
||||
use gateway_admin::{
|
||||
model::reset_credit_automation::*,
|
||||
ports::reset_credit_automation::ResetCreditAutomationStore as _,
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::middleware::{self, FrozenMiddlewarePlan, MiddlewarePlan},
|
||||
routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ServiceForwarder;
|
||||
|
||||
impl ExtensionSetLease for ServiceForwarder {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl MiddlewarePlan for ServiceForwarder {
|
||||
fn has_service(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_service(
|
||||
&self,
|
||||
_: middleware::service::Context,
|
||||
input: middleware::service::Value,
|
||||
next: middleware::service::Next,
|
||||
) -> BoxFuture<'static, Result<middleware::service::Value, middleware::service::Error>> {
|
||||
Box::pin(async move {
|
||||
// 模拟 RPC 回调跨任务执行 next,且该任务带有另一插件的身份
|
||||
tokio::spawn(gateway_admin::public_service::scope_actor(
|
||||
gateway_admin::model::usage_data::UsageActor::Plugin {
|
||||
publisher: "test".into(),
|
||||
name: "forwarder".into(),
|
||||
instance_id: "forwarding-plugin".into(),
|
||||
},
|
||||
next.run(input),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
fn handle(
|
||||
&self,
|
||||
_: middleware::MiddlewareContext,
|
||||
request: middleware::MiddlewareRequest,
|
||||
next: middleware::MiddlewareNext,
|
||||
) -> BoxFuture<'static, Result<middleware::MiddlewareResponse, middleware::MiddlewareError>>
|
||||
{
|
||||
next.run(request)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_service_reads_and_updates_reset_credit_automation_with_host_identity() {
|
||||
use gateway_admin::{
|
||||
model::usage_data::UsageActor,
|
||||
public_service::{Origin, Plan, scope, scope_actor},
|
||||
};
|
||||
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let registry = services.public_services();
|
||||
let read = "accounts.reset_credit_automation_status";
|
||||
let write = "accounts.set_reset_credit_automation";
|
||||
let initial = registry.call(read, json!("acct_test")).await.unwrap();
|
||||
assert_eq!(initial["enabled"], false);
|
||||
assert_eq!(initial["lead_minutes"], 10);
|
||||
|
||||
let actor = UsageActor::Plugin {
|
||||
publisher: "test".into(),
|
||||
name: "automation".into(),
|
||||
instance_id: "installed-plugin".into(),
|
||||
};
|
||||
let middleware = Arc::new(ServiceForwarder);
|
||||
let plan = FrozenMiddlewarePlan::new(
|
||||
middleware.clone(),
|
||||
ExtensionSetReference::new(
|
||||
ExtensionSetId::new("service-forwarder".into()).unwrap(),
|
||||
middleware,
|
||||
),
|
||||
);
|
||||
let origin = Origin {
|
||||
request_id: "plugin-request".into(),
|
||||
call_id: "plugin-parent".into(),
|
||||
cancellation: Default::default(),
|
||||
extensions: Default::default(),
|
||||
plan: Plan::Frozen(Some(plan)),
|
||||
};
|
||||
let error = scope(
|
||||
origin.clone(),
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "invalid");
|
||||
let enabled = scope_actor(
|
||||
actor.clone(),
|
||||
scope(
|
||||
origin.clone(),
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: ResetCreditAutomationStatus = serde_json::from_value(enabled.clone()).unwrap();
|
||||
assert!(status.enabled);
|
||||
assert!(status.next_discovery_at.is_some());
|
||||
assert_eq!(
|
||||
registry.call(read, json!("acct_test")).await.unwrap(),
|
||||
enabled
|
||||
);
|
||||
assert!(store.automations.lock().unwrap()["acct_test"].enabled);
|
||||
let audit = store.audit_requests();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert!(audit[0].starts_with("plugin:installed-plugin:request:plugin-request:call:"));
|
||||
|
||||
let repeated = scope_actor(
|
||||
actor.clone(),
|
||||
scope(
|
||||
origin.clone(),
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated, enabled);
|
||||
assert_eq!(store.audit_requests(), audit);
|
||||
|
||||
let disabled = scope_actor(
|
||||
actor,
|
||||
scope(origin, registry.call(write, json!(["acct_test", false]))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(disabled["enabled"], false);
|
||||
assert!(disabled["next_discovery_at"].is_null());
|
||||
assert!(disabled["next_consume_at"].is_null());
|
||||
assert!(!store.automations.lock().unwrap()["acct_test"].enabled);
|
||||
assert_eq!(store.audit_requests().len(), 2);
|
||||
assert!(!recorded(&events).contains(&"provider.reset_credits"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_service_automation_rejects_invalid_and_cancelled_writes_without_side_effects() {
|
||||
use gateway_admin::{
|
||||
model::usage_data::UsageActor,
|
||||
public_service::{Origin, Plan, scope, scope_actor},
|
||||
};
|
||||
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let registry = services.public_services();
|
||||
let write = "accounts.set_reset_credit_automation";
|
||||
// 直接服务调用也必须有可信主体,输入不能自报管理员身份
|
||||
let error = registry
|
||||
.call(write, json!(["acct_test", true]))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "invalid");
|
||||
let error = scope_actor(
|
||||
UsageActor::System,
|
||||
registry.call(
|
||||
write,
|
||||
json!([{"actor": "admin_api_key"}, "acct_test", true]),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "invalid");
|
||||
let error = scope_actor(
|
||||
UsageActor::System,
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "invalid");
|
||||
assert_eq!(error.message, "当前账号不支持自动重置卡");
|
||||
|
||||
enable_capability(&provider);
|
||||
store.set_accounts(Vec::new());
|
||||
let error = scope_actor(
|
||||
UsageActor::System,
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "not_found");
|
||||
let cancellation = gateway_core::lifecycle::CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
let error = scope_actor(
|
||||
UsageActor::System,
|
||||
scope(
|
||||
Origin {
|
||||
request_id: "cancelled".into(),
|
||||
call_id: "parent".into(),
|
||||
cancellation,
|
||||
extensions: Default::default(),
|
||||
plan: Plan::Frozen(None),
|
||||
},
|
||||
registry.call(write, json!(["acct_test", true])),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "unavailable");
|
||||
assert!(store.automations.lock().unwrap().is_empty());
|
||||
assert!(store.audit_requests().is_empty());
|
||||
}
|
||||
|
||||
fn enable_capability(provider: &FakeProviderAdmin) {
|
||||
let mut capabilities = provider.capabilities.lock().unwrap();
|
||||
capabilities.reset_credits = true;
|
||||
capabilities.consume_reset_credit = true;
|
||||
}
|
||||
|
||||
fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
|
||||
ResetCreditAutomation {
|
||||
account_id: "acct_test".into(),
|
||||
enabled: true,
|
||||
revision: 1,
|
||||
next_run_at: Utc::now(),
|
||||
state: ResetCreditAutomationState {
|
||||
next_discovery_at: Utc::now() + TimeDelta::days(1),
|
||||
credits: vec![PlannedResetCredit {
|
||||
id: "card_1".into(),
|
||||
expires_at,
|
||||
}],
|
||||
pending: None,
|
||||
retry_at: None,
|
||||
failures: 0,
|
||||
last_result: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn card(
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
) -> gateway_admin::model::provider_credentials::ProviderResetCredit {
|
||||
gateway_admin::model::provider_credentials::ProviderResetCredit {
|
||||
id: "card_1".into(),
|
||||
status: Some("available".into()),
|
||||
expires_at: Some(expires_at),
|
||||
title: None,
|
||||
reset_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_restores_pending_idempotency_after_service_restart() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let expiry = Utc::now() + TimeDelta::minutes(5);
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
*provider.reset_credit_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Ambiguous);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id.clone())
|
||||
.await
|
||||
.expect_err("unknown consume result");
|
||||
let persisted = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let pending = persisted.state.pending.as_ref().unwrap();
|
||||
let command = provider.reset_credit_commands()[0].clone();
|
||||
assert_eq!(
|
||||
pending.redeem_request_id,
|
||||
command.redeem_request_id.to_string()
|
||||
);
|
||||
assert_eq!(command.redeem_request_id.get_version_num(), 4);
|
||||
assert_eq!(provider.quota_requests().len(), 0);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.reset_credit_commands().len(),
|
||||
1,
|
||||
"wait until persisted retry time"
|
||||
);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
drop(services);
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
|
||||
restarted
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
|
||||
assert_eq!(
|
||||
recorded(&events)
|
||||
.iter()
|
||||
.filter(|e| **e == "provider.reset_credits")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.state.pending.is_none());
|
||||
assert!(record.state.credits.is_empty());
|
||||
assert_eq!(record.state.last_result.as_deref(), Some("reset"));
|
||||
assert!(provider.quota_requests().iter().any(|q| q.refresh));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_only_queries_due_accounts_and_rechecks_credit_before_consume() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let mut record = plan(Utc::now() + TimeDelta::hours(6));
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record.clone());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!recorded(&events).contains(&"provider.reset_credits"));
|
||||
record.state.credits[0].expires_at = Utc::now() + TimeDelta::minutes(5);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recorded(&events).contains(&"provider.reset_credits"));
|
||||
assert!(
|
||||
provider.reset_credit_commands().is_empty(),
|
||||
"disappeared credit must never be consumed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_defaults_off_and_disabled_pending_survives_reenable() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
assert!(
|
||||
!services
|
||||
.accounts()
|
||||
.reset_credit_automation_status(account_id.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
services
|
||||
.accounts()
|
||||
.set_reset_credit_automation(&context("enable"), account_id.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let first = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(first.state.next_discovery_at >= Utc::now() - TimeDelta::seconds(1));
|
||||
let pending = PendingResetCredit {
|
||||
credit: PlannedResetCredit {
|
||||
id: "pending".into(),
|
||||
expires_at: Utc::now() + TimeDelta::hours(1),
|
||||
},
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.pending = Some(pending.clone());
|
||||
services
|
||||
.accounts()
|
||||
.set_reset_credit_automation(&context("disable"), account_id.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
services
|
||||
.accounts()
|
||||
.set_reset_credit_automation(&context("reenable"), account_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let next = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next.state.pending, Some(pending));
|
||||
assert_eq!(next.state.next_discovery_at, first.state.next_discovery_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_schedules_each_account_inside_the_lead_window_and_next_local_day() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let now = Utc::now();
|
||||
let expiry = now + TimeDelta::days(2);
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
let accounts = (0..100)
|
||||
.map(|n| {
|
||||
let mut account = account_record("openai");
|
||||
account.id = format!("acct_{n}");
|
||||
let mut record = plan(expiry);
|
||||
record.account_id = account.id.clone();
|
||||
record.state.next_discovery_at = now - TimeDelta::seconds(1);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(account.id.clone(), record);
|
||||
account
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
store.set_accounts(accounts.clone());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let timezone = gateway_core::time::DeploymentTimeZone::default();
|
||||
let start = timezone.days_after(now, 1).unwrap();
|
||||
let end = timezone.days_after(now, 2).unwrap();
|
||||
let mut times = std::collections::BTreeSet::new();
|
||||
for account in accounts {
|
||||
let id = ProviderAccountId::new(account.id.clone()).unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = services
|
||||
.accounts()
|
||||
.reset_credit_automation_status(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let discovery = status.next_discovery_at.unwrap();
|
||||
assert!(discovery >= start && discovery < end);
|
||||
times.insert(status.next_consume_at.unwrap());
|
||||
}
|
||||
assert!(
|
||||
times.len() > 30,
|
||||
"accounts must not all fire at the same instant"
|
||||
);
|
||||
assert!(times.iter().all(
|
||||
|due| *due >= expiry - TimeDelta::minutes(10) && *due < expiry - TimeDelta::minutes(9)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_discards_externally_used_credit_and_stops_on_already_redeemed() {
|
||||
for already_redeemed_after_query in [false, true] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let expiry = Utc::now() + TimeDelta::minutes(5);
|
||||
let mut remote_credit = card(expiry);
|
||||
if !already_redeemed_after_query {
|
||||
remote_credit.status = Some("redeemed".into());
|
||||
}
|
||||
provider.reset_credits.lock().unwrap().push(remote_credit);
|
||||
*provider.reset_credit_result.lock().unwrap() = "already_redeemed".into();
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.reset_credit_commands().len(),
|
||||
usize::from(already_redeemed_after_query)
|
||||
);
|
||||
let stored = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(stored.state.credits.is_empty());
|
||||
assert!(stored.state.pending.is_none());
|
||||
if already_redeemed_after_query {
|
||||
assert_eq!(
|
||||
stored.state.last_result.as_deref(),
|
||||
Some("already_redeemed")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_daily_discovery_is_not_polled_again_after_success_or_failure() {
|
||||
for failed in [false, true] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
if failed {
|
||||
provider.fail_next(ProviderAdminErrorKind::Unavailable);
|
||||
}
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let mut record = plan(Utc::now() + TimeDelta::days(3));
|
||||
record.state.credits.clear();
|
||||
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let first = services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await;
|
||||
assert_eq!(first.is_err(), failed);
|
||||
let scheduled = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state
|
||||
.next_discovery_at;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recorded(&events)
|
||||
.iter()
|
||||
.filter(|e| **e == "provider.reset_credits")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state
|
||||
.next_discovery_at,
|
||||
scheduled
|
||||
);
|
||||
assert!(scheduled > Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_cancelled_discovery_preserves_next_random_time_across_restart() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let mut record = plan(Utc::now() + TimeDelta::days(3));
|
||||
record.state.credits.clear();
|
||||
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let (release, gate) = tokio::sync::oneshot::channel();
|
||||
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let task = tokio::spawn(async move {
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
provider.reset_credit_query_started.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
drop(release);
|
||||
let before = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(before.state.next_discovery_at > Utc::now());
|
||||
let restarted = accounts_service(provider.clone(), store.clone()).await;
|
||||
restarted
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let after = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before.state.next_discovery_at,
|
||||
after.state.next_discovery_at
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&events)
|
||||
.iter()
|
||||
.filter(|e| **e == "provider.reset_credits")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_expires() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let mut record = plan(Utc::now() + TimeDelta::minutes(5));
|
||||
record.enabled = false;
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
credit: record.state.credits[0].clone(),
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record.clone());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let command = ConsumeProviderResetCredit {
|
||||
account_id: ProviderAccountId::new("acct_test").unwrap(),
|
||||
credit_id: Some("another_card".into()),
|
||||
redeem_request_id: uuid::Uuid::new_v4(),
|
||||
};
|
||||
services
|
||||
.accounts()
|
||||
.consume_reset_credit(&context("manual-with-pending"), command.clone())
|
||||
.await
|
||||
.expect_err("unresolved automation is still active");
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
record.state.pending.as_mut().unwrap().credit.expires_at = Utc::now() - TimeDelta::seconds(1);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
services
|
||||
.accounts()
|
||||
.consume_reset_credit(&context("manual-with-pending"), command)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
}
|
||||
|
||||
async fn threshold_services(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
max_remaining: Option<u32>,
|
||||
) -> AdminServices {
|
||||
super::super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore {
|
||||
reset_credit_max_weekly_remaining_percent: max_remaining,
|
||||
}))
|
||||
.provider(provider)
|
||||
.probe(Arc::new(SuccessfulAccountProbe))
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn weekly_quota(used_percent: f64) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "week".into(),
|
||||
group: "shortTerm".into(),
|
||||
label: "周额度".into(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(7 * 86_400),
|
||||
used_percent: Some(used_percent),
|
||||
reset_at: Some(Utc::now() + TimeDelta::days(1)),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_due_credit(provider: &FakeProviderAdmin, store: &FakeAccountStore) {
|
||||
enable_capability(provider);
|
||||
let expiry = Utc::now() + TimeDelta::minutes(5);
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
}
|
||||
|
||||
fn advance_retry(store: &FakeAccountStore) {
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_quota() {
|
||||
for (threshold, used, consume) in [
|
||||
(None, 0.0, true),
|
||||
(Some(70), 29.9, false),
|
||||
(Some(70), 30.0, true),
|
||||
(Some(0), 99.9, false),
|
||||
(Some(0), 100.0, true),
|
||||
(Some(100), 0.0, true),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(used));
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
!provider.reset_credit_commands().is_empty(),
|
||||
consume,
|
||||
"threshold={threshold:?}, used={used}"
|
||||
);
|
||||
let quota_requests = provider.quota_requests();
|
||||
// 消费成功已有一次回读;未设置上限时不增加消费前查询
|
||||
assert_eq!(
|
||||
quota_requests.len(),
|
||||
usize::from(threshold.is_some()) + usize::from(consume)
|
||||
);
|
||||
assert!(quota_requests.iter().all(|request| request.refresh));
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.is_empty(), consume);
|
||||
if !consume {
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("weekly_quota_above_threshold")
|
||||
);
|
||||
assert!(record.state.retry_at.unwrap() > Utc::now());
|
||||
assert!(record.state.retry_at.unwrap() <= record.state.credits[0].expires_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(10.0));
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
provider.set_quota(weekly_quota(40.0));
|
||||
advance_retry(&store);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(record.state.credits.is_empty());
|
||||
assert!(record.state.retry_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
|
||||
let weekly = weekly_quota(90.0).windows.remove(0);
|
||||
let mut short = weekly.clone();
|
||||
short.window_seconds = Some(18_000);
|
||||
let mut model = weekly.clone();
|
||||
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let mut month = weekly.clone();
|
||||
month.group = "monthly".into();
|
||||
month.window_seconds = Some(30 * 86_400);
|
||||
let mut missing_percent = weekly.clone();
|
||||
missing_percent.used_percent = None;
|
||||
for (quota, failure) in [
|
||||
(empty_quota(), None),
|
||||
(
|
||||
ProviderQuota {
|
||||
windows: vec![short, model, month],
|
||||
..empty_quota()
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
ProviderQuota {
|
||||
windows: vec![missing_percent],
|
||||
..empty_quota()
|
||||
},
|
||||
None,
|
||||
),
|
||||
(weekly_quota(f64::NAN), None),
|
||||
(
|
||||
weekly_quota(90.0),
|
||||
Some(ProviderAdminErrorKind::Unavailable),
|
||||
),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(quota);
|
||||
*provider.quota_failure.lock().unwrap() = failure;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let result = services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await;
|
||||
assert_eq!(result.is_err(), failure.is_some());
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("weekly_quota_unavailable")
|
||||
);
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.len(), 1);
|
||||
assert!(record.state.retry_at.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
*provider.reset_credit_result.lock().unwrap() = "nothing_to_reset".into();
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let record = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.state.last_result.as_deref(),
|
||||
Some("nothing_to_reset")
|
||||
);
|
||||
assert!(record.state.pending.is_none());
|
||||
assert_eq!(record.state.credits.len(), 1);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
advance_retry(&store);
|
||||
*provider.reset_credit_result.lock().unwrap() = "reset".into();
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0].credit_id, commands[1].credit_id);
|
||||
assert_ne!(commands[0].redeem_request_id, commands[1].redeem_request_id);
|
||||
assert!(
|
||||
store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.state
|
||||
.credits
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,8 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -158,6 +160,19 @@ impl FreezeRuntimeStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for FreezeRuntimeStore {
|
||||
async fn account_concurrency(
|
||||
&self,
|
||||
_: &str,
|
||||
_: u64,
|
||||
_: u32,
|
||||
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"account concurrency",
|
||||
"not configured in this fixture",
|
||||
))
|
||||
}
|
||||
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod plugins;
|
||||
mod proxies;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod usage_data;
|
||||
|
||||
use std::{
|
||||
str::FromStr,
|
||||
@@ -95,6 +96,7 @@ pub(super) struct AdminHarness {
|
||||
client_session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
proxy_probe: Arc<dyn gateway_admin::ports::proxy::ProxyProbe>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
@@ -120,6 +122,7 @@ impl AdminHarness {
|
||||
client_session_ttl_minutes: 1_440,
|
||||
accounts: unavailable.clone(),
|
||||
proxies: Arc::new(proxies::TestProxies::default()),
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
account_runtime: unavailable.clone(),
|
||||
account_groups: Arc::new(UnavailableAccountGroupStore),
|
||||
auth: Arc::new(BootstrapAuthStore::default()),
|
||||
@@ -273,6 +276,7 @@ impl AdminHarness {
|
||||
self.plugin_store,
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone: Default::default(),
|
||||
@@ -289,7 +293,7 @@ impl AdminHarness {
|
||||
.unwrap(),
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
account_probe: self.probe,
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
proxy_probe: self.proxy_probe,
|
||||
client_distribution: Arc::new(NoopClientDistribution),
|
||||
system: self.system,
|
||||
client_key_verifier: self.client_key_verifier,
|
||||
@@ -474,6 +478,10 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for UnavailableStore {
|
||||
async fn account_exists(&self, _: &str) -> AdminStoreResult<bool> {
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
async fn list_plugin_accounts(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
|
||||
@@ -643,6 +651,19 @@ impl AccountStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for UnavailableStore {
|
||||
async fn account_concurrency(
|
||||
&self,
|
||||
_: &str,
|
||||
_: u64,
|
||||
_: u32,
|
||||
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"account concurrency",
|
||||
"not configured in this fixture",
|
||||
))
|
||||
}
|
||||
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
@@ -747,6 +768,14 @@ impl ClientKeyStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnavailableStore {
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
_target: &gateway_admin::model::diagnostics::DiagnosticTarget,
|
||||
_limit: u16,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::OpsError>> {
|
||||
Err(unavailable("diagnostic errors"))
|
||||
}
|
||||
|
||||
fn query_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(10)
|
||||
}
|
||||
@@ -1150,3 +1179,31 @@ fn unavailable_system() -> SystemOperationError {
|
||||
)
|
||||
}
|
||||
mod service_contract;
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
|
||||
for UnavailableStore
|
||||
{
|
||||
async fn reset_credit_automation(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> AdminStoreResult<
|
||||
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
|
||||
> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn due_reset_credit_automations(
|
||||
&self,
|
||||
_: chrono::DateTime<chrono::Utc>,
|
||||
_: u32,
|
||||
) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn save_reset_credit_automation(
|
||||
&self,
|
||||
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
|
||||
_: Option<&gateway_admin::model::MutationContext>,
|
||||
) -> AdminStoreResult<u64> {
|
||||
Err(unavailable("reset-credit automation"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -945,6 +1064,14 @@ impl FixtureObservabilityStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for FixtureObservabilityStore {
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
_target: &gateway_admin::model::diagnostics::DiagnosticTarget,
|
||||
_limit: u16,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::OpsError>> {
|
||||
Err(super::unavailable("diagnostic errors"))
|
||||
}
|
||||
|
||||
fn query_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(10)
|
||||
}
|
||||
@@ -965,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(),
|
||||
@@ -1132,6 +1260,8 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1194,6 +1324,7 @@ fn observation_range(end: DateTime<Utc>) -> TimeRange {
|
||||
|
||||
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
status: Default::default(),
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
current_page: 1,
|
||||
@@ -1208,6 +1339,10 @@ 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(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
|
||||
@@ -534,6 +534,7 @@ fn preview_fixture() -> Fixture {
|
||||
fn source_proxy() -> ProxyRecord {
|
||||
let now = chrono::Utc::now();
|
||||
ProxyRecord {
|
||||
enabled: true,
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
@@ -608,6 +609,38 @@ async fn saved_source_proxy_is_resolved_for_queries_and_frozen_into_verified_sou
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_source_proxy_rejects_queries_and_downloads_before_network_access() {
|
||||
let mut fixture = preview_fixture();
|
||||
fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into());
|
||||
let mut proxy = source_proxy();
|
||||
proxy.enabled = false;
|
||||
*fixture.proxy.get_mut().unwrap() = Some(proxy);
|
||||
let fixture = Arc::new(fixture);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
let mut request = preview_request();
|
||||
request.outbound_proxy_id = Some("source-proxy".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.verify_remote(request)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.egresses.lock().unwrap().is_empty());
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_revision_change_during_download_rejects_the_verified_candidate() {
|
||||
let mut fixture = preview_fixture();
|
||||
|
||||
@@ -434,6 +434,7 @@ impl PluginStateStore for LifecycleFixture {
|
||||
from_schema_version: 1,
|
||||
to_schema_version: 2,
|
||||
}],
|
||||
guard: Arc::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 插件管理用例的测试入口与共用端口替身
|
||||
|
||||
mod usage_data;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::model::plugins::distribution::{
|
||||
DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 通用用量端口的插件管理测试夹具,非本测试调用路径禁止访问
|
||||
use super::TestPluginPorts;
|
||||
use gateway_admin::{model::usage_data::*, ports::usage_data::*};
|
||||
#[async_trait::async_trait]
|
||||
impl UsageDataStore for TestPluginPorts {
|
||||
async fn get_record(&self, _: GetUsageRecord) -> UsageDataResult<UsageRecord> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn list_records(&self, _: ListUsageRecords) -> UsageDataResult<UsageRecordPage> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn write_records(&self, _: StoredUsageWrite) -> UsageDataResult<UsageWriteResult> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn list_aggregates(&self, _: ListUsageAggregates) -> UsageDataResult<UsageAggregatePage> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
_: StoredAggregateWrite,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub(super) struct TestProxies {
|
||||
pub events: Option<super::accounts::EventLog>,
|
||||
pub accounts: Option<Vec<ProxyAccountRef>>,
|
||||
pub record: Option<ProxyRecord>,
|
||||
pub records: Vec<ProxyRecord>,
|
||||
}
|
||||
|
||||
struct ImportGuard(super::accounts::EventLog);
|
||||
@@ -77,9 +78,13 @@ impl ProxyStore for TestProxies {
|
||||
self.record
|
||||
.clone()
|
||||
.filter(|record| record.id == id)
|
||||
.or_else(|| self.records.iter().find(|record| record.id == id).cloned())
|
||||
.ok_or_else(|| super::unavailable("proxy"))
|
||||
}
|
||||
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
if let Some(events) = &self.events {
|
||||
events.lock().unwrap().push("proxy.create");
|
||||
}
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
@@ -95,18 +100,23 @@ impl ProxyStore for TestProxies {
|
||||
}
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
id: &str,
|
||||
_: Revision,
|
||||
_: ProxyTestResult,
|
||||
result: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
let mut record = self.get(id).await?;
|
||||
record.last_test = Some(result);
|
||||
Ok(ProxyMutation {
|
||||
config_revision: record.revision,
|
||||
record,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for TestProxies {
|
||||
async fn test(&self, _: &OutboundProxy, _: bool) -> ProxyTestResult {
|
||||
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
|
||||
panic!("unexpected proxy probe")
|
||||
}
|
||||
}
|
||||
@@ -126,6 +136,7 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
.accounts(FakeAccountStore::new(kind, events.clone()))
|
||||
.proxies(Arc::new(TestProxies {
|
||||
record: Some(ProxyRecord {
|
||||
enabled: true,
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
@@ -309,3 +320,163 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_create_stops_after_uncertain_store_failure_without_replaying() {
|
||||
use super::accounts::{context, events, recorded};
|
||||
use gateway_admin::model::AdminErrorKind;
|
||||
let events = events();
|
||||
let services = super::AdminHarness::new()
|
||||
.proxies(std::sync::Arc::new(TestProxies {
|
||||
events: Some(events.clone()),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let commands = [" ", "First", "Skipped"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
Ok(NewProxy {
|
||||
name: name.to_owned(),
|
||||
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
|
||||
auto_location: false,
|
||||
location: None,
|
||||
test: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let results = services
|
||||
.proxies()
|
||||
.create_batch(commands, &context("batch-create"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&results[0], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Invalid)
|
||||
);
|
||||
assert!(
|
||||
matches!(&results[1], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Unavailable)
|
||||
);
|
||||
assert!(matches!(&results[2], ProxyBatchResult::Skipped));
|
||||
assert_eq!(recorded(&events), vec!["proxy.create"]);
|
||||
}
|
||||
|
||||
struct GatedProxyProbe {
|
||||
started: tokio::sync::mpsc::UnboundedSender<()>,
|
||||
release: tokio::sync::Semaphore,
|
||||
active: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
struct ActiveProbe<'a>(&'a std::sync::atomic::AtomicUsize);
|
||||
impl Drop for ActiveProbe<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for GatedProxyProbe {
|
||||
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
|
||||
self.active
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let _active = ActiveProbe(&self.active);
|
||||
self.started.send(()).unwrap();
|
||||
self.release.acquire().await.unwrap().forget();
|
||||
ProxyTestResult {
|
||||
location: Default::default(),
|
||||
success: true,
|
||||
latency_ms: 1,
|
||||
exit_ip: None,
|
||||
exit_ipv4: None,
|
||||
exit_ipv6: None,
|
||||
message: "ok".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_tests_share_capacity_preserve_order_and_release_slots_on_cancellation() {
|
||||
use super::accounts::context;
|
||||
use std::sync::{Arc, atomic::Ordering};
|
||||
let (started, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let probe = Arc::new(GatedProxyProbe {
|
||||
started,
|
||||
release: tokio::sync::Semaphore::new(0),
|
||||
active: Default::default(),
|
||||
});
|
||||
let now = chrono::Utc::now();
|
||||
let records = (0..4)
|
||||
.map(|i| ProxyRecord {
|
||||
id: format!("proxy_{i}"),
|
||||
name: format!("Proxy {i}"),
|
||||
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
|
||||
revision: Revision::new(1).unwrap(),
|
||||
enabled: true,
|
||||
auto_location: false,
|
||||
location: None,
|
||||
detected_location: None,
|
||||
account_count: 0,
|
||||
last_test_at: None,
|
||||
last_test: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.collect();
|
||||
let mut harness = super::AdminHarness::new().proxies(Arc::new(TestProxies {
|
||||
records,
|
||||
..Default::default()
|
||||
}));
|
||||
harness.proxy_probe = probe.clone();
|
||||
let services = harness.build().await;
|
||||
let commands = || {
|
||||
(0..4)
|
||||
.map(|i| {
|
||||
Ok(TestProxy {
|
||||
id: format!("proxy_{i}"),
|
||||
revision: Revision::new(1).unwrap(),
|
||||
detect_location: true,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let context = context("batch-test");
|
||||
let mut batch = Box::pin(services.proxies().test_batch(commands(), &context));
|
||||
tokio::select! {
|
||||
_ = &mut batch => panic!("batch finished before probes were released"),
|
||||
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
|
||||
}
|
||||
assert_eq!(probe.active.load(Ordering::Relaxed), 2);
|
||||
let mut single_a = Box::pin(services.proxies().probe(None, false));
|
||||
let mut single_b = Box::pin(services.proxies().probe(None, false));
|
||||
tokio::select! {
|
||||
_ = &mut batch => panic!("batch unexpectedly finished"),
|
||||
_ = &mut single_a => panic!("single probe unexpectedly finished"),
|
||||
_ = &mut single_b => panic!("single probe unexpectedly finished"),
|
||||
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
|
||||
}
|
||||
assert_eq!(probe.active.load(Ordering::Relaxed), 4);
|
||||
assert_eq!(
|
||||
services
|
||||
.proxies()
|
||||
.probe(None, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
gateway_admin::model::AdminErrorKind::RateLimited
|
||||
);
|
||||
drop((batch, single_a, single_b));
|
||||
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
|
||||
probe.release.add_permits(4);
|
||||
let results = services
|
||||
.proxies()
|
||||
.test_batch(commands(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
for (index, result) in results.into_iter().enumerate() {
|
||||
let ProxyBatchResult::Completed(Ok(record)) = result else {
|
||||
panic!("probe failed after cancellation")
|
||||
};
|
||||
assert_eq!(record.id, format!("proxy_{index}"));
|
||||
assert!(record.last_test.unwrap().success);
|
||||
}
|
||||
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ fn contract_type(mut item: Item, name: &str) -> Item {
|
||||
| "RegeneratedAdminApiKey"
|
||||
| "CodexPrivacyPolicy"
|
||||
| "PrivacyRule"
|
||||
| "PrivacyStep"
|
||||
| "PrivacyPreviewRequest"
|
||||
| "PrivacyPreviewResult"
|
||||
);
|
||||
@@ -123,6 +124,89 @@ fn contract_type(mut item: Item, name: &str) -> Item {
|
||||
item
|
||||
}
|
||||
|
||||
fn service_operations(
|
||||
root: &Path,
|
||||
namespace: &str,
|
||||
service_path: &str,
|
||||
service_name: &str,
|
||||
) -> (Vec<syn::File>, usize) {
|
||||
let service = source(root, service_path)
|
||||
.items
|
||||
.into_iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Trait(item) if item.ident == service_name => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let outputs: BTreeMap<_, _> = service
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
syn::TraitItem::Fn(method) => {
|
||||
let syn::ReturnType::Type(_, output) = method.sig.output else {
|
||||
panic!("服务必须返回 Result")
|
||||
};
|
||||
let Type::Path(result) = *output else {
|
||||
panic!("服务必须返回 Result")
|
||||
};
|
||||
let segment = result.path.segments.last().unwrap();
|
||||
assert_eq!(segment.ident, "Result");
|
||||
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
|
||||
panic!("Result 缺少类型参数")
|
||||
};
|
||||
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
|
||||
else {
|
||||
panic!("Result 缺少输出类型")
|
||||
};
|
||||
WireTypes.visit_type_mut(&mut output);
|
||||
Some((method.sig.ident.to_string(), output))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let register = source(root, &format!("src/public_service/{namespace}.rs"))
|
||||
.items
|
||||
.into_iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Fn(item) if item.sig.ident == "register" => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let operations: Vec<syn::File> = register
|
||||
.block
|
||||
.stmts
|
||||
.into_iter()
|
||||
.filter_map(|stmt| {
|
||||
let syn::Stmt::Macro(stmt) = stmt else {
|
||||
return None;
|
||||
};
|
||||
if !stmt.mac.path.is_ident("register") {
|
||||
return None;
|
||||
}
|
||||
let Operation {
|
||||
name,
|
||||
method,
|
||||
mut input,
|
||||
} = syn::parse2(stmt.mac.tokens).unwrap();
|
||||
WireTypes.visit_type_mut(&mut input);
|
||||
let output = outputs.get(&method.to_string()).unwrap();
|
||||
let operation = format!("{namespace}.{method}");
|
||||
Some(
|
||||
syn::parse2(quote! {
|
||||
pub struct #name;
|
||||
impl Operation for #name {
|
||||
const NAME: &'static str = #operation;
|
||||
type Input = #input;
|
||||
type Output = #output;
|
||||
}
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(operations, outputs.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdk_settings_contract_matches_host_declarations() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -137,6 +221,7 @@ fn sdk_settings_contract_matches_host_declarations() {
|
||||
"PrivacyScope",
|
||||
"PrivacyAction",
|
||||
"PrivacyRule",
|
||||
"PrivacyStep",
|
||||
"PrivacyRuleOutcome",
|
||||
"PrivacyPreviewRequest",
|
||||
"PrivacyPreviewResult",
|
||||
@@ -238,78 +323,13 @@ fn sdk_settings_contract_matches_host_declarations() {
|
||||
}
|
||||
}
|
||||
|
||||
let service = source(root, "src/use_case/settings.rs")
|
||||
.items
|
||||
.into_iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Trait(item) if item.ident == "SettingsService" => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let outputs: BTreeMap<_, _> = service
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
syn::TraitItem::Fn(method) => {
|
||||
let syn::ReturnType::Type(_, output) = method.sig.output else {
|
||||
panic!("服务必须返回 Result")
|
||||
};
|
||||
let Type::Path(result) = *output else {
|
||||
panic!("服务必须返回 Result")
|
||||
};
|
||||
let segment = result.path.segments.last().unwrap();
|
||||
assert_eq!(segment.ident, "Result");
|
||||
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
|
||||
panic!("Result 缺少类型参数")
|
||||
};
|
||||
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
|
||||
else {
|
||||
panic!("Result 缺少输出类型")
|
||||
};
|
||||
WireTypes.visit_type_mut(&mut output);
|
||||
Some((method.sig.ident.to_string(), output))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let register = source(root, "src/public_service/settings.rs")
|
||||
.items
|
||||
.into_iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Fn(item) if item.sig.ident == "register" => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let operations: Vec<_> = register
|
||||
.block
|
||||
.stmts
|
||||
.into_iter()
|
||||
.filter_map(|stmt| {
|
||||
let syn::Stmt::Macro(stmt) = stmt else {
|
||||
return None;
|
||||
};
|
||||
if !stmt.mac.path.is_ident("register") {
|
||||
return None;
|
||||
}
|
||||
let Operation {
|
||||
name,
|
||||
method,
|
||||
mut input,
|
||||
} = syn::parse2(stmt.mac.tokens).unwrap();
|
||||
WireTypes.visit_type_mut(&mut input);
|
||||
let output = outputs.get(&method.to_string()).unwrap();
|
||||
let operation = format!("settings.{method}");
|
||||
Some(quote! {
|
||||
pub struct #name;
|
||||
impl Operation for #name {
|
||||
const NAME: &'static str = #operation;
|
||||
type Input = #input;
|
||||
type Output = #output;
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(operations.len(), outputs.len(), "公开服务方法必须完整声明");
|
||||
let (operations, method_count) = service_operations(
|
||||
root,
|
||||
"settings",
|
||||
"src/use_case/settings.rs",
|
||||
"SettingsService",
|
||||
);
|
||||
assert_eq!(operations.len(), method_count, "公开服务方法必须完整声明");
|
||||
let assignments = replacement_fields.iter().map(|field| match field.to_string().as_str() {
|
||||
"expected_revision" => quote!(expected_revision: settings.config_revision),
|
||||
"request_profile_updates" => quote!(request_profile_updates: settings.request_profiles.into_iter().map(|(provider, value)| (provider, Some(value))).collect()),
|
||||
@@ -338,14 +358,48 @@ fn sdk_settings_contract_matches_host_declarations() {
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs");
|
||||
let expected = prettyplease::unparse(&syn::parse2::<syn::File>(generated).unwrap());
|
||||
check_contract(root, "settings", syn::parse2(generated).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdk_accounts_contract_matches_host_declarations() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let status = source(root, "src/model/reset_credit_automation.rs")
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|item| matches!(item, Item::Struct(item) if item.ident == "ResetCreditAutomationStatus"))
|
||||
.unwrap();
|
||||
let status = contract_type(status, "ResetCreditAutomationStatus");
|
||||
let (operations, _) = service_operations(
|
||||
root,
|
||||
"accounts",
|
||||
"src/use_case/accounts/mod.rs",
|
||||
"AccountsService",
|
||||
);
|
||||
let generated = quote! {
|
||||
//! 账号自动重置的类型化操作与状态合同
|
||||
//!
|
||||
//! 从宿主状态类型与 public_service/accounts.rs 生成;更新命令见 SDK 维护说明
|
||||
|
||||
use super::Operation;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#status
|
||||
#(#operations)*
|
||||
};
|
||||
check_contract(root, "accounts", syn::parse2(generated).unwrap());
|
||||
}
|
||||
|
||||
fn check_contract(root: &Path, namespace: &str, generated: syn::File) {
|
||||
let output = root.join(format!(
|
||||
"../gateway-plugin/sdk/src/call/services/{namespace}.rs"
|
||||
));
|
||||
let expected = prettyplease::unparse(&generated);
|
||||
if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() {
|
||||
std::fs::write(&output, &expected).unwrap();
|
||||
}
|
||||
let actual = syn::parse_file(&std::fs::read_to_string(output).unwrap()).unwrap();
|
||||
assert!(
|
||||
prettyplease::unparse(&actual) == expected,
|
||||
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_settings_contract_matches_host_declarations"
|
||||
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_{namespace}_contract_matches_host_declarations"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,6 +355,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! 用量服务的 wire 精度、可信主体和独立 SDK 合同校验
|
||||
use gateway_admin::model::usage_data::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn counters_preserve_integer_precision_and_reject_json_numbers() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UsageCount>(json!(u64::MAX.to_string()))
|
||||
.unwrap()
|
||||
.get(),
|
||||
u64::MAX
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UsageDelta>(json!(i64::MIN.to_string()))
|
||||
.unwrap()
|
||||
.get(),
|
||||
i64::MIN
|
||||
);
|
||||
for value in [json!(1), json!("1.5"), json!("+1")] {
|
||||
assert!(serde_json::from_value::<UsageCount>(value.clone()).is_err());
|
||||
assert!(serde_json::from_value::<UsageDelta>(value).is_err());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_absent() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let denied = registry
|
||||
.call("usage.records.write", json!({"changes":[]}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.message.contains("trusted caller"));
|
||||
assert!(
|
||||
registry
|
||||
.call(
|
||||
"usage.records.write",
|
||||
json!({"changes":[],"actor":{"kind":"system"}})
|
||||
)
|
||||
.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() {
|
||||
use quote::ToTokens as _;
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let items = |path: std::path::PathBuf| {
|
||||
syn::parse_file(&std::fs::read_to_string(path).unwrap())
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
syn::Item::Struct(_)
|
||||
| syn::Item::Enum(_)
|
||||
| syn::Item::Impl(_)
|
||||
| syn::Item::Type(_)
|
||||
)
|
||||
})
|
||||
.map(|item| item.into_token_stream().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for name in [
|
||||
"value.rs",
|
||||
"record.rs",
|
||||
"query.rs",
|
||||
"aggregate.rs",
|
||||
"pricing.rs",
|
||||
"error.rs",
|
||||
] {
|
||||
assert_eq!(
|
||||
items(root.join("src/model/usage_data").join(name)),
|
||||
items(
|
||||
root.join("../gateway-plugin/sdk/src/call/services/usage")
|
||||
.join(name)
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_and_wire_limits_fail_before_reaching_storage() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let changes = (0..501)
|
||||
.map(|i| json!({"kind":"delete","id":format!("item_{i}")}))
|
||||
.collect::<Vec<_>>();
|
||||
let too_many = gateway_admin::public_service::scope_actor(
|
||||
UsageActor::System,
|
||||
registry.call("usage.records.write", json!({"changes":changes})),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(too_many.kind, "capacity_exceeded");
|
||||
let oversized = registry
|
||||
.call(
|
||||
"usage.records.get",
|
||||
json!({"id":"x".repeat(USAGE_DATA_MAX_REQUEST_BYTES)}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(oversized.kind, "capacity_exceeded");
|
||||
}
|
||||
@@ -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]]
|
||||
@@ -25,6 +25,7 @@ gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
gateway-protocol = { path = "../gateway-protocol" }
|
||||
headers = "0.4.1"
|
||||
regex = "1.13.1"
|
||||
secrecy = "0.10"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
@@ -32,7 +33,7 @@ sha2 = "0.11.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tower-http = { version = "0.7.0", features = ["compression-gzip", "cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
@@ -91,9 +91,15 @@ impl ListAccountGroupsQuery {
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_allow_image_generation() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CreateAccountGroupRequest {
|
||||
#[serde(default = "default_allow_image_generation")]
|
||||
allow_image_generation: bool,
|
||||
#[serde(default)]
|
||||
fast_mode: FastMode,
|
||||
name: String,
|
||||
@@ -104,6 +110,7 @@ struct CreateAccountGroupRequest {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpdateAccountGroupRequest {
|
||||
allow_image_generation: Option<bool>,
|
||||
fast_mode: Option<FastMode>,
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -120,6 +127,7 @@ struct AccountGroupIdRequest {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupView {
|
||||
allow_image_generation: bool,
|
||||
fast_mode: FastMode,
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -167,6 +175,7 @@ impl From<(AccountGroupRecord, crate::time::TimePresenter)> for AccountGroupView
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
fast_mode: record.fast_mode,
|
||||
allow_image_generation: record.allow_image_generation,
|
||||
color: record.color.as_str().to_owned(),
|
||||
enabled: record.enabled,
|
||||
member_count: record.member_count,
|
||||
@@ -362,6 +371,7 @@ where
|
||||
.create(
|
||||
&auth.context().mutation_context(),
|
||||
CreateAccountGroup {
|
||||
allow_image_generation: request.allow_image_generation,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
fast_mode: request.fast_mode,
|
||||
@@ -390,6 +400,7 @@ where
|
||||
.update(
|
||||
&auth.context().mutation_context(),
|
||||
UpdateAccountGroup {
|
||||
allow_image_generation: request.allow_image_generation,
|
||||
id: group_id(request.id)?,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user