mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3baddbda | ||
|
|
09b22a9550 | ||
|
|
04a48e848d | ||
|
|
1655ecc972 | ||
|
|
4527db7b5d | ||
|
|
2b7a64dae6 | ||
|
|
0c07aac1d6 | ||
|
|
3e33e84a45 | ||
|
|
c73e7126a2 | ||
|
|
1d88c9a4a5 | ||
|
|
26dbc12010 | ||
|
|
2dc6bf0150 | ||
|
|
f7f64ead3b | ||
|
|
37749001db | ||
|
|
272534a3b6 | ||
|
|
b9bcb4e086 | ||
|
|
9039cef3d8 | ||
|
|
a9a77f5f9d | ||
|
|
6f7d92594e | ||
|
|
6859520969 | ||
|
|
21d84af2d8 | ||
|
|
311e4d228f | ||
|
|
d604adfbeb | ||
|
|
46b4665c5b | ||
|
|
1daa0623a0 | ||
|
|
860fb39622 | ||
|
|
f5c5a6e5b7 | ||
|
|
bdfc201795 | ||
|
|
789609dd53 | ||
|
|
10e25ce25f | ||
|
|
6b362a5029 | ||
|
|
13374e2a70 | ||
|
|
19ef484969 | ||
|
|
242ffb2324 | ||
|
|
53a09bbfe9 | ||
|
|
239def9a5b | ||
|
|
5eaca14a64 | ||
|
|
78e943f406 | ||
|
|
0663df14df | ||
|
|
39aa20a13a | ||
|
|
621b1faceb | ||
|
|
28eb4708b5 | ||
|
|
aa003f7d90 | ||
|
|
4c57a0405d | ||
|
|
49cee2397f | ||
|
|
1915806bd1 | ||
|
|
5100f6333e | ||
|
|
187af36306 | ||
|
|
be35f8a6ee | ||
|
|
317c05c74a | ||
|
|
12c32fcdec | ||
|
|
26de671226 | ||
|
|
a92b95c48c | ||
|
|
e7c24781fb | ||
|
|
3b4b547e9c | ||
|
|
2016bda031 | ||
|
|
790f3147e0 | ||
|
|
96e8443ede | ||
|
|
42badf64ca | ||
|
|
35afe1123f | ||
|
|
2ac4265832 | ||
|
|
d22ec29824 | ||
|
|
7a32c6cec2 | ||
|
|
0ff1a72919 | ||
|
|
79083a5632 | ||
|
|
90f7456db8 | ||
|
|
6f71b27c3c | ||
|
|
7b05b14a2b | ||
|
|
f9b5b5ff77 | ||
|
|
4d5778a84f | ||
|
|
6d22d73506 | ||
|
|
d8ab207282 | ||
|
|
3816b46ba1 | ||
|
|
4a7c06f895 | ||
|
|
e0b7a841ca | ||
|
|
96a292dd0f | ||
|
|
6f138c8256 | ||
|
|
c8ffb1a8c8 | ||
|
|
79df82a4f0 | ||
|
|
62516d9769 | ||
|
|
892cba68ae | ||
|
|
fada9bc0bd | ||
|
|
27b9bbe994 | ||
|
|
db849c1251 | ||
|
|
c57af9b0dd | ||
|
|
641f43b303 | ||
|
|
6ce723ce93 | ||
|
|
2ff43c73da | ||
|
|
cb79f4d5a5 | ||
|
|
1e57805598 | ||
|
|
aca75490cb | ||
|
|
6a95869a72 | ||
|
|
dfc29d4709 | ||
|
|
3cbebb5f9b | ||
|
|
fbdfe22757 | ||
|
|
7cf99769b1 | ||
|
|
839054cdaf | ||
|
|
bcf0582814 | ||
|
|
a5d3d9f12d | ||
|
|
58a1f7541e | ||
|
|
35e8b73075 | ||
|
|
73f0706f99 | ||
|
|
76eecd771e | ||
|
|
7293d03d70 | ||
|
|
5ae38dc0c5 | ||
|
|
503e47fac2 | ||
|
|
b4aea0ea09 | ||
|
|
63036a3694 | ||
|
|
a3e24198cd | ||
|
|
fdd124e72f | ||
|
|
3a2ccbc5e6 | ||
|
|
aa0ce52034 | ||
|
|
7e7acad6a3 | ||
|
|
b3dd9b5337 | ||
|
|
0e1c609cd9 | ||
|
|
12771aca87 | ||
|
|
23b85172b5 | ||
|
|
d89db84a77 | ||
|
|
6504761ce4 | ||
|
|
5877b0cb2f | ||
|
|
958e13c370 | ||
|
|
80f7d53085 | ||
|
|
2f2c1787be | ||
|
|
d679d5e708 | ||
|
|
0f214c3e25 | ||
|
|
736eca6593 | ||
|
|
be5b6d199e | ||
|
|
53e1e9cfb8 | ||
|
|
e03851e23f | ||
|
|
7cd17aff42 | ||
|
|
005466bcb0 | ||
|
|
a6031310f1 | ||
|
|
1ac2a4eac3 | ||
|
|
790a643020 | ||
|
|
1fa37ac92f | ||
|
|
0ceac908f4 | ||
|
|
86a27bed94 | ||
|
|
60f8f1ee80 | ||
|
|
7b6a204889 | ||
|
|
47d30474af | ||
|
|
f1d5b73255 | ||
|
|
18c75132c3 | ||
|
|
a48139b4c2 | ||
|
|
dd529d07ec | ||
|
|
19fbb5da75 | ||
|
|
9296885cce | ||
|
|
af9790ac37 | ||
|
|
c58e280739 | ||
|
|
e33f50ec09 | ||
|
|
1db8b0e319 | ||
|
|
8c8618aa0f | ||
|
|
c43a8872db | ||
|
|
51c5caed0e | ||
|
|
a7f95c3a78 | ||
|
|
421e37896a | ||
|
|
1d1ccda42a | ||
|
|
e38cf31323 | ||
|
|
614c81c375 | ||
|
|
f0484c4e11 | ||
|
|
dffc417b9f | ||
|
|
2d4294c3c9 | ||
|
|
3d9906facf | ||
|
|
da9c2b92b0 | ||
|
|
de5c10c4e4 | ||
|
|
939cb99985 | ||
|
|
2bb8ebc01c | ||
|
|
6a5300a7b5 | ||
|
|
2c5b8b5c1a | ||
|
|
0a87ac7f76 | ||
|
|
3ac9d5f6f8 | ||
|
|
e7e5b659cd | ||
|
|
b225b3732d | ||
|
|
9dca763ea4 | ||
|
|
d26bc36db5 | ||
|
|
96d5ab674a | ||
|
|
84c0db2613 | ||
|
|
d819315d8b | ||
|
|
06e1d8f0ac | ||
|
|
bdc8274b36 | ||
|
|
3f6607209b | ||
|
|
3c05bc8f07 | ||
|
|
9ccd84f1a0 | ||
|
|
a8f183707e | ||
|
|
3f20af61ff | ||
|
|
efaed4c3a6 | ||
|
|
a36137bafb | ||
|
|
2e7a39e1e8 | ||
|
|
6648198df4 | ||
|
|
2eed9b20ed | ||
|
|
16aa08091c | ||
|
|
abae4a631b | ||
|
|
8983eb4ce9 | ||
|
|
cd605b850e | ||
|
|
7beebb2340 | ||
|
|
31de0bd83c | ||
|
|
5cfc6dee10 | ||
|
|
ef5b4d7dbd | ||
|
|
31974bc289 | ||
|
|
e033e21994 | ||
|
|
2818edb9fc | ||
|
|
1d1f34ba7e | ||
|
|
76291f448c | ||
|
|
c10efea12c | ||
|
|
806f7ad9ee | ||
|
|
632edee7dc | ||
|
|
22518d064c | ||
|
|
64c1ec2afd | ||
|
|
a3f0c1b028 | ||
|
|
605354c861 | ||
|
|
60e6bc0125 | ||
|
|
bfea3e1d6f | ||
|
|
6b4b8faaf4 | ||
|
|
f195de38f5 | ||
|
|
07bdd425b6 | ||
|
|
d48982eb04 | ||
|
|
39b35d46a0 | ||
|
|
8fc8c6228d | ||
|
|
7da79b82fd | ||
|
|
a23afbf2f5 | ||
|
|
b4a5210e44 | ||
|
|
f7f59103a3 | ||
|
|
5b77a6b8de | ||
|
|
3f95f8b0c4 | ||
|
|
28fa655c26 | ||
|
|
7a60bc6ded | ||
|
|
a5b767e69b |
@@ -1,28 +1,40 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
|
||||
description: Codex Proxy RS 开发、修复、排障、代码审查和文档修改入口;页面、样式、文案、接口、调度、数据库及宿主 SDK、Runtime、插件接入均适用;PR、Issue、独立网关插件与发版叠加对应技能
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
## 先匹配任务与文件
|
||||
## 首要写作要求:言简意赅
|
||||
|
||||
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
|
||||
所有面向人的文字都按此要求编写和审查,包括界面、通知、后端错误、任务状态、命令行、日志、注释、文档和交付说明
|
||||
|
||||
| 命中条件,操作或文件满足其一即可 | 必须执行 |
|
||||
- 先写读者需要知道的事实或动作。删掉一句不影响理解、判断或操作,就删;标题、正文和通知不重复同一信息
|
||||
- 保留会改变决定的条件、权限、后果和恢复步骤。错误通知只说已知问题,有明确办法才补一句;后端返回的提示在后端改,前端不重复解释。上游原文、用户内容和诊断事实保持原样
|
||||
|
||||
## 先确定领域与必读规范
|
||||
|
||||
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
|
||||
|
||||
职责边界决定问题由谁修复;即使代码位于账号、调度或 API 模块,只要影响插件接入或为插件增加处理,也命中插件边界。确定方案时说明相关业务事实的归属及已有实现的复用依据,不能以功能恢复或测试通过替代职责判断
|
||||
|
||||
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
|
||||
| --- | --- |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
|
||||
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
| 新增、修改或审查客户端可见错误、失败提示、后台任务状态文案 | 下方[错误文案](#错误文案),并按实际出口核对[错误与诊断三层边界](../../../docs/architecture.md#错误与诊断三层边界) |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 |
|
||||
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
|
||||
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
|
||||
| 修改或审查文档;实现或审查用法、公开合同、职责、约束或运维操作的变化 | [文档检查](references/documentation.md)及相关章节,尚无文档差异时也检查是否漏更;纯文档任务不默认读取代码开发、页面验收或发布流程 |
|
||||
| 代码审查,包括 PR 审查 | [审查标准](../../../CONTRIBUTING.md#审查标准),叠加被审查行为所属领域的规范;审查默认只读 |
|
||||
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
|
||||
|
||||
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
|
||||
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
|
||||
|
||||
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
@@ -31,10 +43,10 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
|
||||
## 执行与收尾
|
||||
|
||||
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
|
||||
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
|
||||
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
|
||||
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交前确认已覆盖[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
|
||||
## 测试维护
|
||||
|
||||
@@ -51,3 +63,23 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
|
||||
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
|
||||
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
|
||||
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
|
||||
|
||||
## 错误文案
|
||||
|
||||
适用于项目自定义的客户端提示,包括 HTTP、WS、SSE 和任务失败状态;原始上游错误与内部诊断遵守[三层边界](../../../docs/architecture.md#错误与诊断三层边界),不为统一外观翻译、截短或改写
|
||||
|
||||
- 管理端自定义提示统一中文,模型 API 自定义提示统一英文;英文句首大写、句末使用句号,API、Codex 等缩写及专有名词保持正确大小写,中文短提示不加句末句号
|
||||
- 默认一句说明具体问题,确有必要才补第二句处理办法,不设机械字数限制;明确字段、对象或限制,避免只说「请求不合法」或堆叠内部术语
|
||||
- 处理办法必须明确且适用;不把「请稍后重试」「联系管理员」作为统一后缀,不罗列尚未确认的网络、权限、磁盘等可能原因,不对执行结果未知的操作笼统建议重试
|
||||
- 机器码、状态与结构化字段负责程序判断,文案供人阅读;只改措辞时保留分类、重试和账号处置语义,先核对现有调用方是否依赖消息文本
|
||||
- 在拥有错误事实的模块提供安全提示,保留独立的内部原因链;不在响应出口批量首字母大写、翻译或裁剪任意消息,以免改写上游原文
|
||||
|
||||
| 场景 | 文案示例 |
|
||||
| --- | --- |
|
||||
| 日费用额度耗尽 | `Daily spending limit reached.` |
|
||||
| 请求过大 | `Request too large. Reduce the input size.` |
|
||||
| 客户端过旧 | `Codex CLI is outdated. Upgrade to 0.152.0 or later.` |
|
||||
| 名称为空 | `请输入 API Key 名称` |
|
||||
| 更新包校验失败 | `更新包校验失败,请重新下载` |
|
||||
|
||||
审查时沿实际出口检查语言、大小写、标点、长度和处理建议;复用现有行为测试核对错误码及敏感信息边界,不为逐条文案建立重复的快照或字符串清单测试
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-dev-guide"
|
||||
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
|
||||
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
short_description: "开发、排障与审查入口,按行为影响匹配领域边界并验证实现"
|
||||
default_prompt: "使用 $cpr-dev-guide 按请求、行为影响和实际文件匹配领域必读规范,读完职责边界再确定方案,完成已授权的开发或审查、自审与验证。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
# 文档检查
|
||||
|
||||
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
|
||||
修改或审查文档,以及实现或审查涉及文档更新条件的行为变化时,完整读取本流程和 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),覆盖内容归属与层次行文
|
||||
|
||||
## 核实与同步范围
|
||||
|
||||
- 以当前目标分支及任务内工作区的实现、配置和有效运行证据核实行为;提交记录用于发现待核实的主题,不能直接当作现行事实或按提交顺序组织正文。按 [CodeGraph 约定](../../../../AGENTS.md#codegraph)同步索引后定位,证据不足时报告缺口,不把计划或推测写成已支持能力
|
||||
- 开发任务按最终行为检查哪些用法、合同、职责、约束或运维操作需要同步,即使尚无 Markdown 差异也要检查;独立文档更新按用户范围核对现行说明,不等到发版时再补普通开发所需的文档
|
||||
- 沿同一事实查找所属正文及相关概述、示例和引用,重点核对“支持/不支持”等能力声明、默认值、配置模板和命令。概述保留必要摘要并链接详细合同;不能只更新详细章节,留下 README 或示例中的矛盾说明,也不扩展为无关全仓改写
|
||||
|
||||
## 写之前
|
||||
|
||||
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
|
||||
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对。涉及合同或职责说明时,补读所属架构、API 或 SDK 的对应条款,核实事实;不因文档提及实现而执行无关的开发与构建流程
|
||||
|
||||
在所属章节就地替换失效说明,先组织读者现在需要的操作、结果和限制,不从变更摘要改写正文。仅介绍被替代实现、修复动机或修改经过的段落直接删除;删掉“新增”“此前”“不再”等词后仍在回答变更经过的内容,也不保留
|
||||
|
||||
## 逐段验收
|
||||
|
||||
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
|
||||
检查工作区和暂存区中本任务的最终文档差异,并连同受影响章节及相关概述、示例通读,按内容用途处理,不只搜索关键词:
|
||||
|
||||
| 段落回答的问题 | 处理 |
|
||||
| --- | --- |
|
||||
@@ -26,6 +34,6 @@
|
||||
|
||||
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
|
||||
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
|
||||
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
|
||||
- 交付时简述已同步的文档范围;涉及更新条件却未改文档时,说明现有内容为何仍准确或还有什么缺口。仅报告实际核对范围,不把链接检查通过称为事实核实,也不把检查记录写进常规文档
|
||||
|
||||
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
|
||||
|
||||
## 开始前
|
||||
## 领域必读
|
||||
|
||||
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
|
||||
## 按改动补齐规范
|
||||
|
||||
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
|
||||
|
||||
@@ -12,7 +14,7 @@
|
||||
|
||||
| 改动 | 规范入口 |
|
||||
| --- | --- |
|
||||
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
|
||||
| Vue 组件、逻辑、请求或状态 | 使用 `$vue-best-practices`,读取[项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目;请求与接口合同同时执行开发入口的 API 分支 |
|
||||
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
|
||||
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
|
||||
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 插件职责边界
|
||||
|
||||
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
|
||||
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
|
||||
开发、排查或审查插件接入、SDK、Runtime、宿主扩展接口及独立网关插件时完整读取,不以文件位于插件目录或正在选择能力为条件
|
||||
确定方案前读取[职责与源码入口](../../../../docs/architecture.md#职责与源码入口)、[发布与执行边界](../../../../docs/architecture.md#发布与执行边界)及[信任与资源边界](../../../../docs/architecture.md#信任与资源边界),明确问题归属、必要宿主保护与插件责任
|
||||
|
||||
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
|
||||
|
||||
|
||||
@@ -13,13 +13,15 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
|
||||
|
||||
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
|
||||
|
||||
## 按需取材
|
||||
## 按动作读取与核实
|
||||
|
||||
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
目标、授权、事实与脱敏要求适用于草拟、提交和回复;按下列动作读取必需规则与证据,再组织正文。症状反馈可保留未知根因,进入技术排查或方案判断时补齐对应规范
|
||||
|
||||
- 新建 Issue 时,读取本仓库的[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
|
||||
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
|
||||
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
|
||||
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
|
||||
- 需要技术排查、判断实现方案或修改时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读,不能仅凭症状建议跨职责补偿;提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
|
||||
|
||||
## 正文与证据
|
||||
@@ -40,7 +42,11 @@ Issue 可记录复现、排查和变更经过;涉及常规文档修改时执
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 Issue 回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
|
||||
草拟、发布或修改 Issue 回复时,按 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)将核实结果写成面向提问者的交流答复,具体口吻规则集中维护在该节
|
||||
|
||||
按平常回帖的口吻写,语气自然、委婉,说清判断和原因,少用铺垫、转折和自我说明;暂不采纳或提出不同意见时说明具体原因
|
||||
|
||||
发送或更新前从提问者视角通读:判断和原因是否清楚,建议是否便于尝试,补充材料的用途是否明确。删去不影响理解的客套、重复解释和过程说明;发现生硬时调整整句,不能仅加缓和词,保留已核实的结论
|
||||
|
||||
## 发布与收尾
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
|
||||
|
||||
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
|
||||
|
||||
为本仓库拟写或更新 PR 标题时,完整标题使用英文,沿用提交信息的 Conventional Commits 格式,例如 `fix(store): preserve client key ledgers after deletion`
|
||||
|
||||
涉及上游行为的判断按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行
|
||||
|
||||
## 按模式读取
|
||||
|
||||
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
|
||||
先完成上述范围判断,再按当前动作与差异涉及的领域叠加下表。命中的流程引用完整读取,长文档读取指定章节;只复用已实际读取且仍适用的内容,单纯整理正文或回复不扩展为完整实现审查
|
||||
|
||||
| 当前动作 | 读取与执行 |
|
||||
| --- | --- |
|
||||
@@ -27,7 +29,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
|
||||
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
|
||||
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
|
||||
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
|
||||
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
|
||||
| 实现自审、审查或修改代码与文档 | 执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与对应检查,包括后端、插件、测试和 API 等命中项;只读审查同样执行,不能仅核对 PR 正文与 CI |
|
||||
| 含前端改动 | [前端流程](../cpr-dev-guide/references/frontend.md),核对适用规范与当前实现的运行证据 |
|
||||
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-pr"
|
||||
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,实现自审与审查叠加开发指南的领域规范,核实问题、方案、验证、页面截图和 AI 模型披露。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
按实际行为与文件执行 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)的领域必读,再判断方案与实现;只读审查也核对职责边界与生命周期。
|
||||
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
|
||||
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
|
||||
description: 开发、修复、排查、审查或打包 Codex Proxy RS 独立网关插件,含处理器、清单与插件页面;仅改宿主能力时使用 cpr-dev-guide 的插件边界,不适用于 Codex 插件或 Vue 插件
|
||||
---
|
||||
|
||||
# 网关插件开发
|
||||
|
||||
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
|
||||
## 先读职责边界
|
||||
|
||||
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
|
||||
插件在独立项目中通过公开 SDK 实现业务,插件业务错误在插件侧修复;宿主维护自身合同、资源与稳定性,不猜测插件意图、不补造业务数据,不在宿主、SDK 或 Runtime 中增加插件专属补偿
|
||||
|
||||
开发、排查和审查先完整读取[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md),再选择实现步骤;已有处理器修复和页面改动也执行,不以是否新增能力为条件。需要新增宿主能力时,先界定可独立使用的基础接口及其职责;必要的协议保护与资源回收仍由宿主负责
|
||||
|
||||
同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与收尾要求;已实际读取且仍适用的内容复用,不递归重读入口。仅构建、打包或分发已有插件时按对应操作读取,若需诊断或修改行为,再补齐开发分支
|
||||
|
||||
## 范围与合同
|
||||
|
||||
@@ -18,13 +22,13 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
|
||||
|
||||
## 按当前步骤读取
|
||||
|
||||
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
|
||||
完成上述适用边界后,按当前步骤读取下表;同时涉及多项时叠加。命中的实现或页面参考完整读取,长文档读取指定章节,示例、前端和打包资料在对应步骤加载
|
||||
|
||||
| 当前工作 | 入口 |
|
||||
| --- | --- |
|
||||
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
|
||||
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 选择、使用或审查能力 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,不以已有调用能编译代替合同核对 |
|
||||
| 开发、排查或审查清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;涉及清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建、修改或审查页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
|
||||
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
|
||||
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-plugin-dev"
|
||||
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
|
||||
short_description: "独立网关插件开发、排障与审查,先核对宿主边界再实现和验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查、审查或打包独立网关插件,先读取适用职责边界,插件业务错误在插件侧修复,再按步骤读取实现与验证资料。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 插件页面
|
||||
|
||||
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
|
||||
创建、修改或审查插件页面时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断并执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
|
||||
|
||||
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
|
||||
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 清单与处理器实现
|
||||
|
||||
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
|
||||
开发、排查或审查插件清单、Rust 处理器时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断,涉及 Rust 时执行[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
使用能力前读取 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,已有实现的修复与审查也核对合同
|
||||
|
||||
## 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- `contributes` 每种能力最多一项,派生 ID 和固定阶段可省略;能力版本与中间件阶段按[扩展项简写](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)声明。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
|
||||
@@ -15,25 +15,25 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
|
||||
|
||||
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
|
||||
|
||||
## 必须保持的边界
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
## 按任务读取
|
||||
|
||||
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
|
||||
先落实上述范围与边界,再读取当前阶段的完整指定章节;实际发布包含准备与验收,不能只读执行命令。阶段变化时补齐读取,准备文案不加载执行流程,实际发布前须核对发布入口的完整副作用
|
||||
|
||||
| 任务 | 读取范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
|
||||
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
|
||||
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
|
||||
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
|
||||
| 实际发布 | 先完成[基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明)、[发行准备检查](references/preparation.md#发行准备检查),再执行[通道核对](references/operations.md#发行通道核对)、[执行发版](references/operations.md#执行发版)与[恢复与验收](references/operations.md#恢复与验收) |
|
||||
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md);实际发布必须覆盖分支与版本、发布能力、文案和执行与验收 |
|
||||
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
|
||||
## 必须保持的边界
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
只读预检使用 `release/publish --check "$release_version"`。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
@@ -43,6 +43,7 @@ release/publish "$release_version"
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
|
||||
@@ -19,13 +19,16 @@
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
|
||||
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
实际发布由 `release/publish` 提交说明、版本文件及[插件接口弃用清单](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)的必要更新,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
|
||||
|
||||
## 发行准备检查
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
|
||||
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
|
||||
|
||||
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
|
||||
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# SQLx migration checksums are byte-sensitive; keep migration inputs portable across hosts.
|
||||
backend/migrations/*.sql text eol=lf
|
||||
backend/migrations/.frozen-sha256 text eol=lf
|
||||
@@ -139,7 +139,6 @@ body:
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ body:
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Build binary
|
||||
description: Build a native release binary with shared dependency caching and timings
|
||||
inputs:
|
||||
rust:
|
||||
description: Pinned Rust toolchain
|
||||
required: true
|
||||
target:
|
||||
description: Rust target triple
|
||||
required: true
|
||||
platform:
|
||||
description: Release platform name, such as linux-amd64
|
||||
required: true
|
||||
binary:
|
||||
description: Executable filename
|
||||
default: codex-proxy-rs
|
||||
rust-image:
|
||||
description: Pinned Linux builder image; empty for native builds
|
||||
default: ''
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ inputs.rust }}
|
||||
targets: ${{ inputs.target }}
|
||||
|
||||
- name: Configure Windows runtime linkage
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
# 发行包静态链接 MSVC 运行库,解压运行时无需安装 VC++ Redistributable
|
||||
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
shared-key: release-${{ inputs.target }}-${{ inputs.rust-image }}
|
||||
cache-bin: false
|
||||
# 只缓存依赖,避免随版本变化的 workspace 产物挤占缓存
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
BUILD_TARGET: ${{ inputs.target }}
|
||||
BUILD_PLATFORM: ${{ inputs.platform }}
|
||||
BUILD_BINARY: ${{ inputs.binary }}
|
||||
RUST_IMAGE: ${{ inputs.rust-image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$RUST_IMAGE" ]; then
|
||||
mkdir -p "$HOME/.cargo"
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
-e CARGO_HOME=/cargo -e HOME=/tmp \
|
||||
-e CPR_VERSION -e CPR_GIT_SHA -e CPR_BUILD_TIME -e CPR_BUILD_TYPE \
|
||||
-v "$HOME/.cargo:/cargo" -v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend "$RUST_IMAGE" \
|
||||
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
|
||||
else
|
||||
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
|
||||
fi
|
||||
"target/$BUILD_TARGET/release/$BUILD_BINARY" --help
|
||||
"target/$BUILD_TARGET/release/$BUILD_BINARY" --version
|
||||
mkdir -p "../dist/binaries/$BUILD_PLATFORM"
|
||||
cp "target/$BUILD_TARGET/release/$BUILD_BINARY" "../dist/binaries/$BUILD_PLATFORM/$BUILD_BINARY"
|
||||
|
||||
- name: Upload build timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: build-timings-${{ inputs.platform }}
|
||||
path: backend/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -43,4 +43,4 @@ runs:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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/ /
|
||||
@@ -1,31 +0,0 @@
|
||||
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"
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/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,snapshot}
|
||||
# tar 流避开 local exporter 的逐文件同步阻塞,直接解包以免额外落盘整份归档。
|
||||
# https://github.com/moby/buildkit/issues/2950
|
||||
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
|
||||
| tar -xf - -C "$temporary/snapshot"
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
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
|
||||
@@ -70,7 +70,6 @@ updates:
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- /deploy
|
||||
- /.github/actions/cargo-cache
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require 'open3'
|
||||
require 'tmpdir'
|
||||
require_relative 'release-quality'
|
||||
|
||||
# 使用临时 Git 仓库验证远端状态和只读边界,不访问真实 GitHub 或修改当前仓库
|
||||
ROOT = File.expand_path('../..', __dir__)
|
||||
|
||||
def assert(condition, message)
|
||||
raise message unless condition
|
||||
end
|
||||
|
||||
def git(*arguments)
|
||||
output, error, status = Open3.capture3('git', *arguments)
|
||||
raise "git #{arguments.first}: #{error}" unless status.success?
|
||||
output.strip
|
||||
end
|
||||
|
||||
def commit(message)
|
||||
git('add', '.')
|
||||
git('commit', '-qm', message)
|
||||
end
|
||||
|
||||
Dir.mktmpdir('release-checks-') do |directory|
|
||||
remote = File.join(directory, 'remote.git')
|
||||
system('git', 'init', '--bare', '-q', remote, exception: true)
|
||||
checkout = File.join(directory, 'checkout')
|
||||
FileUtils.mkdir_p(checkout)
|
||||
Dir.chdir(checkout) do
|
||||
git('init', '-q', '-b', 'main')
|
||||
git('config', 'user.email', 'test@example.invalid')
|
||||
git('config', 'user.name', 'Release test')
|
||||
files = %w[
|
||||
release/publish release/version.yaml release/notes.md release/platforms.yaml
|
||||
.github/scripts/toolchains.rb .github/scripts/release-metadata.rb
|
||||
backend/rust-toolchain.toml deploy/Dockerfile
|
||||
backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
|
||||
backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json
|
||||
backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json
|
||||
]
|
||||
files.each do |path|
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
FileUtils.cp(File.join(ROOT, path), path)
|
||||
end
|
||||
commit('initial source')
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'root commit should require full checks')
|
||||
git('remote', 'add', 'origin', remote)
|
||||
git('push', '-qu', 'origin', 'main')
|
||||
File.write('release/notes.md', "# v99.0.0\n\nFixture notes\n")
|
||||
|
||||
bin = File.join(directory, 'bin')
|
||||
FileUtils.mkdir_p(bin)
|
||||
File.write(File.join(bin, 'gh'), <<~RUBY)
|
||||
#!/usr/bin/env ruby
|
||||
case ARGV
|
||||
when ['auth', 'status'] then exit 0
|
||||
when ['repo', 'view', #{remote.inspect}, '--json', 'nameWithOwner', '--jq', '.nameWithOwner']
|
||||
puts 'owner/repo'
|
||||
else
|
||||
abort "unexpected gh mutation: \#{ARGV.inspect}"
|
||||
end
|
||||
RUBY
|
||||
File.chmod(0o755, File.join(bin, 'gh'))
|
||||
environment = { 'PATH' => "#{bin}:#{ENV.fetch('PATH')}" }
|
||||
snapshot = lambda do
|
||||
[git('show-ref'), git('status', '--porcelain'), File.binread('.git/index'),
|
||||
files.to_h { |path| [path, File.binread(path)] },
|
||||
git('ls-remote', 'origin'), File.exist?('.git/FETCH_HEAD')]
|
||||
end
|
||||
check = lambda do |success, message = nil|
|
||||
before = snapshot.call
|
||||
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
|
||||
assert(status.success? == success, "preflight outcome: #{output}#{error}")
|
||||
assert("#{output}#{error}".include?(message), "missing error: #{message}; #{error}") if message
|
||||
assert(snapshot.call == before, 'preflight modified files, refs, index, or FETCH_HEAD')
|
||||
end
|
||||
check.call(true, 'Preflight passed')
|
||||
File.write('deploy/Dockerfile', File.read('deploy/Dockerfile') + "\n# fixture\n")
|
||||
check.call(false, 'unstaged changes')
|
||||
git('checkout', '--', 'deploy/Dockerfile')
|
||||
git('tag', 'v99.0.0')
|
||||
check.call(false, 'local tag already exists')
|
||||
git('push', '-q', 'origin', 'refs/tags/v99.0.0')
|
||||
git('tag', '-d', 'v99.0.0')
|
||||
check.call(false, 'remote tag already exists')
|
||||
git('push', '-q', 'origin', ':refs/tags/v99.0.0')
|
||||
|
||||
# 未履行的兼容窗口必须在只读预检中失败,不能等待提交版本后再发现
|
||||
deprecations = 'backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json'
|
||||
previous = File.read(deprecations)
|
||||
File.write(deprecations, JSON.generate([{
|
||||
capability: 'removed-fixture', version: 1, replacement_version: 2, remaining_releases: 7
|
||||
}]))
|
||||
git('add', deprecations)
|
||||
git('commit', '-qm', 'invalid lifecycle fixture')
|
||||
check.call(false, 'replacement v2 must be available')
|
||||
File.write(deprecations, previous)
|
||||
commit('restore lifecycle and notes')
|
||||
File.write('release/notes.md', "# v99.0.0\n\nUpdated fixture notes\n")
|
||||
|
||||
# 远端前进但没有本地对象时,提示同步而不隐式 fetch
|
||||
other = File.join(directory, 'other')
|
||||
git('clone', '-q', '-b', 'main', remote, other)
|
||||
Dir.chdir(other) do
|
||||
git('config', 'user.email', 'test@example.invalid')
|
||||
git('config', 'user.name', 'Release test')
|
||||
File.write('remote-only', 'change')
|
||||
commit('remote advance')
|
||||
git('push', '-q')
|
||||
end
|
||||
check.call(false, 'unseen commits')
|
||||
git('fetch', '-q', 'origin')
|
||||
check.call(false, 'diverged')
|
||||
FileUtils.mv(remote, "#{remote}.hidden")
|
||||
# 读取远端失败不能当作 tag 不存在,仍须保持本地不变
|
||||
before = [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')]
|
||||
_, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
|
||||
assert(!status.success? && error.include?('failed to read remote refs'), 'remote failure was ignored')
|
||||
assert(before == [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')], 'remote failure changed local state')
|
||||
FileUtils.mv("#{remote}.hidden", remote)
|
||||
|
||||
File.write('release/version.yaml', "version: \"99.0.0\"\n")
|
||||
commit('release metadata')
|
||||
parent = git('rev-parse', 'HEAD^')
|
||||
run = { 'head_sha' => parent, 'conclusion' => 'success', 'head_repository' => { 'full_name' => 'owner/repo' }, 'id' => 42 }
|
||||
job = { 'name' => 'quality / backend', 'conclusion' => 'success', 'html_url' => 'https://example.invalid/42' }
|
||||
runs = [run]
|
||||
jobs = [job]
|
||||
original_capture = ReleaseQuality.method(:capture)
|
||||
ReleaseQuality.define_singleton_method(:capture) do |*arguments|
|
||||
if arguments.first == 'gh'
|
||||
endpoint = arguments.find { |argument| argument.start_with?('repos/') }
|
||||
if endpoint.include?('/workflows/ci.yml/runs?')
|
||||
assert(endpoint.include?("event=push&branch=main&head_sha=#{parent}"), 'wrong CI evidence scope')
|
||||
JSON.generate('workflow_runs' => runs)
|
||||
else
|
||||
assert(endpoint == 'repos/owner/repo/actions/runs/42/jobs?per_page=100', 'wrong run jobs')
|
||||
jobs.map { |entry| JSON.generate(entry) }.join("\n")
|
||||
end
|
||||
else
|
||||
original_capture.call(*arguments)
|
||||
end
|
||||
end
|
||||
assert(ReleaseQuality.evidence('owner/repo') == job['html_url'], 'valid CI evidence not reused')
|
||||
%w[skipped failure cancelled].each do |conclusion|
|
||||
job['conclusion'] = conclusion
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, "reused #{conclusion} backend")
|
||||
end
|
||||
job['conclusion'] = 'success'
|
||||
jobs = []
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused missing backend job')
|
||||
jobs = [job]
|
||||
run['conclusion'] = 'failure'
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused failed CI run')
|
||||
run['conclusion'] = nil
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused unfinished CI run')
|
||||
run['conclusion'] = 'success'
|
||||
run['head_repository']['full_name'] = 'fork/repo'
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused foreign repository CI')
|
||||
run['head_repository']['full_name'] = 'owner/repo'
|
||||
runs = []
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused absent CI run')
|
||||
runs = [run]
|
||||
output, _, status = Open3.capture3(environment.merge('GITHUB_REPOSITORY' => 'owner/repo'), 'ruby', File.join(ROOT, '.github/scripts/release-quality.rb'))
|
||||
assert(status.success? && output.strip == 'run_backend=true', 'API failure did not fall back to full checks')
|
||||
File.write('backend/source.rs', '// changed source')
|
||||
commit('source changed')
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused CI across source changes')
|
||||
merge = git('commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-p', 'HEAD^', '-m', 'merge fixture')
|
||||
git('update-ref', 'HEAD', merge)
|
||||
assert(ReleaseQuality.evidence('owner/repo').nil?, 'merge commit should require full checks')
|
||||
|
||||
# 在同一个隔离仓库走实际提交与原子推送路径,dispatch 仍由本地替身接收
|
||||
git('merge', '--no-edit', 'origin/main')
|
||||
File.write('release/notes.md', "# v100.0.0\n\nPublish fixture notes\n")
|
||||
dispatch = File.join(directory, 'dispatch.json')
|
||||
mock = File.join(bin, 'gh')
|
||||
File.write(mock, File.read(mock).sub("else\n", <<~RUBY))
|
||||
when ['workflow', 'run', 'release.yml', '--repo', 'owner/repo', '--ref', 'main', '--raw-field', 'tag=v100.0.0']
|
||||
File.write(#{dispatch.inspect}, ARGV.join(' '))
|
||||
else
|
||||
RUBY
|
||||
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '100.0.0')
|
||||
assert(status.success?, "publish failed: #{output}#{error}")
|
||||
assert(File.exist?(dispatch), 'workflow was not dispatched')
|
||||
assert(git('log', '-1', '--format=%s') == 'chore(release): v100.0.0', 'wrong release commit')
|
||||
assert(git('status', '--porcelain').empty?, 'release left tracked changes')
|
||||
assert(git('rev-parse', 'HEAD') == git('rev-parse', 'v100.0.0^{commit}'), 'tag differs from release commit')
|
||||
assert(git('ls-remote', 'origin', 'refs/tags/v100.0.0').split.first == git('rev-parse', 'v100.0.0'), 'tag not pushed')
|
||||
end
|
||||
end
|
||||
puts 'Release preflight and CI evidence checks passed'
|
||||
@@ -82,6 +82,7 @@ module ReleaseMetadata
|
||||
'platform_matrix' => JSON.generate('include' => entries),
|
||||
'docker_matrix' => JSON.generate('include' => docker),
|
||||
'non_docker_matrix' => JSON.generate('include' => native),
|
||||
'windows_matrix' => JSON.generate('include' => entries.select { |entry| entry['os'] == 'windows' }),
|
||||
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
|
||||
'previous_tag' => previous,
|
||||
'prerelease' => prerelease?(value),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'json'
|
||||
require 'open3'
|
||||
|
||||
# 仅复用同一源码的主分支 CI,缺少证据时完整检查发行提交
|
||||
module ReleaseQuality
|
||||
def self.capture(*command)
|
||||
output, error, status = Open3.capture3(*command)
|
||||
raise "#{command.first} failed: #{error.strip}" unless status.success?
|
||||
output
|
||||
end
|
||||
|
||||
def self.evidence(repository)
|
||||
parents = capture('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
|
||||
return nil unless parents.length == 2
|
||||
parent = parents.last
|
||||
changes = capture('git', 'diff', '--name-only', '-z', parent, 'HEAD').split("\0")
|
||||
return nil if changes.empty? || (changes - %w[release/version.yaml release/notes.md]).any?
|
||||
|
||||
# 按最新一次运行判断,失败或仍在执行时不回退到旧的成功记录
|
||||
endpoint = "repos/#{repository}/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=#{parent}&per_page=1"
|
||||
run = JSON.parse(capture('gh', 'api', endpoint)).fetch('workflow_runs').first
|
||||
return nil unless run && run.fetch('head_sha') == parent && run.fetch('conclusion') == 'success'
|
||||
return nil unless run.fetch('head_repository').fetch('full_name') == repository
|
||||
|
||||
rows = capture('gh', 'api', '--paginate', "repos/#{repository}/actions/runs/#{run.fetch('id')}/jobs?per_page=100", '--jq', '.jobs[] | @json')
|
||||
job = rows.lines.map { |line| JSON.parse(line) }.find { |entry| entry.fetch('name') == 'quality / backend' }
|
||||
return nil unless job && job.fetch('conclusion') == 'success'
|
||||
job.fetch('html_url')
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
evidence = ReleaseQuality.evidence(ENV.fetch('GITHUB_REPOSITORY'))
|
||||
rescue StandardError => error
|
||||
warn "CI evidence unavailable; running full release checks: #{error.message}"
|
||||
evidence = nil
|
||||
end
|
||||
puts "run_backend=#{evidence.nil?}"
|
||||
if ENV['GITHUB_STEP_SUMMARY']
|
||||
message = evidence ? "后端质量检查复用版本提交父提交的 [CI 结果](#{evidence});仅版本与发布说明不同" : '未找到可复用的后端 CI 结果,完整检查发行提交'
|
||||
File.open(ENV.fetch('GITHUB_STEP_SUMMARY'), 'a') { |file| file.puts(message) }
|
||||
end
|
||||
end
|
||||
@@ -7,20 +7,73 @@ on:
|
||||
description: Exact commit SHA to build and validate
|
||||
required: true
|
||||
type: string
|
||||
source_build:
|
||||
description: Validate the complete Docker source build
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
if: ${{ !inputs.source_build }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
upload-artifact: 'true'
|
||||
|
||||
binary:
|
||||
if: ${{ !inputs.source_build }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
- uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ steps.toolchains.outputs.rust }}
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-amd64
|
||||
rust-image: ${{ steps.toolchains.outputs.rust_image }}
|
||||
env:
|
||||
CPR_GIT_SHA: ${{ inputs.ref }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-linux-amd64
|
||||
path: dist/binaries/linux-amd64/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, binary]
|
||||
if: always() && !cancelled()
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
steps:
|
||||
# 上游失败必须传递给已有的容器检查,不能让必需状态变成 skipped
|
||||
- name: Require prebuilt inputs
|
||||
if: ${{ !inputs.source_build }}
|
||||
env:
|
||||
FRONTEND_RESULT: ${{ needs.frontend.result }}
|
||||
BINARY_RESULT: ${{ needs.binary.result }}
|
||||
run: test "$FRONTEND_RESULT" = success && test "$BINARY_RESULT" = success
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -30,39 +83,51 @@ jobs:
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Restore Cargo snapshot
|
||||
id: cargo-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
- name: Download frontend
|
||||
if: ${{ !inputs.source_build }}
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
# 同一平台和依赖复用快照,源码变化由 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-v3-${{ runner.os }}-${{ runner.arch }}-
|
||||
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
|
||||
name: frontend-dist
|
||||
path: dist/docker/web/dist
|
||||
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
id: cargo-restore
|
||||
uses: ./.github/actions/cargo-cache
|
||||
- name: Download binary
|
||||
if: ${{ !inputs.source_build }}
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
operation: restore
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
name: codex-proxy-rs-linux-amd64
|
||||
path: dist/docker-artifacts/codex-proxy-rs-linux-amd64
|
||||
|
||||
- name: Prepare prebuilt image
|
||||
if: ${{ !inputs.source_build }}
|
||||
env:
|
||||
MATRIX_OS: linux
|
||||
MATRIX_ARCH: amd64
|
||||
MATRIX_BINARY: codex-proxy-rs
|
||||
run: |
|
||||
ruby -r './.github/scripts/release-metadata' -e '
|
||||
ReleaseMetadata.seal_manifest(
|
||||
"version" => ReleaseMetadata.version(YAML.load_file("release/version.yaml").fetch("version")),
|
||||
"git_sha" => ReleaseMetadata.git("rev-parse", "HEAD")
|
||||
)'
|
||||
ruby .github/scripts/package-release.rb image
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
target: runtime
|
||||
target: ${{ inputs.source_build && 'runtime' || 'runtime-prebuilt' }}
|
||||
build-args: |
|
||||
CPR_PREBUILT_DIR=dist/docker
|
||||
CPR_GIT_SHA=${{ inputs.ref }}
|
||||
load: true
|
||||
tags: ${{ env.CPR_IMAGE }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
|
||||
runtime-prebuilt
|
||||
cache-from: type=gha,scope=runtime-${{ inputs.source_build && 'source' || 'prebuilt' }}
|
||||
cache-to: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && format('type=gha,mode=max,scope=runtime-{0}', inputs.source_build && 'source' || 'prebuilt') || '' }}
|
||||
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
@@ -70,23 +135,3 @@ 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 }}
|
||||
|
||||
@@ -24,17 +24,18 @@ permissions:
|
||||
jobs:
|
||||
backend:
|
||||
if: inputs.run_backend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_PROFILE_DEV_DEBUG: '1'
|
||||
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
|
||||
RUST_MIN_STACK: 16777216
|
||||
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
|
||||
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
|
||||
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
env:
|
||||
POSTGRES_DB: codex_proxy
|
||||
POSTGRES_USER: codex_proxy
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
|
||||
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -108,10 +109,12 @@ jobs:
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
shared-key: quality
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Rust format
|
||||
working-directory: backend
|
||||
@@ -122,7 +125,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -131,7 +134,7 @@ jobs:
|
||||
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 \
|
||||
cargo nextest list --test main --locked --timings --list-type binaries-only --message-format json \
|
||||
> "$RUNNER_TEMP/nextest-binaries.json"
|
||||
|
||||
- name: Configure PostgreSQL and Redis test authentication
|
||||
@@ -173,10 +176,22 @@ jobs:
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
cargo nextest run \
|
||||
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
|
||||
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--no-tests fail
|
||||
--test-threads 8 --no-tests fail --profile ci
|
||||
|
||||
- name: Upload test and build reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-quality-reports
|
||||
path: |
|
||||
backend/target/nextest/ci/junit.xml
|
||||
backend/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Store tests with Redis ACL authentication
|
||||
working-directory: backend
|
||||
@@ -195,11 +210,11 @@ jobs:
|
||||
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::/)'
|
||||
--test-threads 8 --no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -213,7 +228,7 @@ jobs:
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
workflow-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -223,6 +238,8 @@ jobs:
|
||||
|
||||
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
|
||||
# https://github.com/rhysd/actionlint/issues/657
|
||||
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
|
||||
# https://github.com/actions/runner-images#available-images
|
||||
- name: Lint GitHub Actions workflows
|
||||
run: >-
|
||||
docker run --rm
|
||||
@@ -231,6 +248,7 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
|
||||
-ignore '^label "ubuntu-26\.04" is unknown\.'
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check workflow script syntax
|
||||
@@ -241,3 +259,6 @@ jobs:
|
||||
for script in .github/scripts/*.rb; do ruby -c "$script"; done
|
||||
ruby .github/scripts/toolchains.rb
|
||||
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
|
||||
|
||||
- name: Verify release preflight and CI evidence
|
||||
run: ruby .github/scripts/release-checks-test.rb
|
||||
|
||||
+30
-79
@@ -20,13 +20,16 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source: ${{ steps.filter.outputs.source }}
|
||||
source_container: ${{ steps.filter.outputs.source_container }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -38,7 +41,7 @@ jobs:
|
||||
id: filter
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置
|
||||
predicate-quantifier: some-with-excludes
|
||||
filters: |
|
||||
backend:
|
||||
@@ -58,17 +61,6 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
source:
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'deploy/Dockerfile'
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
@@ -76,10 +68,30 @@ jobs:
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/verify-container/**'
|
||||
- '.github/actions/cargo-cache/**'
|
||||
- '.github/actions/build-binary/**'
|
||||
- '.github/actions/build-frontend/**'
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- '.github/scripts/package-release.rb'
|
||||
- '.github/scripts/release-metadata.rb'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
source_container:
|
||||
- 'deploy/Dockerfile'
|
||||
- 'deploy/Dockerfile.dockerignore'
|
||||
- '.dockerignore'
|
||||
- 'backend/**/Cargo.toml'
|
||||
- 'backend/Cargo.lock'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'backend/**/build.rs'
|
||||
- 'backend/.cargo/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/pnpm-lock.yaml'
|
||||
- 'frontend/pnpm-workspace.yaml'
|
||||
- 'frontend/.npmrc'
|
||||
- 'frontend/patches/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
quality:
|
||||
needs: changes
|
||||
@@ -87,7 +99,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run_backend: ${{ needs.changes.outputs.backend == 'true' }}
|
||||
run_frontend: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
# 预构建容器复用同一次前端检查产物,源码 Docker 构建仍独立验证
|
||||
run_frontend: ${{ needs.changes.outputs.frontend == 'true' && (needs.changes.outputs.container != 'true' || needs.changes.outputs.source_container == 'true') }}
|
||||
|
||||
container:
|
||||
needs: changes
|
||||
@@ -98,66 +111,4 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ steps.toolchains.outputs.rust }}
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
|
||||
- name: Check UI source integration
|
||||
run: |
|
||||
pnpm --dir frontend build:source
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
- name: Check local SDK integration
|
||||
env:
|
||||
RUST_MIN_STACK: 16777216
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
|
||||
run: |
|
||||
sdk_check_dir="$RUNNER_TEMP/plugin-source"
|
||||
mkdir -p "$sdk_check_dir/backend"
|
||||
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
|
||||
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
|
||||
- name: Ensure source checks preserve lockfiles
|
||||
run: |
|
||||
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
|
||||
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
|
||||
source_build: ${{ needs.changes.outputs.source_container == 'true' }}
|
||||
|
||||
@@ -21,8 +21,11 @@ env:
|
||||
|
||||
jobs:
|
||||
release-info:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
@@ -32,12 +35,14 @@ jobs:
|
||||
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
||||
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
|
||||
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
|
||||
windows_matrix: ${{ steps.version.outputs.windows_matrix }}
|
||||
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
|
||||
previous_tag: ${{ steps.version.outputs.previous_tag }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
build_type: ${{ steps.version.outputs.build_type }}
|
||||
rust: ${{ steps.toolchains.outputs.rust }}
|
||||
rust_image: ${{ steps.toolchains.outputs.rust_image }}
|
||||
run_backend: ${{ steps.quality.outputs.run_backend }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -56,24 +61,91 @@ jobs:
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check reusable backend CI evidence
|
||||
id: quality
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ruby .github/scripts/release-quality.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
quality:
|
||||
needs: [release-info]
|
||||
uses: ./.github/workflows/_quality.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
run_backend: ${{ needs.release-info.outputs.run_backend == 'true' }}
|
||||
run_frontend: false
|
||||
|
||||
windows:
|
||||
needs: release-info
|
||||
name: windows-tests (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.release-info.outputs.windows_matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
RUSTFLAGS: '-C target-feature=+crt-static'
|
||||
CARGO_PROFILE_DEV_DEBUG: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
shared-key: platform-tests-${{ matrix.target }}
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
- name: Verify Windows platform behavior
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
# 同一次构建统一两个 package 的依赖特性,保留原有平台用例和静态运行库
|
||||
run: |
|
||||
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
|
||||
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" --locked --target "$BUILD_TARGET" \
|
||||
-p gateway-admin -p gateway-host --test main --no-tests fail --profile ci \
|
||||
-E '(package(=gateway-admin) & test(/^use_case::plugins::official::/)) | (package(=gateway-host) & test(/^system_update::/))'
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: windows-tests-${{ matrix.arch }}
|
||||
path: backend/target/nextest/ci/junit.xml
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
security:
|
||||
needs: release-info
|
||||
uses: ./.github/workflows/security.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -103,40 +175,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$HOME/.cargo" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
docker run --rm \
|
||||
--platform "${{ matrix.dockerPlatform }}" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e CARGO_HOME=/cargo \
|
||||
-e HOME=/tmp \
|
||||
-e CPR_VERSION="${CPR_VERSION}" \
|
||||
-e CPR_GIT_SHA="${CPR_GIT_SHA}" \
|
||||
-e CPR_BUILD_TIME="${CPR_BUILD_TIME}" \
|
||||
-e CPR_BUILD_TYPE="${CPR_BUILD_TYPE}" \
|
||||
-v "$HOME/.cargo:/cargo" \
|
||||
-v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend \
|
||||
"${RUST_IMAGE}" \
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ needs.release-info.outputs.rust }}
|
||||
target: ${{ matrix.target }}
|
||||
platform: ${{ matrix.os }}-${{ matrix.arch }}
|
||||
binary: ${{ matrix.binary }}
|
||||
rust-image: ${{ needs.release-info.outputs.rust_image }}
|
||||
env:
|
||||
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
@@ -148,7 +195,7 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
build-binary:
|
||||
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
@@ -165,30 +212,13 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ needs.release-info.outputs.rust }}
|
||||
target: ${{ matrix.target }}
|
||||
platform: ${{ matrix.os }}-${{ matrix.arch }}
|
||||
binary: ${{ matrix.binary }}
|
||||
env:
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
@@ -201,11 +231,11 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -255,7 +285,7 @@ jobs:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
|
||||
compression-level: 0
|
||||
|
||||
@@ -312,7 +342,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -367,15 +397,15 @@ jobs:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
concurrency:
|
||||
group: release-publication
|
||||
queue: max
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-info, quality, windows, security, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -409,7 +439,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Exact commit SHA to audit
|
||||
required: true
|
||||
type: string
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,21 +20,21 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
group: security-${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -38,13 +44,13 @@ jobs:
|
||||
cargo-audit audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
@@ -34,6 +34,7 @@ Thumbs.db
|
||||
|
||||
# Local tools and worktrees
|
||||
/.codegraph/
|
||||
/.playwright-cli/
|
||||
/.codex-ws-audit/
|
||||
/.reasonix/
|
||||
/.claude
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [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 |
|
||||
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);实现自审、审查或修改同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
|
||||
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
|
||||
| 写、修或审查独立网关插件,排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide;仅改宿主能力时由 cpr-dev-guide 加载插件边界 |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
|
||||
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
@@ -21,11 +23,19 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
|
||||
### 任务延续与收尾
|
||||
|
||||
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
|
||||
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
|
||||
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
|
||||
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
||||
|
||||
- **Index freshness**: Run `codegraph sync` from the repository root before the first code query in a task. After pulling updates, switching branches, or changing code, run it again before the next query so symbols and call paths match the working tree. If synchronization fails, report the failure and verify against current source rather than treating the index as current.
|
||||
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
|
||||
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
|
||||
|
||||
|
||||
+14
-4
@@ -21,12 +21,15 @@
|
||||
|
||||
### GitHub 回复口吻
|
||||
|
||||
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
|
||||
AI 草拟、发布或修改 Issue、PR 讨论及审查意见的回复时,以一起排查问题、讨论方案的口吻交流:
|
||||
|
||||
- 围绕问题、事实和结论展开,能自然省略主语时省略;需要说明个人判断或行动归属时用“我”,避免反复以“我”起句或强调自己,不用“我们”“本助手”代称自己
|
||||
- 普通回复直接回应问题,不加问候语或称呼;向对方提问时称呼“佬”,同一条回复不重复称呼
|
||||
- 使用自然、客气的日常交流口吻,适度用“看了下”“方便补充一下吗”等缓和表达,例如“我看了截图”可写为“我看了下截图”,不机械堆叠客套或语气词
|
||||
- 补充信息或提出不同意见时先说明观察和依据,再温和表达判断,避免生硬纠正、命令或责备;已确认的事实保持明确,不为委婉写成猜测
|
||||
- 先接住对方想完成的事和遇到的困难,再给出核实结果与建议;回复让对方感到问题被理解、有下一步可做,避免一上来归类、纠正或划定责任
|
||||
- 给出操作建议时说明它能帮助判断什么,措辞自然、委婉,不靠堆叠缓和词修饰命令
|
||||
- 回应已有能力、暂不支持或未采纳的建议时,结合场景说明可用入口或具体原因,有可行替代办法时一并说明,再交代必要边界;避免只回复“已有功能”“重复问题”“不在范围内”等结论
|
||||
- 需要补充材料或配合操作时,先利用已有信息,只询问仍缺少的部分,说明用途和最小范围;例如“佬,方便再观察几次吗?如果还出现,可以附一下对应请求的诊断包,便于对照终态用量”
|
||||
- 澄清误解或提出不同意见时,从观察和依据说起,照顾对方的使用环境与理解路径,避免责备、反问或居高临下的判断;已确认的事实保持明确,尚未确认的原因保留余地
|
||||
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 排查、复现、修改和验证的完成状态与实际一致;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
|
||||
@@ -146,6 +149,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
|
||||
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
|
||||
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
|
||||
| [共用组件](modules/ui/README.md)及其组件参考 | 如何安装、使用和开发独立 UI 库 | 组件公开 API、主题能力与库内开发;宿主页面约定归管理端主题,跨仓库联调归开发文档 |
|
||||
| [官方插件示例](modules/plugins/README.md)及其示例说明 | 如何构建、使用和维护示例插件 | 示例自身的功能、配置与验证;通用协议归 SDK,宿主安装操作归插件使用文档 |
|
||||
| [AGENTS.md](AGENTS.md) 与[项目技能](.agents/skills/cpr-dev-guide/SKILL.md) | AI 如何选择并执行任务流程 | 任务匹配、执行步骤和规范入口,不另建模块合同、版本表或协作标准 |
|
||||
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
|
||||
|
||||
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
|
||||
@@ -222,10 +228,14 @@ git diff --check
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端和容器任务,工作流语法检查独立执行
|
||||
|
||||
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
|
||||
|
||||
常规容器检查与发行构建共用二进制构建入口,并复用已检查的前端产物;Dockerfile、工具链、依赖或构建配置变化时执行完整源码镜像构建,定期安全检查也覆盖源码构建。两条路径均验证容器运行、安全扫描与关闭行为
|
||||
|
||||
发行构建与质量检查并行,Windows 两种架构的插件与系统更新测试独立运行。仅当发行提交只修改版本与发布说明,且其父提交有本仓库主分支成功的后端 CI 时复用该结果;缺少证据则完整重跑。对外发布等待质量、Windows 测试、依赖审计和镜像验证全部成功,构建耗时与测试报告保留 7 天
|
||||
|
||||
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
|
||||
> 本项目提供 Responses API 和 Chat Completions API;Chat Completions 请求通过 Responses 链路执行,支持范围见 [API 参考](docs/api.md#chat-completions-请求)
|
||||
|
||||
## 快速预览
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
@@ -80,7 +82,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
|
||||
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CC Switch
|
||||
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
@@ -89,15 +91,15 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
|
||||
|
||||
1. 在「账号」中添加账号,完成授权或导入
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置
|
||||
3. [连接客户端](#客户端接入)
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,合并配置后重启 Codex;也可通过「导入到」把服务连接与密钥添加到 CC Switch
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息:
|
||||
**其他 Responses / Chat Completions API 客户端**:填写以下信息:
|
||||
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
@@ -118,17 +120,14 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](https://github.com/zyycn/codex-proxy-plugins/blob/main/README.md) |
|
||||
| 开发共用组件 | [组件库说明](https://github.com/zyycn/codex-proxy-ui/blob/main/README.md) · [组件参考](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/components/index.md) · [Playground](https://zyycn.github.io/codex-proxy-ui/) |
|
||||
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
|
||||
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
|
||||
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
|
||||
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
|
||||
问题反馈与功能建议请使用 [GitHub Issues](https://github.com/zyycn/codex-proxy-rs/issues),填写要求见 [贡献说明](CONTRIBUTING.md#提交-issue)
|
||||
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
|
||||
|
||||
|
||||
Generated
+132
-130
@@ -147,9 +147,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.50"
|
||||
version = "0.4.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -224,9 +224,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-credential-types"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
|
||||
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -260,9 +260,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.10.0"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
|
||||
checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -286,9 +286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.152.0"
|
||||
version = "1.157.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
|
||||
checksum = "6ea9794c741520d8baec9462bcd0ad12164d4f5f78d008a8da9d6a0b7222caa8"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -322,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.6.0"
|
||||
version = "1.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
|
||||
checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
@@ -344,9 +344,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-async"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
|
||||
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
@@ -376,9 +376,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.4"
|
||||
version = "0.61.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609"
|
||||
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -387,9 +387,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.1"
|
||||
version = "0.64.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
|
||||
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
|
||||
dependencies = [
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -403,15 +403,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.5.0"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
|
||||
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -433,9 +432,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.63.1"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18"
|
||||
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -453,9 +452,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.16.0"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
|
||||
checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -470,16 +469,15 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.19.0"
|
||||
version = "1.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
|
||||
checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -494,9 +492,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api-macros"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
|
||||
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -505,9 +503,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-schema"
|
||||
version = "0.2.1"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
|
||||
checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -516,9 +514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.8.1"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
|
||||
checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -530,7 +528,6 @@ dependencies = [
|
||||
"itoa",
|
||||
"num-integer",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"ryu",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -540,9 +537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-xml"
|
||||
version = "0.62.1"
|
||||
version = "0.64.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf"
|
||||
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -552,9 +549,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -791,9 +788,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -945,9 +942,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -956,9 +953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.33"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
@@ -1084,9 +1081,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -1129,9 +1126,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -1166,14 +1163,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.13.1",
|
||||
"phf 0.14.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -1189,9 +1186,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -1708,6 +1705,7 @@ dependencies = [
|
||||
"cron",
|
||||
"futures",
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"heck",
|
||||
"hex",
|
||||
"hkdf",
|
||||
@@ -1746,6 +1744,8 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"regex",
|
||||
"secrecy",
|
||||
"serde",
|
||||
@@ -1759,6 +1759,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
@@ -1802,6 +1803,7 @@ dependencies = [
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"hex",
|
||||
"http",
|
||||
"http-body-util",
|
||||
@@ -2124,12 +2126,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2252,8 +2255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -2261,6 +2265,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
@@ -2480,9 +2485,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.58.6"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
|
||||
checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2508,23 +2513,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.58.6"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
|
||||
checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.58.6"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
|
||||
checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"memchr",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
@@ -2639,9 +2645,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
@@ -2723,9 +2729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
@@ -3098,23 +3104,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3129,12 +3135,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3152,12 +3158,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -3183,9 +3189,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -3196,12 +3202,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
@@ -3325,6 +3325,7 @@ dependencies = [
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"gateway-host",
|
||||
"gateway-protocol",
|
||||
"getrandom 0.4.3",
|
||||
"h2",
|
||||
@@ -3577,9 +3578,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.7.1"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -3633,9 +3634,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.58.6"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
|
||||
checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
@@ -3758,9 +3759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3872,9 +3873,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.27.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||
checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
@@ -3882,7 +3883,6 @@ dependencies = [
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3947,17 +3947,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
@@ -4064,9 +4062,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
@@ -4398,24 +4396,24 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared 0.14.0",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@@ -4652,9 +4650,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
version = "1.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -4955,9 +4953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
@@ -5054,9 +5052,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.28.0"
|
||||
version = "1.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -5211,11 +5209,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf 0.14.0",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
@@ -5471,13 +5469,14 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wnaf"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
|
||||
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
"primefield",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5647,16 +5646,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.4"
|
||||
version = "9.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5673,27 +5675,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
|
||||
+4
-2
@@ -49,6 +49,10 @@ opt-level = 2
|
||||
[profile.dev.package."miniz_oxide@0.9.1"]
|
||||
opt-level = 2
|
||||
|
||||
# workspace 的 WebSocket deflate 特性会让 flate2 选用 zlib-rs
|
||||
[profile.dev.package.zlib-rs]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
strip = "symbols"
|
||||
@@ -57,7 +61,5 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
|
||||
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
|
||||
@@ -100,9 +100,18 @@ async fn execute(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
let management = ManagementPorts::new(
|
||||
&store,
|
||||
providers.admin.clone(),
|
||||
core.snapshot_control(),
|
||||
providers.peer.service(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
|
||||
);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
@@ -128,6 +137,7 @@ async fn execute(
|
||||
providers.admin.clone(),
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
Some(providers.peer.service()),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, services.register_accounts(&accounts));
|
||||
let usage_data = gateway_admin::initialize_usage_data(
|
||||
@@ -136,6 +146,10 @@ async fn execute(
|
||||
providers.admin,
|
||||
);
|
||||
or_shutdown!(plugin_runtime, services.register_usage_data(&usage_data));
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_usage_queries(&usage_data)
|
||||
);
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
|
||||
@@ -42,6 +42,7 @@ impl ManagementPorts {
|
||||
store: &gateway_store::StoreBundle,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
upstream_info: Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>,
|
||||
) -> Self {
|
||||
let ports = store.admin_ports();
|
||||
Self {
|
||||
@@ -49,6 +50,7 @@ impl ManagementPorts {
|
||||
providers.clone(),
|
||||
ports.accounts(),
|
||||
snapshot.clone(),
|
||||
Some(upstream_info),
|
||||
),
|
||||
keys: gateway_admin::initialize_plugin_client_keys(
|
||||
providers,
|
||||
|
||||
@@ -35,9 +35,18 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
let management = ManagementPorts::new(
|
||||
&store,
|
||||
providers.admin.clone(),
|
||||
core.snapshot_control(),
|
||||
providers.peer.service(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
|
||||
);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
@@ -73,6 +82,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
config.client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
upstream_info: Some(providers.peer.service()),
|
||||
timezone,
|
||||
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
|
||||
providers: providers.admin,
|
||||
@@ -150,6 +160,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_usage_queries(&admin.services().usage_data())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(analytics_worker);
|
||||
@@ -161,6 +175,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plan.extend(providers.openai.take_worker_contributions());
|
||||
plan.extend(providers.xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.extend(providers.peer.take_worker_contributions());
|
||||
plan.push(or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.maintenance_worker(core.snapshots())
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use gateway_admin::ports::provider::ProviderAdminRegistry;
|
||||
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
|
||||
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_host::{HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_store::{StoreBundle, StoreConfig};
|
||||
|
||||
use super::BootstrapError;
|
||||
@@ -33,6 +33,8 @@ impl Environment {
|
||||
let timezone = host.timezone;
|
||||
let store = store.with_timezone(timezone);
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
// 插件兼容检查只需本地运行版本,管理端版本查询还会等待远端发行服务
|
||||
let version = host.system_update.version.clone();
|
||||
let host = match mode {
|
||||
Mode::Server => gateway_host::initialize(host).await?,
|
||||
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
|
||||
@@ -44,12 +46,6 @@ impl Environment {
|
||||
Mode::Help => gateway_store::initialize_read_only(store).await?,
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = Arc::new(HttpClient::new()?);
|
||||
Ok(Self {
|
||||
host,
|
||||
@@ -67,6 +63,7 @@ pub(super) struct Providers {
|
||||
pub(super) xai: provider_xai::ProviderBundle,
|
||||
pub(super) core: ProviderRegistry,
|
||||
pub(super) admin: ProviderAdminRegistry,
|
||||
pub(super) peer: gateway_admin::PeerUpstreamBundle,
|
||||
}
|
||||
|
||||
impl Providers {
|
||||
@@ -75,16 +72,32 @@ impl Providers {
|
||||
environment: &Environment,
|
||||
) -> Result<Self, BootstrapError> {
|
||||
let ports = environment.store.provider_ports();
|
||||
let openai =
|
||||
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
|
||||
.await?;
|
||||
let upstream_reader = Arc::new(gateway_core::account::UpstreamInfoReader::default());
|
||||
let openai = provider_openai::initialize(
|
||||
config.with_timezone(environment.timezone),
|
||||
ports.clone(),
|
||||
upstream_reader.clone(),
|
||||
)
|
||||
.await?;
|
||||
environment.host.report_startup_ready("OpenAI Provider");
|
||||
let xai = provider_xai::initialize(ports).await?;
|
||||
environment.host.report_startup_ready("xAI Provider");
|
||||
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
let peer = gateway_admin::initialize_peer_upstream(
|
||||
environment.store.provider_ports().accounts(),
|
||||
&admin,
|
||||
Arc::new(gateway_host::peer::HttpPeerTransport::with_client_builder(
|
||||
provider_openai::build_reqwest_client_with_custom_ca,
|
||||
)),
|
||||
)
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("互联后台任务配置无效"))?;
|
||||
upstream_reader
|
||||
.bind(&peer.scheduling_source())
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("互联观测端口重复绑定"))?;
|
||||
Ok(Self {
|
||||
peer,
|
||||
openai,
|
||||
xai,
|
||||
core,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! 网关命令行入口,选择启动模式并创建异步运行时
|
||||
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
io::{self, Write},
|
||||
process::ExitCode,
|
||||
@@ -21,7 +20,7 @@ fn main() -> ExitCode {
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
let mut arguments = gateway_host::system_update::startup_arguments()?;
|
||||
let command = arguments
|
||||
.next()
|
||||
.map(|value| {
|
||||
@@ -92,7 +91,7 @@ fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
|
||||
|
||||
fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> {
|
||||
if arguments.next().is_some() {
|
||||
return Err(invalid_cli("unexpected extra command arguments"));
|
||||
return Err(invalid_cli("too many arguments"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
|
||||
#[test]
|
||||
fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
let root = backend_root().join("crates/gateway-core/src");
|
||||
let reexports = read_root_reexport_owners(&root);
|
||||
for relative in super::rust_files(&root) {
|
||||
let allowed: Option<&[&str]> = match relative.to_str().expect("source path") {
|
||||
"validation.rs" => Some(&[]),
|
||||
@@ -96,7 +97,7 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
let Some(allowed) = allowed else { continue };
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
let syntax = syn::parse_file(&source).expect("parse core source");
|
||||
let mut references = CrateReferences::in_module(&relative);
|
||||
let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports);
|
||||
references.visit_file(&syntax);
|
||||
// 相对路径中的 self 指向当前 owner,不是跨 owner 依赖
|
||||
references.owners.remove(&references.module_path[0]);
|
||||
@@ -111,17 +112,24 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_owner_paths_form_a_dag() {
|
||||
let root = backend_root().join("crates/gateway-core/src");
|
||||
fn domain_owner_paths_form_a_dag() {
|
||||
for member in ["crates/gateway-core", "crates/gateway-admin"] {
|
||||
assert_owner_paths_form_a_dag(member);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_owner_paths_form_a_dag(member: &str) {
|
||||
let root = backend_root().join(member).join("src");
|
||||
let reexports = read_root_reexport_owners(&root);
|
||||
let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
for relative in super::rust_files(&root) {
|
||||
if relative == Path::new("lib.rs") {
|
||||
continue;
|
||||
}
|
||||
let mut references = CrateReferences::in_module(&relative);
|
||||
let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports);
|
||||
let owner = references.module_path[0].clone();
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
|
||||
references.visit_file(&syn::parse_file(&source).expect("parse core source"));
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read owner source");
|
||||
references.visit_file(&syn::parse_file(&source).expect("parse owner source"));
|
||||
references.owners.remove(&owner);
|
||||
graph.entry(owner).or_default().extend(references.owners);
|
||||
}
|
||||
@@ -131,7 +139,7 @@ fn core_owner_paths_form_a_dag() {
|
||||
while let Some(dependency) = pending.pop() {
|
||||
assert_ne!(
|
||||
&dependency, owner,
|
||||
"Core owner dependency cycle reaches {owner}"
|
||||
"{member} owner dependency cycle reaches {owner}"
|
||||
);
|
||||
if visited.insert(dependency.clone())
|
||||
&& let Some(next) = graph.get(&dependency)
|
||||
@@ -143,7 +151,44 @@ fn core_owner_paths_form_a_dag() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
|
||||
fn shared_owners_do_not_depend_on_their_callers() {
|
||||
let boundaries: &[(&str, &str, &[&str])] = &[
|
||||
("crates/providers/xai", "transport", &["credential"]),
|
||||
("crates/providers/openai", "transport", &["provider"]),
|
||||
("crates/gateway-api", "time", &["admin"]),
|
||||
(
|
||||
"crates/gateway-api",
|
||||
"client_error",
|
||||
&["admin", "openai", "middleware"],
|
||||
),
|
||||
("crates/gateway-api", "middleware", &["openai"]),
|
||||
("crates/gateway-host", "system_update", &["config"]),
|
||||
("crates/gateway-plugin/runtime", "package", &["callback"]),
|
||||
];
|
||||
for &(member, owner, forbidden) in boundaries {
|
||||
let root = backend_root().join(member).join("src");
|
||||
let reexports = read_root_reexport_owners(&root);
|
||||
for relative in super::rust_files(&root) {
|
||||
let mut references =
|
||||
CrateReferences::in_module(&relative).with_root_reexports(&reexports);
|
||||
if references.module_path.first().map(String::as_str) != Some(owner) {
|
||||
continue;
|
||||
}
|
||||
let source = fs::read_to_string(root.join(&relative)).expect("read owner source");
|
||||
references.visit_file(&syn::parse_file(&source).expect("parse owner source"));
|
||||
for dependency in forbidden {
|
||||
assert!(
|
||||
!references.owners.contains(*dependency),
|
||||
"{member}/src/{} must not depend on crate::{dependency}",
|
||||
relative.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reference_scan_resolves_groups_reexports_and_relative_paths() {
|
||||
let syntax = syn::parse_file(
|
||||
r#"
|
||||
pub use crate::{engine::AttemptContext, settings::SettingsValues as Values};
|
||||
@@ -167,13 +212,86 @@ fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrateReferences {
|
||||
owners: BTreeSet<String>,
|
||||
module_path: Vec<String>,
|
||||
#[test]
|
||||
fn owner_reference_scan_resolves_crate_root_reexports() {
|
||||
let root = syn::parse_file(
|
||||
r#"
|
||||
mod provider;
|
||||
mod transport;
|
||||
mod secret;
|
||||
pub use provider::{OFFICIAL_URL, Options as ProviderOptions};
|
||||
pub use self::transport::{self as network, Request};
|
||||
pub use crate::secret::SecretValue;
|
||||
pub use external::Unrelated;
|
||||
"#,
|
||||
)
|
||||
.expect("parse root reexport fixture");
|
||||
let syntax = syn::parse_file(
|
||||
r#"
|
||||
use crate::{OFFICIAL_URL, ProviderOptions, network::Client};
|
||||
use super::super::SecretValue;
|
||||
fn request() -> crate::Request { unimplemented!() }
|
||||
"#,
|
||||
)
|
||||
.expect("parse reexport consumer fixture");
|
||||
let reexports = root_reexport_owners(&root);
|
||||
assert!(!reexports.contains_key("Unrelated"));
|
||||
let mut references = CrateReferences::in_module(Path::new("consumer/request.rs"))
|
||||
.with_root_reexports(&reexports);
|
||||
references.visit_file(&syntax);
|
||||
assert_eq!(
|
||||
references.owners,
|
||||
BTreeSet::from([
|
||||
"provider".to_owned(),
|
||||
"transport".to_owned(),
|
||||
"secret".to_owned(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for CrateReferences {
|
||||
fn read_root_reexport_owners(root: &Path) -> BTreeMap<String, String> {
|
||||
let source = fs::read_to_string(root.join("lib.rs")).expect("read crate root");
|
||||
root_reexport_owners(&syn::parse_file(&source).expect("parse crate root"))
|
||||
}
|
||||
|
||||
// 只解析根模块的显式重导出,不推断 glob、宏展开或完整 Rust 名称解析
|
||||
fn root_reexport_owners(syntax: &syn::File) -> BTreeMap<String, String> {
|
||||
let modules = syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::Mod(module) => Some(module.ident.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut reexports = BTreeMap::new();
|
||||
for item in &syntax.items {
|
||||
if let Item::Use(item) = item
|
||||
&& !matches!(item.vis, syn::Visibility::Inherited)
|
||||
{
|
||||
visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, name| {
|
||||
let owner = path
|
||||
.iter()
|
||||
.find(|segment| !matches!(segment.as_str(), "crate" | "self"));
|
||||
if let (Some(owner), Some(name)) = (owner, name)
|
||||
&& modules.contains(owner)
|
||||
{
|
||||
reexports.insert(name.to_owned(), owner.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
reexports
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CrateReferences<'a> {
|
||||
owners: BTreeSet<String>,
|
||||
module_path: Vec<String>,
|
||||
root_reexports: Option<&'a BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for CrateReferences<'_> {
|
||||
fn visit_path(&mut self, path: &'ast syn::Path) {
|
||||
self.record(
|
||||
&path
|
||||
@@ -186,11 +304,13 @@ impl<'ast> Visit<'ast> for CrateReferences {
|
||||
}
|
||||
|
||||
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
|
||||
self.use_tree(&item.tree, &mut Vec::new());
|
||||
visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, _| {
|
||||
self.record(path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl CrateReferences {
|
||||
impl<'a> CrateReferences<'a> {
|
||||
fn in_module(relative: &Path) -> Self {
|
||||
let mut module = relative
|
||||
.with_extension("")
|
||||
@@ -208,13 +328,26 @@ impl CrateReferences {
|
||||
Self {
|
||||
owners: BTreeSet::new(),
|
||||
module_path: module,
|
||||
root_reexports: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_root_reexports(mut self, reexports: &'a BTreeMap<String, String>) -> Self {
|
||||
self.root_reexports = Some(reexports);
|
||||
self
|
||||
}
|
||||
|
||||
fn root_owner(&self, name: &str) -> String {
|
||||
self.root_reexports
|
||||
.and_then(|reexports| reexports.get(name))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| name.to_owned())
|
||||
}
|
||||
|
||||
fn record(&mut self, path: &[String]) {
|
||||
let Some(first) = path.first() else { return };
|
||||
let owner = if first == "crate" {
|
||||
path.get(1).cloned()
|
||||
path.get(1).map(|name| self.root_owner(name))
|
||||
} else if first == "self" || first == "super" {
|
||||
let mut module = self.module_path.clone();
|
||||
let mut rest = path;
|
||||
@@ -227,7 +360,10 @@ impl CrateReferences {
|
||||
}
|
||||
rest = &rest[1..];
|
||||
}
|
||||
module.first().or_else(|| rest.first()).cloned()
|
||||
module
|
||||
.first()
|
||||
.cloned()
|
||||
.or_else(|| rest.first().map(|name| self.root_owner(name)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -235,31 +371,41 @@ impl CrateReferences {
|
||||
self.owners.insert(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec<String>) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) => {
|
||||
prefix.push(path.ident.to_string());
|
||||
self.use_tree(&path.tree, prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Name(name) => {
|
||||
prefix.push(name.ident.to_string());
|
||||
self.record(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Rename(rename) => {
|
||||
prefix.push(rename.ident.to_string());
|
||||
self.record(prefix);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
self.use_tree(item, prefix);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Glob(_) => self.record(prefix),
|
||||
fn visit_use_leaves(
|
||||
tree: &syn::UseTree,
|
||||
prefix: &mut Vec<String>,
|
||||
visit: &mut impl FnMut(&[String], Option<&str>),
|
||||
) {
|
||||
match tree {
|
||||
syn::UseTree::Path(path) => {
|
||||
prefix.push(path.ident.to_string());
|
||||
visit_use_leaves(&path.tree, prefix, visit);
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Name(name) => {
|
||||
let name = name.ident.to_string();
|
||||
let export = if name == "self" {
|
||||
prefix.last().cloned()
|
||||
} else {
|
||||
Some(name.clone())
|
||||
};
|
||||
prefix.push(name);
|
||||
visit(prefix, export.as_deref());
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Rename(rename) => {
|
||||
prefix.push(rename.ident.to_string());
|
||||
visit(prefix, Some(&rename.rename.to_string()));
|
||||
prefix.pop();
|
||||
}
|
||||
syn::UseTree::Group(group) => {
|
||||
for item in &group.items {
|
||||
visit_use_leaves(item, prefix, visit);
|
||||
}
|
||||
}
|
||||
syn::UseTree::Glob(_) => visit(prefix, None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +467,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"outbound",
|
||||
"peer",
|
||||
"official_plugins",
|
||||
"pricing",
|
||||
"plugin_distribution",
|
||||
@@ -358,11 +505,13 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "provider-openai"),
|
||||
("codex-proxy-rs", "provider-xai"),
|
||||
("gateway-admin", "gateway-core"),
|
||||
("gateway-admin", "gateway-protocol"),
|
||||
("gateway-api", "gateway-admin"),
|
||||
("gateway-api", "gateway-core"),
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-host", "gateway-protocol"),
|
||||
("gateway-plugin-runtime", "gateway-host"),
|
||||
("gateway-plugin-runtime", "gateway-core"),
|
||||
("gateway-plugin-runtime", "gateway-admin"),
|
||||
|
||||
@@ -370,10 +370,28 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_validate_openai_residency() {
|
||||
fn config_loader_should_accept_optional_openai_routing_strings() {
|
||||
for value in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!("us"),
|
||||
serde_json::json!("custom-value"),
|
||||
serde_json::json!(""),
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["residency"] = value.clone();
|
||||
document["openai"]["account_routing_override"] = value;
|
||||
parse_config(&document.to_string()).expect("optional routing strings");
|
||||
}
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["residency"] = serde_json::json!("invalid");
|
||||
assert_rejected(document.to_string());
|
||||
document["openai"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("residency");
|
||||
document["openai"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("account_routing_override");
|
||||
parse_config(&document.to_string()).expect("omitted routing strings");
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -15,7 +15,7 @@ cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/lo
|
||||
|
||||
## 打包
|
||||
|
||||
先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数:
|
||||
先构建目标平台的二进制;有管理页面时,也构建静态资源。全部参数见 `cpr-plugin package --help`
|
||||
|
||||
```bash
|
||||
cpr-plugin package \
|
||||
@@ -26,10 +26,15 @@ cpr-plugin package \
|
||||
--output-dir /path/to/output
|
||||
```
|
||||
|
||||
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
|
||||
`--resource-map web=web/dist` 将包内 `web/` 前缀映射到作者清单所在目录下的 `web/dist/`。
|
||||
可重复传入不同前缀,源目录必须位于项目内;没有对应资源时省略该参数,未使用的映射会被拒绝。
|
||||
未映射的资源按清单中的相对路径读取,只收集 `resources` 声明的文件
|
||||
|
||||
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
|
||||
输出为 `<插件 ID>-<版本>-<目标 triple>.tar.gz` 及同名 `.tar.gz.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和声明资源。
|
||||
输入清单及生成后的清单均不得超过 64 KiB,压缩包上限为 32 MiB,展开归档上限为 128 MiB
|
||||
|
||||
作者清单不填写 `package`;字段省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
|
||||
CLI 生成规范化后的 `plugin.json`,补入协议版本、目标平台和文件摘要。打包成功只证明输入符合包合同,实际启动与能力仍需在目标宿主验证
|
||||
|
||||
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
|
||||
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
|
||||
|
||||
@@ -250,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
|
||||
"main":"bin/plugin",
|
||||
"runtime":"trustedProcess",
|
||||
"contributes":{
|
||||
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"middleware":{"version":6,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{"version":2}
|
||||
},
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
gateway-protocol = { path = "../gateway-protocol" }
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hkdf = "0.13.0"
|
||||
|
||||
@@ -126,30 +126,32 @@ pub struct RetentionDecision {
|
||||
/// 自动保留规则:
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(全局倒序下标 0)
|
||||
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理
|
||||
/// - `offset` 是当前页在全部 completed 计划备份中的起始位置
|
||||
#[must_use]
|
||||
pub fn decide_retention(
|
||||
retention_days: u32,
|
||||
retention_count: u32,
|
||||
now: DateTime<Utc>,
|
||||
offset: u64,
|
||||
scheduled_completed_desc: &[BackupRecord],
|
||||
) -> Vec<RetentionDecision> {
|
||||
let mut decisions = Vec::new();
|
||||
for (index, record) in scheduled_completed_desc.iter().enumerate() {
|
||||
let position = offset.saturating_add(index as u64);
|
||||
if record.status != BackupStatus::Completed {
|
||||
continue;
|
||||
}
|
||||
// 最近一个成功的计划备份永远保留
|
||||
if index == 0 {
|
||||
if position == 0 {
|
||||
continue;
|
||||
}
|
||||
let expired_by_days = retention_days > 0
|
||||
&& record.completed_at.is_some_and(|completed_at| {
|
||||
completed_at <= now - Duration::days(i64::from(retention_days))
|
||||
});
|
||||
let exceeded_count =
|
||||
retention_count > 0 && index >= usize::try_from(retention_count).unwrap_or(usize::MAX);
|
||||
let exceeded_count = retention_count > 0 && position >= u64::from(retention_count);
|
||||
if expired_by_days || exceeded_count {
|
||||
decisions.push(RetentionDecision {
|
||||
record_id: record.id.clone(),
|
||||
|
||||
@@ -11,11 +11,15 @@ use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerDaemonContext, WorkerTaskError},
|
||||
task::{
|
||||
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId,
|
||||
WorkerKind, WorkerRegistration, WorkerRunnable, WorkerTaskError,
|
||||
},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
model::AdminError,
|
||||
model::backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
|
||||
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
|
||||
@@ -34,12 +38,14 @@ impl From<BackupError> for WorkerTaskError {
|
||||
|
||||
use super::policy::{BackupSchedule, decide_retention};
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
|
||||
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待
|
||||
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// 每个循环最多执行的保留删除数量
|
||||
const RETENTION_BATCH_SIZE: usize = 10;
|
||||
/// 保留扫描一次读取的 completed 计划备份上限
|
||||
const RETENTION_SCAN_LIMIT: u32 = 1000;
|
||||
/// 保留扫描每页读取的 completed 计划备份数量
|
||||
const RETENTION_PAGE_SIZE: u32 = 1000;
|
||||
/// 每个循环最多完成的待删除记录数量
|
||||
const PENDING_DELETION_BATCH: u32 = 20;
|
||||
|
||||
@@ -72,6 +78,22 @@ impl BackupTask {
|
||||
self.timezone = timezone;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
|
||||
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(self),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
|
||||
Ok(WorkerContribution::Registration(registration))
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
@@ -112,7 +134,7 @@ impl BackupTask {
|
||||
self.recover_intermediate(cancellation).await?;
|
||||
self.finish_pending_deletions().await?;
|
||||
self.process_next_task(cancellation).await?;
|
||||
self.run_retention_batch(Utc::now()).await?;
|
||||
self.run_retention_batch(Utc::now(), cancellation).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -637,7 +659,11 @@ impl BackupTask {
|
||||
}
|
||||
|
||||
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份
|
||||
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
async fn run_retention_batch(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
|
||||
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除
|
||||
@@ -649,7 +675,7 @@ impl BackupTask {
|
||||
for record in expired {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&record.id, now)
|
||||
.transition_to_deleting(&record.id, now, None)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
@@ -659,21 +685,44 @@ impl BackupTask {
|
||||
}
|
||||
|
||||
// 2. 计划备份按 retentionDays / retentionCount 清理
|
||||
let records = self
|
||||
.repository
|
||||
.list_scheduled_completed_desc(RETENTION_SCAN_LIMIT)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let decisions = decide_retention(
|
||||
settings.retention_days,
|
||||
settings.retention_count,
|
||||
now,
|
||||
&records,
|
||||
);
|
||||
for decision in decisions.into_iter().take(RETENTION_BATCH_SIZE) {
|
||||
if settings.retention_days == 0 && settings.retention_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut offset = 0;
|
||||
let mut decisions = Vec::new();
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let records = self
|
||||
.repository
|
||||
.list_scheduled_completed_desc(offset, RETENTION_PAGE_SIZE)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let remaining = RETENTION_BATCH_SIZE - decisions.len();
|
||||
decisions.extend(
|
||||
decide_retention(
|
||||
settings.retention_days,
|
||||
settings.retention_count,
|
||||
now,
|
||||
offset,
|
||||
&records,
|
||||
)
|
||||
.into_iter()
|
||||
.take(remaining),
|
||||
);
|
||||
if decisions.len() == RETENTION_BATCH_SIZE
|
||||
|| records.len() < RETENTION_PAGE_SIZE as usize
|
||||
{
|
||||
break;
|
||||
}
|
||||
offset += records.len() as u64;
|
||||
}
|
||||
// 收齐本批候选后再删除,避免删除前页记录导致后续分页跳过数据
|
||||
for decision in decisions {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&decision.record_id, now)
|
||||
.transition_to_deleting(&decision.record_id, now, None)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! 管理服务与后台任务的启动组装,窄端口供服务器和 CLI 复用
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use gateway_core::{engine::probe::AccountProbe, runtime::SnapshotControl};
|
||||
|
||||
use crate::{
|
||||
AccountsService, AdminBundle, AdminConfig, AdminRuntimePorts, AdminServices, ClientConfig,
|
||||
ClientKeyService, CredentialsService, PluginDistributionPorts, PluginManagementService,
|
||||
PluginsService, SettingsService, UsageDataService, backup, freeze_recovery,
|
||||
model::AdminError,
|
||||
ports::{
|
||||
self, plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
|
||||
provider::ProviderAdminRegistry, store::AdminStorePorts,
|
||||
},
|
||||
public_service, reset_credit_worker, use_case,
|
||||
use_case::{
|
||||
account_groups::DefaultAccountGroupService,
|
||||
accounts::DefaultAccountsService,
|
||||
auth::{AuthService, DefaultAuthService},
|
||||
backup::DefaultBackupService,
|
||||
client_distribution::DefaultClientDistributionService,
|
||||
client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService,
|
||||
settings::DefaultSettingsService,
|
||||
system::DefaultSystemService,
|
||||
},
|
||||
};
|
||||
|
||||
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 配置非法或默认管理员初始化失败时返回错误
|
||||
pub async fn initialize(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, None).await
|
||||
}
|
||||
|
||||
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
|
||||
pub async fn initialize_with_plugin_accounts(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
|
||||
}
|
||||
|
||||
async fn initialize_inner(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
upstream_info,
|
||||
timezone,
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
published_snapshot,
|
||||
plugin_distribution,
|
||||
plugin_inspector,
|
||||
pricing_source,
|
||||
providers,
|
||||
snapshot,
|
||||
account_probe: probe,
|
||||
proxy_probe,
|
||||
client_distribution,
|
||||
system,
|
||||
client_key_verifier,
|
||||
} = runtime;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
client_config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
&config,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier.clone(),
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
|
||||
let accounts = initialize_accounts(
|
||||
store.accounts(),
|
||||
store.account_runtime(),
|
||||
store.settings(),
|
||||
timezone,
|
||||
providers.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
upstream_info.clone(),
|
||||
);
|
||||
let backup_ports = store.backup();
|
||||
let backups = Arc::new(DefaultBackupService::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
timezone,
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
)
|
||||
.with_timezone(timezone);
|
||||
let system_preflight = Arc::new(
|
||||
use_case::system_preflight::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
),
|
||||
);
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let observations: Arc<dyn ports::store::ObservabilityStore> = Arc::new(
|
||||
use_case::observability::CachedObservabilityStore::new(store.observability()),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
client_key_verifier,
|
||||
store.client_keys(),
|
||||
observations.clone(),
|
||||
system.clone(),
|
||||
timezone,
|
||||
));
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
providers.clone(),
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
upstream_info.clone(),
|
||||
));
|
||||
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
|
||||
initialize_plugin_accounts(
|
||||
providers.clone(),
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
upstream_info.clone(),
|
||||
)
|
||||
});
|
||||
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(),
|
||||
providers.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), providers.clone());
|
||||
let mut public_services = public_service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
public_services.register_accounts(&accounts)?;
|
||||
public_services.register_usage_data(&usage_data)?;
|
||||
let services = AdminServices {
|
||||
peer_link: Arc::new(use_case::peer_link::DefaultPeerLinkService::new(
|
||||
store.client_keys(),
|
||||
store.accounts(),
|
||||
store.account_runtime(),
|
||||
providers.clone(),
|
||||
)),
|
||||
timezone,
|
||||
public_services: Arc::new(public_services),
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
store.plugins(),
|
||||
published_snapshot.clone(),
|
||||
)),
|
||||
plugins: Arc::new(PluginsService::new(
|
||||
store.plugins(),
|
||||
plugin_inspector,
|
||||
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
|
||||
snapshot.clone(),
|
||||
plugin_preparation,
|
||||
published_snapshot,
|
||||
store.plugin_state(),
|
||||
)),
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
snapshot.clone(),
|
||||
providers.clone(),
|
||||
upstream_info.clone(),
|
||||
)),
|
||||
auth,
|
||||
accounts: accounts.clone(),
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
upstream_info.clone(),
|
||||
store.account_groups(),
|
||||
store.account_runtime(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
providers.clone(),
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
observations.clone(),
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
providers.clone(),
|
||||
timezone,
|
||||
system.clone(),
|
||||
upstream_info,
|
||||
)),
|
||||
settings,
|
||||
usage_data,
|
||||
system,
|
||||
credentials,
|
||||
_plugin_accounts: plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
let freeze_recovery =
|
||||
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
|
||||
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
|
||||
store: store.accounts(),
|
||||
runtime: store.account_runtime(),
|
||||
settings: store.settings(),
|
||||
});
|
||||
let worker_contributions = vec![
|
||||
backup_task.into_worker()?,
|
||||
import_task.into_worker()?,
|
||||
freeze_recovery.into_worker()?,
|
||||
reset_credit_worker::ResetCreditAutomationTask {
|
||||
accounts,
|
||||
store: store.accounts(),
|
||||
settings: store.settings(),
|
||||
}
|
||||
.into_worker()?,
|
||||
];
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
})
|
||||
}
|
||||
|
||||
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
|
||||
pub fn initialize_plugin_accounts(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
|
||||
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
|
||||
) -> Arc<dyn PluginAccountAccess> {
|
||||
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
|
||||
providers,
|
||||
accounts,
|
||||
snapshot,
|
||||
upstream_info,
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
store: Arc<dyn ports::store::ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn PluginClientKeyAccess> {
|
||||
let service: Arc<dyn ClientKeyService> =
|
||||
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
|
||||
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
|
||||
}
|
||||
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_resources(
|
||||
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
|
||||
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
|
||||
}
|
||||
|
||||
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
|
||||
)]
|
||||
pub fn initialize_accounts(
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
|
||||
) -> Arc<dyn AccountsService> {
|
||||
Arc::new(DefaultAccountsService::new(
|
||||
accounts,
|
||||
account_runtime,
|
||||
settings,
|
||||
timezone,
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
upstream_info,
|
||||
))
|
||||
}
|
||||
|
||||
/// 设置服务不依赖 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,
|
||||
))
|
||||
}
|
||||
|
||||
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
|
||||
#[must_use]
|
||||
pub fn initialize_usage_data(
|
||||
store: Arc<dyn ports::usage_data::UsageDataStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Arc<dyn UsageDataService> {
|
||||
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
|
||||
store, settings, providers,
|
||||
))
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调
|
||||
//!
|
||||
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
|
||||
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
|
||||
//! (原子降低并发 / 按冻结代次结束探测)
|
||||
//! 旧观测不会覆盖管理员操作
|
||||
//! 账号错误保护的到期探测与按冻结快照恢复
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -11,22 +6,22 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
|
||||
use gateway_core::account::{AccountErrorProtection, ProviderAccountId};
|
||||
use gateway_core::engine::probe::AccountProbeTarget;
|
||||
use gateway_core::routing::UpstreamModelId;
|
||||
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
|
||||
use gateway_core::task::{
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind,
|
||||
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::model::AdminError;
|
||||
use crate::model::accounts::{
|
||||
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
|
||||
};
|
||||
use crate::model::{MutationActor, MutationContext};
|
||||
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
|
||||
use crate::use_case::accounts::AccountsService;
|
||||
|
||||
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%
|
||||
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
|
||||
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结
|
||||
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
|
||||
/// worker 周期;冷却观测与探测都依赖该节奏
|
||||
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
|
||||
@@ -34,9 +29,6 @@ pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
|
||||
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排
|
||||
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
|
||||
|
||||
/// 冻结恢复所需的管理端口组合
|
||||
#[derive(Clone)]
|
||||
pub struct FreezeRecoveryDeps {
|
||||
@@ -55,26 +47,40 @@ impl FreezeRecoveryTask {
|
||||
Self { deps }
|
||||
}
|
||||
|
||||
fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: SYSTEM_REQUEST_ID.to_owned(),
|
||||
}
|
||||
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
|
||||
let id = WorkerId::try_new(
|
||||
WorkerKind::AccountFreezeRecovery,
|
||||
FREEZE_RECOVERY_WORKER_OWNER,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
FREEZE_RECOVERY_INTERVAL,
|
||||
WORKER_INITIAL_BACKOFF,
|
||||
WORKER_MAXIMUM_BACKOFF,
|
||||
WORKER_LEASE_TTL,
|
||||
WORKER_LEASE_RENEWAL,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), WORKER_LEASE_TTL)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(self),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(WorkerContribution::Registration(registration))
|
||||
}
|
||||
|
||||
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻
|
||||
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
|
||||
/// 无法读取或校验配置时跳过本轮,不能把未知状态当作允许解冻
|
||||
async fn freeze_policy(&self) -> Option<AccountErrorProtection> {
|
||||
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
|
||||
settings.values.account_auto_freeze_enabled,
|
||||
settings.values.account_auto_freeze_threshold,
|
||||
settings.values.account_auto_freeze_window_seconds,
|
||||
settings.values.account_auto_freeze_duration_seconds,
|
||||
settings.values.account_auto_freeze_probe_enabled,
|
||||
settings.values.account_auto_freeze_probe_model,
|
||||
settings.values.account_auto_freeze_adaptive_concurrency,
|
||||
)
|
||||
.ok()
|
||||
let policy = settings.values.account_error_protection;
|
||||
policy.validate().ok()?;
|
||||
Some(policy)
|
||||
}
|
||||
|
||||
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
|
||||
@@ -89,55 +95,9 @@ impl FreezeRecoveryTask {
|
||||
if freezes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if policy.enabled() && policy.adaptive_concurrency() {
|
||||
self.adapt_concurrency_limits(&freezes, &policy).await;
|
||||
}
|
||||
self.recover_due_freezes(&freezes, &policy).await;
|
||||
}
|
||||
|
||||
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
|
||||
/// 只降不升;未观测到峰值证据的账号保持现状
|
||||
async fn adapt_concurrency_limits(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
|
||||
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
|
||||
return;
|
||||
};
|
||||
for (account_id, peak) in peaks {
|
||||
let Some(target) = adaptive_target(peak) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(limit) = AccountConcurrencyLimit::new(target) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.deps
|
||||
.accounts
|
||||
.lower_concurrency_limit(&Self::system_context(), account, limit)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!(
|
||||
account_id,
|
||||
adapted_limit = target,
|
||||
freeze_seconds = policy.freeze_duration().as_secs(),
|
||||
"容量熔断:按观测在途峰值下调账号并发上限",
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
warn!(account_id, error = %error, "自适应并发下调失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
|
||||
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
|
||||
self.deps
|
||||
@@ -151,13 +111,13 @@ impl FreezeRecoveryTask {
|
||||
async fn recover_due_freezes(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
policy: &AccountErrorProtection,
|
||||
) {
|
||||
for (account_id, freeze) in freezes {
|
||||
if freeze.until > Utc::now() {
|
||||
continue;
|
||||
}
|
||||
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
|
||||
if policy.enabled && policy.probe_enabled && freeze.requires_probe {
|
||||
self.probe_and_recover(account_id, freeze, policy).await;
|
||||
} else if let Err(error) = self
|
||||
.deps
|
||||
@@ -174,11 +134,19 @@ impl FreezeRecoveryTask {
|
||||
&self,
|
||||
account_id: &str,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
policy: &AccountErrorProtection,
|
||||
) {
|
||||
let Some(Some(item)) = self.load_account(account_id).await else {
|
||||
return;
|
||||
};
|
||||
if item.account.credential_revision != freeze.credential_revision {
|
||||
let _ = self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account_id, freeze, None)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !item.account.enabled {
|
||||
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复
|
||||
return;
|
||||
@@ -186,7 +154,7 @@ impl FreezeRecoveryTask {
|
||||
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = self.resolve_probe_model(&account, policy).await else {
|
||||
let Some(model) = self.resolve_probe_model(&account, freeze, policy).await else {
|
||||
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
return;
|
||||
@@ -194,7 +162,7 @@ impl FreezeRecoveryTask {
|
||||
let probe_succeeded = match self
|
||||
.deps
|
||||
.accounts
|
||||
.test_connection(account.clone(), model)
|
||||
.test_connection(AccountProbeTarget::FreezeRecovery(account.clone()), model)
|
||||
.await
|
||||
{
|
||||
Ok(events) => drain_probe(events).await,
|
||||
@@ -224,9 +192,23 @@ impl FreezeRecoveryTask {
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
policy: &AccountErrorProtection,
|
||||
) {
|
||||
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
|
||||
// 探测期间关闭保护或探测时,不再把已经到期的冻结向后延长
|
||||
let Some(current) = self.freeze_policy().await else {
|
||||
return;
|
||||
};
|
||||
if !current.enabled || !current.probe_enabled {
|
||||
let _ = self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account.as_str(), freeze, None)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let Some(until) = SystemTime::now().checked_add(Duration::from_secs(
|
||||
freeze.duration_seconds.unwrap_or(policy.duration_seconds),
|
||||
)) else {
|
||||
return;
|
||||
};
|
||||
let until = DateTime::<Utc>::from(until);
|
||||
@@ -239,7 +221,10 @@ impl FreezeRecoveryTask {
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
account_id = account.as_str(),
|
||||
postpone_seconds = policy.freeze_duration().as_secs(),
|
||||
postpone_seconds = Duration::from_secs(
|
||||
freeze.duration_seconds.unwrap_or(policy.duration_seconds)
|
||||
)
|
||||
.as_secs(),
|
||||
"冻结恢复探测失败:账号冻结顺延",
|
||||
);
|
||||
}
|
||||
@@ -257,9 +242,14 @@ impl FreezeRecoveryTask {
|
||||
async fn resolve_probe_model(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &AccountErrorProtection,
|
||||
) -> Option<UpstreamModelId> {
|
||||
if let Some(model) = policy.probe_model() {
|
||||
if let Some(model) = if freeze.duration_seconds.is_some() {
|
||||
freeze.probe_model.as_deref()
|
||||
} else {
|
||||
policy.probe_model.as_deref()
|
||||
} {
|
||||
return UpstreamModelId::new(model.to_owned()).ok();
|
||||
}
|
||||
let models = self.deps.accounts.models(account, false).await.ok()?;
|
||||
@@ -282,15 +272,6 @@ impl ScheduledTask for FreezeRecoveryTask {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整
|
||||
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
|
||||
if peak_in_flight == 0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
|
||||
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
|
||||
}
|
||||
|
||||
/// 消耗连接测试事件流并返回是否以 `Completed` 终止
|
||||
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
|
||||
let mut completed = false;
|
||||
|
||||
@@ -2,30 +2,27 @@
|
||||
//!
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现
|
||||
|
||||
use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
use std::{fmt, path::Path, sync::Arc};
|
||||
|
||||
use gateway_core::{
|
||||
engine::execution::ClientKeyVerifier,
|
||||
engine::probe::AccountProbe,
|
||||
runtime::SnapshotControl,
|
||||
task::{
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
|
||||
WorkerRegistration, WorkerRunnable, WorkerSchedule,
|
||||
},
|
||||
engine::execution::ClientKeyVerifier, engine::probe::AccountProbe, runtime::SnapshotControl,
|
||||
task::WorkerContribution,
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod backup;
|
||||
mod bootstrap;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
pub mod public_service;
|
||||
mod reset_credit_automation;
|
||||
mod reset_credit_worker;
|
||||
mod use_case;
|
||||
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
|
||||
|
||||
pub use use_case::key_usage::KeyUsageService;
|
||||
pub use use_case::peer_link::{PeerLinkError, PeerLinkService};
|
||||
|
||||
pub use use_case::{
|
||||
account_groups::AccountGroupService,
|
||||
@@ -43,18 +40,15 @@ pub use use_case::{
|
||||
usage_data::UsageDataService,
|
||||
};
|
||||
|
||||
use model::AdminError;
|
||||
pub use bootstrap::{
|
||||
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
|
||||
initialize_plugin_resources, initialize_settings, initialize_usage_data,
|
||||
initialize_with_plugin_accounts,
|
||||
};
|
||||
|
||||
use ports::{
|
||||
client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess,
|
||||
plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry,
|
||||
store::AdminStorePorts, system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService,
|
||||
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService, settings::DefaultSettingsService,
|
||||
system::DefaultSystemService,
|
||||
provider::ProviderAdminRegistry, system::SystemOperations,
|
||||
};
|
||||
|
||||
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
|
||||
@@ -69,7 +63,6 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[
|
||||
"codex-proxy-rs",
|
||||
];
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码
|
||||
@@ -213,6 +206,7 @@ pub struct AdminServices {
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
peer_link: Arc<dyn PeerLinkService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
@@ -258,6 +252,11 @@ impl AdminServices {
|
||||
self.key_usage.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn peer_link(&self) -> &dyn PeerLinkService {
|
||||
self.peer_link.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
@@ -289,8 +288,8 @@ impl AdminServices {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn usage_data(&self) -> &dyn UsageDataService {
|
||||
self.usage_data.as_ref()
|
||||
pub fn usage_data(&self) -> Arc<dyn UsageDataService> {
|
||||
self.usage_data.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -336,8 +335,13 @@ impl AdminBundle {
|
||||
}
|
||||
}
|
||||
|
||||
pub use use_case::peer_link::upstream::{
|
||||
PeerUpstreamBundle, initialize as initialize_peer_upstream,
|
||||
};
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入
|
||||
pub struct AdminRuntimePorts {
|
||||
pub upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
|
||||
pub timezone: gateway_core::time::DeploymentTimeZone,
|
||||
pub service_middleware: public_service::PlanSource,
|
||||
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
|
||||
@@ -354,369 +358,3 @@ pub struct AdminRuntimePorts {
|
||||
pub system: Arc<dyn SystemOperations>,
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 配置非法或默认管理员初始化失败时返回错误
|
||||
pub async fn initialize(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, None).await
|
||||
}
|
||||
|
||||
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
|
||||
pub async fn initialize_with_plugin_accounts(
|
||||
config: AdminConfig,
|
||||
client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Arc<dyn PluginAccountAccess>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
|
||||
}
|
||||
|
||||
async fn initialize_inner(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
runtime: AdminRuntimePorts,
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
published_snapshot,
|
||||
plugin_distribution,
|
||||
plugin_inspector,
|
||||
pricing_source,
|
||||
providers,
|
||||
snapshot,
|
||||
account_probe: probe,
|
||||
proxy_probe,
|
||||
client_distribution,
|
||||
system,
|
||||
client_key_verifier,
|
||||
} = runtime;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
client_config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
let registry = providers;
|
||||
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
&config,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier.clone(),
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
|
||||
let accounts = initialize_accounts(
|
||||
store.accounts(),
|
||||
store.account_runtime(),
|
||||
store.settings(),
|
||||
timezone,
|
||||
registry.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
);
|
||||
let backup_ports = store.backup();
|
||||
let backups = Arc::new(DefaultBackupService::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
timezone,
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
)
|
||||
.with_timezone(timezone);
|
||||
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
));
|
||||
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
|
||||
let observations: Arc<dyn ports::store::ObservabilityStore> = Arc::new(
|
||||
use_case::observability::CachedObservabilityStore::new(store.observability()),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
client_key_verifier,
|
||||
store.client_keys(),
|
||||
observations.clone(),
|
||||
system.clone(),
|
||||
timezone,
|
||||
));
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
registry.clone(),
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
|
||||
initialize_plugin_accounts(registry.clone(), store.accounts(), snapshot.clone())
|
||||
});
|
||||
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 usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone());
|
||||
let mut public_services = public_service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
public_services.register_accounts(&accounts)?;
|
||||
public_services.register_usage_data(&usage_data)?;
|
||||
let services = AdminServices {
|
||||
timezone,
|
||||
public_services: Arc::new(public_services),
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
store.plugins(),
|
||||
published_snapshot.clone(),
|
||||
)),
|
||||
plugins: Arc::new(PluginsService::new(
|
||||
store.plugins(),
|
||||
plugin_inspector,
|
||||
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
|
||||
snapshot.clone(),
|
||||
plugin_preparation,
|
||||
published_snapshot,
|
||||
store.plugin_state(),
|
||||
)),
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
)),
|
||||
auth,
|
||||
accounts: accounts.clone(),
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
store.account_groups(),
|
||||
store.account_runtime(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
observations.clone(),
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
timezone,
|
||||
system.clone(),
|
||||
)),
|
||||
settings,
|
||||
usage_data,
|
||||
system,
|
||||
credentials,
|
||||
_plugin_accounts: plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
let freeze_recovery =
|
||||
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
|
||||
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
|
||||
store: store.accounts(),
|
||||
runtime: store.account_runtime(),
|
||||
settings: store.settings(),
|
||||
});
|
||||
let mut worker_contributions = backup_worker_contribution(backup_task)?;
|
||||
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
|
||||
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(import_task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(registration));
|
||||
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
|
||||
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(90),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(
|
||||
WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
|
||||
accounts,
|
||||
store: store.accounts(),
|
||||
settings: store.settings(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
|
||||
));
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
})
|
||||
}
|
||||
|
||||
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
|
||||
pub fn initialize_plugin_accounts(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
|
||||
) -> Arc<dyn PluginAccountAccess> {
|
||||
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
|
||||
providers, accounts, snapshot,
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
store: Arc<dyn ports::store::ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn PluginClientKeyAccess> {
|
||||
let service: Arc<dyn ClientKeyService> =
|
||||
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
|
||||
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
|
||||
}
|
||||
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_resources(
|
||||
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
|
||||
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
|
||||
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定
|
||||
fn freeze_recovery_worker_contribution(
|
||||
task: freeze_recovery::FreezeRecoveryTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(
|
||||
WorkerKind::AccountFreezeRecovery,
|
||||
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
|
||||
freeze_recovery::WORKER_INITIAL_BACKOFF,
|
||||
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
|
||||
freeze_recovery::WORKER_LEASE_TTL,
|
||||
freeze_recovery::WORKER_LEASE_RENEWAL,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
|
||||
#[must_use]
|
||||
pub fn initialize_accounts(
|
||||
accounts: Arc<dyn ports::store::AccountStore>,
|
||||
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> Arc<dyn AccountsService> {
|
||||
Arc::new(DefaultAccountsService::new(
|
||||
accounts,
|
||||
account_runtime,
|
||||
settings,
|
||||
timezone,
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
))
|
||||
}
|
||||
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
|
||||
pub fn initialize_settings(
|
||||
store: Arc<dyn ports::store::SettingsStore>,
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
|
||||
#[must_use]
|
||||
pub fn initialize_usage_data(
|
||||
store: Arc<dyn ports::usage_data::UsageDataStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Arc<dyn UsageDataService> {
|
||||
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
|
||||
store, settings, providers,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ pub struct AccountGroupUsage {
|
||||
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMemberFact {
|
||||
pub inherited_version: Option<gateway_core::account::QuotaObservationVersion>,
|
||||
pub group_id: AccountGroupId,
|
||||
pub account_id: String,
|
||||
pub status: AccountStatusFacts,
|
||||
|
||||
@@ -30,9 +30,10 @@ pub struct ProviderAccountCapabilities {
|
||||
pub consume_reset_credit: bool,
|
||||
}
|
||||
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
|
||||
/// 导入时应用的账号设置;名称只覆盖单账号导入,其余设置统一应用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub name: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
@@ -81,6 +82,7 @@ pub struct AccountListQuery {
|
||||
/// Admin query service 从运行态存储取得的当前账号冷却快照
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountRuntimeSnapshot {
|
||||
pub protection_rules: BTreeMap<String, Vec<gateway_core::account::AccountProtectionMatch>>,
|
||||
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
|
||||
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零
|
||||
pub in_flight: Option<BTreeMap<String, u64>>,
|
||||
@@ -93,6 +95,9 @@ pub struct AccountFreeze {
|
||||
pub until: DateTime<Utc>,
|
||||
pub generation: String,
|
||||
pub requires_probe: bool,
|
||||
pub duration_seconds: Option<u64>,
|
||||
pub probe_model: Option<String>,
|
||||
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
|
||||
}
|
||||
|
||||
/// 可选的账号分组成员筛选条件
|
||||
@@ -203,6 +208,7 @@ pub struct AccountPage {
|
||||
/// 同一状态快照下的账号事实与唯一状态投影
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPageItem {
|
||||
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
pub capacity: AccountCapacity,
|
||||
@@ -213,7 +219,7 @@ pub struct AccountPageItem {
|
||||
pub struct AccountCapacity {
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲
|
||||
pub used_slots: Option<u64>,
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限
|
||||
/// 应用账号覆盖或继承默认值后的普通并发上限;`None` 表示不限
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -221,6 +227,8 @@ pub struct AccountCapacity {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
/// 缺省保留名称,空字符串清除名称并使用邮箱展示
|
||||
pub name: Option<String>,
|
||||
/// 缺省保留备注;空字符串清空备注
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::observability::{DecimalAmount, RequestMetricPoint, UsageOverview};
|
||||
use super::observability::{
|
||||
DecimalAmount, RequestMetricPoint, UsageConcurrencyPoint, UsageOverview,
|
||||
};
|
||||
|
||||
/// 数据可见性状态,不以缺失投影冒充零统计
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -57,9 +59,10 @@ pub struct UsageProjectedCostPoint {
|
||||
}
|
||||
|
||||
/// 同一数据库快照内的完整洞察输入
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsObservation {
|
||||
pub overview: UsageOverview,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub costs: Vec<UsageProjectedCostPoint>,
|
||||
pub concurrency: Vec<UsageConcurrencyPoint>,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ pub enum MutationAuditOperation {
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
ProviderAccountAdaptConcurrency,
|
||||
BackupCreate,
|
||||
BackupDelete,
|
||||
BackupConnectionTest,
|
||||
BackupDownloadUrl,
|
||||
ProviderAccountRecover,
|
||||
ProviderAccountBatchUpdate,
|
||||
ProviderAccountDelete,
|
||||
@@ -76,7 +79,10 @@ impl MutationAuditOperation {
|
||||
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::BackupCreate => ("backup.created", "backup"),
|
||||
Self::BackupDelete => ("backup.delete_requested", "backup"),
|
||||
Self::BackupConnectionTest => ("backup.s3_connection_tested", "backup_settings"),
|
||||
Self::BackupDownloadUrl => ("backup.download_url_created", "backup"),
|
||||
Self::ProviderAccountRecover => ("recover", "provider_account"),
|
||||
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
|
||||
Self::ProviderAccountDelete => ("delete", "provider_account"),
|
||||
|
||||
@@ -108,6 +108,7 @@ pub struct ClientKeyListQuery {
|
||||
/// 不含完整明文 Key 的管理投影
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
@@ -167,6 +168,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
@@ -179,6 +181,7 @@ pub struct CreateClientKey {
|
||||
/// 管理用例生成 ID 与明文后的持久化命令
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub peer_sharing: gateway_core::policy::ClientPeerSharing,
|
||||
pub request_profile_overrides: ProviderRequestProfileOverrides,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
@@ -204,6 +207,7 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub peer_sharing: Option<gateway_core::policy::ClientPeerSharing>,
|
||||
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod diagnostics;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod peer_link;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_resources;
|
||||
pub mod plugins;
|
||||
@@ -26,6 +27,7 @@ pub mod reset_credit_automation;
|
||||
pub mod retention;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod upstream_info;
|
||||
pub mod usage_data;
|
||||
|
||||
/// 管理用例对外返回的稳定错误分类
|
||||
@@ -68,6 +70,7 @@ pub struct AdminError {
|
||||
kind: AdminErrorKind,
|
||||
message: String,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
sensitive_context_redacted: bool,
|
||||
}
|
||||
|
||||
impl AdminError {
|
||||
@@ -77,6 +80,7 @@ impl AdminError {
|
||||
kind,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
sensitive_context_redacted: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +90,19 @@ impl AdminError {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn redact_sensitive_context(mut self) -> Self {
|
||||
self.sensitive_context_redacted = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
|
||||
gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false)
|
||||
gateway_core::error::ErrorDetails::capture(
|
||||
self.source.as_ref(),
|
||||
None,
|
||||
self.sensitive_context_redacted,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -157,12 +157,15 @@ impl OtherRequestOutcome {
|
||||
/// 用量记录过滤条件
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct UsageFilter {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub operation: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub model_contains: Option<String>,
|
||||
pub outcome: Option<RequestOutcome>,
|
||||
pub status_code: Option<u16>,
|
||||
pub transport: Option<String>,
|
||||
@@ -469,6 +472,90 @@ impl CostCoverage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求健康计数与服务成功率,调用方错误和取消不计入有效样本
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RequestHealth {
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
}
|
||||
|
||||
impl RequestHealth {
|
||||
#[must_use]
|
||||
pub fn from_counts(
|
||||
success: u64,
|
||||
failed: u64,
|
||||
cancelled: u64,
|
||||
incomplete: u64,
|
||||
caller_error: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
success_requests: success,
|
||||
failed_requests: failed.saturating_sub(caller_error),
|
||||
cancelled_requests: cancelled,
|
||||
incomplete_requests: incomplete,
|
||||
caller_error_requests: caller_error,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_metrics(metrics: &RequestMetrics) -> Self {
|
||||
Self::from_counts(
|
||||
metrics.success_count,
|
||||
metrics.failure_count,
|
||||
metrics.cancelled_count,
|
||||
metrics.incomplete_count,
|
||||
metrics.caller_error_count,
|
||||
)
|
||||
}
|
||||
|
||||
/// 账号与健康时间线使用百分数,洞察接口使用 0 到 1 的比例
|
||||
#[must_use]
|
||||
pub fn success_rate(self) -> Option<f64> {
|
||||
self.success_ratio().map(|ratio| ratio * 100.0)
|
||||
}
|
||||
|
||||
pub(crate) fn success_ratio(self) -> Option<f64> {
|
||||
let total = self.eligible_requests();
|
||||
(total > 0).then(|| self.success_requests as f64 / total as f64)
|
||||
}
|
||||
|
||||
pub(crate) fn error_ratio(self) -> Option<f64> {
|
||||
let total = self.eligible_requests();
|
||||
(total > 0).then(|| self.non_success_requests() as f64 / total as f64)
|
||||
}
|
||||
|
||||
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
|
||||
pub(crate) fn non_success_requests(self) -> u64 {
|
||||
self.failed_requests
|
||||
.saturating_add(self.incomplete_requests)
|
||||
}
|
||||
|
||||
pub(crate) fn eligible_requests(self) -> u64 {
|
||||
self.success_requests
|
||||
.saturating_add(self.non_success_requests())
|
||||
}
|
||||
|
||||
pub(crate) fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.add_window(Self::from_metrics(metrics));
|
||||
}
|
||||
|
||||
pub(crate) fn add_window(&mut self, other: Self) {
|
||||
self.success_requests = self.success_requests.saturating_add(other.success_requests);
|
||||
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(other.cancelled_requests);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(other.incomplete_requests);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(other.caller_error_requests);
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求级聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestMetrics {
|
||||
@@ -506,6 +593,46 @@ pub struct RequestMetrics {
|
||||
pub cache_hit_request_count: u64,
|
||||
}
|
||||
|
||||
/// 摘要实际消费的请求计数与延迟样本,不承诺分位数或分布
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestSummaryMetrics {
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub caller_error_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub cache_write_input_tokens: u64,
|
||||
pub reasoning_output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub latency_sum_ms: u64,
|
||||
pub latency_count: u64,
|
||||
}
|
||||
|
||||
impl From<&RequestMetrics> for RequestSummaryMetrics {
|
||||
fn from(metrics: &RequestMetrics) -> Self {
|
||||
Self {
|
||||
request_count: metrics.request_count,
|
||||
success_count: metrics.success_count,
|
||||
failure_count: metrics.failure_count,
|
||||
cancelled_count: metrics.cancelled_count,
|
||||
incomplete_count: metrics.incomplete_count,
|
||||
caller_error_count: metrics.caller_error_count,
|
||||
input_tokens: metrics.input_tokens,
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_input_tokens: metrics.cached_input_tokens,
|
||||
cache_write_input_tokens: metrics.cache_write_input_tokens,
|
||||
reasoning_output_tokens: metrics.reasoning_output_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
latency_sum_ms: metrics.latency_sum_ms,
|
||||
latency_count: metrics.latency_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上游 attempt 聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AttemptMetrics {
|
||||
@@ -579,7 +706,7 @@ pub struct AccountPoolMetrics {
|
||||
pub struct AccountModelUsage {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub health: RequestHealth,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
@@ -592,7 +719,7 @@ pub struct AccountModelUsage {
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 账号在单小时窗口内的请求数
|
||||
@@ -605,11 +732,13 @@ pub struct AccountRequestBucket {
|
||||
/// Dashboard 中一个账号的完整用量事实
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardAccountUsage {
|
||||
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
|
||||
pub account_id: String,
|
||||
pub provider_kind: String,
|
||||
pub authentication_kind: String,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Admin 按 Provider 补齐的套餐展示名称
|
||||
pub plan_type_display: Option<String>,
|
||||
@@ -639,8 +768,10 @@ pub struct DashboardAccountUsage {
|
||||
}
|
||||
|
||||
/// Dashboard 当前账号池的可重建运行时槽位事实
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardRuntimeSlots {
|
||||
/// 仅未设置本地覆盖的账号可能继承有效互联观测
|
||||
pub inherited_versions: Vec<gateway_core::account::QuotaObservationVersion>,
|
||||
/// 继承运行参数默认并发上限的可调度账号数
|
||||
pub inherited_accounts: u64,
|
||||
/// 已配置账号级并发上限的可调度账号槽位总数
|
||||
@@ -679,6 +810,11 @@ pub struct UsageListRecord {
|
||||
pub outcome: RequestOutcome,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
pub websocket_close_code: Option<u16>,
|
||||
pub continuation_unavailable_reason: Option<String>,
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub recovered: bool,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
@@ -694,10 +830,11 @@ pub struct UsageListRecord {
|
||||
/// 请求选号时冻结的账号套餐
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
/// 查询时的互联识别结果,缺少当前摘要时为未知
|
||||
pub provider_account_upstream_peer: Option<bool>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
@@ -736,6 +873,23 @@ pub struct UsageListRecord {
|
||||
pub started_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 受控请求详情中的上游错误字段,不参与错误分类或普通日志输出
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct UsageUpstreamError {
|
||||
pub code: Option<String>,
|
||||
pub error_type: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub param: Option<String>,
|
||||
pub close_reason: Option<String>,
|
||||
pub last_event_type: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UsageUpstreamError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("UsageUpstreamError(<restricted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次完整模型请求的公共观测记录
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageRecord {
|
||||
@@ -759,7 +913,6 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
|
||||
pub provider_metadata_json: Option<String>,
|
||||
@@ -775,6 +928,11 @@ pub struct UsageRecord {
|
||||
pub error_kind: Option<String>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub upstream_error: Option<UsageUpstreamError>,
|
||||
pub websocket_close_code: Option<u16>,
|
||||
pub continuation_unavailable_reason: Option<String>,
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub recovered: bool,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
@@ -893,10 +1051,19 @@ pub struct UsageOverview {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
/// 用量摘要所需的窄聚合事实
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSummaryObservation {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub range: TimeRange,
|
||||
pub requests: RequestSummaryMetrics,
|
||||
pub attempts: AttemptMetrics,
|
||||
}
|
||||
|
||||
/// 用量摘要的用例结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSummary {
|
||||
pub overview: UsageOverview,
|
||||
pub observation: UsageSummaryObservation,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -914,6 +1081,7 @@ pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_authentication_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
@@ -923,7 +1091,9 @@ pub struct DiagnosticObservation {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub caller_error_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
@@ -1011,7 +1181,7 @@ pub struct TrendPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub granularity: Granularity,
|
||||
pub metrics: RequestMetrics,
|
||||
pub service_failure_count: u64,
|
||||
pub error_count: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub average_first_token_latency_ms: Option<u64>,
|
||||
pub cached_token_rate: f64,
|
||||
@@ -1029,7 +1199,7 @@ pub struct TrendSummary {
|
||||
pub output_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub service_failure_count: u64,
|
||||
pub error_count: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub max_latency_ms: Option<u64>,
|
||||
pub min_latency_ms: Option<u64>,
|
||||
@@ -1138,10 +1308,10 @@ pub struct DashboardWireProfile {
|
||||
pub release: Option<DashboardDesktopRelease>,
|
||||
}
|
||||
|
||||
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
|
||||
/// Provider 画像的差异字段,以稳定标识传递语义,名称由前端本地化
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardWireAttribute {
|
||||
pub label: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
@@ -1193,7 +1363,7 @@ pub struct UsageInsightsHealthPoint {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察健康汇总;失败数已排除调用方错误
|
||||
@@ -1205,43 +1375,49 @@ pub struct UsageInsightsHealth {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub success_rate: f64,
|
||||
pub success_rate: Option<f64>,
|
||||
pub completion_rate: f64,
|
||||
pub points: Vec<UsageInsightsHealthPoint>,
|
||||
}
|
||||
|
||||
/// 一个时间桶内按重叠时长加权的逻辑请求并发
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageConcurrencyPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub average_concurrency: f64,
|
||||
pub peak_concurrency: u64,
|
||||
}
|
||||
|
||||
/// 请求并发与调度等待的负载指标
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsLoad {
|
||||
pub average_concurrency: f64,
|
||||
pub peak_concurrency: u64,
|
||||
pub admission_decision_p95_ms: Option<PercentileMilliseconds>,
|
||||
pub account_selection_wait_p95_ms: Option<PercentileMilliseconds>,
|
||||
}
|
||||
|
||||
/// 洞察性能时间点
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsPerformancePoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub latency_percentiles: LatencyPercentiles,
|
||||
pub first_token_latency_percentiles: LatencyPercentiles,
|
||||
pub admission_decision_percentiles: LatencyPercentiles,
|
||||
pub account_selection_wait_percentiles: LatencyPercentiles,
|
||||
pub output_throughput_p10: Option<u64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub output_throughput_p90: Option<u64>,
|
||||
pub capacity_utilization_avg_basis_points: Option<u64>,
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
pub load: UsageInsightsLoad,
|
||||
}
|
||||
|
||||
/// 洞察性能汇总与可观测覆盖率
|
||||
/// 洞察性能汇总
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsightsPerformance {
|
||||
pub latency_percentiles: LatencyPercentiles,
|
||||
pub first_token_latency_percentiles: LatencyPercentiles,
|
||||
pub latency_coverage: f64,
|
||||
pub first_token_coverage: f64,
|
||||
pub admission_decision_percentiles: LatencyPercentiles,
|
||||
pub account_selection_wait_percentiles: LatencyPercentiles,
|
||||
pub admission_decision_coverage: f64,
|
||||
pub account_selection_wait_coverage: f64,
|
||||
pub output_throughput_p10: Option<u64>,
|
||||
pub output_throughput_p50: Option<u64>,
|
||||
pub output_throughput_p90: Option<u64>,
|
||||
pub capacity_utilization_avg_basis_points: Option<u64>,
|
||||
pub capacity_utilization_p95_basis_points: Option<u64>,
|
||||
pub capacity_coverage: f64,
|
||||
pub load: UsageInsightsLoad,
|
||||
pub points: Vec<UsageInsightsPerformancePoint>,
|
||||
}
|
||||
|
||||
@@ -1295,17 +1471,98 @@ pub struct UsageInsights {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
/// 单次洞察查询所需的指标,持久化层据此选择计数、分布与费用状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum UsageInsightMetric {
|
||||
Health,
|
||||
Latency,
|
||||
FirstToken,
|
||||
Throughput,
|
||||
Load,
|
||||
Cost,
|
||||
Savings,
|
||||
Cache,
|
||||
}
|
||||
|
||||
/// 单个指标的时间点,活动计数与指标共用读取快照
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageMetricPoint<T> {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
/// 各指标复用时间序列形状,汇总与时间点保留各自的类型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageMetricSeries<S, P = S> {
|
||||
pub summary: S,
|
||||
pub points: Vec<UsageMetricPoint<P>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageThroughputPercentiles {
|
||||
pub p10: Option<u64>,
|
||||
pub p50: Option<u64>,
|
||||
pub p90: Option<u64>,
|
||||
}
|
||||
|
||||
/// 成本卡片各视图共同显示的汇总
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageCostSummary {
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub cache_savings: Option<DecimalAmount>,
|
||||
pub tier_premium: Option<DecimalAmount>,
|
||||
pub cost_per_successful_request: Option<DecimalAmount>,
|
||||
pub coverage: CostCoverage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageCostComparison {
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub no_cache_cost: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageCacheEfficiency {
|
||||
pub input_tokens: u64,
|
||||
pub cached_token_rate: f64,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 只发布本次请求的指标,未查询的值不伪装成零
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum UsageInsightData {
|
||||
Health(UsageInsightsHealth),
|
||||
Latency(UsageMetricSeries<LatencyPercentiles>),
|
||||
FirstToken(UsageMetricSeries<LatencyPercentiles>),
|
||||
Throughput(UsageMetricSeries<UsageThroughputPercentiles>),
|
||||
Load(UsageMetricSeries<UsageInsightsLoad>),
|
||||
Cost(UsageMetricSeries<UsageCostSummary, UsageCostComparison>),
|
||||
Savings(UsageMetricSeries<UsageCostSummary, Option<DecimalAmount>>),
|
||||
Cache(UsageMetricSeries<UsageCostSummary, UsageCacheEfficiency>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageInsight {
|
||||
pub analytics: AnalyticsMetadata,
|
||||
pub granularity: Granularity,
|
||||
pub data: UsageInsightData,
|
||||
}
|
||||
|
||||
/// 已完成分母计算的诊断项
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_authentication_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
/// 仅账号维度补充当前互联识别结果
|
||||
pub account_upstream_peer: Option<bool>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! 当前认证 Key 可公开的本地互联观测,不携带 HTTP 或 Provider 私有字段
|
||||
|
||||
use super::provider_credentials::ProviderQuotaWindowRole;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::engine::budget::{ClientBudgetAccess, ClientBudgetStatus};
|
||||
|
||||
/// 调用边界提供的响应资源预算,超限时整体拒绝而不截断授权范围
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PeerLinkLimits {
|
||||
pub max_accounts: usize,
|
||||
pub max_quota_windows: usize,
|
||||
pub max_models: usize,
|
||||
pub max_response_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PeerLinkExport {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub key_limits: gateway_core::policy::RateLimits,
|
||||
pub budget: ClientBudgetStatus,
|
||||
pub budget_access: ClientBudgetAccess,
|
||||
pub budget_observed_at: DateTime<Utc>,
|
||||
pub pool: Option<PeerPoolSummary>,
|
||||
pub quota: Option<Vec<PeerAccountQuota>>,
|
||||
pub models: Vec<PeerModelSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PeerPoolSummary {
|
||||
pub ready: u32,
|
||||
pub cooldown: u32,
|
||||
pub exhausted: u32,
|
||||
pub disabled: u32,
|
||||
pub error: u32,
|
||||
pub unknown: u32,
|
||||
pub plans: Option<std::collections::BTreeMap<String, u32>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PeerAccountQuota {
|
||||
pub id: String,
|
||||
pub models: Vec<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<PeerQuotaWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PeerQuotaWindow {
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub role: Option<ProviderQuotaWindowRole>,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at: Option<DateTime<Utc>>,
|
||||
pub limit_reached: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeerModelState {
|
||||
Available,
|
||||
Exhausted,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PeerModelSummary {
|
||||
pub model: String,
|
||||
pub complete: bool,
|
||||
pub state: PeerModelState,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -84,6 +84,7 @@ pub struct PluginInstanceMutation {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginInstanceRuntimeStatus {
|
||||
Disabled,
|
||||
Unknown,
|
||||
AwaitingPublication,
|
||||
Preparing,
|
||||
Running,
|
||||
@@ -108,6 +109,21 @@ pub struct PluginInstanceRuntime {
|
||||
pub actual_artifact_sha256: Option<String>,
|
||||
pub failure: Option<PluginInstanceRuntimeFailure>,
|
||||
pub draining_revisions: Vec<u64>,
|
||||
pub load: Option<PluginInstanceRuntimeLoad>,
|
||||
}
|
||||
|
||||
/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntimeLoad {
|
||||
/// 总在途名额,包含排队、取消确认和仍由响应流持有的调用
|
||||
pub active_calls: usize,
|
||||
pub executing_calls: usize,
|
||||
pub queued_calls: usize,
|
||||
pub resuming_calls: usize,
|
||||
/// 包含已返回但尚未由插件消费的回调结果
|
||||
pub pending_callbacks: usize,
|
||||
/// 已交付首结果但尚未收到 End 的流
|
||||
pub active_streams: usize,
|
||||
}
|
||||
|
||||
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
|
||||
@@ -136,6 +152,8 @@ pub struct PluginInstanceView {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfigurePluginInstance {
|
||||
/// 只随本次启动请求传递,不写入实例配置
|
||||
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
|
||||
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
|
||||
pub creation_id: Option<String>,
|
||||
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
|
||||
@@ -147,8 +165,9 @@ pub struct ConfigurePluginInstance {
|
||||
pub artifact_sha256: String,
|
||||
pub enabled: bool,
|
||||
pub configuration: serde_json::Value,
|
||||
/// 缺省保留旧值;显式空对象清除全部 secret
|
||||
pub secrets: Option<BTreeMap<String, SecretString>>,
|
||||
/// 按字段更新,省略的字段保留,null 清除对应字段
|
||||
#[serde(default)]
|
||||
pub secrets: BTreeMap<String, Option<SecretString>>,
|
||||
pub bindings: Vec<PluginCapabilityBinding>,
|
||||
}
|
||||
|
||||
@@ -159,6 +178,16 @@ pub struct PluginInstanceReplacement {
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// 管理员确认的宿主、实例配置与目标包;不构成永久兼容豁免
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginCompatibilityConfirmation {
|
||||
pub host_fingerprint: String,
|
||||
pub instance_id: String,
|
||||
pub instance_revision: u64,
|
||||
pub artifact_sha256: String,
|
||||
}
|
||||
|
||||
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -196,6 +225,9 @@ pub struct PluginVersionConfiguration {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginVersionPlan {
|
||||
pub compatibility_warning: Option<String>,
|
||||
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
|
||||
pub load_error: Option<String>,
|
||||
pub instance_revision: u64,
|
||||
pub artifact_sha256: String,
|
||||
pub configuration: serde_json::Value,
|
||||
|
||||
@@ -62,6 +62,48 @@ impl PluginHostCompatibility {
|
||||
.iter()
|
||||
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
|
||||
}
|
||||
|
||||
/// 启动和升级预检共用比较规则,只检查插件声明的能力
|
||||
#[must_use]
|
||||
pub fn incompatibilities(
|
||||
&self,
|
||||
requirements: &PluginCompatibilityRequirements,
|
||||
host: &semver::Version,
|
||||
) -> Vec<String> {
|
||||
let mut reasons = Vec::new();
|
||||
if !self
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
{
|
||||
reasons.push(format!(
|
||||
"清单 v{} 不在宿主支持范围内",
|
||||
requirements.manifest_schema_version
|
||||
));
|
||||
}
|
||||
if !self
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
{
|
||||
reasons.push(format!(
|
||||
"RPC v{} 不在宿主支持范围内",
|
||||
requirements.protocol_version
|
||||
));
|
||||
}
|
||||
if !semver::VersionReq::parse(&requirements.host_version)
|
||||
.is_ok_and(|range| range.matches(host))
|
||||
{
|
||||
reasons.push(format!(
|
||||
"插件声明的宿主范围为 {},当前为 {host}",
|
||||
requirements.host_version
|
||||
));
|
||||
}
|
||||
for (capability, version) in &requirements.capabilities {
|
||||
if !self.supports_capability(capability, *version) {
|
||||
reasons.push(format!("{capability} v{version} 不在宿主支持范围内"));
|
||||
}
|
||||
}
|
||||
reasons
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_versions(versions: &[u32]) -> bool {
|
||||
|
||||
@@ -790,6 +790,7 @@ impl ProviderQuotaWindowRole {
|
||||
/// 一个 Provider quota 窗口的公共投影
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
pub upstream: Option<super::upstream_info::UpstreamQuotaWindow>,
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
@@ -805,11 +806,12 @@ pub struct ProviderQuotaWindow {
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板
|
||||
/// 账号用量统计周期,完整周/月额度优先,随后使用 Provider 提供的本地滚动日统计
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AccountUsagePeriod {
|
||||
Weekly,
|
||||
Monthly,
|
||||
Daily,
|
||||
}
|
||||
|
||||
/// Provider 解释 quota 所需的公共请求事实
|
||||
@@ -820,9 +822,17 @@ pub struct ProviderQuotaRequest {
|
||||
pub rolling_usage: Option<AccountUsage>,
|
||||
}
|
||||
|
||||
/// 当前目录页已读取的账号事实;批量投影不触发上游刷新
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProviderQuotaReadRequest<'a> {
|
||||
pub account: &'a AccountRecord,
|
||||
pub rolling_usage: Option<&'a AccountUsage>,
|
||||
}
|
||||
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示
|
||||
pub plan_type: Option<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
@@ -989,19 +999,45 @@ impl ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实
|
||||
/// 选择账号用量统计窗口,同时返回展示所需的周期事实
|
||||
///
|
||||
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
|
||||
/// Provider 投影顺序
|
||||
/// 没有符合条件的窗口时不回退到短期或历史累计
|
||||
/// 周/月额度使用边界完整、可归属到整个账号的窗口
|
||||
/// 周优先于月,同周期保持 Provider 投影顺序
|
||||
/// Provider 提供的无固定重置点的滚动日统计可独立展示
|
||||
#[must_use]
|
||||
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.usage_windows()
|
||||
.filter(|(window, _)| window.local_usage.is_some())
|
||||
.min_by_key(|(_, period)| *period)
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.find(|window| {
|
||||
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
&& window.window_seconds == Some(86_400)
|
||||
&& window.reset_at.is_none()
|
||||
&& window.local_usage.is_some()
|
||||
})
|
||||
.map(|window| (window, AccountUsagePeriod::Daily))
|
||||
})
|
||||
}
|
||||
|
||||
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量
|
||||
/// 互联账号按最长的 Key 消费周期统计本地用量,池内账号窗口不代表此 Key 的周期
|
||||
#[must_use]
|
||||
pub fn upstream_usage_window(&self) -> Option<&ProviderQuotaWindow> {
|
||||
self.windows
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
window
|
||||
.upstream
|
||||
.as_ref()
|
||||
.is_some_and(|upstream| upstream.amount.is_some())
|
||||
&& window.window_seconds.is_some_and(|seconds| seconds > 0)
|
||||
})
|
||||
.max_by_key(|window| window.window_seconds)
|
||||
}
|
||||
|
||||
/// 周/月额度的归属规则,供额度周期统计与容量预测共用
|
||||
pub(crate) fn usage_windows(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
@@ -1239,6 +1275,7 @@ impl fmt::Debug for ProviderExport {
|
||||
/// 统一账号目录的一行完整结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
|
||||
pub account: AccountRecord,
|
||||
pub capacity: super::accounts::AccountCapacity,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct ProxyListQuery {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyAccountRef {
|
||||
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
|
||||
@@ -62,17 +62,18 @@ pub fn account_quota_forecasts(
|
||||
now: DateTime<Utc>,
|
||||
samples: &[QuotaForecastSample],
|
||||
) -> [AccountQuotaForecast; 2] {
|
||||
[AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| {
|
||||
[
|
||||
(AccountUsagePeriod::Weekly, 7 * DAY_SECONDS),
|
||||
(AccountUsagePeriod::Monthly, 30 * DAY_SECONDS),
|
||||
]
|
||||
.map(|(period, target_seconds)| {
|
||||
let selected = quota_forecast_source_window(quota, period);
|
||||
let mut forecast = AccountQuotaForecast {
|
||||
period,
|
||||
target_seconds: match period {
|
||||
AccountUsagePeriod::Weekly => 7 * DAY_SECONDS,
|
||||
AccountUsagePeriod::Monthly => 30 * DAY_SECONDS,
|
||||
},
|
||||
target_seconds,
|
||||
extrapolated: false,
|
||||
source: None,
|
||||
unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"),
|
||||
unavailable_reason: Some("缺少周/月额度窗口,请刷新账号额度"),
|
||||
low_sample: false,
|
||||
incomplete_cost: false,
|
||||
incomplete_tokens: false,
|
||||
@@ -138,30 +139,27 @@ impl AccountQuotaForecast {
|
||||
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
|
||||
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
|
||||
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
|
||||
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
|
||||
self.unavailable_reason = Some("额度窗口已过期或无效,请刷新账号额度");
|
||||
return;
|
||||
};
|
||||
if !observed_at.is_some_and(|observed| start <= observed && observed <= now) {
|
||||
self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。");
|
||||
self.unavailable_reason = Some("缺少本周期的额度快照,请刷新账号额度");
|
||||
return;
|
||||
}
|
||||
if sample.is_some_and(|sample| sample.discontinuous) {
|
||||
self.unavailable_reason =
|
||||
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
|
||||
self.unavailable_reason = Some("额度回落或记录不连续,正在重新积累配对样本");
|
||||
return;
|
||||
}
|
||||
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
|
||||
self.unavailable_reason = Some(
|
||||
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
|
||||
);
|
||||
self.unavailable_reason = Some("周期初记录不完整,正在积累至少 5 个百分点的配对观测");
|
||||
return;
|
||||
}
|
||||
let Some(percent) = percent else {
|
||||
self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。");
|
||||
self.unavailable_reason = Some("已用比例未知,请刷新账号额度");
|
||||
return;
|
||||
};
|
||||
let Some(usage) = usage.filter(|usage| usage.request_count > 0) else {
|
||||
self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。");
|
||||
self.unavailable_reason = Some("本周期没有网关用量记录,无法预测额度");
|
||||
return;
|
||||
};
|
||||
let Some(sample) = sample.filter(|sample| {
|
||||
@@ -172,8 +170,7 @@ impl AccountQuotaForecast {
|
||||
&& sample.sampled_percent >= MIN_USED_PERCENT
|
||||
&& sample.sampled_percent <= percent
|
||||
}) else {
|
||||
self.unavailable_reason =
|
||||
Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。");
|
||||
self.unavailable_reason = Some("有效额度进度不足 5 个百分点或采样边界无效");
|
||||
return;
|
||||
};
|
||||
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|
||||
@@ -196,7 +193,7 @@ impl AccountQuotaForecast {
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
Some("本周期缺少可用于估算的 Token 或费用数据")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsValues {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
pub max_account_rotations: u32,
|
||||
@@ -65,13 +66,7 @@ pub struct RuntimeSettingsValues {
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_error_protection: gateway_core::account::AccountErrorProtection,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
@@ -232,12 +227,7 @@ impl RuntimeSettingsValues {
|
||||
self.ops_event_retention_days,
|
||||
self.audit_retention_days,
|
||||
)?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
|
||||
self.account_auto_freeze_threshold,
|
||||
self.account_auto_freeze_window_seconds,
|
||||
self.account_auto_freeze_duration_seconds,
|
||||
self.account_auto_freeze_probe_model.as_deref(),
|
||||
)?;
|
||||
self.account_error_protection.validate()?;
|
||||
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
|
||||
self.account_warmup_enabled,
|
||||
&self.account_warmup_schedule_time,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 管理端互联快照的中立展示事实,不携带 Provider 原始协议
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderUpstreamInfo {
|
||||
/// 当前连接的互联识别结果,明确关闭共享或不支持协议时清除
|
||||
pub discovered: bool,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub stale: bool,
|
||||
pub connection_revision: String,
|
||||
pub last_error: Option<String>,
|
||||
pub max_concurrency: Option<u64>,
|
||||
pub requests_per_minute: Option<u64>,
|
||||
pub pool: Option<UpstreamPool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpstreamPool {
|
||||
pub account_count: u32,
|
||||
pub states: UpstreamPoolStates,
|
||||
pub plans: Vec<UpstreamPlanCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpstreamPoolStates {
|
||||
pub ready: u32,
|
||||
pub cooldown: u32,
|
||||
pub exhausted: u32,
|
||||
pub disabled: u32,
|
||||
pub error: u32,
|
||||
pub unknown: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpstreamPlanCount {
|
||||
pub name: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UpstreamQuotaWindow {
|
||||
pub account_id: Option<String>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub stale: bool,
|
||||
pub amount: Option<UpstreamQuotaAmount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpstreamQuotaAmount {
|
||||
pub unit: String,
|
||||
pub used: String,
|
||||
pub remaining: Option<String>,
|
||||
pub total: Option<String>,
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub enum UsageErrorKind {
|
||||
Conflict,
|
||||
CapacityExceeded,
|
||||
Unavailable,
|
||||
Cancelled,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -20,3 +20,6 @@ pub use actor::*;
|
||||
|
||||
mod aggregate;
|
||||
pub use aggregate::*;
|
||||
|
||||
mod read;
|
||||
pub use read::*;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! 历史用量的窄事实、分组统计与调用内分批读取合同
|
||||
|
||||
use super::{UsageCount, UsageMetric, UsageRecordData, UsageRecordFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const USAGE_READ_MAX_BATCH_ROWS: u16 = 1024;
|
||||
pub const USAGE_READ_MAX_BATCH_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadFilter {
|
||||
pub records: UsageRecordFilter,
|
||||
pub routing_group_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScanUsageRecords {
|
||||
pub filter: UsageReadFilter,
|
||||
pub batch_size: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageGrouping {
|
||||
Model,
|
||||
Key,
|
||||
KeyModel,
|
||||
RoutingGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QueryUsageStatistics {
|
||||
pub filter: UsageReadFilter,
|
||||
pub group_by: UsageGrouping,
|
||||
pub batch_size: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum UsageReadQuery {
|
||||
Records(ScanUsageRecords),
|
||||
Statistics(QueryUsageStatistics),
|
||||
}
|
||||
|
||||
impl UsageReadQuery {
|
||||
pub fn filter(&self) -> &UsageReadFilter {
|
||||
match self {
|
||||
Self::Records(query) => &query.filter,
|
||||
Self::Statistics(query) => &query.filter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_size(&self) -> u16 {
|
||||
match self {
|
||||
Self::Records(query) => query.batch_size,
|
||||
Self::Statistics(query) => query.batch_size,
|
||||
}
|
||||
.unwrap_or(USAGE_READ_MAX_BATCH_ROWS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageRecordFact {
|
||||
pub id: String,
|
||||
pub data: UsageRecordData,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub request_kind: Option<String>,
|
||||
pub routing_scope: Option<String>,
|
||||
pub routing_group_refs: Vec<String>,
|
||||
pub recovered: bool,
|
||||
/// 由宿主现行规则派生,原始审计中的恢复请求仍可读取
|
||||
pub request_counted: bool,
|
||||
/// 包含完整交付、预热排除及恢复关联规则
|
||||
pub usage_counted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageStatisticsGroup {
|
||||
pub client_key_ref: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub routing_group_ref: Option<String>,
|
||||
pub unassigned_contribution: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageStatisticsCost {
|
||||
pub currency: String,
|
||||
pub amount: String,
|
||||
pub native_amount: Option<String>,
|
||||
/// 外部修正允许负值,原生费用来源覆盖单独返回
|
||||
pub contribution_amount: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageStatisticsCostCoverage {
|
||||
pub provider_reported: UsageCount,
|
||||
pub calculated: UsageCount,
|
||||
pub unavailable: UsageCount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageStatisticsMetrics {
|
||||
/// 缺失的 Token 指标表示未知,计数指标始终返回
|
||||
pub values: BTreeMap<UsageMetric, UsageCount>,
|
||||
pub native_request_count: UsageCount,
|
||||
pub contribution_count: UsageCount,
|
||||
pub native_token_samples: BTreeMap<UsageMetric, UsageCount>,
|
||||
/// 数量表示提供该指标的贡献记录数,不能用作请求覆盖率的分母
|
||||
pub contribution_token_samples: BTreeMap<UsageMetric, UsageCount>,
|
||||
pub cost_coverage: UsageStatisticsCostCoverage,
|
||||
pub costs: Vec<UsageStatisticsCost>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageStatisticsRow {
|
||||
/// null 表示全局总计,分组重叠不会重复计入该行
|
||||
pub group: Option<UsageStatisticsGroup>,
|
||||
pub metrics: UsageStatisticsMetrics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum UsageReadItem {
|
||||
Record(Box<UsageRecordFact>),
|
||||
Statistics(Box<UsageStatisticsRow>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadAnalytics {
|
||||
pub coverage_start_ms: Option<i64>,
|
||||
pub coverage_end_ms: Option<i64>,
|
||||
pub lag_seconds: u64,
|
||||
pub state: String,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadObservation {
|
||||
pub generated_at_ms: i64,
|
||||
pub analytics: Option<UsageReadAnalytics>,
|
||||
pub total_groups: Option<UsageCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadBatch {
|
||||
pub observation: UsageReadObservation,
|
||||
pub items: Vec<UsageReadItem>,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadOpened {
|
||||
pub reader: Option<String>,
|
||||
pub batch: UsageReadBatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UsageReadHandle {
|
||||
pub reader: String,
|
||||
}
|
||||
@@ -66,8 +66,12 @@ pub trait BackupRepository: Send + Sync {
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
/// 同事务插入人工 queued 任务与审计;存在活跃任务时返回 `backup.active_task_conflict`
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务
|
||||
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`
|
||||
@@ -111,11 +115,12 @@ pub trait BackupRepository: Send + Sync {
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用
|
||||
/// 迁移到删除流程(completed/failed → deleting);人工操作同事务记录审计,保留策略传 None
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 远端删除成功后硬删除记录行
|
||||
@@ -131,9 +136,10 @@ pub trait BackupRepository: Send + Sync {
|
||||
timezone: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)
|
||||
/// 按完成时间及 ID 倒序分页读取 completed 计划备份,offset 相对完整集合
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
offset: u64,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
}
|
||||
@@ -313,6 +319,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
@@ -363,6 +370,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
&self,
|
||||
_id: &str,
|
||||
_now: DateTime<Utc>,
|
||||
_context: Option<&MutationContext>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
@@ -381,6 +389,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
}
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
_offset: u64,
|
||||
_limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod peer;
|
||||
pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_management;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! CPR 连接材料与传输端口;Provider 解释凭据,Host 执行受限网络请求
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::{ProviderAccount, UpstreamInfoFailure};
|
||||
use secrecy::SecretString;
|
||||
|
||||
/// 仅供后台请求使用,不进入日志、持久观测或管理响应
|
||||
pub struct PeerConnection {
|
||||
pub base_url: String,
|
||||
pub bearer_token: SecretString,
|
||||
}
|
||||
|
||||
pub struct PeerFetchFailure {
|
||||
pub kind: UpstreamInfoFailure,
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl From<UpstreamInfoFailure> for PeerFetchFailure {
|
||||
fn from(kind: UpstreamInfoFailure) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PeerTransport: Send + Sync {
|
||||
async fn fetch(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
connection: &PeerConnection,
|
||||
) -> Result<Vec<u8>, PeerFetchFailure>;
|
||||
}
|
||||
|
||||
/// 账号用例读取公共互联观测,显式刷新等待额度查询完成
|
||||
#[async_trait]
|
||||
pub trait PeerUpstreamService: gateway_core::account::UpstreamInfoSource {
|
||||
/// 仅在指定持久观测与当前调度缓存一致时估算容量
|
||||
fn observed_concurrency_limit(
|
||||
&self,
|
||||
version: &gateway_core::account::QuotaObservationVersion,
|
||||
now: std::time::SystemTime,
|
||||
) -> Option<gateway_core::account::AccountConcurrencyLimit>;
|
||||
|
||||
async fn account_changed(
|
||||
&self,
|
||||
id: &gateway_core::account::ProviderAccountId,
|
||||
) -> Result<(), gateway_core::error::StoreError>;
|
||||
fn forget(&self, id: &gateway_core::account::ProviderAccountId);
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
id: &gateway_core::account::ProviderAccountId,
|
||||
) -> Result<
|
||||
crate::model::provider_credentials::ProviderQuota,
|
||||
super::provider::ProviderAdminError,
|
||||
>;
|
||||
async fn read_quota(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> Result<
|
||||
Option<crate::model::provider_credentials::ProviderQuota>,
|
||||
gateway_core::error::StoreError,
|
||||
>;
|
||||
/// 读取版本化摘要,不展开远端节点窗口
|
||||
async fn read_summary(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> Result<
|
||||
Option<crate::model::provider_credentials::ProviderQuota>,
|
||||
gateway_core::error::StoreError,
|
||||
>;
|
||||
async fn read_quotas(
|
||||
&self,
|
||||
requests: &[crate::model::provider_credentials::ProviderQuotaReadRequest<'_>],
|
||||
) -> Vec<
|
||||
Result<
|
||||
crate::model::provider_credentials::ProviderQuota,
|
||||
super::provider::ProviderAdminError,
|
||||
>,
|
||||
>;
|
||||
}
|
||||
@@ -46,6 +46,15 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
|
||||
/// 确认由 Admin 校验配置版本,Runtime 复核宿主并仅放行目标实例
|
||||
async fn prepare_confirmed(
|
||||
&self,
|
||||
_snapshot: PluginInstanceSnapshot,
|
||||
_confirmation: crate::model::plugins::instances::PluginCompatibilityConfirmation,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
Err(AdminError::invalid("插件运行时不支持确认启动"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None
|
||||
@@ -175,6 +184,10 @@ pub trait PluginDistribution: Send + Sync {
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
|
||||
#[async_trait]
|
||||
pub trait PluginPackageInspector: Send + Sync {
|
||||
/// 当前宿主版本、兼容声明与平台的稳定身份,不含敏感信息
|
||||
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
|
||||
Err(AdminError::internal("宿主兼容声明不可用"))
|
||||
}
|
||||
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
|
||||
async fn compatibility_warning(
|
||||
&self,
|
||||
|
||||
@@ -18,8 +18,9 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaReadRequest,
|
||||
ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
|
||||
explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -147,7 +148,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
> {
|
||||
Err(gateway_core::settings::privacy::PrivacyError {
|
||||
rule_index: 0,
|
||||
reason: "Provider 不支持隐私策略",
|
||||
reason: "该服务商不支持隐私策略",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,7 +161,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
> {
|
||||
Err(gateway_core::settings::privacy::PrivacyError {
|
||||
rule_index: 0,
|
||||
reason: "Provider 不支持隐私策略",
|
||||
reason: "该服务商不支持隐私策略",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,6 +179,19 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
/// 与执行 Provider 共用的直连资格,未知实现默认不公开账号
|
||||
fn is_direct_account(&self, _authentication_kind: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 仅依据本地账号事实判断通用模型资格;未知实现不能宣称完整覆盖
|
||||
fn peer_model_eligible(
|
||||
&self,
|
||||
_account: &crate::model::accounts::AccountRecord,
|
||||
_model: &str,
|
||||
) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
|
||||
fn account_capabilities(
|
||||
@@ -208,6 +222,15 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// Key 可以继承当前全局画像;额外字段的合法性由对应 Provider 判定
|
||||
fn preview_key_client_profile(
|
||||
&self,
|
||||
key: &gateway_core::account::OpaqueProviderData,
|
||||
_global: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
|
||||
self.preview_client_profile(key)
|
||||
}
|
||||
|
||||
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
default_plan_type_display(plan_type)
|
||||
@@ -289,6 +312,43 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 只读投影当前页额度,返回值与输入逐项对应,单个失败不影响其他账号
|
||||
/// 没有批量存储能力的 Provider 继续使用原有单账号语义
|
||||
async fn read_quotas(
|
||||
&self,
|
||||
requests: &[ProviderQuotaReadRequest<'_>],
|
||||
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
|
||||
futures::future::join_all(requests.iter().map(|request| async {
|
||||
let account_id = ProviderAccountId::new(request.account.id.clone())
|
||||
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid))?;
|
||||
self.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: request.rolling_usage.cloned(),
|
||||
})
|
||||
.await
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 仅依据账号类型声明 CPR 连接能力,通用用例不解析 Provider 凭据
|
||||
fn peer_authentication_kinds(&self) -> &[&str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn supports_peer_connection(&self, authentication_kind: &str) -> bool {
|
||||
self.peer_authentication_kinds()
|
||||
.contains(&authentication_kind)
|
||||
}
|
||||
|
||||
/// 从本轮加载的凭据提取连接材料,避免在校验连接版本后另读一份凭据
|
||||
fn peer_connection(
|
||||
&self,
|
||||
_loaded: &gateway_core::account::LoadedCredential,
|
||||
) -> Result<Option<super::peer::PeerConnection>, ProviderAdminError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
@@ -365,6 +425,10 @@ pub struct ProviderAdminRegistry {
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
pub(crate) fn registered(&self) -> BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>> {
|
||||
self.providers.as_ref().clone()
|
||||
}
|
||||
|
||||
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
|
||||
self.providers
|
||||
.iter()
|
||||
|
||||
@@ -30,7 +30,8 @@ use crate::model::{
|
||||
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
|
||||
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageBillingProjection, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageInsightsObservation, UsageOverview, UsagePage, UsageQuery,
|
||||
UsageInsightMetric, UsageInsightsObservation, UsagePage, UsageQuery,
|
||||
UsageSummaryObservation,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
@@ -130,8 +131,43 @@ pub trait AccountStore:
|
||||
runtime: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<Option<AccountPageItem>>;
|
||||
|
||||
/// 批量读取指定账号与当前启用分组的交集,分组只用于授权筛选,不加载展示信息
|
||||
/// 不加载凭据正文或触发 Provider 查询
|
||||
async fn load_accounts_in_enabled_groups(
|
||||
&self,
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
group_ids: &[gateway_core::routing::AccountGroupId],
|
||||
) -> AdminStoreResult<Vec<crate::model::accounts::AccountRecord>> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut accounts = Vec::with_capacity(account_ids.len());
|
||||
for id in account_ids {
|
||||
if let Some(item) = self
|
||||
.load_account(id.as_str(), AccountRuntimeSnapshot::default())
|
||||
.await?
|
||||
&& item
|
||||
.account
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| group.enabled && group_ids.contains(&group.id))
|
||||
{
|
||||
let mut account = item.account;
|
||||
account.groups.clear();
|
||||
accounts.push(account);
|
||||
}
|
||||
}
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 只读取账号记录,不加载运行配置、分组或额度展示
|
||||
async fn load_account_record(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<crate::model::accounts::AccountRecord>>;
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
@@ -209,14 +245,6 @@ pub trait AccountStore:
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<AccountUpdateResult>>;
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
@@ -259,17 +287,19 @@ pub trait AccountRuntimeStore: Send + Sync {
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内
|
||||
/// 只需账号可用状态时不读取在途租约,避免池规模放大容量查询
|
||||
async fn account_rate_limits(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
self.account_runtime(account_ids).await
|
||||
}
|
||||
|
||||
/// 账号错误保护中的账号与其冻结快照;临时限流不包含在内
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
|
||||
|
||||
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>>;
|
||||
|
||||
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
@@ -470,11 +500,22 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 同一读取快照中的汇总、趋势和已投影情景费用
|
||||
/// 不需要并发时关闭 include_concurrency,返回空并发序列并跳过明细计算
|
||||
async fn usage_insights_observation(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
include_concurrency: bool,
|
||||
) -> AdminStoreResult<UsageInsightsObservation>;
|
||||
|
||||
/// 只读取所选指标及其可见汇总依赖,未选择的字段不供业务层消费
|
||||
async fn usage_metric_observation(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
metric: UsageInsightMetric,
|
||||
) -> AdminStoreResult<UsageInsightsObservation>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
@@ -485,7 +526,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview>;
|
||||
) -> AdminStoreResult<UsageSummaryObservation>;
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
|
||||
@@ -38,11 +38,13 @@ pub enum SystemOperationErrorKind {
|
||||
}
|
||||
|
||||
/// 不泄漏路径、命令行或发布凭据的系统操作错误
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("system operation failed: {message}")]
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct SystemOperationError {
|
||||
kind: SystemOperationErrorKind,
|
||||
message: String,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
sensitive_context_redacted: bool,
|
||||
}
|
||||
|
||||
impl SystemOperationError {
|
||||
@@ -51,9 +53,37 @@ impl SystemOperationError {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
sensitive_context_redacted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn redact_sensitive_context(mut self) -> Self {
|
||||
self.sensitive_context_redacted = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn sensitive_context_redacted(&self) -> bool {
|
||||
self.sensitive_context_redacted
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
|
||||
gateway_core::error::ErrorDetails::capture(
|
||||
self.source.as_ref(),
|
||||
None,
|
||||
self.sensitive_context_redacted,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> SystemOperationErrorKind {
|
||||
self.kind
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
use crate::model::{auth::AdminAuditEvent, usage_data::*};
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageReadSession: Send {
|
||||
async fn next_batch(&mut self) -> UsageDataResult<UsageReadBatch>;
|
||||
fn is_finished(&self) -> bool;
|
||||
async fn close(&mut self);
|
||||
}
|
||||
|
||||
pub struct StoredUsageWrite {
|
||||
pub changes: Vec<(UsageRecordChange, AdminAuditEvent)>,
|
||||
@@ -13,6 +21,11 @@ pub struct StoredAggregateWrite {
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageDataStore: Send + Sync {
|
||||
async fn open_read(
|
||||
&self,
|
||||
query: UsageReadQuery,
|
||||
cancellation: CancellationToken,
|
||||
) -> UsageDataResult<Box<dyn UsageReadSession>>;
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
|
||||
|
||||
use crate::{
|
||||
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
|
||||
ports::store::{AccountStore, SettingsStore},
|
||||
use_case::accounts::AccountsService,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
|
||||
time::DeploymentTimeZone,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
|
||||
pub(crate) fn next_discovery(
|
||||
now: DateTime<Utc>,
|
||||
timezone: DeploymentTimeZone,
|
||||
random: u64,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let start = timezone.days_after(now, 1)?;
|
||||
let end = timezone.days_after(now, 2)?;
|
||||
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
|
||||
if seconds == 0 {
|
||||
return None;
|
||||
}
|
||||
start.checked_add_signed(chrono::Duration::seconds(
|
||||
i64::try_from(random % seconds).ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
|
||||
pub(crate) fn consume_at(
|
||||
account_id: &str,
|
||||
credit: &PlannedResetCredit,
|
||||
lead_minutes: u32,
|
||||
) -> DateTime<Utc> {
|
||||
let lead_seconds = i64::from(lead_minutes) * 60;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(account_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(credit.id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let offset =
|
||||
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
|
||||
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
|
||||
}
|
||||
|
||||
pub(crate) fn next_consumption(
|
||||
record: &ResetCreditAutomation,
|
||||
lead_minutes: u32,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
|
||||
.min()
|
||||
}
|
||||
|
||||
pub(crate) fn reschedule(
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead_minutes: u32,
|
||||
now: DateTime<Utc>,
|
||||
) {
|
||||
record.next_run_at = if let Some(retry) = record.state.retry_at {
|
||||
retry
|
||||
} else if record.state.pending.is_some() {
|
||||
now
|
||||
} else {
|
||||
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
|
||||
due.min(record.state.next_discovery_at)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) struct ResetCreditAutomationTask {
|
||||
pub(crate) accounts: Arc<dyn AccountsService>,
|
||||
pub(crate) store: Arc<dyn AccountStore>,
|
||||
pub(crate) settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
impl ScheduledTask for ResetCreditAutomationTask {
|
||||
fn run_cycle(
|
||||
&self,
|
||||
context: WorkerCycleContext,
|
||||
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let settings = self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
|
||||
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
|
||||
let due = self
|
||||
.store
|
||||
.due_reset_credit_automations(Utc::now(), 100)
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
|
||||
let work = futures::stream::iter(due)
|
||||
.map(|account_id| async move {
|
||||
let id = ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
|
||||
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
error_kind = error.kind().as_str(),
|
||||
"自动重置卡任务失败"
|
||||
);
|
||||
}
|
||||
Ok::<_, WorkerTaskError>(())
|
||||
})
|
||||
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
tokio::select! {
|
||||
() = context.cancellation().cancelled() => {},
|
||||
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 自动重置卡 Worker 的到期扫描、并发预算与取消,消费编排由账号用例负责
|
||||
|
||||
use crate::{
|
||||
model::AdminError,
|
||||
ports::store::{AccountStore, SettingsStore},
|
||||
use_case::accounts::AccountsService,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
task::{
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind,
|
||||
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
},
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
pub(crate) struct ResetCreditAutomationTask {
|
||||
pub(crate) accounts: Arc<dyn AccountsService>,
|
||||
pub(crate) store: Arc<dyn AccountStore>,
|
||||
pub(crate) settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
impl ResetCreditAutomationTask {
|
||||
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(90),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(self),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?;
|
||||
Ok(WorkerContribution::Registration(registration))
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledTask for ResetCreditAutomationTask {
|
||||
fn run_cycle(
|
||||
&self,
|
||||
context: WorkerCycleContext,
|
||||
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let settings = self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
|
||||
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
|
||||
let due = self
|
||||
.store
|
||||
.due_reset_credit_automations(Utc::now(), 100)
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
|
||||
let work = futures::stream::iter(due)
|
||||
.map(|account_id| async move {
|
||||
let id = ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
|
||||
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
error_kind = error.kind().as_str(),
|
||||
"自动重置卡任务失败"
|
||||
);
|
||||
}
|
||||
Ok::<_, WorkerTaskError>(())
|
||||
})
|
||||
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
tokio::select! {
|
||||
() = context.cancellation().cancelled() => {},
|
||||
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ pub trait AccountGroupService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultAccountGroupService {
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
@@ -68,11 +69,13 @@ pub(crate) struct DefaultAccountGroupService {
|
||||
impl DefaultAccountGroupService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
upstream_info,
|
||||
store,
|
||||
runtime,
|
||||
snapshot,
|
||||
@@ -105,7 +108,7 @@ impl DefaultAccountGroupService {
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
project_group_runtime(records, &members, &runtime);
|
||||
project_group_runtime(records, &members, &runtime, self.upstream_info.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -114,10 +117,14 @@ impl DefaultAccountGroupService {
|
||||
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let mut result = result.map_err(|error| map_store_error(error, "account group"))?;
|
||||
if let Some(record) = result.record.as_mut() {
|
||||
self.enrich_records(std::slice::from_mut(record)).await?;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
if let Some(record) = result.record.as_mut()
|
||||
&& let Err(error) = self.enrich_records(std::slice::from_mut(record)).await
|
||||
{
|
||||
tracing::warn!(group_id = %result.id.as_str(), error = %error, "committed account group runtime projection is unavailable");
|
||||
// record 可缺省;不要用未完成的容量投影冒充有效展示数据。
|
||||
result.record = None;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -211,6 +218,7 @@ fn project_group_runtime(
|
||||
records: &mut [AccountGroupRecord],
|
||||
members: &[AccountGroupMemberFact],
|
||||
runtime: &crate::model::accounts::AccountRuntimeSnapshot,
|
||||
upstream: Option<&dyn crate::ports::peer::PeerUpstreamService>,
|
||||
) {
|
||||
let now = SystemTime::now();
|
||||
let mut status_by_account = BTreeMap::new();
|
||||
@@ -224,7 +232,14 @@ fn project_group_runtime(
|
||||
.or_insert_with(|| resolve_account_status(&status, now).status);
|
||||
slots_by_account
|
||||
.entry(member.account_id.as_str())
|
||||
.or_insert(member.total_slots);
|
||||
.or_insert_with(|| {
|
||||
member
|
||||
.inherited_version
|
||||
.as_ref()
|
||||
.and_then(|version| upstream?.observed_concurrency_limit(version, now))
|
||||
.map(|limit| u64::from(limit.get()))
|
||||
.or(member.total_slots)
|
||||
});
|
||||
accounts_by_group
|
||||
.entry(member.group_id.as_str())
|
||||
.or_default()
|
||||
|
||||
@@ -12,7 +12,7 @@ use chrono::{Duration, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
engine::probe::{AccountProbe, AccountProbeRequest},
|
||||
engine::probe::{AccountProbe, AccountProbeRequest, AccountProbeTarget},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
@@ -30,8 +30,9 @@ use crate::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
|
||||
PrepareCredentialRefresh, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
ProviderProfileAvatar, ProviderQuota, ProviderQuotaReadRequest, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast::{
|
||||
AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window,
|
||||
@@ -87,13 +88,6 @@ pub trait AccountsService: Send + Sync {
|
||||
command: UpdateAccount,
|
||||
) -> Result<AccountUpdateResult, AdminError>;
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
) -> Result<Option<AccountUpdateResult>, AdminError>;
|
||||
|
||||
async fn batch_update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
@@ -122,14 +116,14 @@ pub trait AccountsService: Send + Sync {
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
|
||||
Err(AdminError::invalid("该服务商不支持个人信息查询"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileAvatar, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
|
||||
Err(AdminError::invalid("该服务商不支持账号头像"))
|
||||
}
|
||||
|
||||
async fn reset_credit_automation_status(
|
||||
@@ -160,7 +154,7 @@ pub trait AccountsService: Send + Sync {
|
||||
_context: &MutationContext,
|
||||
_account_id: ProviderAccountId,
|
||||
) -> Result<ProviderResetCredits, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
|
||||
Err(AdminError::invalid("该服务商不支持额度重置"))
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
@@ -168,7 +162,7 @@ pub trait AccountsService: Send + Sync {
|
||||
_context: &MutationContext,
|
||||
_command: ConsumeProviderResetCredit,
|
||||
) -> Result<ProviderResetCreditResult, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
|
||||
Err(AdminError::invalid("该服务商不支持额度重置"))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
@@ -184,7 +178,7 @@ pub trait AccountsService: Send + Sync {
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
target: AccountProbeTarget,
|
||||
upstream_model: UpstreamModelId,
|
||||
) -> Result<AccountConnectionTestEventStream, AdminError>;
|
||||
}
|
||||
@@ -197,12 +191,17 @@ pub(crate) struct DefaultAccountsService {
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
reset_credit_locks:
|
||||
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl DefaultAccountsService {
|
||||
#[must_use]
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
|
||||
)]
|
||||
pub(crate) fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
@@ -211,6 +210,7 @@ impl DefaultAccountsService {
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -220,6 +220,7 @@ impl DefaultAccountsService {
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
upstream_info,
|
||||
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +237,84 @@ impl DefaultAccountsService {
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_quotas(
|
||||
&self,
|
||||
items: &[AccountPageItem],
|
||||
rolling_usage: &BTreeMap<String, AccountUsage>,
|
||||
) -> Vec<ProviderQuota> {
|
||||
let providers = &self.providers;
|
||||
let mut groups = BTreeMap::<_, Vec<usize>>::new();
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
groups
|
||||
.entry(&item.account.provider_kind)
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
let batches = groups.into_iter().map(|(kind, indices)| async move {
|
||||
let provider = match providers.require(kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = %kind,
|
||||
error = %error,
|
||||
"account directory provider is not registered; showing empty quota"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let requests = indices
|
||||
.iter()
|
||||
.map(|&index| {
|
||||
let account = &items[index].account;
|
||||
ProviderQuotaReadRequest {
|
||||
account,
|
||||
rolling_usage: rolling_usage.get(&account.id),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = super::peer_link::upstream::read_quotas(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
&requests,
|
||||
)
|
||||
.await;
|
||||
if results.len() != indices.len() {
|
||||
tracing::warn!(
|
||||
provider = %kind,
|
||||
expected = indices.len(),
|
||||
actual = results.len(),
|
||||
"account directory quota batch has invalid length; showing empty quota"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
indices
|
||||
.into_iter()
|
||||
.zip(results)
|
||||
.map(|(index, result)| {
|
||||
let quota = result.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
account_id = %items[index].account.id,
|
||||
error = %error,
|
||||
"account directory quota projection failed; showing empty quota"
|
||||
);
|
||||
empty_quota()
|
||||
});
|
||||
(index, quota)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
// Provider 可独立完成,回填原页顺序并保留未注册或失败账号的位置
|
||||
let mut quotas = vec![empty_quota(); items.len()];
|
||||
for (index, quota) in futures::future::join_all(batches)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
quotas[index] = quota;
|
||||
}
|
||||
quotas
|
||||
}
|
||||
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -249,7 +328,7 @@ impl DefaultAccountsService {
|
||||
.load_account(account_id.as_str(), runtime)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 账号不存在"))
|
||||
.ok_or_else(|| AdminError::not_found("服务商账号不存在"))
|
||||
}
|
||||
|
||||
async fn provider_for_account(
|
||||
@@ -315,32 +394,51 @@ impl DefaultAccountsService {
|
||||
async fn load_api_key_usage(
|
||||
&self,
|
||||
accounts: &[AccountPageItem],
|
||||
quotas: &[ProviderQuota],
|
||||
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
|
||||
let now = Utc::now();
|
||||
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录
|
||||
let mut usage = BTreeMap::new();
|
||||
// 互联账号复用已聚合的最长 Key 窗口,普通 API Key 保留累计口径
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.filter(|item| item.account.authentication_kind == "api_key")
|
||||
.map(|item| AccountUsageWindowQuery {
|
||||
account_id: item.account.id.clone(),
|
||||
key: "account-lifetime".to_owned(),
|
||||
range: TimeRange {
|
||||
start: item.account.created_at,
|
||||
end: now,
|
||||
},
|
||||
.zip(quotas)
|
||||
.filter(|(item, _)| item.account.authentication_kind == "api_key")
|
||||
.filter_map(|(item, quota)| {
|
||||
if quota
|
||||
.upstream
|
||||
.as_ref()
|
||||
.is_some_and(|upstream| upstream.discovered)
|
||||
{
|
||||
if let Some(local_usage) = quota
|
||||
.upstream_usage_window()
|
||||
.and_then(|window| window.local_usage.as_ref())
|
||||
{
|
||||
usage.insert(item.account.id.clone(), local_usage.clone());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Some(AccountUsageWindowQuery {
|
||||
account_id: item.account.id.clone(),
|
||||
key: "account-lifetime".to_owned(),
|
||||
range: TimeRange {
|
||||
start: item.account.created_at,
|
||||
end: now,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if windows.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
return Ok(usage);
|
||||
}
|
||||
Ok(self
|
||||
.accounts
|
||||
.load_account_usage_by_windows(&windows)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "API Key account usage"))?
|
||||
.into_iter()
|
||||
.map(|result| (result.account_id, result.usage))
|
||||
.collect())
|
||||
usage.extend(
|
||||
self.accounts
|
||||
.load_account_usage_by_windows(&windows)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "API Key account usage"))?
|
||||
.into_iter()
|
||||
.map(|result| (result.account_id, result.usage)),
|
||||
);
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
async fn load_directory_item(
|
||||
@@ -362,13 +460,17 @@ impl DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "rolling account usage"))?;
|
||||
let rolling_usage = rolling_usage.into_iter().next();
|
||||
let mut quota = match provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
let mut quota = match super::peer_link::upstream::read_quota(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
account,
|
||||
ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: refresh_quota,
|
||||
rolling_usage: rolling_usage.clone(),
|
||||
})
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(quota) => quota,
|
||||
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作
|
||||
@@ -392,7 +494,7 @@ impl DefaultAccountsService {
|
||||
)
|
||||
.await?;
|
||||
let usage = self
|
||||
.load_api_key_usage(std::slice::from_ref(&stored))
|
||||
.load_api_key_usage(std::slice::from_ref(&stored), std::slice::from_ref("a))
|
||||
.await?
|
||||
.remove(&stored.account.id)
|
||||
.or_else(|| {
|
||||
@@ -401,7 +503,12 @@ impl DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: stored.capacity,
|
||||
protection_rules: stored.protection_rules,
|
||||
capacity: super::peer_link::upstream::account_capacity(
|
||||
&stored.account,
|
||||
stored.capacity,
|
||||
"a,
|
||||
),
|
||||
capabilities: provider
|
||||
.account_capabilities(account_id, &stored.account.authentication_kind),
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
@@ -487,55 +594,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
|
||||
let account = &item.account;
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回
|
||||
let provider = match providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory provider is not registered; showing empty quota"
|
||||
);
|
||||
return Ok(empty_quota());
|
||||
}
|
||||
};
|
||||
match provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: rolling_usage.get(&account.id).cloned(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(quota) => Ok(quota),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory quota projection failed; showing empty quota"
|
||||
);
|
||||
Ok(empty_quota())
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
let mut quotas = self.read_quotas(&page.items, &rolling_usage).await;
|
||||
self.attach_quota_local_usage(&page.items, &mut quotas)
|
||||
.await?;
|
||||
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
|
||||
let mut api_key_usage = self.load_api_key_usage(&page.items, "as).await?;
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(mut item, quota)| {
|
||||
let id = ProviderAccountId::new(item.account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?;
|
||||
let capabilities = providers
|
||||
.require(&item.account.provider_kind)
|
||||
.map(|provider| {
|
||||
@@ -548,11 +617,16 @@ impl AccountsService for DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
protection_rules: item.protection_rules,
|
||||
capacity: crate::model::accounts::AccountCapacity {
|
||||
used_slots: in_flight
|
||||
.as_ref()
|
||||
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
|
||||
..item.capacity
|
||||
..super::peer_link::upstream::account_capacity(
|
||||
&item.account,
|
||||
item.capacity,
|
||||
"a,
|
||||
)
|
||||
},
|
||||
capabilities,
|
||||
plan_type_display: providers.resolve_account_plan(
|
||||
@@ -654,9 +728,12 @@ impl AccountsService for DefaultAccountsService {
|
||||
"provider credential refresh",
|
||||
)
|
||||
.await?;
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
super::notify_account_facts_changed(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
std::slice::from_ref(&result.account_id),
|
||||
)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
let account = self.load_directory_item(&result.account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
@@ -696,9 +773,12 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_store_error(error, "enable provider account"))?
|
||||
.config_revision
|
||||
};
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
super::notify_account_facts_changed(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
std::slice::from_ref(&account_id),
|
||||
)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
let account = self.load_directory_item(&account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
@@ -713,7 +793,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
command: UpdateAccount,
|
||||
) -> Result<AccountUpdateResult, AdminError> {
|
||||
let account_id = ProviderAccountId::new(command.account_id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
@@ -722,33 +802,20 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?;
|
||||
if !enabled {
|
||||
provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
super::notify_account_unavailable(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
&account_id,
|
||||
)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
) -> Result<Option<AccountUpdateResult>, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let result = self
|
||||
.accounts
|
||||
.lower_concurrency_limit(&account_id, limit, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?;
|
||||
if let Some(result) = &result {
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
}
|
||||
super::notify_account_facts_changed(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
std::slice::from_ref(&account_id),
|
||||
)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -762,7 +829,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.iter()
|
||||
.map(|id| {
|
||||
ProviderAccountId::new(id.clone())
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))
|
||||
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut providers = BTreeMap::<
|
||||
@@ -789,10 +856,20 @@ impl AccountsService for DefaultAccountsService {
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
super::notify_account_unavailable(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
provider.account_facts_changed(provider_ids).await;
|
||||
super::notify_account_facts_changed(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
provider_ids,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
@@ -822,14 +899,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountQuotaForecastReport, AdminError> {
|
||||
let (stored, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
let quota = super::peer_link::upstream::read_quota(
|
||||
self.upstream_info.as_deref(),
|
||||
provider.as_ref(),
|
||||
&stored.account,
|
||||
ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
|
||||
let now = Utc::now();
|
||||
let mut samples = Vec::new();
|
||||
let mut selected_keys = BTreeSet::new();
|
||||
@@ -1050,10 +1131,10 @@ impl AccountsService for DefaultAccountsService {
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
target: AccountProbeTarget,
|
||||
upstream_model: UpstreamModelId,
|
||||
) -> Result<AccountConnectionTestEventStream, AdminError> {
|
||||
let (stored, provider) = self.provider_for_account(&account_id).await?;
|
||||
let (stored, provider) = self.provider_for_account(target.account_id()).await?;
|
||||
let account = stored.account;
|
||||
let model = upstream_model.as_str().to_owned();
|
||||
let operation = provider
|
||||
@@ -1076,7 +1157,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let result = probe
|
||||
.probe(
|
||||
AccountProbeRequest {
|
||||
account_id,
|
||||
target,
|
||||
provider_kind: account.provider_kind,
|
||||
upstream_model,
|
||||
operation,
|
||||
@@ -1134,15 +1215,7 @@ fn map_reset_credits_error_after_refresh(
|
||||
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
ProviderQuota::default()
|
||||
}
|
||||
|
||||
fn quota_usage_window(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
|
||||
//! 重置卡查询、消费与自动使用排程,统一账号锁、持久幂等键和重试编排
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::model::{MutationActor, reset_credit_automation::*};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
impl DefaultAccountsService {
|
||||
pub(super) async fn reset_credits_upstream(
|
||||
&self,
|
||||
@@ -51,13 +57,6 @@ impl DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
model::{MutationActor, reset_credit_automation::*},
|
||||
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
impl DefaultAccountsService {
|
||||
async fn automation_settings(
|
||||
&self,
|
||||
@@ -511,3 +510,54 @@ impl DefaultAccountsService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
|
||||
fn next_discovery(
|
||||
now: DateTime<Utc>,
|
||||
timezone: DeploymentTimeZone,
|
||||
random: u64,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let start = timezone.days_after(now, 1)?;
|
||||
let end = timezone.days_after(now, 2)?;
|
||||
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
|
||||
if seconds == 0 {
|
||||
return None;
|
||||
}
|
||||
start.checked_add_signed(chrono::Duration::seconds(
|
||||
i64::try_from(random % seconds).ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
|
||||
fn consume_at(account_id: &str, credit: &PlannedResetCredit, lead_minutes: u32) -> DateTime<Utc> {
|
||||
let lead_seconds = i64::from(lead_minutes) * 60;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(account_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(credit.id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let offset =
|
||||
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
|
||||
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
|
||||
}
|
||||
|
||||
fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option<DateTime<Utc>> {
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
|
||||
.min()
|
||||
}
|
||||
|
||||
fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime<Utc>) {
|
||||
record.next_run_at = if let Some(retry) = record.state.retry_at {
|
||||
retry
|
||||
} else if record.state.pending.is_some() {
|
||||
now
|
||||
} else {
|
||||
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
|
||||
due.min(record.state.next_discovery_at)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
audit::{MutationAuditIntent, MutationAuditOperation},
|
||||
backup::{
|
||||
BackupError, BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupSettings,
|
||||
BackupStatus, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
|
||||
@@ -163,7 +162,7 @@ impl BackupService for DefaultBackupService {
|
||||
ok: false,
|
||||
stage: "putObject",
|
||||
code: Some(code::INVALID_CONFIG),
|
||||
message: "备份存储配置不完整,无法执行连接测试".to_owned(),
|
||||
message: "备份存储配置不完整".to_owned(),
|
||||
});
|
||||
};
|
||||
let result = self
|
||||
@@ -180,8 +179,8 @@ impl BackupService for DefaultBackupService {
|
||||
}
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.s3_connection_tested",
|
||||
"backup_settings",
|
||||
MutationAuditOperation::BackupConnectionTest,
|
||||
"1",
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
@@ -199,14 +198,10 @@ impl BackupService for DefaultBackupService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
if !settings.storage_configured() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储配置不完整,请先保存存储配置",
|
||||
));
|
||||
return Err(AdminError::bad_request("请先补全并保存备份存储配置"));
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储尚未通过连接测试,请先测试存储配置",
|
||||
));
|
||||
return Err(AdminError::bad_request("请先通过备份存储连接测试"));
|
||||
}
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let seed = build_backup_seed(
|
||||
@@ -219,11 +214,9 @@ impl BackupService for DefaultBackupService {
|
||||
.map_err(map_backup_error)?;
|
||||
let record = self
|
||||
.repository
|
||||
.insert_backup_record(seed)
|
||||
.insert_backup_record(seed, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?;
|
||||
self.append_audit(context, "backup.created", "backup", vec![record.id.clone()])
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -244,9 +237,7 @@ impl BackupService for DefaultBackupService {
|
||||
) -> Result<DownloadUrlResult, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if record.status != BackupStatus::Completed {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 的备份才能创建下载地址",
|
||||
));
|
||||
return Err(AdminError::conflict("只能下载已完成的备份"));
|
||||
}
|
||||
let settings = self
|
||||
.repository
|
||||
@@ -264,9 +255,9 @@ impl BackupService for DefaultBackupService {
|
||||
.map_err(map_backup_error)?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.download_url_created",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
MutationAuditOperation::BackupDownloadUrl,
|
||||
backup_id,
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
Ok(DownloadUrlResult {
|
||||
@@ -283,24 +274,15 @@ impl BackupService for DefaultBackupService {
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if !record.status.can_be_deleted() {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 或 failed 的备份才能删除",
|
||||
));
|
||||
return Err(AdminError::conflict("只能删除已完成或失败的备份"));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let deleting = self
|
||||
.repository
|
||||
.transition_to_deleting(backup_id, now)
|
||||
.transition_to_deleting(backup_id, now, Some(context))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.delete_requested",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
.ok_or_else(|| AdminError::conflict("备份状态已变更,请刷新列表"))?;
|
||||
Ok(deleting)
|
||||
}
|
||||
}
|
||||
@@ -317,41 +299,15 @@ impl DefaultBackupService {
|
||||
async fn append_audit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
operation: MutationAuditOperation,
|
||||
entity_ref: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> Result<(), AdminError> {
|
||||
let actor_kind = match context.actor {
|
||||
crate::model::MutationActor::AdminSession { .. } => AuditActorKind::AdminSession,
|
||||
crate::model::MutationActor::AdminApiKey => AuditActorKind::AdminApiKey,
|
||||
crate::model::MutationActor::System => AuditActorKind::System,
|
||||
};
|
||||
let actor_ref = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
crate::model::auth::admin_session_actor_ref(admin_user_id)
|
||||
}
|
||||
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
|
||||
crate::model::MutationActor::System => "system".to_owned(),
|
||||
};
|
||||
let actor_admin_user_id = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
Some(admin_user_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let event = AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: "backup".to_owned(),
|
||||
entity_ref: entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: Utc::now(),
|
||||
};
|
||||
let event = MutationAuditIntent {
|
||||
operation,
|
||||
entity_ref,
|
||||
}
|
||||
.event(context, changed_fields);
|
||||
self.auth
|
||||
.append_audit_event(event)
|
||||
.await
|
||||
@@ -362,17 +318,15 @@ impl DefaultBackupService {
|
||||
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值
|
||||
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
|
||||
validate_endpoint(&command.endpoint)?;
|
||||
validate_nonempty("region", &command.region)?;
|
||||
validate_nonempty("bucket", &command.bucket)?;
|
||||
validate_nonempty("accessKeyId", &command.access_key_id)?;
|
||||
validate_nonempty("存储区域", &command.region)?;
|
||||
validate_nonempty("存储桶名称", &command.bucket)?;
|
||||
validate_nonempty("访问密钥 ID", &command.access_key_id)?;
|
||||
if let Some(secret) = command.secret_access_key.as_ref() {
|
||||
let value = secret.expose_secret();
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request(
|
||||
"secretAccessKey 不能为空字符串;缺省表示保留旧值",
|
||||
));
|
||||
return Err(AdminError::bad_request("访问密钥不能为空"));
|
||||
}
|
||||
validate_length("secretAccessKey", value, 512)?;
|
||||
validate_length("访问密钥", value, 512)?;
|
||||
}
|
||||
crate::model::backup::normalize_prefix(&command.prefix).map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
@@ -380,17 +334,15 @@ fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminErr
|
||||
|
||||
fn validate_endpoint(value: &str) -> Result<(), AdminError> {
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request("endpoint 不能为空"));
|
||||
return Err(AdminError::bad_request("备份存储地址不能为空"));
|
||||
}
|
||||
if !value.starts_with("https://") {
|
||||
return Err(AdminError::bad_request(
|
||||
"endpoint 必须使用 HTTPS;仅测试或显式开发配置允许 HTTP",
|
||||
));
|
||||
return Err(AdminError::bad_request("备份存储地址必须使用 HTTPS"));
|
||||
}
|
||||
if value.contains('@') || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(AdminError::bad_request("endpoint 格式非法"));
|
||||
return Err(AdminError::bad_request("备份存储地址格式不合法"));
|
||||
}
|
||||
validate_length("endpoint", value, 2048)
|
||||
validate_length("备份存储地址", value, 2048)
|
||||
}
|
||||
|
||||
fn validate_nonempty(field: &str, value: &str) -> Result<(), AdminError> {
|
||||
|
||||
@@ -94,7 +94,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.get_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
|
||||
.ok_or_else(|| AdminError::not_found("API Key 不存在"))
|
||||
}
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
|
||||
@@ -149,7 +149,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.reveal_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
|
||||
.ok_or_else(|| AdminError::not_found("API Key 不存在"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
@@ -158,13 +158,14 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
crate::model::client_keys::validate_group_ids(&command.group_ids)
|
||||
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
|
||||
.map_err(|_| AdminError::invalid("API Key 分组不合法"))?;
|
||||
for (provider, profile) in &command.request_profile_overrides {
|
||||
self.providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.and_then(|provider| provider.preview_key_client_profile(profile, None))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
validate_peer_sharing(command.peer_sharing, &command.group_ids)?;
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
let plaintext = if let Some(key) = command.custom_key {
|
||||
@@ -176,6 +177,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
peer_sharing: command.peer_sharing,
|
||||
request_profile_overrides: command.request_profile_overrides,
|
||||
id,
|
||||
name: command.name,
|
||||
@@ -202,15 +204,18 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
crate::model::client_keys::validate_group_ids(&command.group_ids)
|
||||
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
|
||||
.map_err(|_| AdminError::invalid("API Key 分组不合法"))?;
|
||||
for (provider, profile) in &command.request_profile_override_updates {
|
||||
if let Some(profile) = profile {
|
||||
self.providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.and_then(|provider| provider.preview_key_client_profile(profile, None))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
if let Some(sharing) = command.peer_sharing {
|
||||
validate_peer_sharing(sharing, &command.group_ids)?;
|
||||
}
|
||||
let id = command.id.clone();
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
@@ -277,9 +282,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
return Ok(());
|
||||
};
|
||||
if cursor.sort != query.sort {
|
||||
return Err(AdminError::invalid(
|
||||
"Client API Key 游标排序与查询条件不一致",
|
||||
));
|
||||
return Err(AdminError::invalid("分页游标与排序条件不一致"));
|
||||
}
|
||||
let matches = matches!(
|
||||
(cursor.sort.field, &cursor.value),
|
||||
@@ -300,7 +303,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("Client API Key 游标不合法"))
|
||||
Err(AdminError::invalid("API Key 分页游标不合法"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +313,16 @@ pub(super) fn generate_key() -> String {
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
fn validate_peer_sharing(
|
||||
sharing: gateway_core::policy::ClientPeerSharing,
|
||||
groups: &[gateway_core::routing::AccountGroupId],
|
||||
) -> Result<(), AdminError> {
|
||||
sharing.validate(!groups.is_empty()).map_err(|error| {
|
||||
use gateway_core::policy::ClientPeerSharingError;
|
||||
AdminError::invalid(match error {
|
||||
ClientPeerSharingError::MissingGroupScope => "开启 CPR 互联需要指定账号分组",
|
||||
ClientPeerSharingError::PlansWithoutPool => "公开套餐分布需要先公开账号池",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,29 +19,54 @@ use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::S
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
pub(super) async fn publish_credentials_and_observe_quota(
|
||||
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
accounts: &std::sync::Arc<dyn AccountStore>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
provider.account_facts_changed(account_ids).await;
|
||||
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
|
||||
let provider = provider.clone();
|
||||
let peer = peer.cloned();
|
||||
let accounts = accounts.clone();
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
let request = ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
};
|
||||
let result = if let Some(peer) = peer.as_deref() {
|
||||
let account = match accounts.load_account_record(&account_id).await {
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !account.enabled || &account.provider_kind != provider.provider_kind() {
|
||||
continue;
|
||||
}
|
||||
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
|
||||
super::peer_link::upstream::read_quota(
|
||||
Some(peer),
|
||||
provider.as_ref(),
|
||||
&account,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
{
|
||||
} else {
|
||||
provider.quota(request).await
|
||||
};
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
@@ -65,7 +90,7 @@ pub(super) async fn required_credential(
|
||||
.credential_details(provider_kind, account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
.ok_or_else(|| AdminError::not_found("服务商凭据不存在"))
|
||||
}
|
||||
|
||||
pub(super) async fn required_plugin_credential(
|
||||
@@ -77,7 +102,7 @@ pub(super) async fn required_plugin_credential(
|
||||
.credential_details_by_id(account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
.ok_or_else(|| AdminError::not_found("服务商凭据不存在"))
|
||||
}
|
||||
|
||||
pub(super) async fn import_proxy_binding(
|
||||
@@ -156,7 +181,7 @@ pub(super) fn validate_prepared_import(
|
||||
.iter()
|
||||
.any(|credential| credential.provider_kind != *provider_kind)
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
|
||||
return Err(AdminError::conflict("服务商返回的凭据与请求范围不符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,7 +201,7 @@ pub(super) fn validate_prepared_rotation_facts(
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
{
|
||||
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
|
||||
return Err(AdminError::conflict("凭据处理结果与当前凭据不符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -343,6 +368,7 @@ pub(super) async fn commit_credential_refresh(
|
||||
}
|
||||
|
||||
pub(super) async fn delete_credentials(
|
||||
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialDeletion,
|
||||
@@ -365,11 +391,37 @@ pub(super) async fn delete_credentials(
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
for account_id in &account_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
notify_account_unavailable(peer, provider, account_id).await;
|
||||
}
|
||||
provider.account_facts_changed(&account_ids).await;
|
||||
notify_account_facts_changed(peer, provider, &account_ids).await;
|
||||
Ok(CredentialDeletionResult {
|
||||
config_revision: revision,
|
||||
account_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn notify_account_facts_changed(
|
||||
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
|
||||
provider: &dyn ProviderAdmin,
|
||||
ids: &[ProviderAccountId],
|
||||
) {
|
||||
provider.account_facts_changed(ids).await;
|
||||
if let Some(peer) = peer {
|
||||
for id in ids {
|
||||
if let Err(error) = peer.account_changed(id).await {
|
||||
tracing::warn!(account_id = %id, error = %error, "upstream information cache invalidation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn notify_account_unavailable(
|
||||
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
|
||||
provider: &dyn ProviderAdmin,
|
||||
id: &ProviderAccountId,
|
||||
) {
|
||||
if let Some(peer) = peer {
|
||||
peer.forget(id);
|
||||
}
|
||||
provider.account_unavailable(id).await;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct ProviderCredentials {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
}
|
||||
|
||||
impl ProviderCredentials {
|
||||
@@ -39,12 +40,14 @@ impl ProviderCredentials {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
upstream_info,
|
||||
}
|
||||
}
|
||||
pub async fn import_document(
|
||||
@@ -89,7 +92,9 @@ impl ProviderCredentials {
|
||||
.map_err(|error| map_store_error(error, "provider credential import"))?;
|
||||
drop(proxy_reservation);
|
||||
publish_credentials_and_observe_quota(
|
||||
self.upstream_info.as_ref(),
|
||||
&self.provider,
|
||||
&self.accounts,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
@@ -160,11 +165,19 @@ impl ProviderCredentials {
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
self.provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
super::super::notify_account_unavailable(
|
||||
self.upstream_info.as_deref(),
|
||||
self.provider.as_ref(),
|
||||
&account_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
super::super::notify_account_facts_changed(
|
||||
self.upstream_info.as_deref(),
|
||||
self.provider.as_ref(),
|
||||
std::slice::from_ref(&result.account_id),
|
||||
)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -174,6 +187,7 @@ impl ProviderCredentials {
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
let result = delete_credentials(
|
||||
self.upstream_info.as_deref(),
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
@@ -236,7 +250,9 @@ impl super::CredentialsService {
|
||||
let result = outcome.result;
|
||||
if outcome.newly_committed {
|
||||
publish_credentials_and_observe_quota(
|
||||
self.upstream_info.as_ref(),
|
||||
provider,
|
||||
&self.accounts,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct CredentialsService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
}
|
||||
|
||||
impl CredentialsService {
|
||||
@@ -28,12 +29,14 @@ impl CredentialsService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
providers,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
upstream_info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ impl CredentialsService {
|
||||
self.accounts.clone(),
|
||||
self.proxies.clone(),
|
||||
self.snapshot.clone(),
|
||||
self.upstream_info.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,17 @@ pub(super) fn map_provider_error(
|
||||
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
let message = match kind {
|
||||
AdminErrorKind::Invalid => "Provider 请求不合法",
|
||||
AdminErrorKind::NotFound => "Provider 资源不存在",
|
||||
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
|
||||
AdminErrorKind::BadGateway => "上游服务请求失败",
|
||||
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
|
||||
AdminErrorKind::Internal => "服务内部错误",
|
||||
_ => "Provider 操作失败",
|
||||
let message = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid => "服务商请求参数不合法",
|
||||
ProviderAdminErrorKind::Unsupported => "该服务商不支持此操作",
|
||||
ProviderAdminErrorKind::NotFound => "服务商资源不存在",
|
||||
ProviderAdminErrorKind::Conflict => "服务商资源状态冲突,请刷新后重试",
|
||||
ProviderAdminErrorKind::BadGateway => "上游服务请求失败",
|
||||
ProviderAdminErrorKind::Ambiguous => "上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
|
||||
"服务商暂不可用"
|
||||
}
|
||||
ProviderAdminErrorKind::Internal => "服务内部错误",
|
||||
};
|
||||
AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure {
|
||||
operation: resource,
|
||||
|
||||
@@ -9,7 +9,10 @@ use std::{
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId, WorkerKind,
|
||||
WorkerRegistration, WorkerRunnable, WorkerTaskError,
|
||||
};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -204,7 +207,7 @@ impl DefaultImportTasksService {
|
||||
}
|
||||
Err(_) => {
|
||||
item.status = ImportItemStatus::Unknown;
|
||||
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
|
||||
item.message = Some("导入结果未知,重新导入前请核对账号列表".to_owned());
|
||||
}
|
||||
}
|
||||
entry.finish_if_done();
|
||||
@@ -328,6 +331,24 @@ impl ImportTasksService for DefaultImportTasksService {
|
||||
|
||||
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
|
||||
|
||||
impl ImportTaskWorker {
|
||||
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
|
||||
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(self),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
|
||||
Ok(WorkerContribution::Registration(registration))
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
|
||||
@@ -106,7 +106,7 @@ impl DefaultKeyUsageService {
|
||||
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
UsageFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model,
|
||||
model_contains: model,
|
||||
..UsageFilter::default()
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,8 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
self.observations.usage_insights_observation(
|
||||
query.range,
|
||||
filter,
|
||||
Granularity::for_range(query.range)
|
||||
Granularity::for_range(query.range),
|
||||
false,
|
||||
),
|
||||
self.observations.usage_trend(
|
||||
today,
|
||||
|
||||
@@ -10,14 +10,15 @@ pub mod credentials;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod peer_link;
|
||||
pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_resources;
|
||||
pub(crate) mod plugin_update;
|
||||
pub mod plugins;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub(crate) mod system_preflight;
|
||||
pub mod usage_data;
|
||||
|
||||
mod credential_mutation;
|
||||
@@ -26,10 +27,10 @@ mod publication;
|
||||
|
||||
use credential_mutation::{
|
||||
commit_authorization, commit_credential_refresh, commit_credential_rotation,
|
||||
delete_credentials, import_proxy_binding, pending_authorization,
|
||||
publish_credentials_and_observe_quota, required_credential, required_plugin_credential,
|
||||
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
|
||||
validate_prepared_rotation_facts,
|
||||
delete_credentials, import_proxy_binding, notify_account_facts_changed,
|
||||
notify_account_unavailable, pending_authorization, publish_credentials_and_observe_quota,
|
||||
required_credential, required_plugin_credential, validate_authorization_commit,
|
||||
validate_prepared_import, validate_prepared_rotation, validate_prepared_rotation_facts,
|
||||
};
|
||||
use error::{map_provider_error, map_store_error};
|
||||
use publication::publish_committed;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 集中归纳已采集证据的缺口,不从缺失事件推断业务结果
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::model::diagnostics::DiagnosticBundle;
|
||||
|
||||
use super::gap;
|
||||
|
||||
pub(super) fn trace_gaps(bundle: &mut DiagnosticBundle, section: &str, trace: &Value) {
|
||||
let trace_section = format!("{section}.trace");
|
||||
for (field, reason) in [
|
||||
("droppedEvents", "events_evicted"),
|
||||
("droppedEvidenceEvents", "critical_evidence_evicted"),
|
||||
("stageCountOverflow", "stage_coverage_incomplete"),
|
||||
] {
|
||||
if trace[field].as_u64().unwrap_or(0) > 0 {
|
||||
gap(bundle, &trace_section, reason);
|
||||
}
|
||||
}
|
||||
if !trace["stageCounts"].is_object() {
|
||||
gap(bundle, &trace_section, "stage_coverage_not_recorded");
|
||||
}
|
||||
let events = trace["events"].as_array();
|
||||
if !events.is_some_and(|events| {
|
||||
events.iter().any(|event| {
|
||||
event["stage"] == "request.routed" && event["data"]["accountPolicy"].is_object()
|
||||
})
|
||||
}) {
|
||||
gap(bundle, section, "incident_policy_not_recorded");
|
||||
}
|
||||
for (index, event) in events.into_iter().flatten().enumerate() {
|
||||
let event_section = format!("{trace_section}.events[{index}]");
|
||||
let data = &event["data"];
|
||||
for (missing, reason) in [
|
||||
(
|
||||
event["stage"] == "provider.response.parse_gap",
|
||||
"response_parse_failed",
|
||||
),
|
||||
(data["jsonValid"] == false, "json_parse_failed"),
|
||||
(data["truncated"] == true, "event_data_truncated"),
|
||||
(
|
||||
data["input"]["omittedItems"].as_u64().unwrap_or(0) > 0,
|
||||
"input_prefix_sample_limited",
|
||||
),
|
||||
(
|
||||
data["correlation"].as_object().is_some_and(|headers| {
|
||||
headers
|
||||
.values()
|
||||
.any(|header| header["omittedValues"].as_u64().unwrap_or(0) > 0)
|
||||
}),
|
||||
"correlation_header_sample_limited",
|
||||
),
|
||||
] {
|
||||
if missing {
|
||||
gap(bundle, &event_section, reason);
|
||||
}
|
||||
}
|
||||
if event["stage"] == "capture.gap" || data["truncated"] == true {
|
||||
gap(bundle, &trace_section, "capture_gap_or_truncation");
|
||||
}
|
||||
if event["stage"] == "retry.decided" && data["phase"].is_null() {
|
||||
gap(bundle, &trace_section, "legacy_retry_facts_not_recorded");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-32
@@ -7,6 +7,8 @@ use std::{
|
||||
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
mod availability;
|
||||
|
||||
use super::{DefaultObservabilityService, map_store_error};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind,
|
||||
@@ -150,38 +152,7 @@ impl DefaultObservabilityService {
|
||||
&format!("{section}.trace"),
|
||||
"not_recorded_or_retained",
|
||||
),
|
||||
Some(trace) => {
|
||||
if trace["droppedEvents"].as_u64().unwrap_or(0) > 0 {
|
||||
gap(&mut bundle, &format!("{section}.trace"), "events_evicted");
|
||||
}
|
||||
let events = trace["events"].as_array();
|
||||
if !events.is_some_and(|events| {
|
||||
events.iter().any(|event| {
|
||||
event["stage"] == "request.routed"
|
||||
&& event["data"]["accountPolicy"].is_object()
|
||||
})
|
||||
}) {
|
||||
gap(&mut bundle, §ion, "incident_policy_not_recorded");
|
||||
}
|
||||
for event in events.into_iter().flatten() {
|
||||
if event["stage"] == "capture.gap" || event["data"]["truncated"] == true
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"capture_gap_or_truncation",
|
||||
);
|
||||
}
|
||||
if event["stage"] == "retry.decided" && event["data"]["phase"].is_null()
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"legacy_retry_facts_not_recorded",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(trace) => availability::trace_gaps(&mut bundle, §ion, trace),
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
@@ -8,6 +8,7 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
@@ -18,11 +19,15 @@ use crate::{
|
||||
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
|
||||
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
|
||||
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
|
||||
TrendSummary, UsageBilling, UsageBillingProjection, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
|
||||
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance,
|
||||
UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary,
|
||||
RequestHealth, RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind,
|
||||
TrendPoint, TrendSummary, UsageBilling, UsageBillingProjection, UsageCacheEfficiency,
|
||||
UsageCalculatedBillingFact, UsageConcurrencyPoint, UsageCostComparison,
|
||||
UsageCostSummary, UsageDetail, UsageFilter, UsageInsight, UsageInsightData,
|
||||
UsageInsightMetric, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
|
||||
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsLoad,
|
||||
UsageInsightsObservation, UsageInsightsPerformance, UsageInsightsPerformancePoint,
|
||||
UsageMetricPoint, UsageMetricSeries, UsageOverview, UsagePage, UsageQuery,
|
||||
UsageSummary, UsageThroughputPercentiles,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
@@ -64,6 +69,12 @@ pub trait ObservabilityService: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError>;
|
||||
async fn usage_metric(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
metric: UsageInsightMetric,
|
||||
) -> Result<UsageInsight, AdminError>;
|
||||
async fn diagnostics(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
@@ -84,6 +95,7 @@ pub(crate) struct DefaultObservabilityService {
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
@@ -95,6 +107,7 @@ impl DefaultObservabilityService {
|
||||
providers: ProviderAdminRegistry,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
@@ -103,6 +116,7 @@ impl DefaultObservabilityService {
|
||||
settings,
|
||||
providers,
|
||||
system,
|
||||
upstream_info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +133,9 @@ impl DefaultObservabilityService {
|
||||
self.settings.load_runtime_settings(),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
let runtime_slots = observation.runtime_slots;
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
let runtime_slots = observation.runtime_slots.as_ref();
|
||||
self.enrich_list_records(&mut observation.recent_requests)
|
||||
.await;
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -165,11 +180,19 @@ impl DefaultObservabilityService {
|
||||
.providers
|
||||
.dashboard_wire_profiles(&settings.request_profiles);
|
||||
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
|
||||
let (inherited_accounts, overridden_slots) = runtime_slots
|
||||
.as_ref()
|
||||
let (mut inherited_accounts, mut overridden_slots) = runtime_slots
|
||||
.map_or((observation.provider_accounts.normal, 0), |slots| {
|
||||
(slots.inherited_accounts, slots.overridden_slots)
|
||||
});
|
||||
if let (Some(peer), Some(slots)) = (&self.upstream_info, runtime_slots) {
|
||||
let now = std::time::SystemTime::now();
|
||||
for version in &slots.inherited_versions {
|
||||
if let Some(limit) = peer.observed_concurrency_limit(version, now) {
|
||||
inherited_accounts -= 1;
|
||||
overridden_slots = overridden_slots.saturating_add(u64::from(limit.get()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let total_slots = (max_concurrent_per_account > 0 || inherited_accounts == 0).then(|| {
|
||||
inherited_accounts
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
@@ -233,6 +256,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range,
|
||||
UsageFilter::default(),
|
||||
Granularity::for_range(range),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "dashboard trend"))?;
|
||||
@@ -245,7 +269,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_records(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items).await;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -267,17 +291,17 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageSummary, AdminError> {
|
||||
let overview = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_summary(range, filter)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage summary"))?;
|
||||
let average_latency_ms = average(
|
||||
overview.requests.latency_sum_ms,
|
||||
overview.requests.latency_count,
|
||||
observation.requests.latency_sum_ms,
|
||||
observation.requests.latency_count,
|
||||
);
|
||||
Ok(UsageSummary {
|
||||
overview,
|
||||
observation,
|
||||
average_latency_ms,
|
||||
})
|
||||
}
|
||||
@@ -290,21 +314,25 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
let granularity = Granularity::for_range(range);
|
||||
let observation = self
|
||||
.store
|
||||
.usage_insights_observation(range, filter, granularity)
|
||||
.usage_insights_observation(range, filter, granularity, true)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
let mut standard_costs = UsageCostScenarios::default();
|
||||
for point in observation.costs {
|
||||
if let Some(amount) = point.standard_amount {
|
||||
standard_costs.standard.add(point.bucket_start, amount)?;
|
||||
}
|
||||
if let Some(amount) = point.no_cache_amount {
|
||||
standard_costs.no_cache.add(point.bucket_start, amount)?;
|
||||
}
|
||||
}
|
||||
let overview = observation.overview;
|
||||
let trend = observation.trend;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
insights_from_observation(observation)
|
||||
}
|
||||
|
||||
async fn usage_metric(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
metric: UsageInsightMetric,
|
||||
) -> Result<UsageInsight, AdminError> {
|
||||
let observation = self
|
||||
.store
|
||||
.usage_metric_observation(range, filter, Granularity::for_range(range), metric)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage metric"))?;
|
||||
let insights = insights_from_observation(observation)?;
|
||||
Ok(select_usage_metric(insights, metric))
|
||||
}
|
||||
|
||||
async fn diagnostics(
|
||||
@@ -318,17 +346,32 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.usage_diagnostics(range, filter, dimension, 100)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let peers = if dimension == DiagnosticDimension::Account {
|
||||
self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str()))
|
||||
.await
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
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 health = RequestHealth::from_counts(
|
||||
item.success_count,
|
||||
item.failure_count,
|
||||
item.cancelled_count,
|
||||
item.incomplete_count,
|
||||
item.caller_error_count,
|
||||
);
|
||||
let non_completion_count =
|
||||
item.cancelled_count.saturating_add(item.incomplete_count);
|
||||
let non_completion_rate = rate_or_zero(non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
account_upstream_peer: peers.get(&item.key).copied(),
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_authentication_kind: item.account_authentication_kind,
|
||||
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|
||||
|provider| {
|
||||
self.providers
|
||||
@@ -338,8 +381,8 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
error_count: health.non_success_requests(),
|
||||
error_rate: health.error_ratio(),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
@@ -347,7 +390,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
non_completion_count: item.non_completion_count,
|
||||
non_completion_count,
|
||||
non_completion_rate,
|
||||
retry_count: item.retry_count,
|
||||
retry_rate,
|
||||
@@ -381,10 +424,153 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
fn insights_from_observation(
|
||||
observation: UsageInsightsObservation,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let mut standard_costs = UsageCostScenarios::default();
|
||||
for point in observation.costs {
|
||||
if let Some(amount) = point.standard_amount {
|
||||
standard_costs.standard.add(point.bucket_start, amount)?;
|
||||
}
|
||||
if let Some(amount) = point.no_cache_amount {
|
||||
standard_costs.no_cache.add(point.bucket_start, amount)?;
|
||||
}
|
||||
}
|
||||
let overview = observation.overview;
|
||||
let trend = observation.trend;
|
||||
build_usage_insights(overview, trend, standard_costs, observation.concurrency)
|
||||
}
|
||||
|
||||
fn select_usage_metric(insights: UsageInsights, metric: UsageInsightMetric) -> UsageInsight {
|
||||
let activity = insights
|
||||
.health
|
||||
.points
|
||||
.iter()
|
||||
.map(|point| (point.bucket_start, point.total_requests))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let data = match metric {
|
||||
UsageInsightMetric::Health => UsageInsightData::Health(insights.health),
|
||||
UsageInsightMetric::Latency => UsageInsightData::Latency(metric_series(
|
||||
insights.performance.latency_percentiles,
|
||||
insights
|
||||
.performance
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|point| (point.bucket_start, point.latency_percentiles)),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::FirstToken => UsageInsightData::FirstToken(metric_series(
|
||||
insights.performance.first_token_latency_percentiles,
|
||||
insights
|
||||
.performance
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|point| (point.bucket_start, point.first_token_latency_percentiles)),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::Throughput => UsageInsightData::Throughput(metric_series(
|
||||
UsageThroughputPercentiles {
|
||||
p10: insights.performance.output_throughput_p10,
|
||||
p50: insights.performance.output_throughput_p50,
|
||||
p90: insights.performance.output_throughput_p90,
|
||||
},
|
||||
insights.performance.points.into_iter().map(|point| {
|
||||
(
|
||||
point.bucket_start,
|
||||
UsageThroughputPercentiles {
|
||||
p10: point.output_throughput_p10,
|
||||
p50: point.output_throughput_p50,
|
||||
p90: point.output_throughput_p90,
|
||||
},
|
||||
)
|
||||
}),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::Load => UsageInsightData::Load(metric_series(
|
||||
insights.performance.load,
|
||||
insights
|
||||
.performance
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|point| (point.bucket_start, point.load)),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::Cost => UsageInsightData::Cost(metric_series(
|
||||
cost_summary(&insights.cost),
|
||||
insights.cost.points.into_iter().map(|point| {
|
||||
(
|
||||
point.bucket_start,
|
||||
UsageCostComparison {
|
||||
estimated_cost: point.estimated_cost,
|
||||
no_cache_cost: point.no_cache_cost,
|
||||
},
|
||||
)
|
||||
}),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::Savings => UsageInsightData::Savings(metric_series(
|
||||
cost_summary(&insights.cost),
|
||||
insights
|
||||
.cost
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|point| (point.bucket_start, point.cache_savings)),
|
||||
&activity,
|
||||
)),
|
||||
UsageInsightMetric::Cache => UsageInsightData::Cache(metric_series(
|
||||
cost_summary(&insights.cost),
|
||||
insights.cost.points.into_iter().map(|point| {
|
||||
(
|
||||
point.bucket_start,
|
||||
UsageCacheEfficiency {
|
||||
input_tokens: point.input_tokens,
|
||||
cached_token_rate: point.cached_token_rate,
|
||||
cache_hit_request_rate: point.cache_hit_request_rate,
|
||||
},
|
||||
)
|
||||
}),
|
||||
&activity,
|
||||
)),
|
||||
};
|
||||
UsageInsight {
|
||||
analytics: insights.analytics,
|
||||
granularity: insights.granularity,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_summary(cost: &UsageInsightsCost) -> UsageCostSummary {
|
||||
UsageCostSummary {
|
||||
estimated_cost: cost.estimated_cost.clone(),
|
||||
cache_savings: cost.cache_savings.clone(),
|
||||
tier_premium: cost.tier_premium.clone(),
|
||||
cost_per_successful_request: cost.cost_per_successful_request.clone(),
|
||||
coverage: cost.coverage.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn metric_series<S, P>(
|
||||
summary: S,
|
||||
points: impl Iterator<Item = (DateTime<Utc>, P)>,
|
||||
activity: &BTreeMap<DateTime<Utc>, u64>,
|
||||
) -> UsageMetricSeries<S, P> {
|
||||
UsageMetricSeries {
|
||||
summary,
|
||||
points: points
|
||||
.map(|(bucket_start, value)| UsageMetricPoint {
|
||||
bucket_start,
|
||||
request_count: activity.get(&bucket_start).copied().unwrap_or_default(),
|
||||
value,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_usage_insights(
|
||||
overview: UsageOverview,
|
||||
trend: Vec<RequestMetricPoint>,
|
||||
standard_costs: UsageCostScenarios,
|
||||
concurrency: Vec<UsageConcurrencyPoint>,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let granularity = trend.first().map_or(
|
||||
crate::model::observability::Granularity::FifteenMinutes,
|
||||
@@ -395,9 +581,8 @@ fn build_usage_insights(
|
||||
}
|
||||
|
||||
let requests = &overview.requests;
|
||||
let health_window = HealthWindow::from_metrics(requests);
|
||||
let health_window = RequestHealth::from_metrics(requests);
|
||||
let failed_requests = health_window.failed_requests;
|
||||
let health_requests = health_window.eligible_requests();
|
||||
let health = UsageInsightsHealth {
|
||||
total_requests: requests.request_count,
|
||||
success_requests: requests.success_count,
|
||||
@@ -405,7 +590,7 @@ fn build_usage_insights(
|
||||
cancelled_requests: requests.cancelled_count,
|
||||
incomplete_requests: requests.incomplete_count,
|
||||
caller_error_requests: requests.caller_error_count,
|
||||
success_rate: rate_or_zero(requests.success_count, health_requests),
|
||||
success_rate: health_window.success_ratio(),
|
||||
completion_rate: rate_or_zero(
|
||||
requests
|
||||
.success_count
|
||||
@@ -415,7 +600,7 @@ fn build_usage_insights(
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let health_window = HealthWindow::from_metrics(&point.metrics);
|
||||
let health_window = RequestHealth::from_metrics(&point.metrics);
|
||||
UsageInsightsHealthPoint {
|
||||
total_requests: point.metrics.request_count,
|
||||
bucket_start: point.bucket_start,
|
||||
@@ -424,57 +609,74 @@ fn build_usage_insights(
|
||||
cancelled_requests: point.metrics.cancelled_count,
|
||||
incomplete_requests: point.metrics.incomplete_count,
|
||||
caller_error_requests: point.metrics.caller_error_count,
|
||||
error_rate: rate_or_zero(
|
||||
health_window.non_success_requests(),
|
||||
health_window.eligible_requests(),
|
||||
),
|
||||
error_rate: health_window.error_ratio(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let concurrency_by_bucket = concurrency
|
||||
.iter()
|
||||
.map(|point| (point.bucket_start, point))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut weighted_concurrency_micros = 0.0;
|
||||
let mut effective_micros = 0_i64;
|
||||
for (index, point) in concurrency.iter().enumerate() {
|
||||
let start = point.bucket_start.max(overview.range.start);
|
||||
let end = concurrency
|
||||
.get(index + 1)
|
||||
.map_or(overview.range.end, |next| next.bucket_start)
|
||||
.min(overview.range.end);
|
||||
let micros = (end - start).num_microseconds().unwrap_or(0).max(0);
|
||||
effective_micros = effective_micros.saturating_add(micros);
|
||||
weighted_concurrency_micros += point.average_concurrency * micros as f64;
|
||||
}
|
||||
let average_concurrency = if effective_micros > 0 {
|
||||
weighted_concurrency_micros / effective_micros as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let peak_concurrency = concurrency
|
||||
.iter()
|
||||
.map(|point| point.peak_concurrency)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let performance = UsageInsightsPerformance {
|
||||
latency_percentiles: requests.latency_percentiles,
|
||||
first_token_latency_percentiles: requests.first_token_latency_percentiles,
|
||||
latency_coverage: rate_or_zero(requests.latency_count, requests.request_count),
|
||||
first_token_coverage: rate_or_zero(
|
||||
requests.first_token_latency_count,
|
||||
requests.request_count,
|
||||
),
|
||||
admission_decision_percentiles: requests.admission_decision_percentiles,
|
||||
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
|
||||
admission_decision_coverage: rate_or_zero(
|
||||
requests.admission_decision_count,
|
||||
requests.request_count,
|
||||
),
|
||||
account_selection_wait_coverage: rate_or_zero(
|
||||
requests.account_selection_wait_count,
|
||||
requests.request_count,
|
||||
),
|
||||
output_throughput_p10: requests.output_throughput_p10,
|
||||
output_throughput_p50: requests.output_throughput_p50,
|
||||
output_throughput_p90: requests.output_throughput_p90,
|
||||
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
|
||||
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
|
||||
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
|
||||
load: UsageInsightsLoad {
|
||||
average_concurrency,
|
||||
peak_concurrency,
|
||||
admission_decision_p95_ms: requests.admission_decision_percentiles.p95_ms,
|
||||
account_selection_wait_p95_ms: requests.account_selection_wait_percentiles.p95_ms,
|
||||
},
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| UsageInsightsPerformancePoint {
|
||||
bucket_start: point.bucket_start,
|
||||
latency_percentiles: point.metrics.latency_percentiles,
|
||||
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
|
||||
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
|
||||
account_selection_wait_percentiles: point
|
||||
.metrics
|
||||
.account_selection_wait_percentiles,
|
||||
output_throughput_p10: point.metrics.output_throughput_p10,
|
||||
output_throughput_p50: point.metrics.output_throughput_p50,
|
||||
output_throughput_p90: point.metrics.output_throughput_p90,
|
||||
capacity_utilization_avg_basis_points: point
|
||||
.metrics
|
||||
.capacity_utilization_avg_basis_points,
|
||||
capacity_utilization_p95_basis_points: point
|
||||
.metrics
|
||||
.capacity_utilization_p95_basis_points,
|
||||
.map(|point| {
|
||||
let concurrency = concurrency_by_bucket.get(&point.bucket_start);
|
||||
UsageInsightsPerformancePoint {
|
||||
bucket_start: point.bucket_start,
|
||||
latency_percentiles: point.metrics.latency_percentiles,
|
||||
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
|
||||
output_throughput_p10: point.metrics.output_throughput_p10,
|
||||
output_throughput_p50: point.metrics.output_throughput_p50,
|
||||
output_throughput_p90: point.metrics.output_throughput_p90,
|
||||
load: UsageInsightsLoad {
|
||||
average_concurrency: concurrency
|
||||
.map_or(0.0, |point| point.average_concurrency),
|
||||
peak_concurrency: concurrency.map_or(0, |point| point.peak_concurrency),
|
||||
admission_decision_p95_ms: point
|
||||
.metrics
|
||||
.admission_decision_percentiles
|
||||
.p95_ms,
|
||||
account_selection_wait_p95_ms: point
|
||||
.metrics
|
||||
.account_selection_wait_percentiles
|
||||
.p95_ms,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
@@ -730,6 +932,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
async fn current_upstream_peers<'a>(
|
||||
&self,
|
||||
ids: impl Iterator<Item = &'a str>,
|
||||
) -> HashMap<String, bool> {
|
||||
let Some(peer) = self.upstream_info.as_deref() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let ids = ids
|
||||
.map(str::to_owned)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
// 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录
|
||||
futures::stream::iter(ids)
|
||||
.map(|id: String| async move {
|
||||
let account_id = ProviderAccountId::new(id.clone()).ok()?;
|
||||
let upstream = peer.read_summary(&account_id).await.ok()??.upstream?;
|
||||
Some((id, upstream.discovered && upstream.observed_at.is_some()))
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.filter_map(|value| async move { value })
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
|
||||
if accounts.is_empty() {
|
||||
return;
|
||||
@@ -754,6 +979,7 @@ impl DefaultObservabilityService {
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let providers = self.providers.clone();
|
||||
let peer = self.upstream_info.as_deref();
|
||||
let quota_reads = accounts.iter().map(|account| {
|
||||
let rolling_usage = rolling_usage.remove(&account.account_id);
|
||||
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
|
||||
@@ -764,6 +990,11 @@ impl DefaultObservabilityService {
|
||||
let (Some(account_id), Some(provider)) = (account_id, provider) else {
|
||||
return None;
|
||||
};
|
||||
if let Some(peer) = peer
|
||||
&& let Ok(Some(quota)) = peer.read_summary(&account_id).await
|
||||
{
|
||||
return Some(quota);
|
||||
}
|
||||
provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
@@ -781,14 +1012,29 @@ impl DefaultObservabilityService {
|
||||
quota.apply_limit_reached_display();
|
||||
account.quota_used_percent = quota.representative_used_percent();
|
||||
account.quota_window = quota.representative_window().cloned();
|
||||
account.upstream = quota.upstream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
async fn enrich_list_records(
|
||||
&self,
|
||||
records: &mut [crate::model::observability::UsageListRecord],
|
||||
) {
|
||||
let peers = self
|
||||
.current_upstream_peers(
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| record.provider_account_ref.as_deref()),
|
||||
)
|
||||
.await;
|
||||
for record in records {
|
||||
record.provider_account_upstream_peer = record
|
||||
.provider_account_ref
|
||||
.as_ref()
|
||||
.and_then(|id| peers.get(id).copied());
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
@@ -882,7 +1128,7 @@ pub(super) fn health_timeline_at(
|
||||
.map(|index| {
|
||||
(
|
||||
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
|
||||
HealthWindow::default(),
|
||||
RequestHealth::default(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -908,7 +1154,7 @@ pub(super) fn health_timeline_at(
|
||||
let totals = buckets
|
||||
.iter()
|
||||
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
|
||||
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
|
||||
.fold(RequestHealth::default(), |mut totals, (_, bucket)| {
|
||||
totals.add_window(*bucket);
|
||||
totals
|
||||
});
|
||||
@@ -972,11 +1218,9 @@ fn trend(
|
||||
}
|
||||
}
|
||||
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
|
||||
let service_failure_count = service_failure_count(&metrics);
|
||||
let success_rate = ratio(
|
||||
metrics.success_count,
|
||||
HealthWindow::from_metrics(&metrics).eligible_requests(),
|
||||
);
|
||||
let health = RequestHealth::from_metrics(&metrics);
|
||||
let error_count = health.non_success_requests();
|
||||
let success_rate = health.success_ratio();
|
||||
let cache_hit_request_rate = ratio(
|
||||
metrics.cache_hit_request_count,
|
||||
metrics.cache_eligible_request_count,
|
||||
@@ -1005,7 +1249,7 @@ fn trend(
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_input_tokens: metrics.cached_input_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
service_failure_count,
|
||||
error_count,
|
||||
average_latency_ms,
|
||||
max_latency_ms: metrics.max_latency_ms,
|
||||
min_latency_ms: metrics.min_latency_ms,
|
||||
@@ -1024,11 +1268,11 @@ fn trend(
|
||||
}
|
||||
|
||||
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
let service_failure_count = service_failure_count(&point.metrics);
|
||||
let health = RequestHealth::from_metrics(&point.metrics);
|
||||
TrendPoint {
|
||||
bucket_start: point.bucket_start,
|
||||
granularity: point.granularity,
|
||||
service_failure_count,
|
||||
error_count: health.non_success_requests(),
|
||||
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
|
||||
average_first_token_latency_ms: average(
|
||||
point.metrics.first_token_latency_sum_ms,
|
||||
@@ -1042,10 +1286,7 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
point.metrics.cache_hit_request_count,
|
||||
point.metrics.cache_eligible_request_count,
|
||||
),
|
||||
success_rate: ratio(
|
||||
point.metrics.success_count,
|
||||
HealthWindow::from_metrics(&point.metrics).eligible_requests(),
|
||||
),
|
||||
success_rate: health.success_ratio(),
|
||||
metrics: point.metrics,
|
||||
cost_coverage: point.cost_coverage,
|
||||
costs: point.costs,
|
||||
@@ -1129,57 +1370,7 @@ fn rate_or_zero(numerator: u64, denominator: u64) -> f64 {
|
||||
ratio(numerator, denominator).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct HealthWindow {
|
||||
success_requests: u64,
|
||||
failed_requests: u64,
|
||||
cancelled_requests: u64,
|
||||
incomplete_requests: u64,
|
||||
caller_error_requests: u64,
|
||||
}
|
||||
|
||||
impl HealthWindow {
|
||||
fn from_metrics(metrics: &RequestMetrics) -> Self {
|
||||
Self {
|
||||
success_requests: metrics.success_count,
|
||||
failed_requests: service_failure_count(metrics),
|
||||
cancelled_requests: metrics.cancelled_count,
|
||||
incomplete_requests: metrics.incomplete_count,
|
||||
caller_error_requests: metrics.caller_error_count,
|
||||
}
|
||||
}
|
||||
|
||||
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
|
||||
fn non_success_requests(self) -> u64 {
|
||||
self.failed_requests
|
||||
.saturating_add(self.incomplete_requests)
|
||||
}
|
||||
|
||||
fn eligible_requests(self) -> u64 {
|
||||
self.success_requests
|
||||
.saturating_add(self.non_success_requests())
|
||||
}
|
||||
|
||||
fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.add_window(Self::from_metrics(metrics));
|
||||
}
|
||||
|
||||
fn add_window(&mut self, other: Self) {
|
||||
self.success_requests = self.success_requests.saturating_add(other.success_requests);
|
||||
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(other.cancelled_requests);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(other.incomplete_requests);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(other.caller_error_requests);
|
||||
}
|
||||
}
|
||||
|
||||
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
fn health_status(bucket: RequestHealth, is_future: bool) -> HealthStatus {
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
if is_future {
|
||||
HealthStatus::Future
|
||||
@@ -1200,14 +1391,6 @@ fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
(eligible_requests > 0)
|
||||
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
|
||||
}
|
||||
|
||||
fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
metrics
|
||||
.failure_count
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
fn health_reliability(bucket: RequestHealth) -> Option<f64> {
|
||||
bucket.success_rate()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ impl AnalyticsValue for DashboardObservation {
|
||||
&mut self.analytics
|
||||
}
|
||||
}
|
||||
impl AnalyticsValue for UsageOverview {
|
||||
impl AnalyticsValue for UsageSummaryObservation {
|
||||
fn metadata(&mut self) -> &mut AnalyticsMetadata {
|
||||
&mut self.analytics
|
||||
}
|
||||
@@ -177,8 +177,9 @@ type UsageKey = (TimeRange, UsageFilter);
|
||||
pub(crate) struct CachedObservabilityStore {
|
||||
inner: Arc<dyn ObservabilityStore>,
|
||||
dashboard: Cache<DashboardQuery, DashboardObservation>,
|
||||
summaries: Cache<UsageKey, UsageOverview>,
|
||||
insights: Cache<(UsageKey, Granularity), UsageInsightsObservation>,
|
||||
summaries: Cache<UsageKey, UsageSummaryObservation>,
|
||||
insights: Cache<(UsageKey, Granularity, bool), UsageInsightsObservation>,
|
||||
metrics: Cache<(UsageKey, Granularity, UsageInsightMetric), UsageInsightsObservation>,
|
||||
diagnostics: Cache<(UsageKey, DiagnosticDimension, u16), DiagnosticsObservation>,
|
||||
}
|
||||
impl CachedObservabilityStore {
|
||||
@@ -188,6 +189,7 @@ impl CachedObservabilityStore {
|
||||
dashboard: Cache::default(),
|
||||
summaries: Cache::default(),
|
||||
insights: Cache::default(),
|
||||
metrics: Cache::default(),
|
||||
diagnostics: Cache::default(),
|
||||
}
|
||||
}
|
||||
@@ -237,24 +239,49 @@ impl ObservabilityStore for CachedObservabilityStore {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
include_concurrency: bool,
|
||||
) -> AdminStoreResult<UsageInsightsObservation> {
|
||||
self.insights
|
||||
.get(
|
||||
((range, filter.clone()), granularity),
|
||||
((range, filter.clone()), granularity, include_concurrency),
|
||||
self.inner.as_ref(),
|
||||
TTL,
|
||||
|| {
|
||||
self.inner
|
||||
.usage_insights_observation(range, filter, granularity)
|
||||
self.inner.usage_insights_observation(
|
||||
range,
|
||||
filter,
|
||||
granularity,
|
||||
include_concurrency,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn usage_metric_observation(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
granularity: Granularity,
|
||||
metric: UsageInsightMetric,
|
||||
) -> AdminStoreResult<UsageInsightsObservation> {
|
||||
self.metrics
|
||||
.get(
|
||||
((range, filter.clone()), granularity, metric),
|
||||
self.inner.as_ref(),
|
||||
TTL,
|
||||
|| {
|
||||
self.inner
|
||||
.usage_metric_observation(range, filter, granularity, metric)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview> {
|
||||
) -> AdminStoreResult<UsageSummaryObservation> {
|
||||
self.summaries
|
||||
.get((range, filter.clone()), self.inner.as_ref(), TTL, || {
|
||||
self.inner.usage_summary(range, filter)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! CPR 互联只读取认证范围内的本地事实,不参与推理准入或上游刷新
|
||||
|
||||
mod projection;
|
||||
mod rate_limit;
|
||||
pub mod upstream;
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::{engine::execution::AuthenticatedClient, policy::ClientPeerSharing};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
peer_link::{PeerLinkExport, PeerLinkLimits},
|
||||
provider_credentials::{ProviderQuotaReadRequest, explicit_plan_type},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
store::{AccountRuntimeStore, AccountStore, ClientKeyStore},
|
||||
},
|
||||
};
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
const MAX_CONCURRENT_SNAPSHOTS: usize = 8;
|
||||
|
||||
/// 查询失败保持独立于推理错误,不改变账号或 Key 状态
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PeerLinkError {
|
||||
#[error("client API key is unavailable")]
|
||||
Unauthorized,
|
||||
#[error("peer sharing is not allowed")]
|
||||
Forbidden,
|
||||
#[error("peer query rate limit reached")]
|
||||
RateLimited { retry_after: u64 },
|
||||
#[error("peer snapshot exceeds the supported bounds")]
|
||||
SnapshotTooLarge,
|
||||
#[error("peer snapshot is unavailable")]
|
||||
Unavailable(#[from] AdminError),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PeerLinkService: Send + Sync {
|
||||
async fn snapshot(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
limits: PeerLinkLimits,
|
||||
) -> Result<PeerLinkExport, PeerLinkError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultPeerLinkService {
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
rate_limit: rate_limit::QueryRateLimit,
|
||||
snapshot_slots: Semaphore,
|
||||
}
|
||||
|
||||
impl DefaultPeerLinkService {
|
||||
pub(crate) fn new(
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
keys,
|
||||
accounts,
|
||||
runtime,
|
||||
providers,
|
||||
rate_limit: Default::default(),
|
||||
snapshot_slots: Semaphore::new(MAX_CONCURRENT_SNAPSHOTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeerLinkService for DefaultPeerLinkService {
|
||||
async fn snapshot(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
limits: PeerLinkLimits,
|
||||
) -> Result<PeerLinkExport, PeerLinkError> {
|
||||
self.rate_limit.acquire(client.policy().key_id())?;
|
||||
let frozen_sharing = client.policy().peer_sharing();
|
||||
// 当前权限只能收窄冻结范围,重查询在读取 Key 分组前拒绝且不排队
|
||||
let _permit = if frozen_sharing.share_pool || frozen_sharing.share_quota {
|
||||
Some(
|
||||
self.snapshot_slots
|
||||
.try_acquire()
|
||||
.map_err(|_| PeerLinkError::RateLimited { retry_after: 1 })?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let key = self
|
||||
.keys
|
||||
.get_client_key(client.policy().key_id())
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.filter(|key| key.enabled)
|
||||
.ok_or(PeerLinkError::Unauthorized)?;
|
||||
let budget_observed_at = Utc::now();
|
||||
let sharing = intersect_sharing(frozen_sharing, key.peer_sharing);
|
||||
if !sharing.enabled || key.groups.is_empty() {
|
||||
return Err(PeerLinkError::Forbidden);
|
||||
}
|
||||
let now = Utc::now();
|
||||
let mut result = PeerLinkExport {
|
||||
observed_at: now,
|
||||
expires_at: now + TimeDelta::minutes(5),
|
||||
budget_access: key
|
||||
.budget
|
||||
.limits
|
||||
.access(key.budget.daily_used_usd, key.budget.weekly_used_usd),
|
||||
key_limits: key.limits,
|
||||
budget: key.budget,
|
||||
budget_observed_at,
|
||||
pool: None,
|
||||
quota: None,
|
||||
models: Vec::new(),
|
||||
};
|
||||
if !sharing.share_pool && !sharing.share_quota {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let ids = client
|
||||
.policy()
|
||||
.account_scope()
|
||||
.allowed_account_ids()
|
||||
.take(limits.max_accounts.saturating_add(1))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if ids.len() > limits.max_accounts {
|
||||
return Err(PeerLinkError::SnapshotTooLarge);
|
||||
}
|
||||
let public_models = if sharing.share_quota {
|
||||
client
|
||||
.snapshot()
|
||||
.public_models_for_scope(client.policy().account_scope())
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if public_models.len() > limits.max_models {
|
||||
return Err(PeerLinkError::SnapshotTooLarge);
|
||||
}
|
||||
let groups = key
|
||||
.groups
|
||||
.iter()
|
||||
.filter(|group| group.enabled)
|
||||
.map(|group| group.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let accounts = self
|
||||
.accounts
|
||||
.load_accounts_in_enabled_groups(&ids, &groups)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "peer accounts"))?
|
||||
.into_iter()
|
||||
.filter(|account| {
|
||||
self.providers
|
||||
.require(&account.provider_kind)
|
||||
.is_ok_and(|provider| provider.is_direct_account(&account.authentication_kind))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let account_ids = accounts
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let runtime = self
|
||||
.runtime
|
||||
.account_rate_limits(&account_ids)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "peer account runtime"))?;
|
||||
let mut quotas = BTreeMap::new();
|
||||
if sharing.share_quota || sharing.share_plans {
|
||||
let mut window_count = 0usize;
|
||||
let mut by_provider = BTreeMap::new();
|
||||
for account in &accounts {
|
||||
// 仅公开套餐时只补读缺失套餐的本地额度,不回源刷新或扩大公开范围
|
||||
if !sharing.share_quota
|
||||
&& explicit_plan_type(account.plan_type.as_deref()).is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
by_provider
|
||||
.entry(&account.provider_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(account);
|
||||
}
|
||||
for (kind, accounts) in by_provider {
|
||||
let Ok(provider) = self.providers.require(kind) else {
|
||||
continue;
|
||||
};
|
||||
let requests = accounts
|
||||
.iter()
|
||||
.map(|account| ProviderQuotaReadRequest {
|
||||
account,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (account, quota) in accounts
|
||||
.into_iter()
|
||||
.zip(provider.read_quotas(&requests).await)
|
||||
{
|
||||
if let Ok(quota) = quota {
|
||||
if sharing.share_quota {
|
||||
window_count = window_count.saturating_add(quota.windows.len());
|
||||
if window_count > limits.max_quota_windows {
|
||||
return Err(PeerLinkError::SnapshotTooLarge);
|
||||
}
|
||||
}
|
||||
quotas.insert(account.id.clone(), quota);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now();
|
||||
result.observed_at = now;
|
||||
result.expires_at = now + TimeDelta::minutes(5);
|
||||
if sharing.share_pool {
|
||||
result.pool = Some(projection::pool_summary(
|
||||
&accounts,
|
||||
"as,
|
||||
&runtime,
|
||||
&self.providers,
|
||||
sharing.share_plans,
|
||||
now,
|
||||
));
|
||||
}
|
||||
if sharing.share_quota {
|
||||
let (quota, mut models) = projection::quota_and_models(
|
||||
client,
|
||||
&accounts,
|
||||
"as,
|
||||
&self.providers,
|
||||
&runtime,
|
||||
&public_models,
|
||||
now,
|
||||
limits.max_response_bytes,
|
||||
)
|
||||
.await?;
|
||||
// 发布窗口内缺失或已变更的账号不能被静默剔除后推导整个模型池耗尽
|
||||
if accounts.len() != ids.len() {
|
||||
for model in &mut models {
|
||||
model.complete = false;
|
||||
if model.state == crate::model::peer_link::PeerModelState::Exhausted {
|
||||
model.state = crate::model::peer_link::PeerModelState::Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.quota = Some(quota);
|
||||
result.models = models;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_sharing(frozen: ClientPeerSharing, current: ClientPeerSharing) -> ClientPeerSharing {
|
||||
ClientPeerSharing {
|
||||
enabled: frozen.enabled && current.enabled,
|
||||
share_pool: frozen.share_pool && current.share_pool,
|
||||
share_quota: frozen.share_quota && current.share_quota,
|
||||
share_plans: frozen.share_plans && current.share_plans,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user