mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30aad4755 | ||
|
|
945f7912ac | ||
|
|
19038f264a | ||
|
|
71c3b03878 | ||
|
|
70d1557b55 | ||
|
|
b4d11694ae | ||
|
|
718b959fc1 | ||
|
|
11c076ef5a | ||
|
|
b37d790144 | ||
|
|
3a04e166c2 | ||
|
|
ec21d83415 | ||
|
|
fe78c230e5 | ||
|
|
d83d3eb803 | ||
|
|
faee3b2642 | ||
|
|
a5a844af16 | ||
|
|
d6bdb9d191 | ||
|
|
33aa161863 | ||
|
|
dc45aee5da | ||
|
|
b3fa5322f1 | ||
|
|
8fb42300c1 | ||
|
|
a1fc368474 | ||
|
|
9a1e90dd3d | ||
|
|
e7b0551f1a | ||
|
|
dde4a91974 | ||
|
|
bb859455f9 | ||
|
|
e06ca56f93 | ||
|
|
2a65b53b44 | ||
|
|
3915aa259b | ||
|
|
d310286e33 | ||
|
|
ea4e3059db | ||
|
|
3bf7030fba | ||
|
|
5d5965c6ca | ||
|
|
0710ed1f7c | ||
|
|
4634d8dc6d | ||
|
|
8a0759905e | ||
|
|
e830b2ef48 | ||
|
|
ae8da00459 | ||
|
|
7bbea42fba | ||
|
|
2e0669d462 | ||
|
|
2df9494e99 |
@@ -1,58 +1,49 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库开发与问题审查入口。用户要求“开发/实现这个功能”“直接改”“修复一下”“看下报错”“审查这个问题/代码”“自审当前改动”“调整页面”“更新文档”,或继续修改本仓库的 Rust、Vue、协议、存储、部署和 CI 时主动使用,无需点名技能。负责定位、方案、自审与验证;提交或审查 PR 另配合 cpr-github-pr。Use for implementation, debugging, code review and documentation in this repository, not generic advice for unrelated projects.
|
||||
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
## 加载边界
|
||||
|
||||
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
|
||||
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
|
||||
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
|
||||
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
|
||||
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
|
||||
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| 当前任务 | 必读的相关部分 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 开发独立网关插件 | [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [cpr-github-issue](../cpr-github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [cpr-release](../cpr-release/SKILL.md) |
|
||||
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
|
||||
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
|
||||
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
|
||||
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
|
||||
|
||||
## 前端任务
|
||||
## 参考仓库的优先级
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
|
||||
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
|
||||
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
|
||||
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
|
||||
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
|
||||
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
|
||||
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
|
||||
|
||||
## 后端任务
|
||||
## 执行与收尾
|
||||
|
||||
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
|
||||
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
|
||||
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
|
||||
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
|
||||
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
|
||||
|
||||
## 执行顺序
|
||||
## 文档硬约束
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定或评估方案。结合上表读取所需文档,先找可复用的同类实现,再按授权修改或审查所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 复核相对目标基线的完整差异及上下游:开发时清理本次引入的职责越界、重复逻辑、冗余状态和无依据的分支;仅审查时报告问题与依据。页面与后端分别按上述任务入口执行,不等待 PR 审查代做开发自审。
|
||||
6. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
7. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。用户要求提交或更新 PR 时,进入 `cpr-github-pr` 流程整理最终证据与正文。
|
||||
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
|
||||
|
||||
发布任务按 `cpr-release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
|
||||
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
|
||||
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 文档检查
|
||||
|
||||
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
|
||||
|
||||
## 写之前
|
||||
|
||||
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
|
||||
|
||||
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
|
||||
|
||||
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
|
||||
|
||||
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
|
||||
|
||||
## 逐段验收
|
||||
|
||||
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
|
||||
|
||||
| 段落回答的问题 | 处理 |
|
||||
| --- | --- |
|
||||
| 现在如何使用、配置或理解结果 | 保留当前事实与必要边界,放回所属章节 |
|
||||
| 谁负责状态、事务、资源和失败处理 | 放入所属架构章节,避免在 API 或部署文档复述实现 |
|
||||
| 这次做了什么、以前怎样、为什么这样修、跑过哪些测试 | 移入 PR、Issue 或任务报告,不以换成现在时的方式留在常规文档 |
|
||||
| 特定配置或数据状态下如何升级、恢复 | 写明适用条件、操作及限制,删除演变过程 |
|
||||
| 别处已完整说明的事实 | 合并或改为必要摘要与链接 |
|
||||
|
||||
脱离提交日期、Issue 和本次任务背景后,读者仍应能独立理解正文
|
||||
|
||||
业务中的请求历史、审计记录、旧数据兼容与回滚操作是当前功能,不能因含“历史”“旧”等词而删除
|
||||
|
||||
## 交付检查
|
||||
|
||||
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
|
||||
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
|
||||
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
|
||||
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
|
||||
|
||||
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
|
||||
@@ -0,0 +1,11 @@
|
||||
# 插件职责边界
|
||||
|
||||
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
|
||||
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
|
||||
|
||||
**宿主可以暴露底层能力,不能替插件完成业务**
|
||||
|
||||
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
|
||||
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
|
||||
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
|
||||
@@ -1,61 +1,62 @@
|
||||
---
|
||||
name: cpr-github-issue
|
||||
description: 整理、创建和补充 GitHub Issue。用户说“提/开/提交一个 Issue”“报个 bug 到仓库”“反馈给上游”“把问题或功能建议整理成 Issue”“补充这个 Issue”时主动使用;已有 Issue 反馈上下文中的“提交吧”“补上复现”也适用。处理缺陷、功能与协作规则反馈,区分草拟和发布。Use for drafting, filing and updating issues; standalone “审查问题/看下报错/修一下” belongs to cpr-dev-guide, not automatic Issue submission.
|
||||
description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功能和协作规则反馈;单独排障或修复不自动发布 Issue
|
||||
---
|
||||
|
||||
# 整理与提交 Issue
|
||||
# Issue 协作
|
||||
|
||||
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
|
||||
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
|
||||
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
|
||||
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
|
||||
## 目标与范围
|
||||
|
||||
## 确定目标和动作
|
||||
从用户 URL、remote 和上下文确认接收仓库,fork 场景区分自己的仓库与上游
|
||||
|
||||
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
草拟只准备正文,明确创建或提交才发布;已有授权直接执行,自动命中技能本身不授权远程写入
|
||||
|
||||
## 证据与去重
|
||||
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
|
||||
|
||||
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
|
||||
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
|
||||
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
|
||||
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
|
||||
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
|
||||
不因发现重复而擅自评论、重开或关闭已有 Issue。
|
||||
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
|
||||
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
|
||||
引用官方行为或提出实现方案时,按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 注明来源、版本与缺口;
|
||||
不把找到官方源码或证明根因作为提交症状反馈的前提。
|
||||
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
|
||||
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
|
||||
## 按需取材
|
||||
|
||||
## 编写正文
|
||||
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
|
||||
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
|
||||
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
|
||||
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
|
||||
|
||||
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
|
||||
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
|
||||
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
|
||||
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
|
||||
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
|
||||
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
|
||||
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
|
||||
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
|
||||
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
|
||||
## 正文与证据
|
||||
|
||||
## 提交与收尾
|
||||
标题说明症状或期望行为,区分发生问题的部署版本、实测结果、预期结果与根因假设
|
||||
|
||||
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
|
||||
功能和规则提案说明场景、缺少的能力及建议结果,无需硬套缺陷复现步骤
|
||||
|
||||
YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必填项、选项与确认项,CLI 不会代做表单校验
|
||||
|
||||
未知信息如实标明,影响理解或复现的关键事实先补齐,不提交原始 YAML、占位答案或未确认的勾选项
|
||||
|
||||
日志和附件仅含定位所需的脱敏片段,不公开凭据、完整配置或请求转储,不擅自修改代理、TUN、证书环境取证
|
||||
|
||||
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
|
||||
|
||||
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 Issue 回复时遵守以下约定:
|
||||
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
|
||||
|
||||
## 发布与收尾
|
||||
|
||||
使用可用连接器或 `gh`,不为提交反馈安装工具、修改仓库配置或启用服务
|
||||
|
||||
多行正文通过结构化字段或 UTF-8 文件传递,以下变量须先从已确认的目标与正文赋值:
|
||||
|
||||
```bash
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
|
||||
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
|
||||
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
发布前核对目标、必填内容、脱敏与重复记录,更新时保留无关原文;不擅自评论、重开或关闭重复 Issue
|
||||
|
||||
超时或结果不明确时先查询远端再重试,发布后回读确认并返回链接;仅草拟或发布失败时提供正文并注明未提交
|
||||
|
||||
@@ -1,121 +1,51 @@
|
||||
---
|
||||
name: cpr-github-pr
|
||||
description: 准备、提交、更新与审查 GitHub PR。用户说“提交 PR/提个 PR/发起拉取请求”“把改动放到这个 PR”“改下 PR 描述”“看下/review/审查这个 PR”“处理审查意见”“这个 PR 能合并吗”,或提供 /pull/ 链接、PR 编号并要求处理时主动使用;已有 PR 上下文中的“提交吧”“继续改”也适用。包含问题核实、自审、页面截图、AI 模型披露与验证。Use for pull request preparation, creation, updates and review; plain git commits or issue debugging without PR context do not create a PR.
|
||||
description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处理审查意见;普通 Git 提交和无 PR 上下文的排障不创建 PR
|
||||
---
|
||||
|
||||
# 提交、更新与审查 PR
|
||||
# PR 协作
|
||||
|
||||
## 先确定模式和仓库
|
||||
## 先确定范围
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
沿用当前会话的目标与授权,创建 PR 包含任务范围内的提交和推送,审查默认只读;二者都不包含合并
|
||||
|
||||
仅准备标题或正文不写远端,“能合并吗”按审查处理,发布评论、批准或请求修改须有对应授权
|
||||
|
||||
从 URL、remote 和上下文确认仓库,区分 fork 的推送端与 PR 接收端;存在无法消除的歧义时只补齐必要信息
|
||||
|
||||
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
|
||||
|
||||
## 按模式读取
|
||||
|
||||
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
|
||||
|
||||
| 当前动作 | 读取与执行 |
|
||||
| --- | --- |
|
||||
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
|
||||
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
|
||||
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
|
||||
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
|
||||
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
|
||||
| 只整理标题或描述 | 当前 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 |
|
||||
|
||||
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
|
||||
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
|
||||
## 回复口吻
|
||||
|
||||
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
|
||||
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
|
||||
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
|
||||
|
||||
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
|
||||
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
|
||||
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
|
||||
|
||||
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
|
||||
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
|
||||
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
|
||||
## 创建与审查的公共核对
|
||||
|
||||
## 创建和审查前的核对
|
||||
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
|
||||
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
|
||||
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
|
||||
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
|
||||
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
|
||||
|
||||
创建和审查都按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实问题,优先依据官方源码确定或评估方案。
|
||||
用户指定其他仓库时,结合其贡献规范与适用的官方实现,不套用本项目的具体业务合同。
|
||||
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
|
||||
|
||||
核对最新目标分支及同一场景相关的 open/closed PR 和 Issue,比较实际行为、触发条件与测试覆盖。
|
||||
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
|
||||
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
|
||||
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
|
||||
|
||||
## 页面证据与 AI 披露
|
||||
|
||||
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
|
||||
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
|
||||
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
|
||||
|
||||
## 创建或更新 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
```bash
|
||||
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
--title "$pr_title" --body-file "$pr_body_file"
|
||||
```
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
|
||||
## 审查 PR
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
|
||||
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
|
||||
返回链接或会话内结论,说明实际范围与缺口,不将检查通过称为维护者批准
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 创建或更新 PR
|
||||
|
||||
仅在创建、推送或更新 PR 时读取,先完成[入口](../SKILL.md)的范围判断与公共核对
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
```bash
|
||||
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
--title "$pr_title" --body-file "$pr_body_file"
|
||||
```
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 审查 PR
|
||||
|
||||
仅在审查 PR 时读取,执行[入口](../SKILL.md)的公共核对,默认只在当前会话报告
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
|
||||
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
|
||||
@@ -1,85 +1,40 @@
|
||||
---
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查和打包 Codex Proxy RS 网关插件。用户说“写/做一个网关插件”“给插件加能力或页面”“改 plugin.json”“接入公开 SDK”“插件不生效/接入报错”“打包插件”时主动使用,覆盖能力选择、SDK 处理器、管理页面和安装包验证。Use for gateway plugin authoring and debugging; host Runtime/installer changes use cpr-dev-guide, not this workflow, and OpenAI .codex-plugin or Vue plugins are outside its scope.
|
||||
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
|
||||
---
|
||||
|
||||
# cpr-plugin-dev
|
||||
# 网关插件开发
|
||||
|
||||
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
|
||||
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
|
||||
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
|
||||
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
|
||||
|
||||
## 先建立正确的模型
|
||||
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
|
||||
|
||||
```text
|
||||
选择能力 → 作者清单 + SDK 处理器 → 构建二进制/页面 → cpr-plugin package
|
||||
↓
|
||||
实际业务验证 ← 查看权限并安装、自动准备配置 ← tar.gz + sha256
|
||||
```
|
||||
## 范围与合同
|
||||
|
||||
- `contributes` 声明提供什么,`permissions` 声明访问域;安装接受记录派生授权,`bindings` 只决定功能挂载和请求匹配
|
||||
- 安装包、运行配置和已启用功能不是同一件事;安装成功不能代替功能验证
|
||||
- `trustedProcess` 与宿主使用相同系统身份,不是操作系统沙箱
|
||||
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
|
||||
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
|
||||
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
|
||||
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
|
||||
|
||||
## 开始工作
|
||||
## 按当前步骤读取
|
||||
|
||||
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
|
||||
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
|
||||
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
|
||||
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
## 按任务选择能力
|
||||
|
||||
| 用户要做什么 | 优先使用 | 需要读取的合同 |
|
||||
| --- | --- | --- |
|
||||
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
|
||||
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
|
||||
| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 |
|
||||
| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 |
|
||||
| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next |
|
||||
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
|
||||
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
|
||||
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
| 提供终端命令 | `command_line` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
|
||||
只声明实现了的能力。Provider 固定为宿主内置的 OpenAI 与 xAI;插件只能扩展已开放的处理与管理能力。
|
||||
网络、账号、模型调用和私有状态属于宿主回调与授权,不重复声明为顶层能力。
|
||||
|
||||
## 实现时的关键选择
|
||||
|
||||
### 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
|
||||
### 中间件与宿主服务
|
||||
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
|
||||
### 只有需要页面时才创建前端
|
||||
|
||||
- 沿用已有插件页面与 [管理端主题](../../../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 实例
|
||||
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
|
||||
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
|
||||
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
|
||||
| 当前工作 | 入口 |
|
||||
| --- | --- |
|
||||
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
|
||||
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
|
||||
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
|
||||
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
|
||||
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
|
||||
|
||||
## 交付
|
||||
|
||||
按[构建与验证](references/development.md#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。
|
||||
开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。
|
||||
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
|
||||
|
||||
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
|
||||
|
||||
PR 任务才使用 [cpr-github-pr](../cpr-github-pr/SKILL.md),文档检查按开发入口收尾执行
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 构建、联调与验证
|
||||
|
||||
仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册。
|
||||
仅在准备工程、构建或验证插件时读取对应章节,分发规则只在准备分发时读取
|
||||
这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册
|
||||
|
||||
## 工程与依赖
|
||||
|
||||
@@ -11,11 +12,11 @@
|
||||
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
|
||||
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
|
||||
本地源码联调可以使用 Cargo 路径依赖和 pnpm 的包级链接/override;路径必须真实可解析,并记录在消费方依赖配置中。
|
||||
这不等于允许从业务源码跨仓 `import .../src/...`,也不能证明第三方能独立安装。
|
||||
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
|
||||
其他工程按其自身联调配置临时使用包级链接或 override,路径须真实可解析,不从业务源码跨仓 `import .../src/...`
|
||||
|
||||
对外交付前核实 UI/SDK 的可用版本和安装来源:仅引用确实存在的发布版本,或包含所需合同的固定 Git commit;
|
||||
不要把浮动 `main` 当版本,也不要自动发布依赖以补齐条件。本地 override 尚未移除时,明确说明独立安装缺口。
|
||||
@@ -23,7 +24,7 @@
|
||||
|
||||
## 复用示例
|
||||
|
||||
找到 `codex-proxy-plugins` 后先读根目录与 `examples/workbench/README.md`。
|
||||
找到 `codex-proxy-plugins` 后,只读根目录约定与 `examples/workbench/README.md` 中当前能力或构建所需的章节
|
||||
该仓库使用 `bash scripts/package` 构建页面、Rust 二进制并打包;前端依赖和工具配置位于示例的 `frontend/`,根目录不维护 Node 工程。执行前核对脚本适用的插件路径和目标平台。
|
||||
|
||||
| 任务 | 示例入口 |
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 插件页面
|
||||
|
||||
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.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 实例
|
||||
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
|
||||
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
|
||||
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
|
||||
@@ -0,0 +1,24 @@
|
||||
# 清单与处理器实现
|
||||
|
||||
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
|
||||
|
||||
## 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
|
||||
## 中间件与宿主服务
|
||||
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
@@ -1,110 +1,38 @@
|
||||
---
|
||||
name: cpr-release
|
||||
description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个版本/正式发版”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“发布 alpha/beta/rc/exp/实验版”“打发行 tag”“重试失败发版”时主动使用;已有发版上下文中的“发布吧”“继续发布”也适用。Use for release planning, notes, publishing and recovery; deployment/restarts and ordinary Git tag cleanup are not release requests.
|
||||
description: Codex Proxy RS 的版本规划、发布说明、正式或预发行发布与失败恢复;部署重启和普通 Git tag 清理不属于发版
|
||||
---
|
||||
|
||||
# 发版
|
||||
|
||||
## 识别任务范围
|
||||
## 范围与授权
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
沿用用户确定的版本、范围与授权,准备版本或询问能否发布不执行远程写入;明确发布时完成已授权步骤,无需重复确认
|
||||
|
||||
仅打 tag 不自动扩展为完整发版,发布也不包含运行实例升级或合并实验功能
|
||||
|
||||
稳定版与 alpha、beta、rc 从 `main` 发布,exp 从对应 `experimental/<用途>` 分支发布
|
||||
|
||||
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
|
||||
|
||||
## 按任务读取
|
||||
|
||||
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
|
||||
|
||||
| 任务 | 读取范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
|
||||
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
|
||||
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
|
||||
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
|
||||
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
|
||||
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
|
||||
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
|
||||
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
|
||||
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
|
||||
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
|
||||
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
|
||||
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
|
||||
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
需要修复本仓库代码、工作流或文档时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备或更新相关 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md),不以发版步骤替代开发自审。
|
||||
## 必须保持的边界
|
||||
|
||||
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
|
||||
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
|
||||
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
|
||||
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
|
||||
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
|
||||
|
||||
## 读取当前流程
|
||||
|
||||
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
|
||||
|
||||
| 事实 | 权威入口 |
|
||||
| --- | --- |
|
||||
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
|
||||
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
|
||||
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
|
||||
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
|
||||
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
|
||||
|
||||
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
|
||||
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
|
||||
|
||||
## 发行通道核对
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
|
||||
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
|
||||
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
|
||||
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
|
||||
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
|
||||
|
||||
## 准备版本与说明
|
||||
|
||||
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
|
||||
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
|
||||
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
|
||||
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
|
||||
不能只用当前版本文件或最近几条提交判断“本次更新”。
|
||||
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
|
||||
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
|
||||
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
|
||||
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
|
||||
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
|
||||
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
|
||||
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
|
||||
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
|
||||
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
|
||||
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
|
||||
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
|
||||
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
|
||||
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
|
||||
|
||||
## 执行发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
|
||||
```bash
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 实验版发布
|
||||
|
||||
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
|
||||
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
|
||||
按当前任务只读版本、发布能力、文案或验收章节,不因准备实验说明而读取执行流程
|
||||
适用于独立于主版本维护、供用户主动选择体验的实验功能,入口使用仓库的 `release/publish`,构建复用目标实验分支的工作流与平台清单
|
||||
|
||||
## 分支与版本
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
不为统一名称移动 tag 或替换原有产物。
|
||||
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
|
||||
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
|
||||
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
|
||||
展示编号 `M` 独立于实际 tag 的 `exp.N`,所有同基线、同用途的已有发行均计入,不随 tag 命名格式重置;同一发行重试沿用已确定编号。
|
||||
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
|
||||
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
|
||||
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
|
||||
发布说明首行匹配实际 tag,关联 Issue 和试验目的写在正文中,已有 Release 不因新发行而改名
|
||||
|
||||
## 先核对发布能力
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
|
||||
## 执行与验收
|
||||
|
||||
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
|
||||
按[版本与说明准备](preparation.md)的对应章节准备后,再按[执行发版](operations.md#执行发版)在实验分支调用入口
|
||||
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
|
||||
|
||||
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
按[发行通道核对](operations.md#发行通道核对)确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
|
||||
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
|
||||
|
||||
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
|
||||
失败时按[恢复与验收](operations.md#恢复与验收)从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
|
||||
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 执行、恢复与验收
|
||||
|
||||
只在实际发布、恢复失败步骤或验收发行时读取对应章节,先核对[入口](../SKILL.md)中的范围与授权
|
||||
|
||||
## 发行通道核对
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
|
||||
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
|
||||
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
|
||||
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
|
||||
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
|
||||
|
||||
## 执行发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
|
||||
```bash
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 版本与说明准备
|
||||
|
||||
只在规划版本、编写发布说明或准备发行时读取对应部分,不因准备文案加载执行流程
|
||||
|
||||
## 基线与版本
|
||||
|
||||
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
|
||||
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
|
||||
|
||||
## 发布说明
|
||||
|
||||
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
|
||||
|
||||
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
|
||||
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
|
||||
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
|
||||
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
|
||||
|
||||
## 发行准备检查
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
|
||||
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
|
||||
|
||||
- 对照同一大版本的上一正式版和本轮预发行/实验线的已发布版本,验证迁移未改写及升级路径
|
||||
- 区分未发布迁移、已执行迁移的源码部署实例与 CI 冻结基线,未发版不代表未执行,也不授权放宽冻结规则
|
||||
- 兼容性与 CI 检查分别报告,跨大版本不默认支持在线升级
|
||||
|
||||
正式执行时再读取[通道核对](operations.md#发行通道核对)与[执行发版](operations.md#执行发版),触发工作流不等于发布成功
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Build frontend
|
||||
description: Validate and build the frontend, optionally uploading release assets
|
||||
inputs:
|
||||
upload-artifact:
|
||||
description: Upload frontend-dist for downstream release jobs
|
||||
default: 'false'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
shell: bash
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Frontend format
|
||||
shell: bash
|
||||
run: pnpm --dir frontend format:check
|
||||
|
||||
- name: Frontend build
|
||||
shell: bash
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload-artifact == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
|
||||
ARG CACHE_STAMP
|
||||
RUN --mount=type=bind,target=/cache \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& cp -a /cache/registry/. /usr/local/cargo/registry/ \
|
||||
&& cp -a /cache/git/. /usr/local/cargo/git/ \
|
||||
&& cp -a /cache/target/. /app/backend/target/
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS collect
|
||||
ARG CACHE_STAMP
|
||||
# 一次导出三个 mount,避免逐目录创建镜像、载入 Docker 再复制文件。
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& mkdir -p /cache/registry /cache/git /cache/target \
|
||||
&& cp -a /usr/local/cargo/registry/. /cache/registry/ \
|
||||
&& cp -a /usr/local/cargo/git/. /cache/git/ \
|
||||
&& cp -a /app/backend/target/. /cache/target/
|
||||
|
||||
FROM scratch AS snapshot
|
||||
COPY --from=collect /cache/ /
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Cargo cache mounts
|
||||
description: Restore or export a validated Cargo snapshot for a BuildKit builder
|
||||
inputs:
|
||||
operation:
|
||||
description: Cache operation, restore or snapshot
|
||||
required: true
|
||||
builder:
|
||||
description: Buildx builder name
|
||||
required: true
|
||||
path:
|
||||
description: Cargo snapshot directory on the runner
|
||||
required: true
|
||||
architecture:
|
||||
description: Cargo target directory architecture, amd64 or arm64
|
||||
required: true
|
||||
outputs:
|
||||
usable:
|
||||
description: Whether the restored or exported snapshot contains Cargo dependencies
|
||||
value: ${{ steps.cache.outputs.usable }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: cache
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
BUILDER: ${{ inputs.builder }}
|
||||
CACHE_PATH: ${{ inputs.path }}
|
||||
ARCHITECTURE: ${{ inputs.architecture }}
|
||||
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
|
||||
exit 2
|
||||
fi
|
||||
operation="$1"
|
||||
builder="$2"
|
||||
directory="$(realpath -m "$3")"
|
||||
architecture="$4"
|
||||
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
|
||||
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
|
||||
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
|
||||
|
||||
usable() {
|
||||
local root="$1" path crates=false libraries=false
|
||||
# 目录或 buildstamp 存在不代表编译发生过,必须同时有下载包与目标依赖。
|
||||
for path in "$root"/registry/cache/*/*.crate; do
|
||||
if [ -f "$path" ] && [ -s "$path" ]; then crates=true; break; fi
|
||||
done
|
||||
for path in "$root"/target/"$architecture"/release/deps/*.rlib; do
|
||||
if [ -f "$path" ] && [ -s "$path" ]; then libraries=true; break; fi
|
||||
done
|
||||
[ "$crates" = true ] && [ "$libraries" = true ]
|
||||
}
|
||||
|
||||
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
|
||||
# mount 的内容不参与层指纹,用时间戳重跑复制;--no-cache 会重置 cache mount。
|
||||
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
|
||||
valid=false
|
||||
if [ "$operation" = restore ]; then
|
||||
if usable "$directory"; then
|
||||
mkdir -p "$directory"/{registry,git,target}
|
||||
"${command[@]}" --output type=cacheonly "$directory"
|
||||
valid=true
|
||||
else
|
||||
echo "No usable Cargo snapshot; cached image layers may still be available"
|
||||
fi
|
||||
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"
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
valid=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Cargo snapshot for $architecture: usable=$valid"
|
||||
if [ "$valid" = true ]; then du -sh "$directory"; fi
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
|
||||
@@ -68,7 +68,9 @@ updates:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /deploy
|
||||
directories:
|
||||
- /deploy
|
||||
- /.github/actions/cargo-cache
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Reusable Container
|
||||
name: Container
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -30,30 +30,25 @@ jobs:
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Cache Cargo mounts
|
||||
- name: Restore Cargo snapshot
|
||||
id: cargo-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
|
||||
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
|
||||
key: container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
|
||||
restore-keys: |
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-
|
||||
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
|
||||
# Extraction runs before actions/cache saves its directories in post steps.
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||
id: cargo-restore
|
||||
uses: ./.github/actions/cargo-cache
|
||||
with:
|
||||
operation: restore
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
scratch-dir: ${{ runner.temp }}/container-cache-dance
|
||||
cache-map: |
|
||||
{
|
||||
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
|
||||
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
|
||||
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
|
||||
}
|
||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
@@ -67,7 +62,7 @@ jobs:
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
|
||||
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
@@ -75,3 +70,23 @@ jobs:
|
||||
image: ${{ env.CPR_IMAGE }}
|
||||
platform: linux/amd64
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
|
||||
|
||||
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
|
||||
- name: Export Cargo snapshot
|
||||
if: >-
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
|
||||
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
|
||||
id: cargo-snapshot
|
||||
uses: ./.github/actions/cargo-cache
|
||||
with:
|
||||
operation: snapshot
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
|
||||
- name: Save Cargo snapshot
|
||||
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Reusable Quality
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -7,11 +7,6 @@ on:
|
||||
description: Exact commit SHA to validate
|
||||
required: true
|
||||
type: string
|
||||
upload_frontend:
|
||||
description: Upload the built frontend for release jobs
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_backend:
|
||||
description: Run backend quality checks
|
||||
required: false
|
||||
@@ -111,6 +106,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Rust format
|
||||
working-directory: backend
|
||||
@@ -120,6 +116,19 @@ jobs:
|
||||
working-directory: backend
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
|
||||
- name: Build Rust tests once
|
||||
working-directory: backend
|
||||
run: |
|
||||
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
|
||||
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
|
||||
> "$RUNNER_TEMP/nextest-binaries.json"
|
||||
|
||||
- name: Configure PostgreSQL and Redis test authentication
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -144,7 +153,30 @@ jobs:
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: cargo test --test main --locked
|
||||
run: |
|
||||
cargo nextest run \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--no-tests fail
|
||||
|
||||
- name: Store tests with Redis ACL authentication
|
||||
working-directory: backend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
redis_acl_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$redis_acl_password"
|
||||
docker exec "${{ job.services.redis.id }}" redis-cli -e --no-auth-warning \
|
||||
-u "$CPR_TEST_REDIS_URL" --user default ACL SETUSER cpr_acl reset on \
|
||||
">$redis_acl_password" '~*' '&*' '+@all' >/dev/null
|
||||
docker exec --env REDISCLI_AUTH="$redis_acl_password" "${{ job.services.redis.id }}" \
|
||||
redis-cli -e --user cpr_acl ACL SETUSER default off >/dev/null
|
||||
export CPR_TEST_REDIS_URL="redis://cpr_acl:${redis_acl_password}@127.0.0.1:6379"
|
||||
# 从同一份构建清单选择 Redis 测试,保留 workspace 特性图且不重复运行 PostgreSQL
|
||||
cargo nextest run \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
@@ -158,35 +190,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Frontend format
|
||||
run: pnpm --dir frontend format:check
|
||||
|
||||
- name: Frontend build
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload_frontend
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Validate and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
workflow-lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -206,5 +211,8 @@ jobs:
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
- name: Check workflow script syntax
|
||||
run: |
|
||||
bash -n release/publish
|
||||
shopt -s globstar nullglob
|
||||
for script in .github/**/*.sh; do bash -n "$script"; done
|
||||
|
||||
@@ -38,14 +38,21 @@ jobs:
|
||||
id: filter
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
|
||||
predicate-quantifier: some-with-excludes
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
- 'release/version.yaml'
|
||||
- '.github/workflows/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/build-frontend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
source:
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
@@ -53,15 +60,18 @@ jobs:
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'deploy/**'
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/**'
|
||||
- '.github/actions/verify-container/**'
|
||||
- '.github/actions/cargo-cache/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
|
||||
quality:
|
||||
needs: changes
|
||||
|
||||
@@ -165,12 +165,64 @@ jobs:
|
||||
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
|
||||
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
|
||||
host = host.merge("schema_version" => 1, "permissions" => [])
|
||||
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
||||
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: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
quality:
|
||||
needs: [release-info]
|
||||
uses: ./.github/workflows/_quality.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
upload_frontend: true
|
||||
run_frontend: false
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Validate and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
upload-artifact: 'true'
|
||||
|
||||
build-docker-binary:
|
||||
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
@@ -194,10 +246,10 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 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') }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -260,10 +312,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
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') }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -287,7 +338,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, quality, build-docker-binary, build-binary]
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -313,12 +364,17 @@ jobs:
|
||||
pattern: codex-proxy-rs-*
|
||||
path: dist/binaries
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -393,21 +449,8 @@ jobs:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
release_packages.append((platform, package_root, archive_path))
|
||||
|
||||
plugin_host_path = pathlib.Path("backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json")
|
||||
plugin_host = json.loads(plugin_host_path.read_text())
|
||||
if plugin_host.get("schema_version") != 1:
|
||||
raise SystemExit(f"Invalid plugin host compatibility declaration: {plugin_host_path}")
|
||||
|
||||
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
plugin_manifest_path.write_text(json.dumps({
|
||||
"schema_version": 1,
|
||||
"sealed": True,
|
||||
"gateway_version": version,
|
||||
"gateway_git_sha": os.environ["GIT_SHA"],
|
||||
"plugin_host": plugin_host,
|
||||
# 示例独立发版;当前不内置插件,仍保留更新与回滚所需的封口清单。
|
||||
"plugins": [],
|
||||
}, indent=2, sort_keys=True) + "\n")
|
||||
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
|
||||
assets.append(plugin_manifest_path)
|
||||
|
||||
# 保留运行时清单目录合同,宿主包不再携带教学示例。
|
||||
@@ -440,10 +483,12 @@ jobs:
|
||||
path: dist/release
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
|
||||
compression-level: 0
|
||||
|
||||
build-docker-image:
|
||||
name: build-docker-image (${{ matrix.dockerPlatform }})
|
||||
needs: [release-info, quality, build-docker-binary, package-assets]
|
||||
needs: [release-info, frontend, build-docker-binary]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
|
||||
@@ -476,11 +521,11 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
- name: Download sealed release assets
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release-assets
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
|
||||
- name: Prepare Docker prebuilt assets
|
||||
shell: bash
|
||||
@@ -508,7 +553,7 @@ jobs:
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
|
||||
release_dir = pathlib.Path("dist/release-assets")
|
||||
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:
|
||||
@@ -601,7 +646,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: [release-info, package-assets, build-docker-image]
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Agent Instructions Guard
|
||||
name: Rules Guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: agents-md-guard-${{ github.event.pull_request.number }}
|
||||
group: rules-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
- name: Enforce agent instructions policy
|
||||
- name: Check protected files
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -33,12 +33,15 @@ jobs:
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
const isInstructions = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
|
||||
const isProtectedRule = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|
||||
|| path === 'CONTRIBUTING.md'
|
||||
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|
||||
|| path === '.github' || path.startsWith('.github/'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
|
||||
core.info('No agent instruction changes detected.');
|
||||
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
|
||||
core.info('No protected contribution rule changes detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,27 +52,31 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// 通知标记保持稳定,以便复用机器人已有评论,避免重复提醒
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
|
||||
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
'麻烦先撤销这些变更,再重新打开 PR;如果希望调整协作规则,可以先提个 Issue 说明原因和建议,交由维护者处理,感谢理解',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
|
||||
const notification = comments.find(comment => comment.user?.login === 'github-actions[bot]'
|
||||
&& comment.body?.includes(marker));
|
||||
if (!notified) {
|
||||
if (!notification) {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
} else if (notification.body !== body) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: notification.id, body });
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Unable to post policy explanation: ${error.message}`);
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({ ...params, state: 'closed' });
|
||||
core.setFailed('Pull requests must not change AGENTS.md.');
|
||||
core.setFailed('Pull requests must not change protected contribution rules (AGENTS.md, CONTRIBUTING.md, .agents/skills/, .github/).');
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Security Scan
|
||||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -27,20 +27,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
|
||||
- name: Run Cargo audits
|
||||
run: |
|
||||
cargo audit --file backend/Cargo.lock
|
||||
cargo-audit audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -5,15 +5,21 @@
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
|
||||
| 提交/提个 PR、准备或更新 PR、提交到已有 PR、看下 PR、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/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 |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入。
|
||||
任务进入新阶段时补载对应技能,不能只读取文件而跳过其中的问题核实、自审、验证与交付步骤。协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
|
||||
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
@@ -28,11 +34,12 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
|
||||
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
@@ -43,7 +50,7 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
### 审查范围
|
||||
|
||||
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
|
||||
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
|
||||
- 按[审查标准](CONTRIBUTING.md#审查标准)检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称
|
||||
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
|
||||
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
|
||||
|
||||
|
||||
+95
-68
@@ -1,47 +1,47 @@
|
||||
# 贡献与审查
|
||||
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理
|
||||
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制
|
||||
|
||||
## 交流与反馈
|
||||
|
||||
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
|
||||
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
|
||||
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案
|
||||
|
||||
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
|
||||
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
|
||||
围绕问题友善交流,欢迎不同使用场景和方案观点。
|
||||
围绕问题友善交流,欢迎不同使用场景和方案观点
|
||||
|
||||
### 提交 Issue
|
||||
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
1. **准备变更**:按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍
|
||||
2. **发起 PR**:目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因
|
||||
3. **自动检查与初审**:CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验
|
||||
4. **修改与合并**:更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档
|
||||
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求
|
||||
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
|
||||
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
@@ -51,26 +51,33 @@ AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;
|
||||
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
|
||||
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查
|
||||
|
||||
### AI 使用披露
|
||||
|
||||
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
|
||||
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
|
||||
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
|
||||
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
|
||||
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
|
||||
- 根目录及任意子目录的 `AGENTS.md`
|
||||
- 根目录的 `CONTRIBUTING.md`
|
||||
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
|
||||
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
|
||||
|
||||
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
|
||||
|
||||
## 审查标准
|
||||
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告
|
||||
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证
|
||||
|
||||
| 维度 | 检查重点 |
|
||||
| --- | --- |
|
||||
@@ -86,59 +93,81 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
审查意见区分三类:
|
||||
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷
|
||||
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则
|
||||
|
||||
## 项目约定
|
||||
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
**文档只描述当前有效状态,不记录变更历史**,在所属章节就地修订,不追加“本次新增”“此前行为”或修复经过
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
只有用法、合同、职责、约束或读者需要执行的操作发生变化时才更新,内部重构和界面微调不默认附带文档改动
|
||||
|
||||
### 内容归属
|
||||
|
||||
| 文档 | 回答的问题 | 详略边界 |
|
||||
| --- | --- | --- |
|
||||
| [README](README.md) | 项目能做什么,如何开始 | 简短介绍、最短可用路径和文档导航 |
|
||||
| [部署与运维](deploy/README.md) | 如何安装、接入、排障和恢复 | 前置条件、配置、操作步骤、结果判断与风险 |
|
||||
| [开发与源码联调](docs/development.md) | 如何准备环境、运行源码和联调独立仓库 | 可执行命令、目录关系和依赖维护 |
|
||||
| [API 参考](docs/api.md) | 调用者如何正确请求和解释结果 | 鉴权、字段、默认值、错误、限制与幂等语义,不展开内部实现或界面布局 |
|
||||
| [系统架构](docs/architecture.md) | 业务事实归谁,调用如何协作 | 模块边界、状态所有权、生命周期和不变量,不重复字段清单 |
|
||||
| [管理端主题](docs/theme.md) | 界面如何保持一致,主题如何扩展 | 文案与视觉约束、Token 合同、实现入口和验收范围 |
|
||||
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
|
||||
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
|
||||
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
|
||||
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
|
||||
|
||||
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
|
||||
- 改动原因、前后对比、排查经过和验证结果放在 PR、Issue 或任务报告;`release/notes.md` 是发版时使用的发布说明,不作为常规文档的历史章节,也不随普通开发任务自动追加
|
||||
- 仍影响升级、恢复或兼容使用的内容,写成明确的适用条件与操作,保留必要限制,不叙述演变过程
|
||||
|
||||
### 层次与行文
|
||||
|
||||
- 按读者任务组织章节,先给入口和操作,再解释限制;长篇参考文档提供导航,独立主题使用小标题
|
||||
- 快速开始只保留走通流程的必要步骤;API、配置、恢复和权限等容易误用的内容写清输入、默认值、边界与失败处理
|
||||
- 短说明直接成段,并列信息用列表,可比较的字段或职责用表格,避免一段堆叠多个主题
|
||||
- 标题、表格单元格、短列表项和独立短段通常不加句号;长段优先拆分,确需区分完整句意时保留句号,不机械替换成逗号或分号
|
||||
- 命令、URL、版本号、字段名和原始输出保持准确,不为统一标点改写代码示例
|
||||
|
||||
审查时逐段核对:是否属于当前状态、是否回答读者问题、是否放在正确文档、是否重复、脱离变更背景能否独立理解
|
||||
|
||||
没有文档改动本身不构成验证缺口,判断依据是当前说明是否仍然准确、完整
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
|
||||
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令
|
||||
|
||||
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
|
||||
|
||||
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
|
||||
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
|
||||
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
|
||||
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
|
||||
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
|
||||
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件
|
||||
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面
|
||||
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件
|
||||
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果
|
||||
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
@@ -148,15 +177,9 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。普通开发与生产构建都使用 UI 编译产物,源码联调通过 `@codex-proxy/ui/vite` 显式开启。
|
||||
环境准备、依赖安装和服务启动见 [开发与源码联调](docs/development.md),联合修改组件库或插件时使用其中的子模块入口
|
||||
|
||||
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级依赖时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
|
||||
|
||||
```bash
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
```
|
||||
|
||||
需要联合修改组件库或插件时,使用 [源码联调](docs/development.md) 中的子模块与开发入口;联调只临时替换解析路径,不改写正式依赖或锁文件。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
|
||||
正式依赖固定到标签或提交,联调只临时替换解析路径,不改写正式依赖或锁文件
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
@@ -166,16 +189,20 @@ pnpm --dir frontend build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)
|
||||
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security.yml)
|
||||
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
|
||||
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
|
||||
|
||||
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
|
||||
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
|
||||
|
||||
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置
|
||||
|
||||
@@ -30,14 +30,17 @@
|
||||
| 账号 | `admin@cpr.local` |
|
||||
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
|
||||
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据
|
||||
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
|
||||
@@ -48,7 +51,9 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
```
|
||||
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
自动生成密码、设置目录权限并启动服务
|
||||
|
||||
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
@@ -65,7 +70,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
重复运行时请使用同一安装目录,检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级
|
||||
|
||||
### 手动安装
|
||||
@@ -74,9 +79,9 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
|
||||
### 登录管理端
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
|
||||
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
|
||||
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
@@ -89,8 +94,9 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息:
|
||||
|
||||
@@ -114,7 +120,8 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+96
-102
@@ -242,9 +242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.9.4"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd"
|
||||
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -258,10 +258,8 @@ dependencies = [
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tracing",
|
||||
@@ -270,9 +268,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.148.0"
|
||||
version = "1.149.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a61c1db3987ab6c8740fb87f248864687d50549401115244a84f02c3047b2d5"
|
||||
checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -294,9 +292,8 @@ dependencies = [
|
||||
"fastrand",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"lru",
|
||||
"percent-encoding",
|
||||
"regex-lite",
|
||||
@@ -307,9 +304,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.5.3"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70"
|
||||
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
@@ -320,8 +317,7 @@ dependencies = [
|
||||
"form_urlencoded",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.0",
|
||||
"time",
|
||||
@@ -350,8 +346,8 @@ dependencies = [
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
@@ -384,8 +380,8 @@ dependencies = [
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -403,7 +399,7 @@ dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
@@ -439,9 +435,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.14.2"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484"
|
||||
checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -452,10 +448,8 @@ dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -465,16 +459,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.16.2"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8"
|
||||
checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -500,23 +493,21 @@ checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.4"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd"
|
||||
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"http 1.5.0",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"num-integer",
|
||||
@@ -567,8 +558,8 @@ dependencies = [
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -599,8 +590,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
@@ -935,9 +926,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.15.25"
|
||||
version = "0.15.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139"
|
||||
checksum = "790374c727a4cb20877dd2cc99ce7011cbe03b0b8df083acccd37df0d161304d"
|
||||
dependencies = [
|
||||
"pathdiff",
|
||||
"serde_core",
|
||||
@@ -1683,6 +1674,8 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"heck",
|
||||
"hex",
|
||||
"prettyplease",
|
||||
"quote",
|
||||
"rand_core 0.6.4",
|
||||
"secrecy",
|
||||
"semver",
|
||||
@@ -1690,6 +1683,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1712,6 +1706,9 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"headers",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1737,6 +1734,9 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1763,7 +1763,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
@@ -1785,6 +1785,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -1809,6 +1810,9 @@ dependencies = [
|
||||
"gateway-protocol",
|
||||
"gateway-store",
|
||||
"hex",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"image",
|
||||
"jsonschema",
|
||||
"roxmltree",
|
||||
@@ -1822,6 +1826,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -1843,6 +1848,7 @@ dependencies = [
|
||||
name = "gateway-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -1971,7 +1977,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
@@ -2012,14 +2018,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||
checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1 0.10.7",
|
||||
@@ -2031,7 +2037,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2080,17 +2086,6 @@ dependencies = [
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -2101,17 +2096,6 @@ dependencies = [
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
@@ -2119,7 +2103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2130,8 +2114,8 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -2175,8 +2159,8 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
@@ -2192,7 +2176,7 @@ version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
@@ -2229,8 +2213,8 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
@@ -2450,9 +2434,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21"
|
||||
checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2478,18 +2462,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181"
|
||||
checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0"
|
||||
checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -3175,6 +3159,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primefield"
|
||||
version = "0.14.0"
|
||||
@@ -3478,9 +3472,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -3534,9 +3528,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c"
|
||||
checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
@@ -3598,8 +3592,8 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
@@ -4486,18 +4480,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4692,8 +4686,8 @@ dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
@@ -4711,8 +4705,8 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.0.1",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
@@ -4843,7 +4837,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"flate2",
|
||||
"headers",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
@@ -4862,7 +4856,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
@@ -5361,7 +5355,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
||||
@@ -35,6 +35,20 @@ needless_collect = { level = "deny", priority = 7 }
|
||||
[profile.dev]
|
||||
debug = true
|
||||
|
||||
# 测试保留真实密码参数和压缩数据量,优化热点依赖以减少 fixture 的计算开销。
|
||||
# test 继承 dev;workspace 代码仍保留默认调试与溢出检查。
|
||||
[profile.dev.package.argon2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."sha2@0.11.0"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."miniz_oxide@0.9.1"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
strip = "symbols"
|
||||
|
||||
@@ -202,6 +202,23 @@ async fn launch(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
admin_providers,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::service::Registry::new({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
});
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
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);
|
||||
or_shutdown!(plugin_runtime, store.start_command_line_writes());
|
||||
let result = commands
|
||||
@@ -277,6 +294,14 @@ async fn launch(
|
||||
client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
service_middleware: {
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
},
|
||||
providers: admin_providers,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
@@ -330,10 +355,30 @@ async fn launch(
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)
|
||||
);
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.resolve(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(gateway_host::retention::worker(store.retention())?);
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
|
||||
@@ -76,7 +76,12 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
|
||||
"concurrency.rs" => Some(&["error"]),
|
||||
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
|
||||
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
|
||||
path if path.starts_with("policy/") => {
|
||||
Some(&["account", "identity", "policy", "validation"])
|
||||
}
|
||||
"settings/values.rs" | "settings/compiled.rs" => {
|
||||
Some(&["account", "concurrency", "identity", "metering", "policy"])
|
||||
}
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
_ => None,
|
||||
};
|
||||
@@ -205,6 +210,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
"plugin_distribution",
|
||||
"process",
|
||||
"proxy_probe",
|
||||
"retention",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
@@ -378,7 +384,9 @@ fn workspace_modules_follow_conventional_file_layout() {
|
||||
let targets = test_source_roots();
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
let src = member_root.join("src");
|
||||
assert_module_tree(&src, &["lib.rs", "main.rs"]);
|
||||
assert_directory_modules_have_children(&src, &["lib.rs", "main.rs"]);
|
||||
|
||||
let tests = member_root.join("tests");
|
||||
if tests.is_dir() {
|
||||
@@ -391,6 +399,29 @@ fn workspace_modules_follow_conventional_file_layout() {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_directory_modules_have_children(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
|| relative.file_name().and_then(|value| value.to_str()) != Some("mod.rs")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let directory = relative.parent().expect("mod.rs parent");
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|candidate| candidate != relative && candidate.starts_with(directory)),
|
||||
"{} is a leaf module and must use {}",
|
||||
root.join(relative).display(),
|
||||
root.join(directory).with_extension("rs").display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModuleLayout {
|
||||
File,
|
||||
@@ -498,6 +529,8 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
|
||||
|
||||
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
|
||||
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
@@ -560,7 +593,14 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
};
|
||||
let declaration_count = declaration_parents
|
||||
.iter()
|
||||
.map(|path| external_module_declaration_count(path, module_name))
|
||||
.map(|path| {
|
||||
declarations
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| external_module_declarations(path))
|
||||
.get(module_name)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
declaration_count,
|
||||
@@ -571,23 +611,21 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
|
||||
fn external_module_declarations(path: &Path) -> BTreeMap<String, usize> {
|
||||
if !path.is_file() {
|
||||
return 0;
|
||||
return BTreeMap::new();
|
||||
}
|
||||
let source = fs::read_to_string(path).expect("read parent module source");
|
||||
let syntax = syn::parse_file(&source).expect("parse parent module source");
|
||||
syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::Mod(module)
|
||||
if module.content.is_none() && module.ident == module_name
|
||||
)
|
||||
})
|
||||
.count()
|
||||
let mut declarations = BTreeMap::new();
|
||||
for item in syntax.items {
|
||||
if let Item::Mod(module) = item
|
||||
&& module.content.is_none()
|
||||
{
|
||||
*declarations.entry(module.ident.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
declarations
|
||||
}
|
||||
|
||||
pub(super) fn backend_root() -> PathBuf {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
|
||||
输出合同与 [SDK 版本](../../crates/gateway-plugin/sdk/README.md)一致
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
|
||||
```
|
||||
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径。
|
||||
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径
|
||||
|
||||
## 打包
|
||||
|
||||
@@ -26,24 +26,26 @@ cpr-plugin package \
|
||||
--output-dir /path/to/output
|
||||
```
|
||||
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖。
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
|
||||
|
||||
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。
|
||||
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `3`,
|
||||
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
|
||||
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
|
||||
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)。
|
||||
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
|
||||
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。完整开发示例在同级 `codex-proxy-plugins/examples/workbench` 仓库目录;本工具不负责源码构建、上传或启用插件。
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
|
||||
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
|
||||
|
||||
### 图标资源
|
||||
|
||||
在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见
|
||||
[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算,
|
||||
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制。
|
||||
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制
|
||||
|
||||
## 开发
|
||||
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`。
|
||||
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help
|
||||
|
||||
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
|
||||
|
||||
fn source_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"manifestVersion":1,
|
||||
"manifestVersion":2,
|
||||
"name":"request-workbench",
|
||||
"displayName":"Request Workbench",
|
||||
"publisher":"codex-proxy",
|
||||
@@ -248,10 +248,10 @@ fn source_manifest() -> &'static [u8] {
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"middleware":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{}
|
||||
},
|
||||
"permissions":["requests","accounts"],
|
||||
|
||||
"icon":"assets/icon.png",
|
||||
"resources":{"LICENSE":"text/plain","assets/icon.png":"image/png","web/index.html":"text/html","web/app.js":"text/javascript","web/app.css":"text/css"}
|
||||
}"#
|
||||
|
||||
@@ -36,6 +36,9 @@ chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
prettyplease = "0.2"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "visit-mut"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
model::backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, build_backup_seed, code,
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
|
||||
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
|
||||
},
|
||||
ports::backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
|
||||
@@ -224,8 +224,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
@@ -274,8 +273,7 @@ impl BackupTask {
|
||||
self.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
|
||||
StatusTransitionUpdate::default(),
|
||||
now,
|
||||
)
|
||||
@@ -402,8 +400,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
@@ -471,8 +468,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
|
||||
StatusTransitionUpdate::default(),
|
||||
Utc::now(),
|
||||
)
|
||||
@@ -555,7 +551,12 @@ impl BackupTask {
|
||||
};
|
||||
let _ = self
|
||||
.repository
|
||||
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
|
||||
.transition_status(
|
||||
&record.id,
|
||||
status_transition(record.status, BackupStatus::Failed)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
@@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_transition(
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
) -> Result<BackupStatusTransition, WorkerTaskError> {
|
||||
BackupStatusTransition::try_new(from, to)
|
||||
.ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid"))
|
||||
}
|
||||
|
||||
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(format!("backup store failed: {error}"))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod backup;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
pub mod service;
|
||||
mod use_case;
|
||||
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
|
||||
|
||||
@@ -186,6 +187,7 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
public_services: Arc<service::Registry>,
|
||||
plugins: Arc<PluginsService>,
|
||||
plugin_management: Arc<PluginManagementService>,
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
@@ -205,6 +207,10 @@ pub struct AdminServices {
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
pub fn public_services(&self) -> Arc<service::Registry> {
|
||||
self.public_services.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_management(&self) -> &PluginManagementService {
|
||||
&self.plugin_management
|
||||
@@ -266,7 +272,6 @@ impl AdminServices {
|
||||
self.observability.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> &dyn SettingsService {
|
||||
self.settings.as_ref()
|
||||
}
|
||||
@@ -313,6 +318,7 @@ impl AdminBundle {
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub service_middleware: service::PlanSource,
|
||||
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
|
||||
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
|
||||
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
@@ -361,6 +367,7 @@ async fn initialize_inner(
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
published_snapshot,
|
||||
@@ -415,6 +422,7 @@ async fn initialize_inner(
|
||||
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
@@ -435,7 +443,16 @@ async fn initialize_inner(
|
||||
});
|
||||
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
|
||||
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
|
||||
let settings = initialize_settings(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
let mut public_services = service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
let services = AdminServices {
|
||||
public_services: Arc::new(public_services),
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
store.plugins(),
|
||||
@@ -476,12 +493,7 @@ async fn initialize_inner(
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
)),
|
||||
settings: Arc::new(DefaultSettingsService::new(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
settings,
|
||||
system,
|
||||
credentials,
|
||||
plugin_accounts,
|
||||
@@ -527,7 +539,7 @@ pub fn initialize_plugin_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口。
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
@@ -597,3 +609,18 @@ fn freeze_recovery_worker_contribution(
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
|
||||
pub fn initialize_settings(
|
||||
store: Arc<dyn ports::store::SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn ports::pricing::PricingSource>,
|
||||
) -> Arc<dyn SettingsService> {
|
||||
Arc::new(DefaultSettingsService::new(
|
||||
store,
|
||||
snapshot,
|
||||
providers,
|
||||
pricing_source,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -223,6 +223,16 @@ pub struct AccountPage {
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
pub capacity: AccountCapacity,
|
||||
}
|
||||
|
||||
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountCapacity {
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲。
|
||||
pub used_slots: Option<u64>,
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
|
||||
|
||||
use super::{
|
||||
MutationActor, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MutationAuditOperation {
|
||||
ModelPricingSync,
|
||||
ModelPricingUpdate,
|
||||
RuntimeSettingsReplace,
|
||||
AdminApiKeyChanged { exists: bool },
|
||||
AccountGroupCreate,
|
||||
AccountGroupUpdate,
|
||||
AccountGroupEnabled { enabled: bool },
|
||||
AccountGroupDelete,
|
||||
ClientApiKeyResetBudget,
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
ProviderAccountAdaptConcurrency,
|
||||
ProviderAccountRecover,
|
||||
ProviderAccountBatchUpdate,
|
||||
ProviderAccountDelete,
|
||||
ProviderAccountExportCredentials,
|
||||
PluginArtifactInstall,
|
||||
PluginArtifactAccept,
|
||||
PluginArtifactDelete,
|
||||
PluginInstanceConfigure,
|
||||
PluginInstanceDelete,
|
||||
PluginSourceCredentialCreate,
|
||||
PluginSourceCredentialDelete,
|
||||
ClientApiKeyUpdateBudgetLimits,
|
||||
ClientApiKeyCreate,
|
||||
ClientApiKeyUpdate,
|
||||
ClientApiKeyEnabled { enabled: bool },
|
||||
ClientApiKeyDelete,
|
||||
PluginSourceChangeSource,
|
||||
PluginSourceDelete,
|
||||
OutboundProxyCreate,
|
||||
OutboundProxyUpdate,
|
||||
OutboundProxyDelete,
|
||||
OutboundProxyTest,
|
||||
AccountGroupPluginReconcile,
|
||||
ClientApiKeyPluginReconcile,
|
||||
ProviderAccountImportDocument,
|
||||
ProviderAccountRotateCredential,
|
||||
ProviderAccountRefreshCredential,
|
||||
ProviderAccountAuthorize,
|
||||
ProviderAccountReauthorize,
|
||||
}
|
||||
|
||||
impl MutationAuditOperation {
|
||||
#[must_use]
|
||||
pub const fn classification(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Self::ModelPricingSync => ("pricing.sync", "model_pricing"),
|
||||
Self::ModelPricingUpdate => ("pricing.update", "model_pricing"),
|
||||
Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"),
|
||||
Self::AdminApiKeyChanged { exists } => (
|
||||
if exists {
|
||||
"admin_api_key.replace"
|
||||
} else {
|
||||
"admin_api_key.delete"
|
||||
},
|
||||
"runtime_settings",
|
||||
),
|
||||
Self::AccountGroupCreate => ("create", "account_group"),
|
||||
Self::AccountGroupUpdate => ("update", "account_group"),
|
||||
Self::AccountGroupEnabled { enabled } => {
|
||||
(if enabled { "enable" } else { "disable" }, "account_group")
|
||||
}
|
||||
Self::AccountGroupDelete => ("delete", "account_group"),
|
||||
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
|
||||
Self::ProviderAccountUpdate => ("update", "provider_account"),
|
||||
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
|
||||
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
|
||||
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
|
||||
Self::ProviderAccountRecover => ("recover", "provider_account"),
|
||||
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
|
||||
Self::ProviderAccountDelete => ("delete", "provider_account"),
|
||||
Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"),
|
||||
Self::PluginArtifactInstall => ("install", "plugin_artifact"),
|
||||
Self::PluginArtifactAccept => ("accept", "plugin_artifact"),
|
||||
Self::PluginArtifactDelete => ("delete", "plugin_artifact"),
|
||||
Self::PluginInstanceConfigure => ("configure", "plugin_instance"),
|
||||
Self::PluginInstanceDelete => ("delete", "plugin_instance"),
|
||||
Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"),
|
||||
Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"),
|
||||
Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"),
|
||||
Self::ClientApiKeyCreate => ("create", "client_api_key"),
|
||||
Self::ClientApiKeyUpdate => ("update", "client_api_key"),
|
||||
Self::ClientApiKeyEnabled { enabled } => {
|
||||
(if enabled { "enable" } else { "disable" }, "client_api_key")
|
||||
}
|
||||
Self::ClientApiKeyDelete => ("delete", "client_api_key"),
|
||||
Self::PluginSourceChangeSource => ("change_source", "plugin_source"),
|
||||
Self::PluginSourceDelete => ("delete", "plugin_source"),
|
||||
Self::OutboundProxyCreate => ("create", "outbound_proxy"),
|
||||
Self::OutboundProxyUpdate => ("update", "outbound_proxy"),
|
||||
Self::OutboundProxyDelete => ("delete", "outbound_proxy"),
|
||||
Self::OutboundProxyTest => ("test", "outbound_proxy"),
|
||||
Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"),
|
||||
Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"),
|
||||
Self::ProviderAccountImportDocument => ("import_document", "provider_account"),
|
||||
Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"),
|
||||
Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"),
|
||||
Self::ProviderAccountAuthorize => ("authorize", "provider_account"),
|
||||
Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MutationAuditIntent<'a> {
|
||||
pub operation: MutationAuditOperation,
|
||||
pub entity_ref: &'a str,
|
||||
}
|
||||
|
||||
impl MutationAuditIntent<'_> {
|
||||
#[must_use]
|
||||
pub fn event(self, context: &MutationContext, changed_fields: Vec<String>) -> AdminAuditEvent {
|
||||
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
|
||||
MutationActor::AdminSession { admin_user_id } => (
|
||||
AuditActorKind::AdminSession,
|
||||
Some(admin_user_id.clone()),
|
||||
admin_session_actor_ref(admin_user_id),
|
||||
),
|
||||
MutationActor::AdminApiKey => (
|
||||
AuditActorKind::AdminApiKey,
|
||||
None,
|
||||
"admin_api_key".to_owned(),
|
||||
),
|
||||
MutationActor::System => (AuditActorKind::System, None, "system".to_owned()),
|
||||
};
|
||||
let (action, entity_kind) = self.operation.classification();
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: entity_kind.to_owned(),
|
||||
entity_ref: self.entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,14 @@ pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
|
||||
/// 已认证的管理主体。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
Session { admin_user_id: String },
|
||||
Session {
|
||||
admin_user_id: String,
|
||||
},
|
||||
ApiKey,
|
||||
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
|
||||
Plugin {
|
||||
instance_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 传给管理用例的安全请求上下文。
|
||||
@@ -33,6 +39,7 @@ impl AdminRequestContext {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
},
|
||||
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
|
||||
AdminPrincipal::Plugin { .. } => MutationActor::System,
|
||||
};
|
||||
MutationContext {
|
||||
actor,
|
||||
|
||||
@@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已通过领域状态机校验的显式迁移。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BackupStatusTransition {
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
}
|
||||
|
||||
impl BackupStatusTransition {
|
||||
/// 校验并构造显式迁移;非法状态边返回 `None`。
|
||||
#[must_use]
|
||||
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
|
||||
from.allows_transition_to(to).then_some(Self { from, to })
|
||||
}
|
||||
|
||||
/// 迁移要求的当前状态。
|
||||
#[must_use]
|
||||
pub const fn from(self) -> BackupStatus {
|
||||
self.from
|
||||
}
|
||||
|
||||
/// 迁移完成后的目标状态。
|
||||
#[must_use]
|
||||
pub const fn to(self) -> BackupStatus {
|
||||
self.to
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateBackupStorageCommand {
|
||||
|
||||
@@ -10,7 +10,10 @@ use gateway_core::{
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
use super::{
|
||||
AdminModelError, Revision, account_groups::AccountGroupRef,
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
pub type ProviderRequestProfileOverrides =
|
||||
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
|
||||
@@ -219,6 +222,21 @@ pub struct ResetClientKeyBudget {
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
pub id: ClientApiKeyId,
|
||||
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientKeyBudgetMutationOrigin {
|
||||
Admin,
|
||||
Plugin(PluginResourceOwner),
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
@@ -19,6 +20,7 @@ pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
pub mod quota_forecast_sampling;
|
||||
pub mod retention;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
@@ -107,7 +109,10 @@ impl AdminError {
|
||||
}
|
||||
|
||||
/// PostgreSQL 中所有正整数 revision 的管理层表示。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct Revision(NonZeroU64);
|
||||
|
||||
impl Revision {
|
||||
@@ -156,7 +161,8 @@ impl PageSize {
|
||||
}
|
||||
|
||||
/// 可审计管理写操作的发起者。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum MutationActor {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
@@ -164,7 +170,8 @@ pub enum MutationActor {
|
||||
}
|
||||
|
||||
/// 管理写操作必须携带的审计上下文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub struct MutationContext {
|
||||
pub actor: MutationActor,
|
||||
pub request_id: String,
|
||||
|
||||
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
/// 账号当前套餐,不属于请求历史快照。
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -757,11 +760,20 @@ pub struct UsageSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -1142,6 +1155,8 @@ pub struct UsageInsights {
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
//! 插件可见的 Client Key 非秘密资源投影。
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyFacts {
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyCursor {
|
||||
pub name: String,
|
||||
|
||||
@@ -5,12 +5,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Revision;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginPermissionGrant {
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginFailurePolicy {
|
||||
@@ -26,6 +20,9 @@ pub struct PluginCapabilityBinding {
|
||||
pub stage: String,
|
||||
pub order: i32,
|
||||
pub failure_policy: PluginFailurePolicy,
|
||||
/// 观察绑定选择事件类型;其他能力不设置事件。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub event: Option<String>,
|
||||
#[serde(default)]
|
||||
pub client_key_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -66,7 +63,6 @@ pub struct PluginInstance {
|
||||
pub trusted_process: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
pub secrets: BTreeMap<String, SecretString>,
|
||||
pub grants: Vec<PluginPermissionGrant>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
pub revision: Revision,
|
||||
}
|
||||
@@ -115,6 +111,7 @@ pub struct PluginInstanceRuntime {
|
||||
pub struct PluginInstanceView {
|
||||
pub instance: PluginInstance,
|
||||
pub configuration_required: bool,
|
||||
pub compatibility_warning: Option<String>,
|
||||
pub running: bool,
|
||||
pub published_revision: Option<u64>,
|
||||
pub runtime: PluginInstanceRuntime,
|
||||
|
||||
@@ -78,6 +78,7 @@ pub struct PluginManagementRequest {
|
||||
pub path: String,
|
||||
pub query: String,
|
||||
pub content_type: Option<String>,
|
||||
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
|
||||
pub body: Vec<u8>,
|
||||
pub request_id: String,
|
||||
}
|
||||
@@ -85,5 +86,6 @@ pub struct PluginManagementRequest {
|
||||
pub struct PluginManagementResponse {
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
|
||||
pub body: Arc<[u8]>,
|
||||
}
|
||||
|
||||
@@ -29,19 +29,17 @@ pub struct PluginHostCompatibility {
|
||||
pub manifest_schema_versions: Vec<u32>,
|
||||
pub protocol_versions: Vec<u32>,
|
||||
pub capabilities: Vec<PluginHostCapability>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginHostCompatibility {
|
||||
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.schema_version != 1
|
||||
if self.schema_version != 2
|
||||
|| !valid_versions(&self.manifest_schema_versions)
|
||||
|| !valid_versions(&self.protocol_versions)
|
||||
|| self.capabilities.is_empty()
|
||||
|| self.capabilities.len() > 64
|
||||
|| self.permissions.len() > 64
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -53,10 +51,7 @@ impl PluginHostCompatibility {
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let mut permissions = BTreeSet::new();
|
||||
!self.permissions.iter().any(|permission| {
|
||||
!valid_identifier(permission) || !permissions.insert(permission.as_str())
|
||||
})
|
||||
true
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -65,11 +60,6 @@ impl PluginHostCompatibility {
|
||||
.iter()
|
||||
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.iter().any(|entry| entry == permission)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_versions(versions: &[u32]) -> bool {
|
||||
@@ -118,7 +108,6 @@ pub struct PluginCompatibilityRequirements {
|
||||
pub manifest_schema_version: u32,
|
||||
pub protocol_version: u32,
|
||||
pub capabilities: Vec<(String, u32)>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
|
||||
@@ -177,12 +166,15 @@ impl PluginSource {
|
||||
|
||||
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContribution {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub stages: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub input_formats: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -195,7 +187,6 @@ pub enum PluginArtifactIcon {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginArtifactIconVariants {
|
||||
pub light: String,
|
||||
pub dark: String,
|
||||
@@ -214,17 +205,10 @@ pub struct PluginArtifactIconResource {
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 管理端使用的静态描述,不承载 SDK 的运行时消息或宿主实现类型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginPermissionDescription {
|
||||
pub permission: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
|
||||
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginArtifactMetadata {
|
||||
pub plugin_id: String,
|
||||
pub version: String,
|
||||
@@ -239,13 +223,18 @@ pub struct PluginArtifactMetadata {
|
||||
#[serde(default)]
|
||||
pub icon: Option<PluginArtifactIcon>,
|
||||
pub contributes: BTreeMap<String, PluginContribution>,
|
||||
pub requested_permissions: Vec<String>,
|
||||
#[serde(default = "empty_configuration_schema")]
|
||||
pub configuration_schema: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub secret_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub state_namespaces: Vec<state::PluginStateSchema>,
|
||||
}
|
||||
|
||||
fn empty_configuration_schema() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
|
||||
#[derive(Clone)]
|
||||
pub struct InspectedPluginArtifact {
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::model::Revision;
|
||||
|
||||
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStateSchema {
|
||||
pub namespace: String,
|
||||
pub schema_version: u32,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PricingCatalog {
|
||||
pub defaults: PricingOverrides,
|
||||
pub overrides: PricingOverrides,
|
||||
@@ -27,6 +27,7 @@ pub struct PricingSyncPreview {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SyncPricing {
|
||||
pub preview: PricingSyncPreview,
|
||||
pub models: BTreeMap<String, BTreeSet<String>>,
|
||||
@@ -34,7 +35,7 @@ pub struct SyncPricing {
|
||||
|
||||
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PricingChange {
|
||||
Replace(ModelPriceOverride),
|
||||
Multiplier(u32),
|
||||
@@ -42,7 +43,7 @@ pub enum PricingChange {
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdatePricing {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
|
||||
@@ -1227,6 +1227,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capacity: super::accounts::AccountCapacity,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
|
||||
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
let remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.zip(remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 历史数据保留规则;存储实现只接受已校验的窗口。
|
||||
|
||||
use super::AdminError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionTarget {
|
||||
ModelRequests,
|
||||
OpsEvents,
|
||||
AdminAuditEvents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RetentionPolicy {
|
||||
usage_days: u32,
|
||||
ops_days: u32,
|
||||
audit_days: u32,
|
||||
}
|
||||
|
||||
impl RetentionPolicy {
|
||||
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
|
||||
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
|
||||
return Err(AdminError::invalid(
|
||||
"请求保留期至少 31 天,事件与审计保留期必须为正数",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
usage_days,
|
||||
ops_days,
|
||||
audit_days,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn days(self, target: RetentionTarget) -> u32 {
|
||||
match target {
|
||||
RetentionTarget::ModelRequests => self.usage_days,
|
||||
RetentionTarget::OpsEvents => self.ops_days,
|
||||
RetentionTarget::AdminAuditEvents => self.audit_days,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ pub type ProviderRequestProfileUpdates =
|
||||
pub use gateway_core::account::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeSettings {
|
||||
pub request_profiles: ProviderRequestProfiles,
|
||||
pub config_revision: Revision,
|
||||
@@ -59,8 +60,11 @@ pub struct RuntimeSettings {
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
|
||||
pub expected_revision: Revision,
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
|
||||
pub request_profile_updates: ProviderRequestProfileUpdates,
|
||||
pub request_location_enabled: bool,
|
||||
@@ -94,7 +98,8 @@ pub struct ReplaceRuntimeSettings {
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AdminApiKey(String);
|
||||
|
||||
impl AdminApiKey {
|
||||
@@ -122,13 +127,16 @@ impl fmt::Debug for AdminApiKey {
|
||||
}
|
||||
|
||||
/// 管理员 API Key 更新结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminApiKeyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// 新管理员 API Key 的一次性返回结果。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegeneratedAdminApiKey {
|
||||
pub mutation: AdminApiKeyMutation,
|
||||
pub key: AdminApiKey,
|
||||
@@ -154,3 +162,45 @@ impl RuntimeSettings {
|
||||
.and_then(|provider| self.request_profiles.get(&provider))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
expected_revision: settings.config_revision,
|
||||
request_profile_updates: settings
|
||||
.request_profiles
|
||||
.into_iter()
|
||||
.map(|(provider, value)| (provider, Some(value)))
|
||||
.collect(),
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings: settings.model_mappings,
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: settings.refresh_concurrency,
|
||||
max_concurrent_per_account: settings.max_concurrent_per_account,
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
usage_retention_days: settings.usage_retention_days,
|
||||
ops_event_retention_days: settings.ops_event_retention_days,
|
||||
audit_retention_days: settings.audit_retention_days,
|
||||
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
|
||||
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
|
||||
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
|
||||
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
|
||||
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency: settings
|
||||
.account_auto_freeze_adaptive_concurrency,
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,3 +162,21 @@ impl SystemOperationAccepted {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemRestartPlan {
|
||||
pub target_version: Option<String>,
|
||||
pub release_manifest_sha256: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemIncompatiblePlugin {
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ use crate::model::{
|
||||
MutationContext,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
|
||||
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig,
|
||||
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
},
|
||||
};
|
||||
use crate::ports::store::AdminStoreResult;
|
||||
@@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync {
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
transition: BackupStatusTransition,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
@@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn transition_status(
|
||||
&self,
|
||||
_id: &str,
|
||||
_from: BackupStatus,
|
||||
_to: BackupStatus,
|
||||
_transition: BackupStatusTransition,
|
||||
_update: StatusTransitionUpdate,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod retention;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -34,6 +34,14 @@ pub trait PluginAccountAccess: Send + Sync {
|
||||
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
|
||||
}
|
||||
|
||||
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
Err(AdminError::unavailable("插件额度观测刷新暂不可用"))
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
//! 插件发现 Client Key 公开身份的窄端口。
|
||||
//! 插件 Client Key 非秘密目录与受控预算管理端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::model::{
|
||||
AdminError,
|
||||
plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
AdminError, MutationContext,
|
||||
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
|
||||
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::model::{
|
||||
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
|
||||
#[async_trait]
|
||||
pub trait PluginManagement: Send + Sync {
|
||||
/// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。
|
||||
async fn authorize_models(
|
||||
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本。
|
||||
async fn validate_target(
|
||||
&self,
|
||||
published: &ExtensionSetReference,
|
||||
target: &PluginManagementTarget,
|
||||
|
||||
@@ -28,7 +28,7 @@ use gateway_core::runtime::extensions::ExtensionSetReference;
|
||||
|
||||
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
|
||||
#[async_trait]
|
||||
pub trait PluginPreparation: Send + Sync {
|
||||
pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
@@ -44,36 +44,33 @@ pub trait PluginPreparation: Send + Sync {
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
|
||||
}
|
||||
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
|
||||
#[async_trait]
|
||||
pub trait PluginRuntimeDiagnostics: Send + Sync {
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
_snapshot: &PluginInstanceSnapshot,
|
||||
_published_revision: Option<u64>,
|
||||
_published: Option<&ExtensionSetReference>,
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
|
||||
None
|
||||
}
|
||||
snapshot: &PluginInstanceSnapshot,
|
||||
published_revision: Option<u64>,
|
||||
published: Option<&ExtensionSetReference>,
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
|
||||
}
|
||||
|
||||
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
|
||||
#[async_trait]
|
||||
pub trait PluginStateLifecycle: Send + Sync {
|
||||
async fn activate_state(
|
||||
&self,
|
||||
_prepared: &ExtensionSetReference,
|
||||
_instance: &PluginInstance,
|
||||
) -> Result<(), AdminError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn quiesce_instance(
|
||||
&self,
|
||||
_instance_id: &str,
|
||||
_artifact_sha256: &str,
|
||||
_revision: Revision,
|
||||
) {
|
||||
}
|
||||
prepared: &ExtensionSetReference,
|
||||
instance: &PluginInstance,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn quiesce_instance(&self, instance_id: &str, artifact_sha256: &str, revision: Revision);
|
||||
async fn migrate_state(
|
||||
&self,
|
||||
_prepared: &ExtensionSetReference,
|
||||
_transition: PluginStateTransition,
|
||||
) -> Result<(), AdminError> {
|
||||
Err(AdminError::unavailable("插件状态迁移运行时不可用"))
|
||||
}
|
||||
prepared: &ExtensionSetReference,
|
||||
transition: PluginStateTransition,
|
||||
) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -176,23 +173,6 @@ pub trait PluginDistribution: Send + Sync {
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
|
||||
#[async_trait]
|
||||
pub trait PluginPackageInspector: Send + Sync {
|
||||
/// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。
|
||||
fn permission_descriptions(
|
||||
&self,
|
||||
permissions: &[String],
|
||||
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
|
||||
permissions
|
||||
.iter()
|
||||
.map(
|
||||
|permission| crate::model::plugins::PluginPermissionDescription {
|
||||
permission: permission.clone(),
|
||||
label: permission.clone(),
|
||||
description: String::new(),
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
@@ -239,6 +219,19 @@ pub trait PluginStore: Send + Sync {
|
||||
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
|
||||
async fn disable_instances(
|
||||
&self,
|
||||
_ids: &[String],
|
||||
_expected_revision: Revision,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::store::AdminStoreError::new(
|
||||
super::store::AdminStoreErrorKind::Unavailable,
|
||||
"plugin",
|
||||
"atomic plugin disable is unavailable",
|
||||
))
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
@@ -314,7 +307,7 @@ pub trait PluginStore: Send + Sync {
|
||||
source: PluginSource,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<PluginArtifactMutation>;
|
||||
/// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。
|
||||
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化。
|
||||
async fn accept_artifact(
|
||||
&self,
|
||||
digest: &str,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 有界保留期删除,不承载周期、暂停或重试策略。
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::retention::{RetentionPolicy, RetentionTarget};
|
||||
|
||||
#[async_trait]
|
||||
pub trait RetentionStore: Send + Sync {
|
||||
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
|
||||
|
||||
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
|
||||
async fn purge_batch(
|
||||
&self,
|
||||
target: RetentionTarget,
|
||||
now: DateTime<Utc>,
|
||||
policy: RetentionPolicy,
|
||||
limit: NonZeroU32,
|
||||
) -> AdminStoreResult<u64>;
|
||||
}
|
||||
@@ -22,11 +22,12 @@ use crate::model::{
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
|
||||
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -332,10 +333,19 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>>;
|
||||
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
@@ -434,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -77,10 +77,27 @@ pub trait SystemUpdatePreflight: Send + Sync {
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError>;
|
||||
|
||||
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
self.validate(candidate).await
|
||||
}
|
||||
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
|
||||
#[async_trait]
|
||||
pub trait SystemRestartPreflight: Send + Sync {
|
||||
async fn prepare(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
@@ -108,5 +125,15 @@ pub trait SystemOperations: Send + Sync {
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn restart(
|
||||
&self,
|
||||
preflight: Arc<dyn SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。
|
||||
|
||||
mod registry;
|
||||
mod settings;
|
||||
pub use registry::Registry;
|
||||
|
||||
use std::{future::Future, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::middleware::FrozenMiddlewarePlan,
|
||||
middleware::{
|
||||
compose,
|
||||
service::{Context, Error, Value},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::model::{AdminError, AdminErrorKind};
|
||||
|
||||
pub type PlanSource = Arc<dyn Fn() -> Option<FrozenMiddlewarePlan> + Send + Sync>;
|
||||
|
||||
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。
|
||||
#[derive(Clone, Default)]
|
||||
pub enum Plan {
|
||||
#[default]
|
||||
Current,
|
||||
Frozen(Option<FrozenMiddlewarePlan>),
|
||||
}
|
||||
|
||||
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。
|
||||
#[derive(Clone)]
|
||||
pub struct Origin {
|
||||
pub request_id: String,
|
||||
pub call_id: String,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
pub extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
pub plan: Plan,
|
||||
}
|
||||
|
||||
impl From<Context> for Origin {
|
||||
fn from(context: Context) -> Self {
|
||||
Self {
|
||||
request_id: context.request_id,
|
||||
call_id: context.call_id,
|
||||
cancellation: context.cancellation,
|
||||
extensions: context.extensions,
|
||||
plan: Plan::Frozen(Some(context.plan)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! { static CURRENT: Origin; }
|
||||
|
||||
/// task local 随 future poll 进入/退出,不泄漏给并行任务。
|
||||
pub async fn scope<T>(context: impl Into<Origin>, future: impl Future<Output = T>) -> T {
|
||||
CURRENT.scope(context.into(), future).await
|
||||
}
|
||||
|
||||
pub(crate) async fn invoke(
|
||||
source: &PlanSource,
|
||||
operation: &'static str,
|
||||
input: Value,
|
||||
terminal: impl FnOnce(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + 'static,
|
||||
) -> Result<Value, Error> {
|
||||
let parent = CURRENT.try_with(Clone::clone).ok();
|
||||
let plan = match parent.as_ref().map(|parent| &parent.plan) {
|
||||
Some(Plan::Frozen(plan)) => plan.clone(),
|
||||
_ => source(),
|
||||
};
|
||||
let call_id = uuid::Uuid::now_v7().to_string();
|
||||
let parent_call_id = parent.as_ref().map(|parent| parent.call_id.clone());
|
||||
let origin = Origin {
|
||||
request_id: parent
|
||||
.as_ref()
|
||||
.map_or_else(|| call_id.clone(), |parent| parent.request_id.clone()),
|
||||
cancellation: parent
|
||||
.as_ref()
|
||||
.map(|parent| parent.cancellation.clone())
|
||||
.unwrap_or_default(),
|
||||
extensions: parent.map(|parent| parent.extensions).unwrap_or_default(),
|
||||
call_id,
|
||||
plan: Plan::Frozen(plan.clone()),
|
||||
};
|
||||
let Some(plan) = plan.filter(FrozenMiddlewarePlan::has_service) else {
|
||||
return tokio::select! {
|
||||
biased;
|
||||
() = origin.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
|
||||
result = scope(origin.clone(), terminal(input)) => result,
|
||||
};
|
||||
};
|
||||
let context = Context {
|
||||
operation,
|
||||
request_id: origin.request_id,
|
||||
call_id: origin.call_id,
|
||||
parent_call_id,
|
||||
cancellation: origin.cancellation,
|
||||
extensions: origin.extensions,
|
||||
plan: plan.clone(),
|
||||
};
|
||||
let inner = context.clone();
|
||||
let next = compose(Vec::new(), move |input| {
|
||||
Box::pin(scope(inner, terminal(input)))
|
||||
});
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = context.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
|
||||
result = plan.handle_service(context.clone(), input, next) => result,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_error(error: AdminError) -> Error {
|
||||
Error {
|
||||
kind: match error.kind() {
|
||||
AdminErrorKind::Invalid => "invalid",
|
||||
AdminErrorKind::Unauthorized => "unauthorized",
|
||||
AdminErrorKind::Forbidden => "forbidden",
|
||||
AdminErrorKind::NotFound => "not_found",
|
||||
AdminErrorKind::Conflict => "conflict",
|
||||
AdminErrorKind::RateLimited => "rate_limited",
|
||||
AdminErrorKind::BadGateway => "bad_gateway",
|
||||
AdminErrorKind::UpstreamResultUnknown => "upstream_result_unknown",
|
||||
AdminErrorKind::Unavailable => "unavailable",
|
||||
AdminErrorKind::Internal => "internal",
|
||||
}
|
||||
.into(),
|
||||
message: error.message().to_owned(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。
|
||||
|
||||
use crate::model::AdminError;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::middleware::service::{Error, Value};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
type Handler = Arc<dyn Fn(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + Sync>;
|
||||
|
||||
pub struct Registry {
|
||||
middleware: super::PlanSource,
|
||||
operations: BTreeMap<&'static str, Handler>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new(middleware: super::PlanSource) -> Self {
|
||||
Self {
|
||||
middleware,
|
||||
operations: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。
|
||||
pub fn register_settings(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::SettingsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
super::settings::register(self, service)
|
||||
}
|
||||
|
||||
pub(crate) fn register<I, O>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
handler: impl Fn(I) -> BoxFuture<'static, Result<O, AdminError>> + 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 input = serde_json::from_value(input)
|
||||
.map_err(|_| Error::invalid("服务输入类型无效"))?;
|
||||
let output = handler(input).await.map_err(super::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
|
||||
.get_key_value(operation)
|
||||
.ok_or_else(|| Error {
|
||||
details: None,
|
||||
kind: "not_found".into(),
|
||||
message: format!("公开服务不存在:{operation}"),
|
||||
})?;
|
||||
let handler = handler.clone();
|
||||
super::invoke(&self.middleware, operation, input, move |input| {
|
||||
handler(input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn operations(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.operations.keys().copied()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。
|
||||
|
||||
use crate::{
|
||||
SettingsService,
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
pricing::{SyncPricing, UpdatePricing},
|
||||
settings::ReplaceRuntimeSettings,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn SettingsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。
|
||||
macro_rules! register {
|
||||
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
|
||||
let service = service.clone();
|
||||
registry.register(concat!("settings.", stringify!($method)), move |$pattern: $input| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move { service.$method($($argument),*).await })
|
||||
})?;
|
||||
}};
|
||||
}
|
||||
register!(Load, load, (), ());
|
||||
register!(
|
||||
Replace,
|
||||
replace,
|
||||
(MutationContext, ReplaceRuntimeSettings),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(ApiKeyExists, admin_api_key_exists, (), ());
|
||||
register!(
|
||||
RegenerateApiKey,
|
||||
regenerate_admin_api_key,
|
||||
MutationContext,
|
||||
context,
|
||||
&context
|
||||
);
|
||||
register!(
|
||||
DeleteApiKey,
|
||||
delete_admin_api_key,
|
||||
MutationContext,
|
||||
context,
|
||||
&context
|
||||
);
|
||||
register!(Pricing, pricing, (), ());
|
||||
register!(PreviewPricingSync, preview_pricing_sync, (), ());
|
||||
register!(
|
||||
Sync,
|
||||
sync_pricing,
|
||||
(MutationContext, SyncPricing),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(
|
||||
Update,
|
||||
update_pricing,
|
||||
(MutationContext, UpdatePricing),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(
|
||||
ClientProfileOptions,
|
||||
client_profile_options,
|
||||
String,
|
||||
provider,
|
||||
&provider
|
||||
);
|
||||
register!(
|
||||
PreviewClientProfile,
|
||||
preview_client_profile,
|
||||
(String, Option<gateway_core::account::OpaqueProviderData>),
|
||||
(provider, configuration),
|
||||
&provider,
|
||||
configuration.as_ref()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -363,6 +363,7 @@ impl DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: stored.capacity,
|
||||
capabilities: provider
|
||||
.account_capabilities(account_id, &stored.account.authentication_kind),
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
@@ -402,6 +403,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
.iter()
|
||||
.map(|item| item.account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
|
||||
let in_flight = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match self.account_runtime.account_runtime(&ids).await {
|
||||
Ok(runtime) => runtime.in_flight,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "account capacity projection is unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let rolling_usage = self
|
||||
.accounts
|
||||
.load_account_usage(rolling_range, &ids)
|
||||
@@ -472,6 +485,12 @@ impl AccountsService for DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: crate::model::accounts::AccountCapacity {
|
||||
used_slots: in_flight
|
||||
.as_ref()
|
||||
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
|
||||
..item.capacity
|
||||
},
|
||||
capabilities,
|
||||
plan_type_display: providers.resolve_account_plan(
|
||||
item.account.provider_kind.as_str(),
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use gateway_core::{
|
||||
engine::budget::ClientBudgetStatus, policy::ClientApiKeyId, runtime::SnapshotControl,
|
||||
};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -13,10 +14,10 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
|
||||
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
|
||||
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
@@ -27,6 +28,7 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Client Key 管理服务。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
async fn create(
|
||||
@@ -49,10 +51,18 @@ pub trait ClientKeyService: Send + Sync {
|
||||
context: &MutationContext,
|
||||
command: DeleteClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
}
|
||||
|
||||
@@ -79,14 +89,48 @@ impl DefaultClientKeyService {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
|
||||
self.store
|
||||
.get_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
|
||||
}
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
|
||||
self.get(id).await.map(|key| key.budget)
|
||||
}
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
if command.daily_limit_usd.is_none() && command.weekly_limit_usd.is_none() {
|
||||
return Err(AdminError::invalid("至少指定一个预算上限"));
|
||||
}
|
||||
let id = command.id.clone();
|
||||
if let Some(revision) = self
|
||||
.store
|
||||
.update_client_key_budget_limits(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
{
|
||||
publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
let id = command.id.clone();
|
||||
self.store
|
||||
.reset_client_key_budget(command, context)
|
||||
.reset_client_key_budget(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
Ok(id)
|
||||
|
||||
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_billing(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -334,29 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let items = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
let items = items
|
||||
let items = observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|
||||
|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, item.account_plan_type.as_deref())
|
||||
},
|
||||
),
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
@@ -772,10 +780,15 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
|
||||
});
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -45,6 +45,22 @@ impl DefaultPluginAccountAccess {
|
||||
}
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
let account = self.get_runtime(account_id).await?;
|
||||
self.provider(&account.provider_kind)?
|
||||
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "plugin quota facts"))
|
||||
}
|
||||
|
||||
fn provider(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -99,16 +115,16 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
let account = self.get_runtime(account_id).await?;
|
||||
self.provider(&account.provider_kind)?
|
||||
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "plugin quota facts"))
|
||||
self.quota(account_id, false).await
|
||||
}
|
||||
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
self.quota(account_id, true).await
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。
|
||||
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
|
||||
ClientKeySort, ClientKeySortField, SortDirection,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
|
||||
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
|
||||
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
|
||||
PluginClientKeyPage,
|
||||
},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
},
|
||||
ports::plugin_client_keys::PluginClientKeyAccess,
|
||||
use_case::client_keys::ClientKeyService,
|
||||
@@ -31,6 +35,49 @@ impl DefaultPluginClientKeyAccess {
|
||||
|
||||
#[async_trait]
|
||||
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
|
||||
let key = self.service.get(id).await?;
|
||||
Ok(PluginClientKeyFacts {
|
||||
id: key.id,
|
||||
enabled: key.enabled,
|
||||
group_ids: key.groups.into_iter().map(|group| group.id).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
|
||||
self.service.budget(id).await
|
||||
}
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
self.service
|
||||
.update_budget_limits(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
self.service
|
||||
.reset_budget(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use super::publish_committed;
|
||||
use crate::model::{
|
||||
MutationContext,
|
||||
system::{SystemIncompatiblePlugin, SystemRestartPlan},
|
||||
};
|
||||
use crate::ports::system::SystemRestartPreflight;
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
model::{AdminErrorKind, Revision},
|
||||
@@ -14,20 +22,26 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
use super::plugins::official::update_compatibility;
|
||||
use super::plugins::official::{update_compatibility, validate_update_release};
|
||||
|
||||
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
|
||||
pub(crate) struct PluginSystemUpdatePreflight {
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
snapshots: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl PluginSystemUpdatePreflight {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn PluginStore>,
|
||||
inspector: Arc<dyn PluginPackageInspector>,
|
||||
snapshots: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self { store, inspector }
|
||||
Self {
|
||||
store,
|
||||
inspector,
|
||||
snapshots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,63 +51,28 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
let target = semver::Version::parse(candidate.target_version.trim_start_matches('v'))
|
||||
.map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
let compatibility = update_compatibility(
|
||||
candidate.release_manifest.as_ref(),
|
||||
candidate.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?;
|
||||
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
|
||||
let mut inspected = BTreeMap::new();
|
||||
for instance in snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| instance.enabled)
|
||||
{
|
||||
let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256)
|
||||
{
|
||||
requirements
|
||||
} else {
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(&instance.artifact_sha256)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let requirements = self
|
||||
.inspector
|
||||
.compatibility(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await
|
||||
.map_err(map_inspection_error)?;
|
||||
inspected.insert(instance.artifact_sha256.clone(), requirements);
|
||||
inspected
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| internal("插件兼容性检查结果不可用"))?
|
||||
};
|
||||
let host_versions = semver::VersionReq::parse(&requirements.host_version)
|
||||
.map_err(|_| incompatible(&instance.id))?;
|
||||
if !host_versions.matches(&target)
|
||||
|| !compatibility
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !compatibility
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!compatibility.supports_capability(capability, *version)
|
||||
})
|
||||
|| requirements
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|permission| !compatibility.supports_permission(permission))
|
||||
{
|
||||
return Err(incompatible(&instance.id));
|
||||
}
|
||||
let target_version = candidate.target_version.trim_start_matches('v');
|
||||
semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。
|
||||
validate_update_release(candidate.release_manifest.as_ref(), target_version)
|
||||
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
|
||||
Ok(self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(map_store_error)?
|
||||
.config_revision)
|
||||
}
|
||||
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
let plan = self.plan(Some(candidate)).await?;
|
||||
if !plan.incompatible_plugins.is_empty() {
|
||||
return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件"));
|
||||
}
|
||||
Ok(snapshot.config_revision)
|
||||
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))
|
||||
}
|
||||
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
|
||||
@@ -131,10 +110,6 @@ fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError
|
||||
}
|
||||
}
|
||||
|
||||
fn incompatible(instance_id: &str) -> SystemOperationError {
|
||||
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
|
||||
}
|
||||
@@ -146,3 +121,158 @@ fn conflict(message: impl Into<String>) -> SystemOperationError {
|
||||
fn internal(message: impl Into<String>) -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
|
||||
}
|
||||
|
||||
impl PluginSystemUpdatePreflight {
|
||||
pub(crate) async fn plan(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<SystemRestartPlan, SystemOperationError> {
|
||||
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
|
||||
let mut plan = SystemRestartPlan {
|
||||
target_version: candidate.as_ref().map(|item| item.target_version.clone()),
|
||||
release_manifest_sha256: candidate
|
||||
.as_ref()
|
||||
.map(|item| hex::encode(Sha256::digest(&item.release_manifest))),
|
||||
config_revision: snapshot.config_revision.get(),
|
||||
incompatible_plugins: Vec::new(),
|
||||
};
|
||||
let target = candidate
|
||||
.as_ref()
|
||||
.map(|item| semver::Version::parse(item.target_version.trim_start_matches('v')))
|
||||
.transpose()
|
||||
.map_err(|_| invalid("目标网关版本不合法"))?;
|
||||
if let Some(candidate) = &candidate {
|
||||
validate_update_release(
|
||||
&candidate.release_manifest,
|
||||
candidate.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
|
||||
}
|
||||
// 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。
|
||||
let host = candidate.as_ref().and_then(|item| {
|
||||
update_compatibility(
|
||||
&item.release_manifest,
|
||||
item.target_version.trim_start_matches('v'),
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
let mut inspected: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
for instance in snapshot.instances.iter().filter(|item| item.enabled) {
|
||||
let reason = if let Some(reason) = inspected.get(&instance.artifact_sha256) {
|
||||
reason.clone()
|
||||
} else {
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(&instance.artifact_sha256)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
let reason = if let Some(target) = &target {
|
||||
match (
|
||||
&host,
|
||||
self.inspector
|
||||
.compatibility(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await,
|
||||
) {
|
||||
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件包或协议与目标版本不兼容".to_owned())
|
||||
}
|
||||
(_, Err(error)) => return Err(map_inspection_error(error)),
|
||||
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
|
||||
(Some(host), Ok(requirements)) => {
|
||||
if !semver::VersionReq::parse(&requirements.host_version)
|
||||
.is_ok_and(|versions| versions.matches(target))
|
||||
{
|
||||
Some(format!("要求网关版本 {}", requirements.host_version))
|
||||
} else if !host
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !host
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!host.supports_capability(capability, *version)
|
||||
})
|
||||
{
|
||||
Some("插件协议或能力与目标版本不兼容".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.inspector
|
||||
.inspect(artifact.archive, Some(instance.artifact_sha256.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => None,
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件与当前宿主不兼容".to_owned())
|
||||
}
|
||||
Err(error) => return Err(map_inspection_error(error)),
|
||||
}
|
||||
};
|
||||
inspected.insert(instance.artifact_sha256.clone(), reason.clone());
|
||||
reason
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
plan.incompatible_plugins.push(SystemIncompatiblePlugin {
|
||||
instance_id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
plan.incompatible_plugins
|
||||
.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
|
||||
Ok(plan)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConfirmedPluginRestart {
|
||||
pub preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
pub confirmation: Option<SystemRestartPlan>,
|
||||
pub context: MutationContext,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemRestartPreflight for ConfirmedPluginRestart {
|
||||
async fn prepare(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<(), SystemOperationError> {
|
||||
let plan = self.preflight.plan(candidate).await?;
|
||||
if self
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.is_some_and(|confirmed| confirmed != &plan)
|
||||
{
|
||||
return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启"));
|
||||
}
|
||||
if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() {
|
||||
return Err(conflict("请先确认停用不兼容插件,再重启服务"));
|
||||
}
|
||||
let mut revision =
|
||||
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?;
|
||||
if !plan.incompatible_plugins.is_empty() {
|
||||
let ids = plan
|
||||
.incompatible_plugins
|
||||
.iter()
|
||||
.map(|item| item.instance_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
revision = self
|
||||
.preflight
|
||||
.store
|
||||
.disable_instances(&ids, revision, &self.context)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
publish_committed(self.preflight.snapshots.as_ref(), revision)
|
||||
.await
|
||||
.map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?;
|
||||
}
|
||||
self.preflight.confirm_revision(revision).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
@@ -14,7 +17,7 @@ use crate::{
|
||||
distribution::VerifiedPluginArtifact,
|
||||
instances::{
|
||||
ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy,
|
||||
PluginInstance, PluginPermissionGrant,
|
||||
PluginInstance,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -49,16 +52,10 @@ pub struct PluginsService {
|
||||
pub(super) preparation: Arc<dyn PluginPreparation>,
|
||||
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
|
||||
pub(super) state: PluginStateService,
|
||||
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, Option<String>>>,
|
||||
}
|
||||
|
||||
impl PluginsService {
|
||||
pub fn permission_descriptions(
|
||||
&self,
|
||||
permissions: &[String],
|
||||
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
|
||||
self.inspector.permission_descriptions(permissions)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn PluginStore>,
|
||||
@@ -77,6 +74,7 @@ impl PluginsService {
|
||||
preparation,
|
||||
published,
|
||||
state: PluginStateService::new(state),
|
||||
compatibility: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,11 +382,14 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
|
||||
| "maintenance"
|
||||
)
|
||||
})
|
||||
.flat_map(|(_, contribution)| {
|
||||
contribution
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| PluginCapabilityBinding {
|
||||
.flat_map(|(capability, contribution)| {
|
||||
contribution.stages.iter().flat_map(move |stage| {
|
||||
let events: &[Option<&str>] = if capability == "observer" {
|
||||
&[Some("request_completed"), Some("websocket_response")]
|
||||
} else {
|
||||
&[None]
|
||||
};
|
||||
events.iter().map(move |event| PluginCapabilityBinding {
|
||||
contribution: contribution.id.clone(),
|
||||
stage: stage.clone(),
|
||||
order: 0,
|
||||
@@ -403,8 +404,10 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
|
||||
account_group_ids: Vec::new(),
|
||||
provider_ids: Vec::new(),
|
||||
models: Vec::new(),
|
||||
event: event.map(str::to_owned),
|
||||
identity_bindings: Vec::new(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -425,12 +428,6 @@ fn default_instance(
|
||||
trusted_process: true,
|
||||
configuration,
|
||||
secrets: Default::default(),
|
||||
grants: metadata
|
||||
.requested_permissions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|permission| PluginPermissionGrant { permission })
|
||||
.collect(),
|
||||
bindings,
|
||||
revision,
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ use secrecy::ExposeSecret;
|
||||
use super::PluginsService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
AdminError, AdminErrorKind, MutationContext, Revision,
|
||||
plugins::instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
|
||||
PluginInstanceSnapshot, PluginInstanceView, PluginPermissionGrant,
|
||||
PluginInstanceSnapshot, PluginInstanceView,
|
||||
},
|
||||
plugins::state::{PluginStateCommit, PluginStateConfiguration},
|
||||
},
|
||||
@@ -18,6 +18,36 @@ use crate::{
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
/// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。
|
||||
async fn compatibility_warning(&self, digest: &str) -> Result<Option<String>, AdminError> {
|
||||
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。
|
||||
// 只缓存有限数量的确定结果,暂时性错误仍可重试。
|
||||
let mut cache = self.compatibility.lock().await;
|
||||
if let Some(warning) = cache.get(digest) {
|
||||
return Ok(warning.clone());
|
||||
}
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let warning = match self
|
||||
.inspector
|
||||
.inspect(artifact.archive, Some(digest.to_owned()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => None,
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some(error.message().to_owned())
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cache.len() < 128 {
|
||||
cache.insert(digest.to_owned(), warning.clone());
|
||||
}
|
||||
Ok(warning)
|
||||
}
|
||||
|
||||
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
@@ -52,10 +82,14 @@ impl PluginsService {
|
||||
let metadata = artifacts
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
let configuration_required = !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
let compatibility_warning = self
|
||||
.compatibility_warning(&instance.artifact_sha256)
|
||||
.await?;
|
||||
let configuration_required = compatibility_warning.is_none()
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
|
||||
let running = instance.enabled
|
||||
&& published_ready
|
||||
@@ -76,6 +110,7 @@ impl PluginsService {
|
||||
});
|
||||
views.push(PluginInstanceView {
|
||||
configuration_required,
|
||||
compatibility_warning,
|
||||
running: runtime.status == PluginInstanceRuntimeStatus::Running,
|
||||
published_revision,
|
||||
runtime,
|
||||
@@ -162,6 +197,13 @@ impl PluginsService {
|
||||
if artifact.accepted_at.is_none() {
|
||||
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
|
||||
}
|
||||
if input.enabled
|
||||
&& let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await?
|
||||
{
|
||||
return Err(AdminError::invalid(format!(
|
||||
"{warning},无法启动,请安装兼容版本"
|
||||
)));
|
||||
}
|
||||
let same_plugin = |digest: &str| {
|
||||
artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == digest
|
||||
@@ -225,13 +267,6 @@ impl PluginsService {
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
}),
|
||||
grants: artifact
|
||||
.metadata
|
||||
.requested_permissions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|permission| PluginPermissionGrant { permission })
|
||||
.collect(),
|
||||
bindings: input.bindings,
|
||||
revision: candidate_revision,
|
||||
};
|
||||
@@ -690,7 +725,6 @@ fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
|
||||
|| !instance.configuration.is_object()
|
||||
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|
||||
|| instance.secrets.len() > 64
|
||||
|| instance.grants.len() > 32
|
||||
|| instance.bindings.len() > 64
|
||||
{
|
||||
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
ports::{plugin_management::PluginManagement, plugins::PluginStore},
|
||||
};
|
||||
|
||||
/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。
|
||||
/// 菜单来自发布视图;每次业务动作复核持久实例,旧页面不能继续使用已停用或换版的目标。
|
||||
pub struct PluginManagementService {
|
||||
runtime: Arc<dyn PluginManagement>,
|
||||
store: Arc<dyn PluginStore>,
|
||||
@@ -25,12 +25,9 @@ pub struct PluginManagementService {
|
||||
|
||||
impl PluginManagementService {
|
||||
/// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。
|
||||
pub async fn authorize_models(
|
||||
&self,
|
||||
target: &PluginManagementTarget,
|
||||
) -> Result<(), AdminError> {
|
||||
pub async fn validate_target(&self, target: &PluginManagementTarget) -> Result<(), AdminError> {
|
||||
let reference = self.authorize(target).await?;
|
||||
self.runtime.authorize_models(&reference, target).await
|
||||
self.runtime.validate_target(&reference, target).await
|
||||
}
|
||||
|
||||
pub async fn start_callback(
|
||||
|
||||
@@ -34,7 +34,7 @@ struct SealedReleaseManifest {
|
||||
sealed: bool,
|
||||
gateway_version: String,
|
||||
gateway_git_sha: String,
|
||||
plugin_host: PluginHostCompatibility,
|
||||
plugin_host: serde_json::Value,
|
||||
plugins: Vec<OfficialPlugin>,
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ fn validate_manifest(
|
||||
identity: &OfficialPluginReleaseIdentity,
|
||||
) -> Result<ValidatedManifest, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
parse_host_compatibility(manifest.plugin_host)?;
|
||||
if manifest.gateway_version != identity.gateway_version
|
||||
|| manifest.gateway_git_sha != identity.gateway_git_sha
|
||||
{
|
||||
@@ -168,8 +169,39 @@ pub(crate) fn update_compatibility(
|
||||
target_version: &str,
|
||||
) -> Result<PluginHostCompatibility, AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
(manifest.gateway_version == target_version)
|
||||
.then_some(manifest.plugin_host)
|
||||
if manifest.gateway_version != target_version {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
parse_host_compatibility(manifest.plugin_host)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_update_release(
|
||||
bytes: &[u8],
|
||||
target_version: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let manifest = parse_manifest(bytes)?;
|
||||
if manifest.gateway_version != target_version {
|
||||
return Err(invalid_manifest());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_host_compatibility(
|
||||
mut value: serde_json::Value,
|
||||
) -> Result<PluginHostCompatibility, AdminError> {
|
||||
// 旧更新器要求 v1 与空权限字段,运行兼容性仍由清单、协议和能力版本表达。
|
||||
if value.get("schema_version") == Some(&serde_json::json!(1))
|
||||
&& value.get("permissions") == Some(&serde_json::json!([]))
|
||||
{
|
||||
let fields = value.as_object_mut().ok_or_else(invalid_manifest)?;
|
||||
fields.remove("permissions");
|
||||
fields.insert("schema_version".into(), serde_json::json!(2));
|
||||
}
|
||||
let compatibility: PluginHostCompatibility =
|
||||
serde_json::from_value(value).map_err(|_| invalid_manifest())?;
|
||||
compatibility
|
||||
.is_valid()
|
||||
.then_some(compatibility)
|
||||
.ok_or_else(invalid_manifest)
|
||||
}
|
||||
|
||||
@@ -183,7 +215,6 @@ fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
|
||||
|| !manifest.sealed
|
||||
|| !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES)
|
||||
|| !valid_git_sha(&manifest.gateway_git_sha)
|
||||
|| !manifest.plugin_host.is_valid()
|
||||
|| manifest.plugins.len() > MAXIMUM_PLUGINS
|
||||
{
|
||||
return Err(invalid_manifest());
|
||||
|
||||
@@ -252,10 +252,11 @@ fn version_bindings(
|
||||
let previous = capability.and_then(|capability| current.contributes.get(capability));
|
||||
previous.is_none_or(|contribution| {
|
||||
!contribution.stages.contains(&binding.stage)
|
||||
|| instance
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|old| old.contribution == contribution.id && old.stage == binding.stage)
|
||||
|| instance.bindings.iter().any(|old| {
|
||||
old.contribution == contribution.id
|
||||
&& old.stage == binding.stage
|
||||
&& old.event == binding.event
|
||||
})
|
||||
})
|
||||
});
|
||||
for previous in &instance.bindings {
|
||||
@@ -276,7 +277,9 @@ fn version_bindings(
|
||||
let mut binding = previous.clone();
|
||||
binding.contribution.clone_from(&contribution.id);
|
||||
if let Some(default) = bindings.iter_mut().find(|default| {
|
||||
default.contribution == binding.contribution && default.stage == binding.stage
|
||||
default.contribution == binding.contribution
|
||||
&& default.stage == binding.stage
|
||||
&& default.event == binding.event
|
||||
}) {
|
||||
*default = binding;
|
||||
} else {
|
||||
|
||||
@@ -102,6 +102,53 @@ impl DefaultSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
|
||||
let valid = command.request_location.validate().is_ok()
|
||||
&& command.responses_max_decompressed_body_bytes > 0
|
||||
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
|
||||
&& command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
&& crate::model::retention::RetentionPolicy::try_new(
|
||||
command.usage_retention_days,
|
||||
command.ops_event_retention_days,
|
||||
command.audit_retention_days,
|
||||
)
|
||||
.is_ok()
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
|
||||
&& gateway_core::provider_ports::valid_warmup_schedule_time(
|
||||
&command.account_warmup_schedule_time,
|
||||
)
|
||||
&& valid_probe_model(command.account_warmup_model.as_deref())
|
||||
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok()
|
||||
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
|
||||
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
|
||||
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("运行时设置不满足约束"))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_client_version(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
|
||||
}
|
||||
|
||||
fn valid_probe_model(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value == value.trim()
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsService for DefaultSettingsService {
|
||||
async fn preview_pricing_sync(
|
||||
@@ -365,7 +412,17 @@ impl SettingsService for DefaultSettingsService {
|
||||
.store
|
||||
.replace_runtime_settings(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "runtime settings"))?;
|
||||
.map_err(|error| {
|
||||
if matches!(
|
||||
error.kind(),
|
||||
crate::ports::store::AdminStoreErrorKind::Conflict
|
||||
| crate::ports::store::AdminStoreErrorKind::StaleRevision
|
||||
) {
|
||||
AdminError::conflict("运行设置已被其他调用修改,请重新读取并确认后提交")
|
||||
} else {
|
||||
map_store_error(error, "runtime settings")
|
||||
}
|
||||
})?;
|
||||
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
@@ -406,47 +463,3 @@ impl SettingsService for DefaultSettingsService {
|
||||
Ok(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
|
||||
let valid = command.request_location.validate().is_ok()
|
||||
&& command.responses_max_decompressed_body_bytes > 0
|
||||
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
|
||||
&& command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
|
||||
&& gateway_core::provider_ports::valid_warmup_schedule_time(
|
||||
&command.account_warmup_schedule_time,
|
||||
)
|
||||
&& valid_probe_model(command.account_warmup_model.as_deref())
|
||||
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok()
|
||||
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
|
||||
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
|
||||
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("运行时设置不满足约束"))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_client_version(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
|
||||
}
|
||||
|
||||
fn valid_probe_model(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value == value.trim()
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
|
||||
use crate::model::{MutationContext, system::SystemRestartPlan};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
@@ -14,7 +16,6 @@ use crate::{
|
||||
},
|
||||
ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
|
||||
SystemUpdatePreflight,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,20 +36,25 @@ pub trait SystemService: Send + Sync {
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError>;
|
||||
async fn restart(
|
||||
&self,
|
||||
confirmation: Option<SystemRestartPlan>,
|
||||
context: &MutationContext,
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
}
|
||||
|
||||
/// 保持 Host 能力窄边界的默认系统用例。
|
||||
pub(crate) struct DefaultSystemService {
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
}
|
||||
|
||||
impl DefaultSystemService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<PluginSystemUpdatePreflight>,
|
||||
) -> Self {
|
||||
Self {
|
||||
operations,
|
||||
@@ -87,7 +93,7 @@ impl SystemService for DefaultSystemService {
|
||||
.map(|version| version.trim().to_owned())
|
||||
.filter(|version| !version.is_empty());
|
||||
self.operations
|
||||
.perform_update(target_version, channel, Arc::clone(&self.preflight))
|
||||
.perform_update(target_version, channel, self.preflight.clone())
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
@@ -101,13 +107,36 @@ impl SystemService for DefaultSystemService {
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations
|
||||
.rollback(Arc::clone(&self.preflight))
|
||||
.rollback(self.preflight.clone())
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations.restart().await.map_err(map_system_error)
|
||||
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError> {
|
||||
let candidate = self
|
||||
.operations
|
||||
.restart_candidate()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
self.preflight
|
||||
.plan(candidate)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(
|
||||
&self,
|
||||
confirmation: Option<SystemRestartPlan>,
|
||||
context: &MutationContext,
|
||||
) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations
|
||||
.restart(Arc::new(ConfirmedPluginRestart {
|
||||
preflight: self.preflight.clone(),
|
||||
confirmation,
|
||||
context: context.clone(),
|
||||
}))
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +149,7 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
|
||||
};
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "系统操作请求不合法",
|
||||
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
|
||||
AdminErrorKind::Conflict => error.message(),
|
||||
AdminErrorKind::BadGateway => "系统更新服务请求失败",
|
||||
AdminErrorKind::Internal => "系统操作失败",
|
||||
_ => "系统操作失败",
|
||||
|
||||
@@ -14,9 +14,9 @@ use gateway_admin::{
|
||||
auth::AdminAuditEvent,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
|
||||
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
|
||||
UpdateBackupStorageCommand,
|
||||
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus,
|
||||
BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -330,19 +330,18 @@ impl BackupRepository for FakeBackupRepository {
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
transition: BackupStatusTransition,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.status == from)
|
||||
.find(|record| record.id == id && record.status == transition.from())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = to;
|
||||
record.status = transition.to();
|
||||
if let Some(size) = update.size_bytes {
|
||||
record.size_bytes = Some(size);
|
||||
}
|
||||
@@ -356,7 +355,11 @@ impl BackupRepository for FakeBackupRepository {
|
||||
record.error_message = Some(message);
|
||||
}
|
||||
record.completed_at = update.completed_at.or_else(|| {
|
||||
matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now)
|
||||
matches!(
|
||||
transition.to(),
|
||||
BackupStatus::Completed | BackupStatus::Failed
|
||||
)
|
||||
.then_some(now)
|
||||
});
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use gateway_admin::model::{
|
||||
MutationActor, MutationContext,
|
||||
audit::{MutationAuditIntent, MutationAuditOperation},
|
||||
auth::AuditActorKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mutation_intent_preserves_actor_request_and_committed_field_names() {
|
||||
let intent = MutationAuditIntent {
|
||||
operation: MutationAuditOperation::PluginInstanceConfigure,
|
||||
entity_ref: "instance-1",
|
||||
};
|
||||
for (actor, expected_kind, expected_user) in [
|
||||
(
|
||||
MutationActor::AdminSession {
|
||||
admin_user_id: "admin-1".to_owned(),
|
||||
},
|
||||
AuditActorKind::AdminSession,
|
||||
Some("admin-1"),
|
||||
),
|
||||
(
|
||||
MutationActor::AdminApiKey,
|
||||
AuditActorKind::AdminApiKey,
|
||||
None,
|
||||
),
|
||||
(MutationActor::System, AuditActorKind::System, None),
|
||||
] {
|
||||
let event = intent.event(
|
||||
&MutationContext {
|
||||
actor,
|
||||
request_id: "request-1".to_owned(),
|
||||
},
|
||||
vec!["configuration_json".to_owned()],
|
||||
);
|
||||
assert_eq!(event.actor_kind, expected_kind);
|
||||
assert_eq!(event.actor_admin_user_id.as_deref(), expected_user);
|
||||
assert_eq!(event.request_id.as_deref(), Some("request-1"));
|
||||
assert_eq!(
|
||||
(event.action.as_str(), event.entity_kind.as_str()),
|
||||
("configure", "plugin_instance")
|
||||
);
|
||||
assert_eq!(event.entity_ref, "instance-1");
|
||||
assert_eq!(event.changed_fields, ["configuration_json"]);
|
||||
assert_eq!(event.config_revision, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resulting_state_selects_the_existing_audit_classification() {
|
||||
for enabled in [true, false] {
|
||||
let action = if enabled { "enable" } else { "disable" };
|
||||
assert_eq!(
|
||||
MutationAuditOperation::AccountGroupEnabled { enabled }.classification(),
|
||||
(action, "account_group"),
|
||||
);
|
||||
assert_eq!(
|
||||
MutationAuditOperation::ClientApiKeyEnabled { enabled }.classification(),
|
||||
(action, "client_api_key"),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
MutationAuditOperation::AdminApiKeyChanged { exists: false }.classification(),
|
||||
("admin_api_key.delete", "runtime_settings"),
|
||||
);
|
||||
assert_eq!(
|
||||
MutationAuditOperation::AdminApiKeyChanged { exists: true }.classification(),
|
||||
("admin_api_key.replace", "runtime_settings"),
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,42 @@ use chrono::{TimeZone as _, Utc};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use gateway_admin::model::backup::{
|
||||
BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key,
|
||||
BackupSettings, BackupStatus, BackupStatusTransition, BackupStorageConfig, BackupTriggerKind,
|
||||
build_object_key,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn status_machine_rejects_invalid_transitions() {
|
||||
assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading));
|
||||
assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting));
|
||||
assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed));
|
||||
fn status_transition_accepts_every_legal_edge() {
|
||||
let transitions = [
|
||||
(BackupStatus::Queued, BackupStatus::Dumping),
|
||||
(BackupStatus::Queued, BackupStatus::Failed),
|
||||
(BackupStatus::Dumping, BackupStatus::Uploading),
|
||||
(BackupStatus::Dumping, BackupStatus::Failed),
|
||||
(BackupStatus::Uploading, BackupStatus::Completed),
|
||||
(BackupStatus::Uploading, BackupStatus::Failed),
|
||||
(BackupStatus::Completed, BackupStatus::Deleting),
|
||||
(BackupStatus::Failed, BackupStatus::Deleting),
|
||||
];
|
||||
assert!(
|
||||
transitions
|
||||
.into_iter()
|
||||
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_some() })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_transition_rejects_invalid_edges() {
|
||||
let transitions = [
|
||||
(BackupStatus::Queued, BackupStatus::Completed),
|
||||
(BackupStatus::Completed, BackupStatus::Dumping),
|
||||
(BackupStatus::Deleting, BackupStatus::Failed),
|
||||
(BackupStatus::Deleting, BackupStatus::Completed),
|
||||
];
|
||||
assert!(
|
||||
transitions
|
||||
.into_iter()
|
||||
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_none() })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
|
||||
|
||||
mod audit;
|
||||
mod backup;
|
||||
mod plugins;
|
||||
mod provider_credentials;
|
||||
mod quota_forecast;
|
||||
mod quota_forecast_sampling;
|
||||
mod retention;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use gateway_admin::model::plugins::PluginArtifactMetadata;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn metadata() -> Value {
|
||||
json!({
|
||||
"pluginId": "test.example", "version": "1.0.0", "name": "example",
|
||||
"displayName": "Example", "publisher": "test", "description": "Example plugin",
|
||||
"license": "MIT", "sha256": "a".repeat(64), "platforms": ["linux-x86_64"],
|
||||
"contributes": {"middleware": {
|
||||
"id": "test.example.middleware", "version": 3,
|
||||
"stages": [], "inputFormats": [], "outputFormats": []
|
||||
}},
|
||||
"configurationSchema": {}, "secretFields": [], "stateNamespaces": []
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_ignores_unknown_fields_at_each_object_boundary() {
|
||||
let mut original = metadata();
|
||||
original["icon"] = json!({"light": "light.svg", "dark": "dark.svg"});
|
||||
original["stateNamespaces"] = json!([{
|
||||
"namespace": "cache", "schemaVersion": 1, "schemaSha256": "b".repeat(64),
|
||||
"schema": {}, "maximumRecords": 10, "maximumBytes": 1024,
|
||||
"maximumValueBytes": 128, "migratesFrom": []
|
||||
}]);
|
||||
let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap();
|
||||
for path in ["", "/contributes/middleware", "/icon", "/stateNamespaces/0"] {
|
||||
let mut extended = original.clone();
|
||||
extended.pointer_mut(path).unwrap()["futureField"] = json!({"enabled": true});
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginArtifactMetadata>(extended).unwrap(),
|
||||
expected,
|
||||
"unknown field at {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_defaults_only_optional_fields() {
|
||||
let original = metadata();
|
||||
let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap();
|
||||
let mut older = original;
|
||||
for field in ["configurationSchema", "secretFields", "stateNamespaces"] {
|
||||
older.as_object_mut().unwrap().remove(field);
|
||||
}
|
||||
for field in ["stages", "inputFormats", "outputFormats"] {
|
||||
older["contributes"]["middleware"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginArtifactMetadata>(older).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_still_requires_identity_and_execution_facts() {
|
||||
for field in [
|
||||
"pluginId",
|
||||
"version",
|
||||
"name",
|
||||
"publisher",
|
||||
"sha256",
|
||||
"platforms",
|
||||
"contributes",
|
||||
] {
|
||||
let mut incomplete = metadata();
|
||||
incomplete.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<PluginArtifactMetadata>(incomplete).is_err(),
|
||||
"missing {field}"
|
||||
);
|
||||
}
|
||||
for field in ["id", "version"] {
|
||||
let mut incomplete = metadata();
|
||||
incomplete["contributes"]["middleware"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<PluginArtifactMetadata>(incomplete).is_err(),
|
||||
"missing contribution {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_still_rejects_wrong_types_in_known_fields() {
|
||||
for (path, value) in [
|
||||
("/version", json!(1)),
|
||||
("/secretFields", json!(false)),
|
||||
("/stateNamespaces", json!({})),
|
||||
("/contributes/middleware/version", json!("3")),
|
||||
("/contributes/middleware/stages", json!("http")),
|
||||
] {
|
||||
let mut invalid = metadata();
|
||||
*invalid.pointer_mut(path).unwrap() = value;
|
||||
assert!(
|
||||
serde_json::from_value::<PluginArtifactMetadata>(invalid).is_err(),
|
||||
"wrong type at {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
assert_eq!(week.estimated_tokens, Some(1_000));
|
||||
assert_eq!(week.estimated_usd, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.estimated_tokens, Some(38_571));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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)] {
|
||||
assert!(RetentionPolicy::try_new(usage, ops, audit).is_err());
|
||||
}
|
||||
let policy = RetentionPolicy::try_new(31, 7, 90).unwrap();
|
||||
assert_eq!(policy.days(RetentionTarget::ModelRequests), 31);
|
||||
assert_eq!(policy.days(RetentionTarget::OpsEvents), 7);
|
||||
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
@@ -55,7 +58,8 @@ use gateway_admin::{
|
||||
ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry,
|
||||
},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore,
|
||||
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreErrorKind,
|
||||
AdminStoreResult, SettingsStore,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -668,6 +672,12 @@ impl FakeAccountStore {
|
||||
last_error_message: account.last_error_message.clone(),
|
||||
};
|
||||
AccountPageItem {
|
||||
capacity: gateway_admin::model::accounts::AccountCapacity {
|
||||
used_slots: None,
|
||||
total_slots: account
|
||||
.concurrency_limit
|
||||
.map(|limit| u64::from(limit.get())),
|
||||
},
|
||||
account,
|
||||
projection: resolve_account_status(&facts, std::time::SystemTime::now()),
|
||||
}
|
||||
@@ -2420,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
store
|
||||
.quota_forecast_history
|
||||
@@ -2461,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
@@ -3405,3 +3413,136 @@ async fn unregistered_provider_accounts_have_no_live_management_capabilities() {
|
||||
);
|
||||
assert_eq!(page.items[0].capabilities, Default::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_quota_refresh_uses_native_provider_without_changing_configuration() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let access = gateway_admin::initialize_plugin_accounts(
|
||||
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
|
||||
store,
|
||||
Arc::new(RecordingPluginAccountPublication(events.clone())),
|
||||
);
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
access.get_quota(&id).await.unwrap();
|
||||
access.refresh_quota(&id).await.unwrap();
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
vec![
|
||||
ProviderQuotaRequest {
|
||||
account_id: id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None
|
||||
},
|
||||
ProviderQuotaRequest {
|
||||
account_id: id,
|
||||
refresh: true,
|
||||
rolling_usage: None
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(!recorded(&events).contains(&"snapshot.publish_committed"));
|
||||
}
|
||||
|
||||
struct CapacityRuntime {
|
||||
counts: Option<BTreeMap<String, u64>>,
|
||||
fail: bool,
|
||||
reads: Mutex<Vec<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for CapacityRuntime {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn account_runtime(&self, ids: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
self.reads.lock().unwrap().push(ids.to_vec());
|
||||
if self.fail {
|
||||
return Err(store_unavailable());
|
||||
}
|
||||
Ok(AccountRuntimeSnapshot {
|
||||
in_flight: self.counts.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(&self, _: &[String]) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &gateway_admin::model::accounts::AccountFreeze,
|
||||
_: Option<chrono::DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavailable() {
|
||||
for (counts, fail, expected) in [
|
||||
(
|
||||
Some(BTreeMap::from([("acct_test".to_owned(), 3)])),
|
||||
false,
|
||||
Some(3),
|
||||
),
|
||||
(Some(BTreeMap::new()), false, Some(0)),
|
||||
(None, false, None),
|
||||
(None, true, None),
|
||||
] {
|
||||
for empty in [false, true] {
|
||||
let runtime = Arc::new(CapacityRuntime {
|
||||
counts: counts.clone(),
|
||||
fail,
|
||||
reads: Mutex::default(),
|
||||
});
|
||||
let mut account = account_record("openai");
|
||||
account.concurrency_limit = gateway_core::account::AccountConcurrencyLimit::new(5);
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
if empty {
|
||||
store.set_accounts(Vec::new());
|
||||
}
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.account_runtime(runtime.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if empty {
|
||||
assert!(page.items.is_empty());
|
||||
assert!(runtime.reads.lock().unwrap().is_empty());
|
||||
} else {
|
||||
assert_eq!(
|
||||
*runtime.reads.lock().unwrap(),
|
||||
[vec!["acct_test".to_owned()]]
|
||||
);
|
||||
assert_eq!(page.items[0].capacity.used_slots, expected);
|
||||
assert_eq!(page.items[0].capacity.total_slots, Some(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,19 @@ struct TestClientKeyStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
self.resets.lock().unwrap().push(command);
|
||||
@@ -147,7 +157,11 @@ async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
|
||||
};
|
||||
let result = services
|
||||
.client_keys()
|
||||
.reset_budget(&mutation_context(), command.clone())
|
||||
.reset_budget(
|
||||
&mutation_context(),
|
||||
command.clone(),
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin::Admin,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, command.id);
|
||||
|
||||
@@ -44,7 +44,7 @@ use gateway_admin::{
|
||||
observability::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
@@ -105,6 +105,7 @@ pub(super) struct AdminHarness {
|
||||
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
|
||||
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
|
||||
client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
service_middleware: gateway_admin::service::PlanSource,
|
||||
}
|
||||
|
||||
impl AdminHarness {
|
||||
@@ -132,6 +133,7 @@ impl AdminHarness {
|
||||
plugin_store: Arc::new(plugins::TestPluginPorts),
|
||||
plugin_inspector: Arc::new(plugins::TestPluginPorts),
|
||||
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
|
||||
service_middleware: Arc::new(|| None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +192,11 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn service_middleware(mut self, source: gateway_admin::service::PlanSource) -> Self {
|
||||
self.service_middleware = source;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn backup(mut self, backup: BackupStorePorts) -> Self {
|
||||
self.backup = backup;
|
||||
self
|
||||
@@ -261,6 +268,7 @@ impl AdminHarness {
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
service_middleware: self.service_middleware,
|
||||
plugin_preparation: Arc::new(plugins::TestPluginPorts),
|
||||
plugin_management: Arc::new(plugins::TestPluginPorts),
|
||||
published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle::default(),
|
||||
@@ -644,9 +652,19 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>> {
|
||||
Err(unavailable("client key budget limits"))
|
||||
}
|
||||
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
Err(unavailable("client key budget reset"))
|
||||
@@ -752,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
@@ -1068,7 +1086,10 @@ impl SystemOperations for UnavailableSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
async fn restart(
|
||||
&self,
|
||||
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
@@ -1091,3 +1112,4 @@ fn unavailable_system() -> SystemOperationError {
|
||||
"unavailable in this test",
|
||||
)
|
||||
}
|
||||
mod service_contract;
|
||||
|
||||
@@ -16,11 +16,11 @@ use gateway_admin::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
|
||||
UsagePage, UsageQuery, china_day_start,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
@@ -513,7 +516,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
let mut failure = diagnostic("single_failure", 1);
|
||||
failure.success_count = 0;
|
||||
failure.failure_count = 1;
|
||||
store.replace_diagnostics(vec![success, failure]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 101,
|
||||
items: vec![success, failure],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
@@ -529,6 +535,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_use_the_total_before_truncating_groups() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 200,
|
||||
items: (0..100)
|
||||
.map(|index| diagnostic(&format!("model-{index}"), 1))
|
||||
.collect(),
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("limited diagnostics");
|
||||
|
||||
assert!(result.items.iter().all(|item| item.request_share == 0.005));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_count_each_retried_request_once() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let mut item = diagnostic("model", 8);
|
||||
item.attempt_count = 14;
|
||||
item.retry_count = 6;
|
||||
item.retried_request_count = 2;
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 8,
|
||||
items: vec![item],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("retry diagnostics");
|
||||
|
||||
assert_eq!(
|
||||
(result.items[0].retry_count, result.items[0].retry_rate),
|
||||
(6, 0.25)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_preserve_empty_results() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
|
||||
.await
|
||||
.expect("empty diagnostics");
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
@@ -632,7 +700,7 @@ struct FixtureObservabilityStore {
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
billing_stream_fails: AtomicBool,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
diagnostics: Mutex<DiagnosticsObservation>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
@@ -653,7 +721,7 @@ impl FixtureObservabilityStore {
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
billing_stream_fails: AtomicBool::new(false),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(DiagnosticsObservation::default()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
@@ -686,7 +754,7 @@ impl FixtureObservabilityStore {
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
|
||||
@@ -798,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
@@ -948,6 +1016,8 @@ fn total_record(
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_plan_type: None,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
@@ -1010,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
first_token_p95_ms: None,
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retried_request_count: 0,
|
||||
account_provider_kind: None,
|
||||
account_plan_type: None,
|
||||
key: name.to_owned(),
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
|
||||
@@ -28,6 +28,8 @@ use gateway_admin::{
|
||||
struct Fixture {
|
||||
revisions: std::sync::Mutex<Vec<Revision>>,
|
||||
requirements: PluginCompatibilityRequirements,
|
||||
instances: Vec<PluginInstance>,
|
||||
disabled: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
@@ -40,6 +42,8 @@ impl Fixture {
|
||||
.collect(),
|
||||
),
|
||||
requirements,
|
||||
instances: vec![instance(true)],
|
||||
disabled: std::sync::Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,18 +99,17 @@ impl PluginStore for Fixture {
|
||||
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
|
||||
Ok(PluginInstanceSnapshot {
|
||||
config_revision: self.next_revision(),
|
||||
instances: vec![PluginInstance {
|
||||
id: "enabled-fixture".into(),
|
||||
name: "Enabled fixture".into(),
|
||||
artifact_sha256: "a".repeat(64),
|
||||
enabled: true,
|
||||
trusted_process: true,
|
||||
configuration: serde_json::json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
grants: Vec::new(),
|
||||
bindings: Vec::new(),
|
||||
revision: Revision::new(1).expect("revision"),
|
||||
}],
|
||||
instances: self
|
||||
.instances
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut instance| {
|
||||
if self.disabled.lock().unwrap().contains(&instance.id) {
|
||||
instance.enabled = false;
|
||||
}
|
||||
instance
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ impl PluginStore for Fixture {
|
||||
platforms: Vec::new(),
|
||||
icon: None,
|
||||
contributes: BTreeMap::new(),
|
||||
requested_permissions: Vec::new(),
|
||||
|
||||
configuration_schema: serde_json::json!({}),
|
||||
secret_fields: Vec::new(),
|
||||
state_namespaces: Vec::new(),
|
||||
@@ -135,6 +138,24 @@ impl PluginStore for Fixture {
|
||||
})
|
||||
}
|
||||
|
||||
async fn disable_instances(
|
||||
&self,
|
||||
ids: &[String],
|
||||
expected: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
let mut revisions = self.revisions.lock().unwrap();
|
||||
if revisions[0] != expected {
|
||||
return Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::StaleRevision,
|
||||
"plugin",
|
||||
"stale",
|
||||
));
|
||||
}
|
||||
revisions[0] = Revision::new(expected.get() + 1).unwrap();
|
||||
self.disabled.lock().unwrap().extend_from_slice(ids);
|
||||
Ok(revisions[0])
|
||||
}
|
||||
async fn save_instance(
|
||||
&self,
|
||||
_: PluginInstance,
|
||||
@@ -273,23 +294,41 @@ impl SystemOperations for PreflightingSystem {
|
||||
|
||||
async fn rollback(
|
||||
&self,
|
||||
_: Arc<dyn SystemUpdatePreflight>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
let revision = preflight.validate_rollback(self.candidate.clone()).await?;
|
||||
preflight.confirm_revision(revision).await?;
|
||||
Ok(SystemOperationAccepted::Rollback {
|
||||
operation_id: "fixture".into(),
|
||||
message: "accepted".into(),
|
||||
need_restart: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!()
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
Ok(Some(self.candidate.clone()))
|
||||
}
|
||||
async fn restart(
|
||||
&self,
|
||||
preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
preflight.prepare(Some(self.candidate.clone())).await?;
|
||||
Ok(SystemOperationAccepted::Restart {
|
||||
operation_id: "fixture".into(),
|
||||
message: "accepted".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor", "log"),
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
@@ -302,28 +341,25 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_rejects_missing_target_capability_and_revision_changes() {
|
||||
let incompatible = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
|
||||
async fn rollback_rejects_missing_target_capability() {
|
||||
let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(incompatible.clone(), incompatible)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models", "log"),
|
||||
candidate: candidate("models"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(services.system().rollback().await.is_err());
|
||||
}
|
||||
|
||||
let stale = Fixture::new(requirements("^1.0", "executor", "log"), &[7, 8]);
|
||||
#[tokio::test]
|
||||
async fn system_update_rejects_revision_changes() {
|
||||
let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(stale.clone(), stale)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor", "log"),
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
@@ -336,32 +372,117 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
|
||||
);
|
||||
}
|
||||
|
||||
fn requirements(
|
||||
host_version: &str,
|
||||
capability: &str,
|
||||
permission: &str,
|
||||
) -> PluginCompatibilityRequirements {
|
||||
#[tokio::test]
|
||||
async fn system_update_accepts_new_plugin_contracts_for_empty_disabled_and_enabled_plugins() {
|
||||
for instances in [vec![], vec![instance(false)], vec![instance(true)]] {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
Arc::get_mut(&mut fixture)
|
||||
.expect("exclusive fixture")
|
||||
.instances = instances;
|
||||
let mut candidate = candidate("models");
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&candidate.release_manifest).unwrap();
|
||||
manifest["plugin_host"] =
|
||||
serde_json::json!({"schema_version": 99, "future_contract": true});
|
||||
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem { candidate }))
|
||||
.build()
|
||||
.await;
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.expect("plugin confirmation is deferred until restart");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_keeps_release_identity_validation_without_plugins() {
|
||||
for (field, value) in [
|
||||
("gateway_version", serde_json::json!("1.3.0")),
|
||||
("gateway_git_sha", serde_json::json!("invalid")),
|
||||
("sealed", serde_json::json!(false)),
|
||||
("schema_version", serde_json::json!(99)),
|
||||
] {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
Arc::get_mut(&mut fixture).unwrap().instances.clear();
|
||||
let mut candidate = candidate("executor");
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&candidate.release_manifest).unwrap();
|
||||
manifest[field] = value;
|
||||
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem { candidate }))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_still_rechecks_revision_without_plugins() {
|
||||
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
Arc::get_mut(&mut fixture).unwrap().instances.clear();
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture)
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn instance(enabled: bool) -> PluginInstance {
|
||||
PluginInstance {
|
||||
id: "enabled-fixture".into(),
|
||||
name: "Enabled fixture".into(),
|
||||
artifact_sha256: "a".repeat(64),
|
||||
enabled,
|
||||
trusted_process: true,
|
||||
configuration: serde_json::json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
bindings: Vec::new(),
|
||||
revision: Revision::new(1).expect("revision"),
|
||||
}
|
||||
}
|
||||
|
||||
fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements {
|
||||
PluginCompatibilityRequirements {
|
||||
host_version: host_version.into(),
|
||||
manifest_schema_version: 2,
|
||||
protocol_version: 3,
|
||||
capabilities: vec![(capability.into(), 1)],
|
||||
permissions: vec![permission.into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate {
|
||||
fn candidate(capability: &str) -> SystemUpdateCandidate {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"sealed": true,
|
||||
"gateway_version": "1.2.0",
|
||||
"gateway_git_sha": "a".repeat(40),
|
||||
"plugin_host": {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [3],
|
||||
"capabilities": [{ "capability": capability, "versions": [1] }],
|
||||
"permissions": [permission],
|
||||
},
|
||||
"plugins": [],
|
||||
}))
|
||||
@@ -371,3 +492,106 @@ fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate {
|
||||
release_manifest: manifest.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::AdminApiKey,
|
||||
request_id: "restart-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_restart_requires_exact_confirmation_before_disabling() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let plan = services.system().restart_plan().await.unwrap();
|
||||
assert_eq!(plan.incompatible_plugins.len(), 1);
|
||||
assert_eq!(plan.incompatible_plugins[0].name, "Enabled fixture");
|
||||
assert!(
|
||||
fixture.disabled.lock().unwrap().is_empty(),
|
||||
"检查及取消不能停用插件"
|
||||
);
|
||||
assert!(services.system().restart(None, &context()).await.is_err());
|
||||
for altered in 0..3 {
|
||||
let mut changed = plan.clone();
|
||||
match altered {
|
||||
0 => changed.config_revision += 1,
|
||||
1 => changed.release_manifest_sha256 = Some("b".repeat(64)),
|
||||
_ => changed.incompatible_plugins.clear(),
|
||||
}
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart(Some(changed), &context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
}
|
||||
services
|
||||
.system()
|
||||
.restart(Some(plan), &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*fixture.disabled.lock().unwrap(), ["enabled-fixture"]);
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart_plan()
|
||||
.await
|
||||
.unwrap()
|
||||
.incompatible_plugins
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatible_restart_does_not_change_plugin_configuration() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("executor"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart_plan()
|
||||
.await
|
||||
.unwrap()
|
||||
.incompatible_plugins
|
||||
.is_empty()
|
||||
);
|
||||
services.system().restart(None, &context()).await.unwrap();
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
assert_eq!(fixture.next_revision().get(), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_configuration_after_restart_check_requires_a_new_confirmation() {
|
||||
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
|
||||
let services = super::AdminHarness::new()
|
||||
.plugins(fixture.clone(), fixture.clone())
|
||||
.system(Arc::new(PreflightingSystem {
|
||||
candidate: candidate("models"),
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let plan = services.system().restart_plan().await.unwrap();
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.restart(Some(plan), &context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(fixture.disabled.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ impl Fixture {
|
||||
let unused = Arc::new(TestPluginPorts);
|
||||
PluginsService::new(
|
||||
self.clone(),
|
||||
unused.clone(),
|
||||
self.clone(),
|
||||
PluginDistributionPorts::new(unused.clone(), unused.clone()),
|
||||
self.clone(),
|
||||
self.clone(),
|
||||
@@ -87,6 +87,20 @@ impl SnapshotControl for Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
_: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
|
||||
archive,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Lease;
|
||||
|
||||
impl ExtensionSetLease for Lease {
|
||||
@@ -95,6 +109,44 @@ impl ExtensionSetLease for Lease {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture {
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
|
||||
_: Option<u64>,
|
||||
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
|
||||
) -> Option<
|
||||
std::collections::BTreeMap<
|
||||
String,
|
||||
gateway_admin::model::plugins::instances::PluginInstanceRuntime,
|
||||
>,
|
||||
> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture {
|
||||
async fn activate_state(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::instances::PluginInstance,
|
||||
) -> Result<(), AdminError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
|
||||
panic!("unexpected instance drain")
|
||||
}
|
||||
async fn migrate_state(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: gateway_admin::model::plugins::state::PluginStateTransition,
|
||||
) -> Result<(), AdminError> {
|
||||
panic!("unexpected state migration")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPreparation for Fixture {
|
||||
async fn configuration_ready(
|
||||
@@ -222,7 +274,10 @@ impl PluginStore for Fixture {
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(store_error(AdminStoreErrorKind::Unavailable))
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
@@ -337,7 +392,7 @@ async fn existing_plugin_instance_prevents_an_extra_default_instance() {
|
||||
trusted_process: false,
|
||||
configuration: json!({}),
|
||||
secrets: BTreeMap::new(),
|
||||
grants: Vec::new(),
|
||||
|
||||
bindings: Vec::new(),
|
||||
revision: revision(1),
|
||||
};
|
||||
@@ -389,7 +444,7 @@ fn metadata(with_required_default: bool) -> PluginArtifactMetadata {
|
||||
contribution("test.example.middleware", &["observation", "request"]),
|
||||
),
|
||||
]),
|
||||
requested_permissions: vec!["network".into()],
|
||||
|
||||
configuration_schema: json!({
|
||||
"type":"object",
|
||||
"properties": {
|
||||
|
||||
@@ -519,7 +519,7 @@ fn preview_fixture() -> Fixture {
|
||||
platforms: vec!["linux-x86_64".into()],
|
||||
icon: None,
|
||||
contributes: Default::default(),
|
||||
requested_permissions: vec!["log".into()],
|
||||
|
||||
configuration_schema: serde_json::json!({"type":"object"}),
|
||||
secret_fields: vec![],
|
||||
state_namespaces: vec![],
|
||||
@@ -635,7 +635,6 @@ async fn verifying_remote_package_uses_inspector_and_returns_fixed_facts_without
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(verified.metadata.sha256, "a".repeat(64));
|
||||
assert_eq!(verified.metadata.requested_permissions, vec!["log"]);
|
||||
assert!(
|
||||
matches!(verified.source, PluginSource::Github { ref repository, ref tag, ref asset, .. }
|
||||
if repository == "owner/plugin" && tag == "v2.0.0" && asset == "plugin.tar.gz")
|
||||
|
||||
@@ -20,8 +20,7 @@ use gateway_admin::{
|
||||
ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy,
|
||||
PluginInstance, PluginInstanceMutation, PluginInstanceReplacement,
|
||||
PluginInstanceRuntime, PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus,
|
||||
PluginInstanceSnapshot, PluginPermissionGrant, PluginVersionConfiguration,
|
||||
RollbackPluginInstance,
|
||||
PluginInstanceSnapshot, PluginVersionConfiguration, RollbackPluginInstance,
|
||||
},
|
||||
state::{
|
||||
ApplyPluginStateMigration, DeletePluginState, PluginStateCommit,
|
||||
@@ -60,6 +59,28 @@ enum MigrationBehavior {
|
||||
ConcurrentChangeThenFail,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
digest: Option<String>,
|
||||
) -> Result<InspectedPluginArtifact, AdminError> {
|
||||
let data = self.data.lock().unwrap();
|
||||
if let Some(kind) = data.inspection_error {
|
||||
return Err(AdminError::new(kind, "插件与当前宿主不兼容"));
|
||||
}
|
||||
let metadata = data
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|item| Some(&item.metadata.sha256) == digest.as_ref())
|
||||
.unwrap()
|
||||
.metadata
|
||||
.clone();
|
||||
Ok(InspectedPluginArtifact { metadata, archive })
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedInstance {
|
||||
artifact_sha256: String,
|
||||
enabled: bool,
|
||||
@@ -70,6 +91,8 @@ struct FixtureData {
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
artifacts: Vec<InstalledPluginArtifact>,
|
||||
artifact_reads: usize,
|
||||
archive_reads: usize,
|
||||
inspection_error: Option<AdminErrorKind>,
|
||||
change_during_artifact_read: bool,
|
||||
fail_preparation: bool,
|
||||
configuration_ready: bool,
|
||||
@@ -103,7 +126,7 @@ impl LifecycleFixture {
|
||||
trusted_process: true,
|
||||
configuration: json!({}),
|
||||
secrets: Default::default(),
|
||||
grants: vec![],
|
||||
|
||||
bindings: vec![],
|
||||
revision: revision(10),
|
||||
}],
|
||||
@@ -113,6 +136,8 @@ impl LifecycleFixture {
|
||||
artifact(NEW_ARTIFACT, "test.lifecycle", "1.0.0"),
|
||||
],
|
||||
artifact_reads: 0,
|
||||
archive_reads: 0,
|
||||
inspection_error: None,
|
||||
change_during_artifact_read: false,
|
||||
fail_preparation: false,
|
||||
configuration_ready: true,
|
||||
@@ -243,7 +268,10 @@ impl PluginPreparation for LifecycleFixture {
|
||||
Arc::new(Lease),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for LifecycleFixture {
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
_: &PluginInstanceSnapshot,
|
||||
@@ -252,7 +280,10 @@ impl PluginPreparation for LifecycleFixture {
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
|
||||
self.data.lock().unwrap().diagnostics.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginStateLifecycle for LifecycleFixture {
|
||||
async fn activate_state(
|
||||
&self,
|
||||
_: &ExtensionSetReference,
|
||||
@@ -507,8 +538,20 @@ impl PluginStore for LifecycleFixture {
|
||||
Ok(data.artifacts.clone())
|
||||
}
|
||||
|
||||
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
Err(admin_error(AdminStoreErrorKind::Unavailable))
|
||||
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.archive_reads += 1;
|
||||
let metadata = data
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|item| item.metadata.sha256 == digest)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.clone();
|
||||
Ok(InspectedPluginArtifact {
|
||||
metadata,
|
||||
archive: Arc::from([1_u8]),
|
||||
})
|
||||
}
|
||||
|
||||
async fn install_artifact(
|
||||
@@ -548,7 +591,7 @@ impl SnapshotControl for Published {
|
||||
fn service(fixture: Arc<LifecycleFixture>, published: Arc<Published>) -> PluginsService {
|
||||
PluginsService::new(
|
||||
fixture.clone(),
|
||||
Arc::new(TestPluginPorts),
|
||||
fixture.clone(),
|
||||
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
|
||||
published,
|
||||
fixture.clone(),
|
||||
@@ -599,7 +642,7 @@ fn artifact(digest: &str, plugin_id: &str, version: &str) -> InstalledPluginArti
|
||||
output_formats: Vec::new(),
|
||||
},
|
||||
)]),
|
||||
requested_permissions: vec![],
|
||||
|
||||
configuration_schema: json!({"type":"object"}),
|
||||
secret_fields: vec![],
|
||||
state_namespaces: vec![],
|
||||
@@ -632,7 +675,7 @@ fn input() -> ConfigurePluginInstance {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_loads_metadata_once_for_shared_artifacts_without_loading_archives() {
|
||||
async fn instance_list_checks_shared_artifact_compatibility_once() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let mut second = fixture.snapshot().instances[0].clone();
|
||||
second.id = "00000000-0000-7000-8000-000000000002".into();
|
||||
@@ -998,13 +1041,12 @@ async fn failed_state_migration_never_overwrites_a_concurrent_configuration_chan
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives_permissions() {
|
||||
async fn rollback_restores_target_configuration_secrets_and_bindings() {
|
||||
use secrecy::ExposeSecret as _;
|
||||
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.artifacts[1].metadata.requested_permissions = vec!["network".into()];
|
||||
let instance = &mut data.snapshot.instances[0];
|
||||
instance.configuration = json!({"label":"retained"});
|
||||
instance
|
||||
@@ -1019,6 +1061,7 @@ async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives
|
||||
account_group_ids: Vec::new(),
|
||||
provider_ids: Vec::new(),
|
||||
models: Vec::new(),
|
||||
event: None,
|
||||
identity_bindings: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -1031,12 +1074,6 @@ async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives
|
||||
assert_eq!(result.instance.name, before.name);
|
||||
assert_eq!(result.instance.enabled, before.enabled);
|
||||
assert_eq!(result.instance.configuration, json!({"oldVersion": true}));
|
||||
assert_eq!(
|
||||
result.instance.grants,
|
||||
[PluginPermissionGrant {
|
||||
permission: "network".into()
|
||||
}]
|
||||
);
|
||||
assert!(result.instance.bindings.is_empty());
|
||||
assert_eq!(
|
||||
result.instance.secrets["token"].expose_secret(),
|
||||
@@ -1275,6 +1312,7 @@ async fn version_plan_keeps_explicit_values_adds_defaults_and_remaps_bindings_wi
|
||||
account_group_ids: vec![],
|
||||
provider_ids: vec![],
|
||||
models: vec!["test-model".into()],
|
||||
event: None,
|
||||
identity_bindings: vec![],
|
||||
}];
|
||||
}
|
||||
@@ -1399,3 +1437,101 @@ async fn switching_versions_uses_saved_settings_and_rejects_stale_retry() {
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(fixture.snapshot().config_revision, revision);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_plan_preserves_observer_event_scopes_and_disabled_subscriptions() {
|
||||
for include_websocket in [false, true] {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.history.clear();
|
||||
for (index, artifact) in data.artifacts.iter_mut().enumerate() {
|
||||
artifact.metadata.contributes.clear();
|
||||
artifact.metadata.contributes.insert(
|
||||
"observer".into(),
|
||||
PluginContribution {
|
||||
id: format!("observer-{index}"),
|
||||
version: 1,
|
||||
stages: vec!["observation".into()],
|
||||
input_formats: vec![],
|
||||
output_formats: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
data.snapshot.instances[0].bindings = ["request_completed", "websocket_response"]
|
||||
.into_iter()
|
||||
.take(if include_websocket { 2 } else { 1 })
|
||||
.map(|event| PluginCapabilityBinding {
|
||||
contribution: "observer-0".into(),
|
||||
stage: "observation".into(),
|
||||
event: Some(event.into()),
|
||||
order: 7,
|
||||
failure_policy: PluginFailurePolicy::Observe,
|
||||
client_key_ids: vec![],
|
||||
account_group_ids: vec![],
|
||||
provider_ids: vec![],
|
||||
models: vec![format!("model-{event}")],
|
||||
identity_bindings: vec![],
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let before = fixture.snapshot();
|
||||
let plan = service(fixture.clone(), Arc::new(Published::default()))
|
||||
.version_plan(&before.instances[0].id, NEW_ARTIFACT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(plan.bindings.len(), before.instances[0].bindings.len());
|
||||
for (binding, previous) in plan.bindings.iter().zip(&before.instances[0].bindings) {
|
||||
assert_eq!(binding.contribution, "observer-1");
|
||||
assert_eq!(binding.event, previous.event);
|
||||
assert_eq!(binding.models, previous.models);
|
||||
assert_eq!(binding.order, previous.order);
|
||||
}
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_disabled_plugin_has_warning_and_cannot_be_enabled() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.snapshot.instances[0].enabled = false;
|
||||
data.inspection_error = Some(AdminErrorKind::Invalid);
|
||||
}
|
||||
let service = service(fixture.clone(), Arc::new(Published::default()));
|
||||
let view = service.instances().await.unwrap().remove(0);
|
||||
assert!(view.compatibility_warning.is_some());
|
||||
assert!(!view.instance.enabled);
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
let error = service
|
||||
.configure_instance(Some(&view.instance.id), request, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
assert_eq!(
|
||||
fixture.data.lock().unwrap().archive_reads,
|
||||
1,
|
||||
"固定摘要的静态检查结论可复用"
|
||||
);
|
||||
service.instances().await.unwrap();
|
||||
assert_eq!(fixture.data.lock().unwrap().archive_reads, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_compatibility_check_failure_can_be_retried() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
fixture.data.lock().unwrap().inspection_error = Some(AdminErrorKind::Unavailable);
|
||||
let service = service(fixture.clone(), Arc::new(Published::default()));
|
||||
assert!(service.instances().await.is_err());
|
||||
fixture.data.lock().unwrap().inspection_error = None;
|
||||
assert!(
|
||||
service.instances().await.unwrap()[0]
|
||||
.compatibility_warning
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ impl gateway_admin::ports::proxy::ProxyStore for TestPluginPorts {
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts {
|
||||
async fn authorize_models(
|
||||
async fn validate_target(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
|
||||
@@ -163,6 +163,44 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts {
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
|
||||
_: Option<u64>,
|
||||
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
|
||||
) -> Option<
|
||||
std::collections::BTreeMap<
|
||||
String,
|
||||
gateway_admin::model::plugins::instances::PluginInstanceRuntime,
|
||||
>,
|
||||
> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts {
|
||||
async fn activate_state(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: &gateway_admin::model::plugins::instances::PluginInstance,
|
||||
) -> Result<(), AdminError> {
|
||||
panic!("unexpected state activation")
|
||||
}
|
||||
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
|
||||
panic!("unexpected instance drain")
|
||||
}
|
||||
async fn migrate_state(
|
||||
&self,
|
||||
_: &gateway_core::runtime::extensions::ExtensionSetReference,
|
||||
_: gateway_admin::model::plugins::state::PluginStateTransition,
|
||||
) -> Result<(), AdminError> {
|
||||
panic!("unexpected state migration")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts {
|
||||
async fn configuration_ready(
|
||||
|
||||
@@ -142,7 +142,7 @@ impl PluginPackageInspector for Fixture {
|
||||
)],
|
||||
icon: None,
|
||||
contributes: Default::default(),
|
||||
requested_permissions: Vec::new(),
|
||||
|
||||
configuration_schema: serde_json::json!({}),
|
||||
secret_fields: Vec::new(),
|
||||
state_namespaces: Vec::new(),
|
||||
@@ -288,6 +288,40 @@ async fn official_release_validates_all_packages_then_uses_builtin_install_path(
|
||||
assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_accepts_legacy_update_envelope_with_current_plugin_contracts() {
|
||||
let mut fixture = Fixture::new(&[]);
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
|
||||
manifest["plugin_host"]["schema_version"] = serde_json::json!(1);
|
||||
manifest["plugin_host"]["permissions"] = serde_json::json!([]);
|
||||
Arc::get_mut(&mut fixture).unwrap().manifest =
|
||||
Some(serde_json::to_vec(&manifest).unwrap().into());
|
||||
let imported = fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imported.artifacts, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_still_rejects_an_unknown_host_contract_at_startup() {
|
||||
let mut fixture = Fixture::new(&[]);
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(fixture.manifest.as_ref().unwrap()).unwrap();
|
||||
manifest["plugin_host"]["schema_version"] = serde_json::json!(99);
|
||||
Arc::get_mut(&mut fixture).unwrap().manifest =
|
||||
Some(serde_json::to_vec(&manifest).unwrap().into());
|
||||
assert!(
|
||||
fixture
|
||||
.service()
|
||||
.import_official_release(fixture.as_ref(), &identity(), &system_context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() {
|
||||
let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]);
|
||||
@@ -372,11 +406,10 @@ fn identity() -> OfficialPluginReleaseIdentity {
|
||||
|
||||
fn plugin_host() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [3],
|
||||
"capabilities": [{ "capability": "executor", "versions": [1] }],
|
||||
"permissions": ["log"],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 从宿主类型和操作声明生成独立 SDK 合同;普通测试阻止两侧声明漂移。
|
||||
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use quote::quote;
|
||||
use syn::{Item, Type, parse::Parse, parse_quote, visit_mut::VisitMut};
|
||||
|
||||
struct Operation {
|
||||
name: syn::Ident,
|
||||
method: syn::Ident,
|
||||
input: Type,
|
||||
}
|
||||
|
||||
impl Parse for Operation {
|
||||
fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
|
||||
let name = input.parse()?;
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
let method = input.parse()?;
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
let arguments = input.parse()?;
|
||||
// 后续模式和参数表达式只属于宿主调用,不进入线协议。
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
let _ = syn::Pat::parse_single(input)?;
|
||||
while !input.is_empty() {
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
if !input.is_empty() {
|
||||
let _ = input.parse::<syn::Expr>()?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
name,
|
||||
method,
|
||||
input: arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn source(root: &Path, path: &str) -> syn::File {
|
||||
syn::parse_file(&std::fs::read_to_string(root.join(path)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
// SDK 保留 wire 值,不依赖宿主的领域构造器、时区库或价格校验实现。
|
||||
struct WireTypes;
|
||||
impl VisitMut for WireTypes {
|
||||
fn visit_type_mut(&mut self, ty: &mut Type) {
|
||||
if let Type::Path(path) = ty {
|
||||
assert!(path.qself.is_none(), "服务合同不支持关联类型");
|
||||
if path
|
||||
.path
|
||||
.segments
|
||||
.first()
|
||||
.is_some_and(|segment| segment.ident == "serde_json")
|
||||
{
|
||||
syn::visit_mut::visit_type_mut(self, ty);
|
||||
return;
|
||||
}
|
||||
let last = path.path.segments.last().unwrap().clone();
|
||||
*ty = match last.ident.to_string().as_str() {
|
||||
"DateTime" | "Tz" | "TokenPrice" | "AdminApiKey" => parse_quote!(String),
|
||||
"OpaqueProviderData" => parse_quote!(serde_json::Map<String, serde_json::Value>),
|
||||
_ => Type::Path(syn::TypePath {
|
||||
qself: None,
|
||||
path: last.into(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
syn::visit_mut::visit_type_mut(self, ty);
|
||||
}
|
||||
}
|
||||
|
||||
fn contract_type(mut item: Item, name: &str) -> Item {
|
||||
let (attrs, ident, fields, comparable) = match &mut item {
|
||||
Item::Struct(item) => {
|
||||
item.vis = parse_quote!(pub);
|
||||
let comparable = !matches!(
|
||||
name,
|
||||
"RuntimeSettings"
|
||||
| "ReplaceRuntimeSettings"
|
||||
| "AdminApiKeyMutation"
|
||||
| "RegeneratedAdminApiKey"
|
||||
);
|
||||
(
|
||||
&mut item.attrs,
|
||||
&mut item.ident,
|
||||
Some(&mut item.fields),
|
||||
comparable,
|
||||
)
|
||||
}
|
||||
Item::Enum(item) => (&mut item.attrs, &mut item.ident, None, true),
|
||||
_ => panic!("公开合同必须是 struct 或 enum"),
|
||||
};
|
||||
*ident = syn::parse_str(name).unwrap();
|
||||
attrs.retain(|attr| attr.path().is_ident("serde"));
|
||||
attrs.insert(
|
||||
0,
|
||||
if name == "SmartSchedulingConfig" {
|
||||
parse_quote!(#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)])
|
||||
} else if comparable {
|
||||
parse_quote!(#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)])
|
||||
} else {
|
||||
parse_quote!(#[derive(Clone, Serialize, Deserialize)])
|
||||
},
|
||||
);
|
||||
if let Some(fields) = fields {
|
||||
for field in fields {
|
||||
field.vis = parse_quote!(pub);
|
||||
}
|
||||
}
|
||||
WireTypes.visit_item_mut(&mut item);
|
||||
item
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdk_settings_contract_matches_host_declarations() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut types = Vec::new();
|
||||
let mut replacement_fields = Vec::new();
|
||||
for (path, names) in [
|
||||
(
|
||||
"src/model/settings.rs",
|
||||
&[
|
||||
"RuntimeSettings",
|
||||
"ReplaceRuntimeSettings",
|
||||
"AdminApiKeyMutation",
|
||||
"RegeneratedAdminApiKey",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"src/model/mod.rs",
|
||||
&["MutationActor", "MutationContext"][..],
|
||||
),
|
||||
(
|
||||
"../gateway-core/src/account/location.rs",
|
||||
&["RequestLocation"][..],
|
||||
),
|
||||
(
|
||||
"../gateway-core/src/account/smart_scheduling.rs",
|
||||
&["SmartSchedulingValues"][..],
|
||||
),
|
||||
(
|
||||
"../gateway-core/src/metering/pricing.rs",
|
||||
&["TokenPriceOverride", "ModelPriceOverride"][..],
|
||||
),
|
||||
(
|
||||
"src/model/pricing.rs",
|
||||
&[
|
||||
"PricingCatalog",
|
||||
"PricingSyncPreview",
|
||||
"SyncPricing",
|
||||
"PricingChange",
|
||||
"UpdatePricing",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let items = source(root, path).items;
|
||||
for name in names {
|
||||
let item = items
|
||||
.iter()
|
||||
.find(|item| match item {
|
||||
Item::Struct(item) => item.ident == name,
|
||||
Item::Enum(item) => item.ident == name,
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("缺少合同类型 {name}"));
|
||||
if let Item::Struct(item) = item
|
||||
&& item.ident == "ReplaceRuntimeSettings"
|
||||
{
|
||||
replacement_fields = item
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| field.ident.clone().unwrap())
|
||||
.collect();
|
||||
}
|
||||
types.push(contract_type(
|
||||
item.clone(),
|
||||
if *name == "SmartSchedulingValues" {
|
||||
"SmartSchedulingConfig"
|
||||
} else {
|
||||
name
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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/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 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()),
|
||||
_ => quote!(#field: settings.#field),
|
||||
});
|
||||
let generated = quote! {
|
||||
use super::Operation;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::{BTreeMap, BTreeSet}, num::NonZeroU64};
|
||||
pub type ProviderRequestProfiles = BTreeMap<String, serde_json::Map<String, serde_json::Value>>;
|
||||
pub type ProviderRequestProfileUpdates = BTreeMap<String, Option<serde_json::Map<String, serde_json::Value>>>;
|
||||
pub type ModelMappings = BTreeMap<String, String>;
|
||||
pub type Revision = NonZeroU64;
|
||||
pub type RotationStrategy = String;
|
||||
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
|
||||
#(#types)*
|
||||
#(#operations)*
|
||||
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self { #(#assignments),* }
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs");
|
||||
let expected = prettyplease::unparse(&syn::parse2::<syn::File>(generated).unwrap());
|
||||
if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() {
|
||||
std::fs::write(&output, format!("// 从宿主设置类型与 service/settings.rs 生成;更新命令见 SDK 维护说明。\n{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"
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,66 @@ struct PricingSettingsStore {
|
||||
updated: Mutex<Option<UpdatePricing>>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_service_calls_freeze_empty_plan_and_cancel_before_entering_terminal() {
|
||||
use gateway_admin::service::{Origin, Plan, scope};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let resolutions = Arc::new(AtomicUsize::new(0));
|
||||
let source_calls = resolutions.clone();
|
||||
let store = Arc::new(PricingSettingsStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(store.clone())
|
||||
.service_middleware(Arc::new(move || {
|
||||
source_calls.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let context = MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "empty-plan".into(),
|
||||
};
|
||||
let command = UpdatePricing {
|
||||
provider: "xai".into(),
|
||||
models: vec!["example-model".into()],
|
||||
change: PricingChange::Reset,
|
||||
};
|
||||
for expected in 1..=2 {
|
||||
services
|
||||
.public_services()
|
||||
.call(
|
||||
"settings.update_pricing",
|
||||
serde_json::to_value((&context, command.clone())).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// 内部 pricing 不再单独进入插件链;显式调用只解析一次发布集合。
|
||||
assert_eq!(resolutions.load(Ordering::SeqCst), expected);
|
||||
}
|
||||
store.updated.lock().unwrap().take();
|
||||
let cancellation = gateway_core::lifecycle::CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
let error = scope(
|
||||
Origin {
|
||||
request_id: "cancelled-native".into(),
|
||||
call_id: "entry".into(),
|
||||
cancellation,
|
||||
extensions: Default::default(),
|
||||
plan: Plan::Frozen(None),
|
||||
},
|
||||
services.public_services().call(
|
||||
"settings.update_pricing",
|
||||
serde_json::to_value((context, command)).unwrap(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, "unavailable");
|
||||
assert!(store.updated.lock().unwrap().is_none());
|
||||
assert_eq!(resolutions.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
|
||||
let services = super::AdminHarness::new()
|
||||
@@ -213,6 +273,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
expected_revision: gateway_admin::model::Revision::new(1).unwrap(),
|
||||
request_profile_updates: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user