mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 16:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431992a7a4 | ||
|
|
d3143406dc | ||
|
|
478f2d1f21 | ||
|
|
3a0552df73 | ||
|
|
c72136c689 | ||
|
|
a2972d6455 | ||
|
|
b5365b5c38 | ||
|
|
a4e60c3d57 | ||
|
|
e3befd862a | ||
|
|
2fc347508a | ||
|
|
e079ba6e4b | ||
|
|
13edec0b07 | ||
|
|
725de6ead6 | ||
|
|
758bd24b32 | ||
|
|
114baf669b | ||
|
|
628533216a | ||
|
|
dd65665128 | ||
|
|
63db306ba2 | ||
|
|
de58b436b1 | ||
|
|
bec4c2d2a6 | ||
|
|
092789f5ff | ||
|
|
c6350fd234 | ||
|
|
620db1b877 | ||
|
|
4bf1208024 | ||
|
|
cf7a9bd501 | ||
|
|
c622f08574 | ||
|
|
5741683ea1 | ||
|
|
16b3671733 | ||
|
|
a708301cb4 | ||
|
|
65fd34991b | ||
|
|
03186b5411 | ||
|
|
47ce36f4bf | ||
|
|
462cef5dd5 | ||
|
|
5647dc8dcb | ||
|
|
0d44492309 | ||
|
|
31f471b051 | ||
|
|
e7ef31d882 | ||
|
|
197e9e517a | ||
|
|
e770c94a9e | ||
|
|
ce620f1b53 | ||
|
|
290aecb7a4 | ||
|
|
1a2a4a56ae | ||
|
|
6dd0da1fbd | ||
|
|
402bc37fbb | ||
|
|
8b9aacd8af | ||
|
|
d9f9eca7e8 | ||
|
|
ff1ea92155 | ||
|
|
f772bda57b | ||
|
|
e0a3dc20dd | ||
|
|
39c8a6352d | ||
|
|
db779a4a21 | ||
|
|
750dd32200 | ||
|
|
6230d95729 | ||
|
|
11b64b3026 | ||
|
|
ca5901ba56 | ||
|
|
81de0f5331 | ||
|
|
69c1ee15bb | ||
|
|
ba87cfde6a | ||
|
|
d0a588fbc3 | ||
|
|
7cee430947 | ||
|
|
ac98a764ff | ||
|
|
56ef1bffa2 | ||
|
|
e275a6dada | ||
|
|
555269739c | ||
|
|
d03d8408fc | ||
|
|
228eea90c1 | ||
|
|
0d25061ef0 | ||
|
|
e97b1faf0d | ||
|
|
90ecac978a | ||
|
|
4326eb1e4b | ||
|
|
2a8d5d8643 | ||
|
|
b7fa5872a3 | ||
|
|
e907f419ab | ||
|
|
ee7e837d1f | ||
|
|
1240f22580 | ||
|
|
e390417821 | ||
|
|
e3f1299a0c | ||
|
|
4e4cd96478 | ||
|
|
6de1beb1df | ||
|
|
ba3baddbda | ||
|
|
09b22a9550 | ||
|
|
04a48e848d | ||
|
|
1655ecc972 | ||
|
|
4527db7b5d | ||
|
|
2b7a64dae6 |
@@ -20,7 +20,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
|
||||
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
|
||||
| --- | --- |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),局部修改同样按影响范围检查组件复用、文案、Token、样式、视觉状态和运行证据 |
|
||||
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
@@ -34,7 +34,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
|
||||
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
|
||||
|
||||
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
|
||||
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
@@ -45,8 +45,9 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
|
||||
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
|
||||
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行
|
||||
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
6. 按授权完成提交与交付;提交前按[提交约定](../../../CONTRIBUTING.md#项目约定)准备提交信息与实际共同作者署名,提交后核对实际提交消息,PR 的 AI 披露不能替代提交署名
|
||||
|
||||
## 测试维护
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 领域必读
|
||||
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节,局部修改同样读取。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
|
||||
## 按改动补齐规范
|
||||
|
||||
@@ -21,9 +21,15 @@
|
||||
|
||||
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
|
||||
|
||||
## 验收范围与收尾
|
||||
|
||||
验证前按[界面验证](../../../../CONTRIBUTING.md#界面验证)明确本次受影响的用户路径、状态和通过条件。局部修改围绕受影响区域及相关操作验收;主题、窗口尺寸和空/加载/错误状态仅在受影响时补充,共用主题或基础组件变更按主题规范扩大范围
|
||||
|
||||
通过后按[验证停止条件](../../../../CONTRIBUTING.md#验证)收尾;只有新改动、失败或未解疑点时补验相关部分。补交截图或 PR 材料时复用仍有效的运行证据
|
||||
|
||||
## 交付前
|
||||
|
||||
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
|
||||
对照最终差异和实际页面核对适用项,不以读取过规范代替检查:
|
||||
|
||||
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
|
||||
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
|
||||
|
||||
@@ -19,7 +19,8 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 同版本失败先区分[续跑原任务与修复源码后重发](references/operations.md#恢复与验收):前者沿用原提交;后者仅在版本尚未公开时清理失败的版本提交,再生成唯一的版本提交。已发布的 tag 和产物保持不变
|
||||
- 备版只更新构建版本;发布后回读 `main:release/version.yaml` 的 `published`,核对已公开的通道候选
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
|
||||
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
|
||||
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
|
||||
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
|
||||
发布任务应将该基线的最高公开实验版写入`main:release/version.yaml` 的 `published[大版本].exp[基线]`,不推进普通通道。
|
||||
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
|
||||
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
|
||||
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore: bump version to <version>`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
@@ -38,13 +38,29 @@ release/publish "$release_version"
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
|
||||
先核对本地与远端的分支头、版本提交、tag,以及对应的工作流、GitHub Release 和版本镜像。超时或任务失败不等于版本未公开;即使 Release 还是草稿,版本镜像也可能已经推送
|
||||
|
||||
### 续跑原任务
|
||||
|
||||
源码无需修改时保留版本提交和 tag,核对两者指向后从断点继续:
|
||||
|
||||
- 尚未推送:补齐缺失的步骤,按入口原子推送分支和 tag
|
||||
- 已推送但未触发:使用入口提示的 dispatch 命令
|
||||
- 已有 run:跟踪对应提交的任务,失败时重跑失败任务。构建产物保留 7 天,过期后从原 tag 重跑完整工作流
|
||||
|
||||
### 修复源码后同版本重发
|
||||
|
||||
1. 确认旧工作流已停止、该版本没有公开 Release 或版本镜像,且待清理提交不在其他已发布 tag 的历史中。已有公开产物时保留原提交和 tag,代码修复另发新版本;发布状态不明时先查清
|
||||
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过已核实的失败版本提交。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
|
||||
3. 只清理本次失败的 tag 和草稿。涉及远端改写时,先核对授权,以事先记录的分支头和 tag 对象 SHA 作显式租约,原子更新分支并删除未发行 tag。租约不匹配就停止,不合并旧线
|
||||
4. 修复验证通过后执行 `release/publish --check <version>`,再按[执行发版](#执行发版)重发。核对上一发行至新 tag 的历史中只有一个本版本的发行提交,且与工作流构建的提交一致
|
||||
|
||||
### 验收
|
||||
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): update release channels to <version>` 提交;重复执行或较旧版本未推进任何通道时无提交
|
||||
- 通道表写入失败时,确认该版本已公开、工作流能写入 `main`,再按已有授权执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务重新校验公开附件、读取并合并当前清单,不重新打包或改 tag
|
||||
- v3.42.0 在线升级:首个包含新更新器的 3.x 正式版公开并通过校验后,从该次通道提交取出 `release/version.yaml`,将顶层 `version` 设为该正式版,作为同名附件上传到该 Release。回读附件,确认 3.x 稳定通道指向该版本且安装包可用,再手动将已有 `update-index` 正文改为 `{"current":"<该正式版>"}`。此后保留该页与附件,无需再编辑
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 基线与版本
|
||||
|
||||
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分顶层构建版本与 `main` 上的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
|
||||
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
|
||||
|
||||
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
|
||||
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
|
||||
- 常规版本按「新增功能」「优化改进」「问题修复」「升级须知」归类,省略空章节;单一改动可直接写一句。安装命令和文档入口由工作流附加
|
||||
- 每条用一句短语讲清一个用户可感知的结果,不加句号,例如「系统更新:确认当前平台有安装包后才提示升级」
|
||||
- 同一变化只写一次,保留会影响使用或升级的默认值和限制,不展开实现过程、逐条翻译提交或写未经验证的承诺
|
||||
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
@@ -26,6 +28,8 @@
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore: bump version to <version>`;查历史时也匹配 `chore(release): prepare v<version>` 和 `chore(release): v<version>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
|
||||
|
||||
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
|
||||
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require 'digest'
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
|
||||
module ReleasePackage
|
||||
APP_NAME = 'codex-proxy-rs'
|
||||
APP_NAME = ReleaseMetadata::APP_NAME
|
||||
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
|
||||
|
||||
def self.install_manifest(directory)
|
||||
@@ -34,7 +34,6 @@ module ReleasePackage
|
||||
default_image = "${CPR_IMAGE:-#{image}:latest}"
|
||||
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
|
||||
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
|
||||
FileUtils.cp(MANIFEST, release_dir)
|
||||
|
||||
platforms.each do |platform|
|
||||
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
|
||||
@@ -47,7 +46,7 @@ module ReleasePackage
|
||||
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
|
||||
FileUtils.cp(config, "#{root}/deploy")
|
||||
install_manifest("#{root}/plugins/official")
|
||||
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
|
||||
archive = File.join(release_dir, ReleaseMetadata.archive_name(ENV.fetch('VERSION'), platform))
|
||||
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'base64'
|
||||
require 'uri'
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 各发行线共用 main 通道表;只合并本次已核实的版本
|
||||
module PublishedChannels
|
||||
PATH = 'release/version.yaml'
|
||||
LIMIT = 128 * 1024
|
||||
|
||||
def self.advance(document, version)
|
||||
version = ReleaseMetadata.version(version)
|
||||
major, = version.split('.', 2)
|
||||
core, stage = version.split('+', 2).first.split('-', 2)
|
||||
channels = (document.fetch('published')[major] ||= {})
|
||||
if stage&.start_with?('exp.')
|
||||
channels = (channels['exp'] ||= {})
|
||||
keys = [core]
|
||||
else
|
||||
stages = %w[stable rc beta alpha]
|
||||
keys = stages.drop(stages.index(stage ? stage.split('.').first : 'stable'))
|
||||
end
|
||||
incoming = ReleaseMetadata.sort_key(version)
|
||||
changed = false
|
||||
keys.each do |key|
|
||||
current = channels[key]
|
||||
if current
|
||||
comparison = incoming <=> ReleaseMetadata.sort_key(current)
|
||||
raise "Conflicting release identities: #{current}, #{version}" if comparison.zero? && current != version
|
||||
next unless comparison.positive?
|
||||
end
|
||||
channels[key] = version
|
||||
changed = true
|
||||
end
|
||||
changed
|
||||
end
|
||||
|
||||
class Publisher
|
||||
def api(endpoint, method: 'GET', data: nil, optional: false)
|
||||
args = ['gh', 'api', endpoint, '--method', method]
|
||||
args.concat(%w[--input -]) if data
|
||||
out, error, status = Open3.capture3(*args, stdin_data: data ? JSON.generate(data) : '')
|
||||
return nil if optional && !status.success? && error.include?('(HTTP 404)')
|
||||
raise "Cannot access #{endpoint}: #{error.strip}" unless status.success?
|
||||
JSON.parse(out)
|
||||
end
|
||||
|
||||
def initialize(repository)
|
||||
@base = "repos/#{repository}"
|
||||
end
|
||||
|
||||
def find_release(version)
|
||||
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
|
||||
end
|
||||
|
||||
def verify_assets(version)
|
||||
release = find_release(version)
|
||||
tag = "v#{version}"
|
||||
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
|
||||
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
|
||||
data = api("#{@base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}")
|
||||
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
|
||||
raise "Missing platforms for #{tag}" if platforms.empty?
|
||||
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
|
||||
expected.concat(%w[checksums.txt compose.yaml config.example.yaml])
|
||||
uploaded = release.fetch('assets').filter_map do |asset|
|
||||
asset.fetch('name') if asset.fetch('state') == 'uploaded' && asset.fetch('size').positive?
|
||||
end
|
||||
missing = expected - uploaded
|
||||
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
|
||||
end
|
||||
|
||||
def check_unpublished(version)
|
||||
existing = find_release(version)
|
||||
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
|
||||
end
|
||||
|
||||
def publish(version)
|
||||
verify_assets(version)
|
||||
# 读取 main 的当前文件,实验分支和较晚结束的旧构建都不能覆盖其他通道或构建版本
|
||||
endpoint = "#{@base}/contents/#{PATH}"
|
||||
file = api("#{endpoint}?ref=main")
|
||||
document = YAML.safe_load(Base64.decode64(file.fetch('content')))
|
||||
return unless PublishedChannels.advance(document, version)
|
||||
|
||||
content = YAML.dump(document).delete_prefix("---\n")
|
||||
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
|
||||
# 按读取的文件 SHA 写入;并发备版改过同一文件时交给恢复任务重新合并
|
||||
api(endpoint, method: 'PUT', data: {
|
||||
branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content),
|
||||
message: "chore(release): update release channels to #{version}"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
mode, raw = ARGV
|
||||
version = ReleaseMetadata.version(raw || '')
|
||||
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
|
||||
case mode
|
||||
when 'check' then publisher.check_unpublished(version)
|
||||
when 'publish' then publisher.publish(version)
|
||||
else abort 'usage: published-channels.rb check|publish VERSION'
|
||||
end
|
||||
end
|
||||
@@ -1,193 +0,0 @@
|
||||
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'
|
||||
@@ -62,6 +62,10 @@ module ReleaseMetadata
|
||||
entries
|
||||
end
|
||||
|
||||
def self.archive_name(version, platform)
|
||||
"#{APP_NAME}_#{version}_#{platform.fetch('os')}_#{platform.fetch('arch')}.#{platform.fetch('archive')}"
|
||||
end
|
||||
|
||||
def self.outputs(tag)
|
||||
value = version(YAML.load_file('release/version.yaml').fetch('version'))
|
||||
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -259,6 +259,3 @@ 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
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
description: 'Tag to release, for example v0.1.0'
|
||||
required: true
|
||||
type: string
|
||||
index_only:
|
||||
description: 'Recover the index for an already public release without rebuilding'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,6 +25,7 @@ env:
|
||||
|
||||
jobs:
|
||||
release-info:
|
||||
if: ${{ !inputs.index_only }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -110,7 +115,7 @@ jobs:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -239,8 +244,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -274,11 +277,6 @@ jobs:
|
||||
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
|
||||
run: ruby .github/scripts/package-release.rb archives
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload release assets
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -342,7 +340,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -436,10 +434,10 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -510,24 +508,12 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
## 📥 安装
|
||||
## Linux 一键安装(Docker Compose)
|
||||
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
curl -fsSLO https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.release-info.outputs.tag }}/deploy/install.sh && CPR_RELEASE_TAG=${{ needs.release-info.outputs.tag }} bash install.sh
|
||||
```
|
||||
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
|
||||
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
|
||||
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
@@ -540,9 +526,20 @@ jobs:
|
||||
File.write(output, "#{notes}\n\n#{installation}\n")
|
||||
RUBY
|
||||
|
||||
- name: Check that the release is unpublished
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb check "$VERSION"
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload GitHub Release assets
|
||||
id: release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
@@ -563,3 +560,25 @@ jobs:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
run: ruby .github/scripts/publish-release.rb
|
||||
|
||||
- name: Publish verified channels to main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb publish "$VERSION"
|
||||
|
||||
recover-index:
|
||||
if: inputs.index_only
|
||||
runs-on: ubuntu-26.04
|
||||
concurrency:
|
||||
group: release-publication
|
||||
queue: max
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Verify public assets and publish channels to main
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: ruby .github/scripts/published-channels.rb publish "$TAG"
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
/.runtime/
|
||||
*.log
|
||||
|
||||
# Local planning documents
|
||||
/docs/plans/
|
||||
|
||||
# Local configuration and secrets
|
||||
/deploy/config.yaml
|
||||
.env
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
@@ -23,6 +23,13 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
|
||||
### 任务延续与收尾
|
||||
|
||||
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
|
||||
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
|
||||
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
|
||||
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
|
||||
+2
-1
@@ -126,7 +126,8 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并
|
||||
- AI 参与的提交按实际助手添加 `Co-authored-by: 名称 <邮箱>`,名称与邮箱采用该助手已知的署名身份,并保留其他实际共同作者署名;PR 的 AI 使用披露不替代提交署名
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API 和 Chat Completions API;Chat Completions 请求通过 Responses 链路执行,支持范围见 [API 参考](docs/api.md#chat-completions-请求)
|
||||
> 生成请求支持 [OpenAI Responses](docs/api.md#responses-请求与传输)、[Chat Completions](docs/api.md#chat-completions-请求) 和 [Anthropic Messages](docs/api.md#messages-请求) 三种协议
|
||||
|
||||
## 快速预览
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
|
||||
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
@@ -99,12 +100,12 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses / Chat Completions API 客户端**:填写以下信息:
|
||||
**其他 API 客户端**:API Key 填写管理端创建的客户端密钥。按客户端自动追加的请求路径填写 Base URL;远程接入时将本机地址换成服务器的 HTTPS 地址
|
||||
|
||||
| 配置 | 值 |
|
||||
| 客户端自动追加的路径 | Base URL(本机部署) |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
| OpenAI Responses:`/responses`;Chat Completions:`/chat/completions` | `http://127.0.0.1:8080/v1` |
|
||||
| Anthropic Messages:`/v1/messages` | `http://127.0.0.1:8080` |
|
||||
|
||||
查询该密钥可见的模型目录:
|
||||
|
||||
|
||||
Generated
+86
-31
@@ -102,6 +102,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -147,9 +153,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.51"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -716,6 +722,17 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -942,9 +959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.46"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -966,7 +983,7 @@ dependencies = [
|
||||
"pathdiff",
|
||||
"serde_core",
|
||||
"winnow 1.0.3",
|
||||
"yaml-rust2",
|
||||
"yaml-rust2 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1126,9 +1143,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.6"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -1186,9 +1203,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.5"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -1773,9 +1790,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
"event-listener",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"gateway-protocol",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"semver",
|
||||
@@ -1834,6 +1853,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
"yaml-rust2 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2064,6 +2084,15 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.2"
|
||||
@@ -2576,9 +2605,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
@@ -2729,9 +2758,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",
|
||||
@@ -2935,15 +2964,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -2982,9 +3010,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3333,6 +3361,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"lru",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
@@ -3351,6 +3380,7 @@ dependencies = [
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
@@ -4217,7 +4247,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -4229,7 +4259,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4244,7 +4274,7 @@ dependencies = [
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"hashlink 0.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"memchr",
|
||||
@@ -4266,7 +4296,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4278,7 +4308,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -4303,7 +4333,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
@@ -4330,7 +4360,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -4366,7 +4396,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -4593,6 +4623,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken-rs"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2aeff724640cfe13037336ddf35befdffd2909cbdb65cf041cc8a4cf8c584cfa"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"bstr",
|
||||
"fancy-regex",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
@@ -5518,13 +5562,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e"
|
||||
checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"encoding_rs",
|
||||
"hashlink",
|
||||
"hashlink 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"encoding_rs",
|
||||
"hashlink 0.12.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5646,9 +5701,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.2.2"
|
||||
version = "9.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
|
||||
checksum = "3cec733c9820347a592f6375c3c0089b479ba0a0b1cbf3dd1958b02959b7c033"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+2
-2
@@ -61,5 +61,5 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
# 固定包含 BEGIN 取消回滚及 SQLite 安全版本支持的上游提交;见 transact-rs/sqlx#4394、#4502
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c" }
|
||||
|
||||
@@ -7,6 +7,7 @@ use gateway_admin::{
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
|
||||
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
|
||||
proxy::PluginProxyAccess,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
|
||||
@@ -35,6 +36,7 @@ pub(super) struct ManagementPorts {
|
||||
pub(super) accounts: Arc<dyn PluginAccountAccess>,
|
||||
keys: Arc<dyn PluginClientKeyAccess>,
|
||||
resources: Arc<dyn PluginResourceAccess>,
|
||||
proxies: Arc<dyn PluginProxyAccess>,
|
||||
}
|
||||
|
||||
impl ManagementPorts {
|
||||
@@ -46,6 +48,7 @@ impl ManagementPorts {
|
||||
) -> Self {
|
||||
let ports = store.admin_ports();
|
||||
Self {
|
||||
proxies: gateway_admin::initialize_plugin_proxies(ports.proxies()),
|
||||
accounts: gateway_admin::initialize_plugin_accounts(
|
||||
providers.clone(),
|
||||
ports.accounts(),
|
||||
@@ -66,6 +69,7 @@ impl ManagementPorts {
|
||||
|
||||
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
|
||||
runtime.bind_account_ports(&self.accounts)?;
|
||||
runtime.bind_proxy_ports(&self.proxies)?;
|
||||
runtime.bind_client_key_ports(&self.keys)?;
|
||||
runtime.bind_resource_ports(&self.resources)
|
||||
}
|
||||
|
||||
@@ -128,9 +128,11 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let peer_node = or_shutdown!(plugin_runtime, store.peer_routing_id().await);
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
peer_node,
|
||||
config.api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
|
||||
@@ -509,6 +509,7 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("gateway-api", "gateway-admin"),
|
||||
("gateway-api", "gateway-core"),
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-core", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-host", "gateway-protocol"),
|
||||
|
||||
@@ -264,6 +264,14 @@ pub fn initialize_plugin_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建代理目录与当前出口解析的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_proxies(
|
||||
store: Arc<dyn ports::proxy::ProxyStore>,
|
||||
) -> Arc<dyn ports::proxy::PluginProxyAccess> {
|
||||
Arc::new(use_case::proxies::DefaultPluginProxyAccess { store })
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
|
||||
@@ -42,8 +42,8 @@ pub use use_case::{
|
||||
|
||||
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,
|
||||
initialize_plugin_proxies, initialize_plugin_resources, initialize_settings,
|
||||
initialize_usage_data, initialize_with_plugin_accounts,
|
||||
};
|
||||
|
||||
use ports::{
|
||||
|
||||
@@ -21,6 +21,8 @@ pub use gateway_core::account::{
|
||||
/// 账号可用的管理操作
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderAccountCapabilities {
|
||||
pub quota_limits_supported: bool,
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
pub profile: bool,
|
||||
@@ -110,6 +112,9 @@ pub enum AccountGroupFilter {
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub quota_paused: bool,
|
||||
pub quota_limits: gateway_core::account::AccountQuotaLimits,
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
@@ -249,6 +254,8 @@ pub struct AccountUpdateResult {
|
||||
/// 仅修改显式字段的批量账号设置命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
@@ -258,6 +265,16 @@ pub struct BatchUpdateAccounts {
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 已按拟提交策略重算的账号暂停结果,以快照版本防止旧判断覆盖新观测
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedQuotaPause {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub revision: Revision,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
pub type PreparedQuotaPauses = std::collections::BTreeMap<String, PreparedQuotaPause>;
|
||||
|
||||
/// 批量账号更新结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
@@ -290,6 +307,7 @@ pub enum AccountConnectionTestEvent {
|
||||
Failed {
|
||||
source: AccountProbeErrorSource,
|
||||
gateway_error_code: GatewayErrorKind,
|
||||
proxy_hint: Option<gateway_core::error::ProxyConnectionHint>,
|
||||
send_state: Option<UpstreamSendState>,
|
||||
message: String,
|
||||
provider_error_code: Option<String>,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum MutationAuditOperation {
|
||||
AccountGroupEnabled { enabled: bool },
|
||||
AccountGroupDelete,
|
||||
ClientApiKeyResetBudget,
|
||||
ClientApiKeySetBudgetWindow,
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
@@ -76,6 +77,7 @@ impl MutationAuditOperation {
|
||||
}
|
||||
Self::AccountGroupDelete => ("delete", "account_group"),
|
||||
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
|
||||
Self::ClientApiKeySetBudgetWindow => ("set_budget_window", "client_api_key"),
|
||||
Self::ProviderAccountUpdate => ("update", "provider_account"),
|
||||
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
|
||||
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
|
||||
|
||||
@@ -233,13 +233,56 @@ pub enum ClientKeyBudgetPeriod {
|
||||
All,
|
||||
}
|
||||
|
||||
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用
|
||||
/// 清零所选窗口并恢复日历规则,保留限额与历史费用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResetClientKeyBudget {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 显式窗口复用已有日/周金额上限,外部周期的选择归调用方
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeyBudgetWindowPeriod {
|
||||
Daily,
|
||||
Weekly,
|
||||
}
|
||||
|
||||
impl ClientKeyBudgetWindowPeriod {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daily => "daily",
|
||||
Self::Weekly => "weekly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按请求完成时间归属的左闭右开预算窗口
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExplicitClientKeyBudgetWindow {
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 窗口配置版本独立于费用累计与全局配置版本
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyBudgetWindow {
|
||||
pub revision: u64,
|
||||
pub explicit: bool,
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub ends_at: Option<DateTime<Utc>>,
|
||||
pub used_usd: gateway_core::metering::Decimal,
|
||||
}
|
||||
|
||||
/// 当前窗口版本标识一次操作,同版本同参数可重试;None 释放显式控制
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyBudgetWindow {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetWindowPeriod,
|
||||
pub expected_revision: u64,
|
||||
pub window: Option<ExplicitClientKeyBudgetWindow>,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
|
||||
@@ -690,11 +690,12 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 账号池的统一五态统计
|
||||
/// 账号池的状态统计
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
@@ -804,10 +805,33 @@ pub struct DashboardObservation {
|
||||
pub recent_requests: Vec<UsageListRecord>,
|
||||
}
|
||||
|
||||
/// 根据已记录事实推导的错误位置,不表示责任归属
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UsageErrorOrigin {
|
||||
Proxy,
|
||||
Upstream,
|
||||
Connection,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl UsageErrorOrigin {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Proxy => "proxy",
|
||||
Self::Upstream => "upstream",
|
||||
Self::Connection => "connection",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用记录表格的窄读模型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageListRecord {
|
||||
pub outcome: RequestOutcome,
|
||||
/// 已保存的上游错误对象含非空字符串 code、type 或 message
|
||||
pub has_upstream_error: bool,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
@@ -816,6 +840,7 @@ pub struct UsageListRecord {
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub recovered: bool,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
@@ -1002,7 +1027,8 @@ pub struct UsageAttempt {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_send_state: Option<String>,
|
||||
pub outcome: RequestOutcome,
|
||||
pub downstream_committed: bool,
|
||||
/// 未记录尝试交付边界时保持未知,不以 false 代替
|
||||
pub downstream_committed: Option<bool>,
|
||||
pub status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub failure_kind: Option<String>,
|
||||
|
||||
@@ -304,6 +304,12 @@ impl PendingAuthorizationMutation {
|
||||
"outbound_proxy_url".to_owned(),
|
||||
Value::String(proxy.expose_url().to_owned()),
|
||||
);
|
||||
if proxy.max_websocket_connections() != 0 {
|
||||
document.insert(
|
||||
"outbound_proxy_max_websocket_connections".to_owned(),
|
||||
Value::from(proxy.max_websocket_connections()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(id) = &self.outbound_proxy_id {
|
||||
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
|
||||
@@ -344,6 +350,11 @@ impl PendingAuthorizationMutation {
|
||||
.map(gateway_core::account::OutboundProxy::parse)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
let proxy = match (proxy, document.outbound_proxy_max_websocket_connections) {
|
||||
(Some(proxy), Some(limit)) => Some(proxy.with_max_websocket_connections(limit)),
|
||||
(None, Some(_)) => return Err(invalid()),
|
||||
(proxy, None) => proxy,
|
||||
};
|
||||
Ok(Self::new(
|
||||
provider_kind,
|
||||
target,
|
||||
@@ -367,6 +378,8 @@ struct StoredAuthorizationMutationV1 {
|
||||
#[serde(default)]
|
||||
outbound_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_max_websocket_connections: Option<u32>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -832,6 +845,7 @@ pub struct ProviderQuotaReadRequest<'a> {
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
pub paused_windows: Vec<String>,
|
||||
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示
|
||||
pub plan_type: Option<String>,
|
||||
@@ -839,7 +853,7 @@ pub struct ProviderQuota {
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
pub credits: Option<ProviderQuotaCredits>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号状态派生
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct PendingResetCredit {
|
||||
pub struct ResetCreditAutomationState {
|
||||
pub next_discovery_at: DateTime<Utc>,
|
||||
pub credits: Vec<PlannedResetCredit>,
|
||||
pub next_weekly_check_at: Option<DateTime<Utc>>,
|
||||
/// 同一次低额度只消费一次,等待消费后的恢复观测再允许提前用卡
|
||||
pub awaiting_weekly_recovery_since: Option<DateTime<Utc>>,
|
||||
pub pending: Option<PendingResetCredit>,
|
||||
pub retry_at: Option<DateTime<Utc>>,
|
||||
pub failures: u32,
|
||||
@@ -39,6 +42,7 @@ pub struct ResetCreditAutomation {
|
||||
pub struct ResetCreditAutomationStatus {
|
||||
pub enabled: bool,
|
||||
pub lead_minutes: u32,
|
||||
pub weekly_trigger_remaining_percent: Option<u32>,
|
||||
pub next_discovery_at: Option<DateTime<Utc>>,
|
||||
pub next_consume_at: Option<DateTime<Utc>>,
|
||||
pub last_result: Option<String>,
|
||||
|
||||
@@ -53,7 +53,9 @@ pub struct RuntimeSettingsValues {
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub key_queue_timeout_seconds: u32,
|
||||
pub account_queue_timeout_seconds: u32,
|
||||
pub switch_account_on_queue_failure: bool,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
@@ -68,8 +70,10 @@ pub struct RuntimeSettingsValues {
|
||||
pub audit_retention_days: u32,
|
||||
pub account_error_protection: gateway_core::account::AccountErrorProtection,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_cron: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reset_credit_on_low_weekly_quota: bool,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
@@ -201,7 +205,8 @@ impl RuntimeSettingsValues {
|
||||
gateway_core::settings::validate_request_limits(
|
||||
self.max_waiting_per_key,
|
||||
self.max_waiting_per_account,
|
||||
self.concurrency_wait_timeout_seconds,
|
||||
self.key_queue_timeout_seconds,
|
||||
self.account_queue_timeout_seconds,
|
||||
self.max_account_rotations,
|
||||
self.openai_session_affinity_ttl_hours,
|
||||
)?;
|
||||
@@ -230,7 +235,7 @@ impl RuntimeSettingsValues {
|
||||
self.account_error_protection.validate()?;
|
||||
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
|
||||
self.account_warmup_enabled,
|
||||
&self.account_warmup_schedule_time,
|
||||
&self.account_warmup_cron,
|
||||
self.account_warmup_model.as_deref(),
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 摘要只读取发布索引,完整详情可按请求刷新
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemUpdateCheck {
|
||||
Summary,
|
||||
Detail,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -69,6 +77,7 @@ pub struct SystemUpdateDetail {
|
||||
pub update_supported: bool,
|
||||
pub unsupported_reason: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub retry_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 系统操作类型
|
||||
|
||||
@@ -5,13 +5,28 @@ use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
|
||||
client_keys::{
|
||||
ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod, ResetClientKeyBudget,
|
||||
SetClientKeyBudgetWindow, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError>;
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
context: &MutationContext,
|
||||
) -> Result<u64, AdminError>;
|
||||
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
|
||||
@@ -5,13 +5,20 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
AdminError, MutationContext, Revision,
|
||||
proxies::{
|
||||
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
|
||||
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
|
||||
/// 插件受管网络读取代理的窄端口,连接配置仅供宿主发送请求
|
||||
#[async_trait]
|
||||
pub trait PluginProxyAccess: Send + Sync {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
|
||||
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyStore: Send + Sync {
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
|
||||
|
||||
@@ -22,8 +22,9 @@ use crate::model::{
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
|
||||
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyBudgetWindow, SetClientKeyEnabled,
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
@@ -254,6 +255,7 @@ pub trait AccountStore:
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
pauses: crate::model::accounts::PreparedQuotaPauses,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult>;
|
||||
|
||||
@@ -365,6 +367,19 @@ pub trait AuthStore: Send + Sync {
|
||||
/// Client API Key 资料读取与管理写入
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> AdminStoreResult<ClientKeyBudgetWindow>;
|
||||
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<u64>;
|
||||
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::system::{
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateEvent, SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
|
||||
@@ -43,6 +44,7 @@ pub enum SystemOperationErrorKind {
|
||||
pub struct SystemOperationError {
|
||||
kind: SystemOperationErrorKind,
|
||||
message: String,
|
||||
retry_at: Option<DateTime<Utc>>,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
sensitive_context_redacted: bool,
|
||||
}
|
||||
@@ -53,11 +55,23 @@ impl SystemOperationError {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
retry_at: None,
|
||||
source: None,
|
||||
sensitive_context_redacted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_at(mut self, retry_at: DateTime<Utc>) -> Self {
|
||||
self.retry_at = Some(retry_at);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn retry_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.retry_at
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
@@ -135,7 +149,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
|
||||
@@ -759,6 +759,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
quota_limits: None,
|
||||
auto_use_quota_credits: None,
|
||||
account_ids: vec![account_id.to_string()],
|
||||
enabled: Some(true),
|
||||
concurrency_limit: None,
|
||||
@@ -767,6 +769,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
Default::default(),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
@@ -837,23 +840,80 @@ impl AccountsService for DefaultAccountsService {
|
||||
(
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
Vec<ProviderAccountId>,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
let mut pauses = BTreeMap::new();
|
||||
for account_id in &account_ids {
|
||||
let (item, provider) = self.provider_for_account(account_id).await?;
|
||||
providers
|
||||
let (mut item, provider) = self.provider_for_account(account_id).await?;
|
||||
if let Some(limits) = &command.quota_limits {
|
||||
if !provider
|
||||
.account_capabilities(account_id, &item.account.authentication_kind)
|
||||
.quota_limits_supported
|
||||
{
|
||||
return Err(AdminError::invalid("该账号不支持通用额度窗口上限"));
|
||||
}
|
||||
let new_keys = limits
|
||||
.keys()
|
||||
.filter(|key| !item.account.quota_limits.contains(key))
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if !limits.is_empty() {
|
||||
item.account.quota_limits = limits.clone();
|
||||
let quota = provider
|
||||
.read_quotas(&[ProviderQuotaReadRequest {
|
||||
account: &item.account,
|
||||
rolling_usage: None,
|
||||
}])
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AdminError::invalid("缺少账号额度观测结果"))?
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
if new_keys.iter().any(|key| {
|
||||
!quota.windows.iter().any(|window| {
|
||||
window.key == *key
|
||||
&& window.local_usage_attribution
|
||||
== QuotaLocalUsageAttribution::AccountWide
|
||||
})
|
||||
}) {
|
||||
return Err(AdminError::invalid("只能设置已观测的通用额度窗口"));
|
||||
}
|
||||
pauses.insert(
|
||||
item.account.id.clone(),
|
||||
crate::model::accounts::PreparedQuotaPause {
|
||||
revision: item.account.credential_revision,
|
||||
observed_at: quota.observed_at,
|
||||
paused: !quota.paused_windows.is_empty(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if command.auto_use_quota_credits.is_some()
|
||||
&& !provider
|
||||
.account_capabilities(account_id, &item.account.authentication_kind)
|
||||
.quota_credits
|
||||
{
|
||||
return Err(AdminError::invalid("该账号不支持使用额度点数"));
|
||||
}
|
||||
let (_, selected, exhausted) = providers
|
||||
.entry(item.account.provider_kind)
|
||||
.or_insert_with(|| (provider, Vec::new()))
|
||||
.1
|
||||
.push(account_id.clone());
|
||||
.or_insert_with(|| (provider, Vec::new(), Vec::new()));
|
||||
selected.push(account_id.clone());
|
||||
if command.auto_use_quota_credits == Some(true)
|
||||
&& !item.account.auto_use_quota_credits
|
||||
&& item.account.quota.is_exhausted()
|
||||
{
|
||||
exhausted.push(account_id.clone());
|
||||
}
|
||||
}
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
.accounts
|
||||
.batch_update_accounts(command, context)
|
||||
.batch_update_accounts(command, pauses, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
for (provider, provider_ids, _) in providers.values() {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
super::notify_account_unavailable(
|
||||
@@ -872,6 +932,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
for (provider, _, exhausted) in providers.into_values() {
|
||||
if exhausted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
super::credential_mutation::observe_account_quotas(
|
||||
self.upstream_info.as_ref(),
|
||||
&provider,
|
||||
&self.accounts,
|
||||
&exhausted,
|
||||
context.request_id.as_str(),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -1188,6 +1260,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
vec![AccountConnectionTestEvent::Failed {
|
||||
source: error.source(),
|
||||
gateway_error_code: error.kind(),
|
||||
proxy_hint: error.proxy_hint(),
|
||||
send_state: error.send_state(),
|
||||
message,
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
|
||||
@@ -2,12 +2,66 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::model::{MutationActor, reset_credit_automation::*};
|
||||
use crate::model::{MutationActor, reset_credit_automation::*, settings::RuntimeSettingsValues};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Trigger {
|
||||
Expiry {
|
||||
lead: u32,
|
||||
max_remaining: Option<u32>,
|
||||
},
|
||||
Weekly {
|
||||
max_remaining: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
fn from_settings(settings: &RuntimeSettingsValues) -> Self {
|
||||
if settings.reset_credit_on_low_weekly_quota {
|
||||
Self::Weekly {
|
||||
max_remaining: settings
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.unwrap_or(0),
|
||||
}
|
||||
} else {
|
||||
Self::Expiry {
|
||||
lead: settings.reset_credit_lead_minutes,
|
||||
max_remaining: settings.reset_credit_max_weekly_remaining_percent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn weekly_trigger(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Expiry { .. } => None,
|
||||
Self::Weekly { max_remaining } => Some(max_remaining),
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_limit(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Expiry { max_remaining, .. } => max_remaining,
|
||||
Self::Weekly { max_remaining } => Some(max_remaining),
|
||||
}
|
||||
}
|
||||
|
||||
fn eligible(self, remaining: f64, limit: u32) -> bool {
|
||||
// 周额度模式即使配置 100% 也不消耗卡片重置满额窗口
|
||||
match self {
|
||||
Self::Expiry { .. } => remaining <= f64::from(limit),
|
||||
Self::Weekly { .. } => weekly_low(remaining, limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn expiry_due(self, account_id: &str, credit: &PlannedResetCredit, now: DateTime<Utc>) -> bool {
|
||||
matches!(self, Self::Expiry { lead, .. } if consume_at(account_id, credit, lead) <= now)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultAccountsService {
|
||||
pub(super) async fn reset_credits_upstream(
|
||||
&self,
|
||||
@@ -74,7 +128,9 @@ impl DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ResetCreditAutomationStatus, AdminError> {
|
||||
self.require_reset_credit_automation(account_id).await?;
|
||||
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead_minutes = settings.reset_credit_lead_minutes;
|
||||
let weekly_trigger_remaining_percent = Trigger::from_settings(&settings).weekly_trigger();
|
||||
let record = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
@@ -83,20 +139,23 @@ impl DefaultAccountsService {
|
||||
Ok(ResetCreditAutomationStatus {
|
||||
enabled: record.as_ref().is_some_and(|r| r.enabled),
|
||||
lead_minutes,
|
||||
weekly_trigger_remaining_percent,
|
||||
next_discovery_at: record
|
||||
.as_ref()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.state.next_discovery_at),
|
||||
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
|
||||
next_consumption(r, lead_minutes)
|
||||
.or_else(|| {
|
||||
r.state
|
||||
.pending
|
||||
.as_ref()
|
||||
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
|
||||
})
|
||||
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
|
||||
}),
|
||||
next_consume_at: record
|
||||
.as_ref()
|
||||
.filter(|r| r.enabled && weekly_trigger_remaining_percent.is_none())
|
||||
.and_then(|r| {
|
||||
next_consumption(r, lead_minutes)
|
||||
.or_else(|| {
|
||||
r.state.pending.as_ref().map(|pending| {
|
||||
consume_at(&r.account_id, &pending.credit, lead_minutes)
|
||||
})
|
||||
})
|
||||
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
|
||||
}),
|
||||
last_result: record.and_then(|r| {
|
||||
if r.state
|
||||
.pending
|
||||
@@ -104,6 +163,15 @@ impl DefaultAccountsService {
|
||||
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
|
||||
{
|
||||
Some("expired_unknown".to_owned())
|
||||
} else if r.enabled
|
||||
&& weekly_trigger_remaining_percent.is_some()
|
||||
&& r.state.awaiting_weekly_recovery_since.is_some()
|
||||
&& matches!(
|
||||
r.state.last_result.as_deref(),
|
||||
Some("reset" | "already_redeemed")
|
||||
)
|
||||
{
|
||||
Some("awaiting_weekly_recovery".to_owned())
|
||||
} else {
|
||||
r.state.last_result
|
||||
}
|
||||
@@ -145,6 +213,8 @@ impl DefaultAccountsService {
|
||||
revision: 0,
|
||||
next_run_at: now,
|
||||
state: ResetCreditAutomationState {
|
||||
next_weekly_check_at: None,
|
||||
awaiting_weekly_recovery_since: None,
|
||||
next_discovery_at: now,
|
||||
credits: vec![],
|
||||
pending: None,
|
||||
@@ -160,8 +230,8 @@ impl DefaultAccountsService {
|
||||
record.state.next_discovery_at =
|
||||
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
|
||||
}
|
||||
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
reschedule(&mut record, lead, now);
|
||||
let trigger = Trigger::from_settings(&self.automation_settings().await?);
|
||||
reschedule(&mut record, trigger, now);
|
||||
self.save_automation(&mut record, Some(context)).await?;
|
||||
let settings = self
|
||||
.settings
|
||||
@@ -246,7 +316,7 @@ impl DefaultAccountsService {
|
||||
self.observe_reset_credits(&mut record, credits, now)?;
|
||||
reschedule(
|
||||
&mut record,
|
||||
self.automation_settings().await?.reset_credit_lead_minutes,
|
||||
Trigger::from_settings(&self.automation_settings().await?),
|
||||
now,
|
||||
);
|
||||
self.save_automation(&mut record, None).await
|
||||
@@ -255,18 +325,16 @@ impl DefaultAccountsService {
|
||||
async fn automation_failed(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
trigger: Trigger,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(), AdminError> {
|
||||
if record.state.pending.is_none()
|
||||
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
|
||||
{
|
||||
if record.state.pending.is_none() && record.state.retry_at.is_none() {
|
||||
record.state.next_discovery_at =
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("discovery_failed".to_owned());
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
return self.save_automation(record, None).await;
|
||||
}
|
||||
record.state.failures = record.state.failures.saturating_add(1);
|
||||
@@ -281,7 +349,7 @@ impl DefaultAccountsService {
|
||||
}
|
||||
.to_owned(),
|
||||
);
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
@@ -289,7 +357,7 @@ impl DefaultAccountsService {
|
||||
async fn defer_automation(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
trigger: Trigger,
|
||||
reason: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let now = Utc::now();
|
||||
@@ -305,38 +373,24 @@ impl DefaultAccountsService {
|
||||
);
|
||||
record.state.last_result = Some(reason.to_owned());
|
||||
record.state.failures = 0;
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
async fn weekly_reset_credit_gate(
|
||||
async fn reset_credit_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
max_remaining: u32,
|
||||
) -> Result<Option<&'static str>, AdminError> {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderQuota, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
refresh,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
|
||||
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
|
||||
let remaining = quota
|
||||
.usage_windows()
|
||||
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0));
|
||||
Ok(match remaining {
|
||||
Some(remaining) if remaining > f64::from(max_remaining) => {
|
||||
Some("weekly_quota_above_threshold")
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
})
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_automation(
|
||||
@@ -368,7 +422,7 @@ impl DefaultAccountsService {
|
||||
}
|
||||
let now = Utc::now();
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead = settings.reset_credit_lead_minutes;
|
||||
let trigger = Trigger::from_settings(&settings);
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
@@ -382,18 +436,51 @@ impl DefaultAccountsService {
|
||||
record.state.pending = None;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("expired_unknown".to_owned());
|
||||
record.state.awaiting_weekly_recovery_since = Some(now);
|
||||
}
|
||||
if record.state.retry_at.is_some_and(|retry| retry > now) {
|
||||
reschedule(&mut record, lead, now);
|
||||
reschedule(&mut record, trigger, now);
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "reset-credit-automation".to_owned(),
|
||||
};
|
||||
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
|
||||
let due_credit = record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.any(|credit| trigger.expiry_due(account_id.as_str(), credit, now));
|
||||
let early_limit = trigger.weekly_trigger();
|
||||
// 错峰读取本地快照,等待恢复期间不因卡片用完而漏过下一轮额度
|
||||
record.state.next_weekly_check_at = early_limit
|
||||
.map(|_| now + chrono::Duration::seconds(60 + i64::from(OsRng.next_u32() % 30)));
|
||||
let mut early_due = false;
|
||||
if let Some(limit) = early_limit
|
||||
&& record.state.pending.is_none()
|
||||
&& needs_weekly_observation(&record.state)
|
||||
{
|
||||
match self.reset_credit_quota(account_id, false).await {
|
||||
Ok(quota) => {
|
||||
observe_weekly_recovery(&mut record.state, "a, limit);
|
||||
early_due = !record.state.credits.is_empty()
|
||||
&& record.state.awaiting_weekly_recovery_since.is_none()
|
||||
&& weekly_remaining("a)
|
||||
.is_some_and(|remaining| trigger.eligible(remaining, limit));
|
||||
}
|
||||
Err(error) => {
|
||||
// 额度读取失败不能阻塞独立的卡片发现计划
|
||||
tracing::warn!(
|
||||
account_id = account_id.as_str(),
|
||||
error_kind = error.kind().as_str(),
|
||||
"重置卡周额度快照读取失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if record.state.pending.is_none()
|
||||
&& (due_credit
|
||||
|| early_due
|
||||
|| record.state.next_discovery_at <= now
|
||||
|| record.state.retry_at.is_some())
|
||||
{
|
||||
@@ -403,10 +490,10 @@ impl DefaultAccountsService {
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
}
|
||||
if due_credit || record.state.retry_at.is_some() {
|
||||
if due_credit || early_due || record.state.retry_at.is_some() {
|
||||
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
|
||||
}
|
||||
reschedule(&mut record, lead, now);
|
||||
reschedule(&mut record, trigger, now);
|
||||
self.save_automation(&mut record, None).await?;
|
||||
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
|
||||
match self
|
||||
@@ -415,7 +502,7 @@ impl DefaultAccountsService {
|
||||
{
|
||||
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
self.automation_failed(&mut record, trigger, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -424,24 +511,28 @@ impl DefaultAccountsService {
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
|
||||
.find(|credit| {
|
||||
early_due || trigger.expiry_due(account_id.as_str(), credit, Utc::now())
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
|
||||
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
|
||||
match self
|
||||
.weekly_reset_credit_gate(account_id, max_remaining)
|
||||
.await
|
||||
{
|
||||
Ok(Some(reason)) => {
|
||||
return self.defer_automation(&mut record, lead, reason).await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
if let Some(limit) = trigger.quota_limit() {
|
||||
// 缓存只能唤醒检查,实际选中的卡片与额度都要重新核实
|
||||
let quota = match self.reset_credit_quota(account_id, true).await {
|
||||
Ok(quota) => quota,
|
||||
Err(error) => {
|
||||
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
|
||||
self.defer_automation(&mut record, trigger, "weekly_quota_unavailable")
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let reason = match weekly_remaining("a) {
|
||||
Some(remaining) if trigger.eligible(remaining, limit) => None,
|
||||
Some(_) => Some("weekly_quota_above_threshold"),
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return self.defer_automation(&mut record, trigger, reason).await;
|
||||
}
|
||||
}
|
||||
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
|
||||
@@ -450,7 +541,7 @@ impl DefaultAccountsService {
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.expires_at > Utc::now());
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
@@ -459,13 +550,13 @@ impl DefaultAccountsService {
|
||||
});
|
||||
record.state.last_result = Some("pending".to_owned());
|
||||
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
|
||||
self.save_automation(&mut record, None).await?;
|
||||
}
|
||||
}
|
||||
let Some(pending) = record.state.pending.clone() else {
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
};
|
||||
let command = ConsumeProviderResetCredit {
|
||||
@@ -478,9 +569,12 @@ impl DefaultAccountsService {
|
||||
Ok(result) => {
|
||||
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
|
||||
record.state.pending = None;
|
||||
if confirmed {
|
||||
record.state.awaiting_weekly_recovery_since = Some(Utc::now());
|
||||
}
|
||||
if result.code == "nothing_to_reset" {
|
||||
return self
|
||||
.defer_automation(&mut record, lead, "nothing_to_reset")
|
||||
.defer_automation(&mut record, trigger, "nothing_to_reset")
|
||||
.await;
|
||||
}
|
||||
record
|
||||
@@ -494,15 +588,20 @@ impl DefaultAccountsService {
|
||||
} else {
|
||||
"not_redeemed".to_owned()
|
||||
});
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
|
||||
self.save_automation(&mut record, None).await?;
|
||||
if confirmed {
|
||||
let _ = self.quota(account_id, true).await;
|
||||
if confirmed
|
||||
&& let Ok(account) = self.quota(account_id, true).await
|
||||
&& let Some(limit) = early_limit
|
||||
&& observe_weekly_recovery(&mut record.state, &account.quota, limit)
|
||||
{
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
self.save_automation(&mut record, None).await?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
self.automation_failed(&mut record, trigger, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -511,6 +610,42 @@ impl DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 复用账号周窗口归属,忽略短期、模型专属和过期窗口
|
||||
fn weekly_remaining(quota: &ProviderQuota) -> Option<f64> {
|
||||
quota
|
||||
.usage_windows()
|
||||
.find(|(window, period)| {
|
||||
*period == AccountUsagePeriod::Weekly
|
||||
&& window.reset_at.is_some_and(|end| end > Utc::now())
|
||||
})
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
fn weekly_low(remaining: f64, limit: u32) -> bool {
|
||||
remaining < 100.0 && remaining <= f64::from(limit)
|
||||
}
|
||||
|
||||
fn needs_weekly_observation(state: &ResetCreditAutomationState) -> bool {
|
||||
!state.credits.is_empty() || state.awaiting_weekly_recovery_since.is_some()
|
||||
}
|
||||
|
||||
fn observe_weekly_recovery(
|
||||
state: &mut ResetCreditAutomationState,
|
||||
quota: &ProviderQuota,
|
||||
limit: u32,
|
||||
) -> bool {
|
||||
if let Some(since) = state.awaiting_weekly_recovery_since
|
||||
&& quota.observed_at.is_some_and(|at| at > since)
|
||||
&& weekly_remaining(quota).is_some_and(|remaining| !weekly_low(remaining, limit))
|
||||
{
|
||||
state.awaiting_weekly_recovery_since = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
|
||||
fn next_discovery(
|
||||
now: DateTime<Utc>,
|
||||
@@ -550,14 +685,21 @@ fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option
|
||||
.min()
|
||||
}
|
||||
|
||||
fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime<Utc>) {
|
||||
fn reschedule(record: &mut ResetCreditAutomation, trigger: Trigger, 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)
|
||||
let scheduled = match trigger {
|
||||
Trigger::Expiry { lead, .. } => next_consumption(record, lead),
|
||||
Trigger::Weekly { .. } if needs_weekly_observation(&record.state) => {
|
||||
Some(record.state.next_weekly_check_at.unwrap_or(now))
|
||||
}
|
||||
Trigger::Weekly { .. } => None,
|
||||
};
|
||||
scheduled.map_or(record.state.next_discovery_at, |at| {
|
||||
at.min(record.state.next_discovery_at)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
|
||||
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeyRecord, ClientKeySecret, ClientKeySortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyBudgetWindow, SetClientKeyEnabled, UpdateClientKey,
|
||||
UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
@@ -28,6 +30,18 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Client Key 管理服务
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError>;
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<u64, AdminError>;
|
||||
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
@@ -89,6 +103,35 @@ impl DefaultClientKeyService {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError> {
|
||||
self.store
|
||||
.client_key_budget_window(id, period)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))
|
||||
}
|
||||
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<u64, AdminError> {
|
||||
if command
|
||||
.window
|
||||
.is_some_and(|window| window.starts_at >= window.ends_at)
|
||||
{
|
||||
return Err(AdminError::invalid("预算窗口结束时间必须晚于开始时间"));
|
||||
}
|
||||
self.store
|
||||
.set_client_key_budget_window(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
|
||||
self.store
|
||||
.get_client_key(id)
|
||||
|
||||
@@ -30,7 +30,18 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
|
||||
observe_account_quotas(peer, provider, accounts, account_ids, request_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn observe_account_quotas(
|
||||
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
provider: &std::sync::Arc<dyn ProviderAdmin>,
|
||||
accounts: &std::sync::Arc<dyn AccountStore>,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) {
|
||||
// 配置已提交,额度查询失败不回滚配置
|
||||
let provider = provider.clone();
|
||||
let peer = peer.cloned();
|
||||
let accounts = accounts.clone();
|
||||
@@ -48,14 +59,14 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
|
||||
tracing::warn!(request_id, account_id = %account_id, error = %error, "quota observation account lookup failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !account.enabled || &account.provider_kind != provider.provider_kind() {
|
||||
continue;
|
||||
}
|
||||
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
|
||||
// 后台查询与手动刷新共用认证能力分派,互联协议由 Admin 解释
|
||||
super::peer_link::upstream::read_quota(
|
||||
Some(peer),
|
||||
provider.as_ref(),
|
||||
@@ -72,12 +83,11 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
"account quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn required_credential(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 从已保存的错误事实投影管理端来源,不参与执行错误分类或重试
|
||||
|
||||
use crate::model::observability::{UsageErrorOrigin, UsageListRecord, UsageRecord};
|
||||
|
||||
impl UsageListRecord {
|
||||
#[must_use]
|
||||
pub fn error_origin(&self) -> UsageErrorOrigin {
|
||||
error_origin(
|
||||
self.has_upstream_error,
|
||||
self.error_code.as_deref(),
|
||||
self.websocket_close_code,
|
||||
self.upstream_status_code,
|
||||
self.error_kind.as_deref(),
|
||||
self.continuation_unavailable_reason.as_deref(),
|
||||
self.upstream_connection_exit_reason.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageRecord {
|
||||
#[must_use]
|
||||
pub fn error_origin(&self) -> UsageErrorOrigin {
|
||||
let has_upstream_error = self.upstream_error.as_ref().is_some_and(|error| {
|
||||
[&error.message, &error.code, &error.error_type]
|
||||
.into_iter()
|
||||
.any(|value| value.as_ref().is_some_and(|value| !value.is_empty()))
|
||||
});
|
||||
error_origin(
|
||||
has_upstream_error,
|
||||
self.provider_error_code.as_deref(),
|
||||
self.websocket_close_code,
|
||||
self.upstream_status_code,
|
||||
self.error_kind.as_deref(),
|
||||
self.continuation_unavailable_reason.as_deref(),
|
||||
self.upstream_connection_exit_reason.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游原始事实优先;客户端 HTTP 状态和任意错误文案不能证明错误来源
|
||||
fn error_origin(
|
||||
has_upstream_error: bool,
|
||||
upstream_code: Option<&str>,
|
||||
websocket_close_code: Option<u16>,
|
||||
upstream_status_code: Option<u16>,
|
||||
error_kind: Option<&str>,
|
||||
continuation_reason: Option<&str>,
|
||||
connection_exit_reason: Option<&str>,
|
||||
) -> UsageErrorOrigin {
|
||||
if has_upstream_error
|
||||
|| upstream_code.is_some_and(|code| !code.is_empty())
|
||||
|| websocket_close_code.is_some()
|
||||
|| upstream_status_code.is_some_and(|status| status >= 400)
|
||||
|| matches!(
|
||||
continuation_reason,
|
||||
Some("upstream_rejected" | "quota_exhausted")
|
||||
)
|
||||
|| matches!(
|
||||
error_kind,
|
||||
// 这里匹配内部 errorKind;message_too_big 只由收到的上游 close 1009 产生
|
||||
Some("permission_denied" | "message_too_big" | "upstream_capacity_unavailable")
|
||||
)
|
||||
{
|
||||
return UsageErrorOrigin::Upstream;
|
||||
}
|
||||
if matches!(
|
||||
continuation_reason,
|
||||
Some(
|
||||
"scope_unavailable"
|
||||
| "pool_unavailable"
|
||||
| "fresh_connection_required"
|
||||
| "connection_busy"
|
||||
| "latest_response_mismatch"
|
||||
| "reused_connection_lost"
|
||||
| "transport_payload_too_large"
|
||||
)
|
||||
) || matches!(
|
||||
error_kind,
|
||||
Some(
|
||||
"process_interrupted"
|
||||
| "process_terminated"
|
||||
| "internal_error"
|
||||
| "model_not_found"
|
||||
| "no_available_provider"
|
||||
| "no_eligible_account"
|
||||
| "account_capacity_unavailable"
|
||||
| "middleware_capacity_unavailable"
|
||||
| "concurrency_queue_full"
|
||||
| "concurrency_queue_timeout"
|
||||
| "provider_infrastructure_unavailable"
|
||||
| "session_policy_restricted"
|
||||
| "request_policy_denied"
|
||||
| "policy_denied"
|
||||
| "budget_exhausted"
|
||||
)
|
||||
) || matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"command_channel_closed"
|
||||
| "local_close"
|
||||
| "message_receiver_closed"
|
||||
| "client_cancelled"
|
||||
| "downstream_send_failed"
|
||||
| "downstream_connection_closed"
|
||||
| "downstream_rebound"
|
||||
| "pool_shutdown"
|
||||
| "max_age_expired"
|
||||
| "idle_timeout_expired"
|
||||
| "proxy_capacity_evicted"
|
||||
| "search_activation_required"
|
||||
)
|
||||
) {
|
||||
return UsageErrorOrigin::Proxy;
|
||||
}
|
||||
if matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"upstream_close"
|
||||
| "normal_close"
|
||||
| "unexpected_binary_event"
|
||||
| "incomplete_response"
|
||||
| "failed_response"
|
||||
)
|
||||
) {
|
||||
return UsageErrorOrigin::Upstream;
|
||||
}
|
||||
if error_kind == Some("transport")
|
||||
|| matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"eof"
|
||||
| "unexpected_eof"
|
||||
| "transport_error"
|
||||
| "connection_reset"
|
||||
| "connection_aborted"
|
||||
| "broken_pipe"
|
||||
| "tcp_reset"
|
||||
| "reset_without_closing_handshake"
|
||||
| "transport_timeout"
|
||||
| "outbound_transport_error"
|
||||
| "receive_idle_timeout"
|
||||
| "pong_timeout"
|
||||
| "ping_write_timeout"
|
||||
| "pong_write_timeout"
|
||||
)
|
||||
)
|
||||
{
|
||||
return UsageErrorOrigin::Connection;
|
||||
}
|
||||
// invalid_request、protocol、rate_limited 等分类同时用于本地和上游失败
|
||||
// 发送状态与笼统的连接退出摘要也不能替代缺失的来源事实
|
||||
UsageErrorOrigin::Unknown
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
|
||||
mod errors;
|
||||
mod export;
|
||||
mod store_cache;
|
||||
pub(crate) use store_cache::CachedObservabilityStore;
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::{engine::execution::AuthenticatedClient, policy::ClientPeerSharing};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId, engine::execution::AuthenticatedClient, policy::ClientPeerSharing,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::{
|
||||
@@ -113,10 +115,7 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
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),
|
||||
budget_access: key.budget.access_at(budget_observed_at.into()),
|
||||
key_limits: key.limits,
|
||||
budget: key.budget,
|
||||
budget_observed_at,
|
||||
@@ -154,16 +153,37 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
.filter(|group| group.enabled)
|
||||
.map(|group| group.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let scope = client.policy().account_scope();
|
||||
let direct_ids = ids
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
scope
|
||||
.directory()
|
||||
.account(id)
|
||||
.is_some_and(|account| account.is_direct_account())
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let accounts = self
|
||||
.accounts
|
||||
.load_accounts_in_enabled_groups(&ids, &groups)
|
||||
.load_accounts_in_enabled_groups(&direct_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))
|
||||
ProviderAccountId::new(account.id.clone())
|
||||
.ok()
|
||||
.and_then(|id| scope.directory().account(&id))
|
||||
.is_some_and(|frozen| {
|
||||
frozen.direct_authentication_kind()
|
||||
== Some(account.authentication_kind.as_str())
|
||||
})
|
||||
&& self
|
||||
.providers
|
||||
.require(&account.provider_kind)
|
||||
.is_ok_and(|provider| {
|
||||
provider.is_direct_account(&account.authentication_kind)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let account_ids = accounts
|
||||
@@ -232,26 +252,18 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
));
|
||||
}
|
||||
if sharing.share_quota {
|
||||
let (quota, mut models) = projection::quota_and_models(
|
||||
let (quota, models) = projection::quota_and_models(
|
||||
client,
|
||||
&accounts,
|
||||
"as,
|
||||
&self.providers,
|
||||
&runtime,
|
||||
&public_models,
|
||||
&ids,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 从已授权的本地账号事实派生公开汇总,保留原始观测时间
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use gateway_core::{
|
||||
@@ -38,7 +38,7 @@ pub(super) fn pool_summary(
|
||||
for account in accounts {
|
||||
match status(account, runtime, now) {
|
||||
AccountStatus::Normal => summary.ready += 1,
|
||||
AccountStatus::RateLimited => summary.cooldown += 1,
|
||||
AccountStatus::QuotaPaused | AccountStatus::RateLimited => summary.cooldown += 1,
|
||||
AccountStatus::QuotaExhausted => summary.exhausted += 1,
|
||||
AccountStatus::Disabled => summary.disabled += 1,
|
||||
AccountStatus::Error => summary.error += 1,
|
||||
@@ -67,6 +67,7 @@ pub(super) async fn quota_and_models(
|
||||
providers: &ProviderAdminRegistry,
|
||||
runtime: &AccountRuntimeSnapshot,
|
||||
models: &[gateway_core::routing::PublicModelId],
|
||||
authorized: &[ProviderAccountId],
|
||||
now: DateTime<Utc>,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<(Vec<PeerAccountQuota>, Vec<PeerModelSummary>), super::PeerLinkError> {
|
||||
@@ -151,6 +152,28 @@ pub(super) async fn quota_and_models(
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
// 转供账号及发布窗口内消失的账号没有本地观测,只影响它们获准执行的模型
|
||||
if accounts.len() != authorized.len() {
|
||||
let exported = accounts
|
||||
.iter()
|
||||
.map(|account| account.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let missing = authorized
|
||||
.iter()
|
||||
.filter(|id| !exported.contains(id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
for (index, model) in models.iter().enumerate() {
|
||||
if index % 16 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
if missing
|
||||
.iter()
|
||||
.any(|id| snapshot.allows_public_model_for_account(model, scope, id))
|
||||
{
|
||||
observations.entry(model.as_str()).or_default().incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let summaries = models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
@@ -248,6 +271,7 @@ fn status(
|
||||
) -> AccountStatus {
|
||||
resolve_account_status(
|
||||
&AccountStatusFacts {
|
||||
quota_paused: account.quota_paused,
|
||||
enabled: account.enabled,
|
||||
credential_state: account.credential_state,
|
||||
access_token_expires_at: account.access_token_expires_at.map(Into::into),
|
||||
|
||||
@@ -214,6 +214,7 @@ impl PeerInfoService {
|
||||
}
|
||||
}
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: Some(info),
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
|
||||
@@ -170,6 +170,7 @@ impl PeerInfoService {
|
||||
.and_then(|peer| quota::observe(&peer, started_at, now))
|
||||
.unwrap_or(QuotaState::unknown());
|
||||
let observation = QuotaObservation {
|
||||
quota_pause: None,
|
||||
expected_connection: Some(connection.clone()),
|
||||
account_id: id.clone(),
|
||||
expected_revision: connection.credential_revision,
|
||||
|
||||
@@ -9,9 +9,10 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
|
||||
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
|
||||
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
|
||||
ClientKeySort, ClientKeySortField, ResetClientKeyBudget, SetClientKeyBudgetWindow,
|
||||
SortDirection, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
|
||||
@@ -35,6 +36,29 @@ impl DefaultPluginClientKeyAccess {
|
||||
|
||||
#[async_trait]
|
||||
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError> {
|
||||
self.service.budget_window(id, period).await
|
||||
}
|
||||
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
context: &MutationContext,
|
||||
) -> Result<u64, AdminError> {
|
||||
self.service
|
||||
.set_budget_window(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
|
||||
let key = self.service.get(id).await?;
|
||||
Ok(PluginClientKeyFacts {
|
||||
|
||||
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
|
||||
fn target_environment_matches(target: &str) -> bool {
|
||||
match target {
|
||||
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
|
||||
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
"x86_64-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
|
||||
_ => false,
|
||||
}
|
||||
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
|
||||
runtime_os: "linux",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "darwin",
|
||||
release_architecture: "amd64",
|
||||
target: "x86_64-apple-darwin",
|
||||
runtime_os: "macos",
|
||||
runtime_architecture: "x86_64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "darwin",
|
||||
release_architecture: "arm64",
|
||||
|
||||
@@ -23,11 +23,51 @@ use crate::{
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
|
||||
store::AdminStoreErrorKind,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) struct DefaultPluginProxyAccess {
|
||||
pub(crate) store: Arc<dyn ProxyStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginProxyAccess for DefaultPluginProxyAccess {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
list_proxies(self.store.as_ref(), query).await
|
||||
}
|
||||
|
||||
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError> {
|
||||
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理 ID 不合法"));
|
||||
}
|
||||
let record = self
|
||||
.store
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
|
||||
}
|
||||
Ok(record.proxy)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_proxies(
|
||||
store: &dyn ProxyStore,
|
||||
query: ProxyListQuery,
|
||||
) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxiesService: Send + Sync {
|
||||
async fn create_batch(
|
||||
@@ -268,14 +308,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
}
|
||||
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
self.store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
list_proxies(self.store.as_ref(), query).await
|
||||
}
|
||||
|
||||
async fn create(
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
system::{
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
},
|
||||
ports::system::{
|
||||
@@ -25,7 +25,7 @@ pub trait SystemService: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, AdminError>;
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError>;
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
@@ -71,11 +71,11 @@ impl SystemService for DefaultSystemService {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError> {
|
||||
self.operations
|
||||
.update_detail(refresh, channel)
|
||||
.update_detail(check, channel)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -129,6 +130,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -148,6 +150,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -178,6 +181,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
|
||||
@@ -116,6 +116,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
|
||||
|
||||
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -168,6 +169,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -267,7 +269,8 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
|
||||
gateway_core::account::OutboundProxy::parse(
|
||||
"http://user:secret@proxy.example:8080",
|
||||
)
|
||||
.unwrap(),
|
||||
.unwrap()
|
||||
.with_max_websocket_connections(16),
|
||||
))
|
||||
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
|
||||
assert_eq!(
|
||||
@@ -293,6 +296,27 @@ fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_do
|
||||
let restored =
|
||||
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
|
||||
let mut legacy_proxy = legacy.clone();
|
||||
legacy_proxy["outbound_proxy_url"] = json!("socks5h://proxy.example.invalid:1080");
|
||||
legacy_proxy["outbound_proxy_id"] = json!("proxy_saved");
|
||||
let restored = PendingAuthorizationMutation::from_storage_v1(legacy_proxy.clone())
|
||||
.expect("legacy proxy without a capacity field remains readable");
|
||||
assert_eq!(
|
||||
restored
|
||||
.outbound_proxy()
|
||||
.unwrap()
|
||||
.max_websocket_connections(),
|
||||
0
|
||||
);
|
||||
assert_eq!(Value::Object(restored.to_storage_v1()), legacy_proxy);
|
||||
for invalid in [json!(-1), json!(4294967296_u64), json!("16")] {
|
||||
let mut document = legacy_proxy.clone();
|
||||
document["outbound_proxy_max_websocket_connections"] = invalid;
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
|
||||
}
|
||||
let mut missing_proxy = legacy.clone();
|
||||
missing_proxy["outbound_proxy_max_websocket_connections"] = json!(16);
|
||||
assert!(PendingAuthorizationMutation::from_storage_v1(missing_proxy).is_err());
|
||||
for (pointer, invalid) in [
|
||||
("/provider_kind", json!("")),
|
||||
("/target/account_id", json!("")),
|
||||
|
||||
@@ -59,6 +59,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
|
||||
|
||||
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
|
||||
@@ -377,6 +377,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
|
||||
group_id: group_id(),
|
||||
account_id: account_id.to_owned(),
|
||||
status: AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
|
||||
@@ -714,6 +714,7 @@ impl FakeAccountStore {
|
||||
|
||||
fn page_item(account: AccountRecord) -> AccountPageItem {
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: account.enabled,
|
||||
credential_state: account.credential_state,
|
||||
access_token_expires_at: account.access_token_expires_at.map(Into::into),
|
||||
@@ -788,6 +789,7 @@ impl AccountStore for FakeAccountStore {
|
||||
summary: AccountSummary {
|
||||
total,
|
||||
normal: 1,
|
||||
quota_paused: 0,
|
||||
quota_exhausted: 0,
|
||||
rate_limited: 0,
|
||||
disabled: 0,
|
||||
@@ -1082,6 +1084,7 @@ impl AccountStore for FakeAccountStore {
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
_: gateway_admin::model::accounts::PreparedQuotaPauses,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
self.record("store.batch_update_accounts");
|
||||
@@ -1128,6 +1131,7 @@ impl AccountStore for FakeAccountStore {
|
||||
|
||||
#[derive(Default)]
|
||||
struct StaticSettingsStore {
|
||||
reset_credit_on_low_weekly_quota: bool,
|
||||
reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -1166,7 +1170,9 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -1181,9 +1187,10 @@ impl SettingsStore for StaticSettingsStore {
|
||||
audit_retention_days: 30,
|
||||
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
},
|
||||
@@ -1906,6 +1913,8 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
quota_limits: None,
|
||||
auto_use_quota_credits: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
@@ -2073,6 +2082,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
let events = events();
|
||||
let openai = FakeProviderAdmin::new("openai", events.clone());
|
||||
openai.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2233,6 +2243,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
|
||||
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2284,6 +2295,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
|
||||
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2890,6 +2902,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::hours(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3054,6 +3067,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::days(7);
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3359,6 +3373,7 @@ pub(super) fn document() -> ProviderDocument {
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3396,6 +3411,9 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
quota_paused: false,
|
||||
quota_limits: Default::default(),
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
|
||||
@@ -231,6 +231,8 @@ fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
|
||||
revision: 1,
|
||||
next_run_at: Utc::now(),
|
||||
state: ResetCreditAutomationState {
|
||||
next_weekly_check_at: None,
|
||||
awaiting_weekly_recovery_since: None,
|
||||
next_discovery_at: Utc::now() + TimeDelta::days(1),
|
||||
credits: vec![PlannedResetCredit {
|
||||
id: "card_1".into(),
|
||||
@@ -309,7 +311,7 @@ async fn automation_restores_pending_idempotency_after_service_restart() {
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
drop(services);
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0), true).await;
|
||||
restarted
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
@@ -712,10 +714,12 @@ async fn threshold_services(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
max_remaining: Option<u32>,
|
||||
early: bool,
|
||||
) -> AdminServices {
|
||||
super::super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore {
|
||||
reset_credit_on_low_weekly_quota: early,
|
||||
reset_credit_max_weekly_remaining_percent: max_remaining,
|
||||
}))
|
||||
.provider(provider)
|
||||
@@ -783,7 +787,7 @@ async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_qu
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(used));
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold, false).await;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
@@ -825,7 +829,7 @@ async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt()
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(10.0));
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
@@ -897,7 +901,7 @@ async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(quota);
|
||||
*provider.quota_failure.lock().unwrap() = failure;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
|
||||
let result = services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
@@ -971,3 +975,256 @@ async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_use_checks_only_known_cards_and_confirms_low_quota_before_spending() {
|
||||
for (enabled, threshold, used, expiring, expected) in [
|
||||
(false, Some(10), 100.0, false, false),
|
||||
(true, None, 99.9, true, false),
|
||||
(true, None, 100.0, false, true),
|
||||
(true, Some(10), 90.0, false, true),
|
||||
(true, Some(100), 0.0, false, false),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
enable_capability(&provider);
|
||||
let expiry = Utc::now()
|
||||
+ if expiring {
|
||||
TimeDelta::minutes(5)
|
||||
} else {
|
||||
TimeDelta::days(3)
|
||||
};
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
let mut record = plan(expiry);
|
||||
let credits = std::mem::take(&mut record.state.credits);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let services =
|
||||
threshold_services(provider.clone(), store.clone(), threshold, enabled).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
provider.set_quota(weekly_quota(used));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(provider.quota_requests().is_empty());
|
||||
let no_card = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(no_card.next_run_at, no_card.state.next_discovery_at);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.credits = credits;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.reset_credit_commands().len(),
|
||||
usize::from(expected)
|
||||
);
|
||||
let status = services
|
||||
.accounts()
|
||||
.reset_credit_automation_status(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status.weekly_trigger_remaining_percent,
|
||||
enabled.then_some(threshold.unwrap_or(0))
|
||||
);
|
||||
if enabled {
|
||||
assert!(status.next_consume_at.is_none());
|
||||
}
|
||||
let requests = provider.quota_requests();
|
||||
assert_eq!(
|
||||
requests.iter().filter(|r| !r.refresh).count(),
|
||||
usize::from(enabled)
|
||||
);
|
||||
assert_eq!(requests.iter().any(|r| r.refresh), expected);
|
||||
if enabled && !expected {
|
||||
assert!(
|
||||
store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.next_run_at
|
||||
<= Utc::now() + TimeDelta::seconds(90)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_use_rechecks_live_quota_and_waits_for_a_new_recovery_after_restart() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
enable_capability(&provider);
|
||||
let expiry = Utc::now() + TimeDelta::days(3);
|
||||
let mut later = card(expiry + TimeDelta::days(1));
|
||||
later.id = "later".into();
|
||||
*provider.reset_credits.lock().unwrap() = vec![later, card(expiry)];
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
let high = ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(15.0)
|
||||
};
|
||||
provider.set_quota(weekly_quota(90.0));
|
||||
let (release, gate) = tokio::sync::oneshot::channel();
|
||||
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
|
||||
let (result, ()) = tokio::join!(
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone()),
|
||||
async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
provider.reset_credit_query_started.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.set_quota(high.clone());
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
);
|
||||
result.unwrap();
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
|
||||
advance_retry(&store);
|
||||
provider.set_quota(weekly_quota(95.0));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].credit_id.as_deref(), Some("card_1"));
|
||||
provider
|
||||
.reset_credits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|credit| credit.id != "card_1");
|
||||
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
for quota in [weekly_quota(95.0), high, weekly_quota(95.0)] {
|
||||
provider.set_quota(quota);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(20.0)
|
||||
});
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
provider.set_quota(weekly_quota(100.0));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[1].credit_id.as_deref(), Some("later"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weekly_recovery_is_observed_without_cards_after_a_failed_quota_readback() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let expiry = Utc::now() + TimeDelta::days(3);
|
||||
let mut record = plan(expiry);
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
credit: record.state.credits[0].clone(),
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
let accounts = services.accounts();
|
||||
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unavailable);
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let waiting = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(waiting.state.credits.is_empty());
|
||||
assert_eq!(waiting.state.last_result.as_deref(), Some("reset"));
|
||||
assert!(waiting.next_run_at <= Utc::now() + TimeDelta::seconds(90));
|
||||
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(20.0)
|
||||
});
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let recovered = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.next_run_at, recovered.state.next_discovery_at);
|
||||
assert!(!recorded(&events).contains(&"provider.reset_credits"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.quota_requests()
|
||||
.iter()
|
||||
.map(|r| r.refresh)
|
||||
.collect::<Vec<_>>(),
|
||||
[true, false]
|
||||
);
|
||||
|
||||
provider.set_quota(weekly_quota(95.0));
|
||||
let mut new_card = card(expiry);
|
||||
new_card.id = "next".into();
|
||||
provider.reset_credits.lock().unwrap().push(new_card);
|
||||
accounts
|
||||
.reset_credits(&context("new-card"), id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[1].credit_id.as_deref(), Some("next"));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,32 @@ struct TestClientKeyStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
|
||||
> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
|
||||
@@ -41,7 +41,9 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
|
||||
max_concurrent_per_account: 5,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -71,9 +73,10 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
|
||||
..Default::default()
|
||||
},
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ impl AccountStore for UnavailableStore {
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
_: BatchUpdateAccounts,
|
||||
_: gateway_admin::model::accounts::PreparedQuotaPauses,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
Err(unavailable("account batch update"))
|
||||
@@ -707,6 +708,32 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
|
||||
> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
@@ -1179,7 +1206,7 @@ impl SystemOperations for UnavailableSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 验证管理端错误位置的证据优先级与未知边界
|
||||
|
||||
use gateway_admin::model::observability::{RequestOutcome, UsageErrorOrigin};
|
||||
|
||||
use super::{Utc, total_record};
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_does_not_infer_from_client_status_or_message() {
|
||||
let mut record = total_record("unknown", Some("openai"), "unavailable", Utc::now());
|
||||
record.client_status_code = Some(502);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_message = Some("Upstream error: connection reset".to_owned());
|
||||
// 这些分类既可能来自本地校验、选凭据、RPC、准入或取消,也可能来自上游
|
||||
for kind in [
|
||||
"invalid_request",
|
||||
"unsupported",
|
||||
"unauthorized",
|
||||
"rate_limited",
|
||||
"quota_exhausted",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"protocol",
|
||||
"unavailable",
|
||||
"upstream_unavailable",
|
||||
"continuation_recovery_required",
|
||||
"future_error_kind",
|
||||
] {
|
||||
record.error_kind = Some(kind.to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_recognizes_unambiguous_failure_kinds() {
|
||||
use UsageErrorOrigin::{Connection, Proxy, Upstream};
|
||||
|
||||
let mut record = total_record("classified", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
for (kind, expected) in [
|
||||
// 本地执行、选路、账号与准入策略已经确定了错误位置
|
||||
("internal_error", Proxy),
|
||||
("model_not_found", Proxy),
|
||||
("no_available_provider", Proxy),
|
||||
("no_eligible_account", Proxy),
|
||||
("account_capacity_unavailable", Proxy),
|
||||
("middleware_capacity_unavailable", Proxy),
|
||||
("concurrency_queue_full", Proxy),
|
||||
("concurrency_queue_timeout", Proxy),
|
||||
("provider_infrastructure_unavailable", Proxy),
|
||||
("session_policy_restricted", Proxy),
|
||||
("request_policy_denied", Proxy),
|
||||
("policy_denied", Proxy),
|
||||
("budget_exhausted", Proxy),
|
||||
("process_interrupted", Proxy),
|
||||
("process_terminated", Proxy),
|
||||
// Provider 的权限拒绝、消息过大和模型容量拒绝来自明确上游拒绝
|
||||
("permission_denied", Upstream),
|
||||
("message_too_big", Upstream),
|
||||
("upstream_capacity_unavailable", Upstream),
|
||||
("transport", Connection),
|
||||
] {
|
||||
record.error_kind = Some(kind.to_owned());
|
||||
assert_eq!(record.error_origin(), expected, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_distinguishes_upstream_and_local_size_rejections() {
|
||||
let mut record = total_record("size", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
// 收到上游 close 1009 才产生 MessageTooBig;本地发送前超限保存续接拒绝原因
|
||||
record.error_kind = Some("message_too_big".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.error_kind = Some("continuation_recovery_required".to_owned());
|
||||
record.continuation_unavailable_reason = Some("transport_payload_too_large".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.continuation_unavailable_reason = None;
|
||||
record.error_message = Some("message too big".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_prefers_upstream_facts_to_local_classification() {
|
||||
let mut record = total_record("upstream", Some("openai"), "unavailable", Utc::now());
|
||||
record.error_kind = Some("internal_error".to_owned());
|
||||
record.continuation_unavailable_reason = Some("reused_connection_lost".to_owned());
|
||||
record.client_status_code = Some(503);
|
||||
record.upstream_status_code = Some(429);
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_code = Some("previous_response_not_found".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.error_code = None;
|
||||
record.websocket_close_code = Some(1011);
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.websocket_close_code = None;
|
||||
record.has_upstream_error = true;
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_uses_connection_exit_boundaries() {
|
||||
use UsageErrorOrigin::{Connection, Proxy, Unknown, Upstream};
|
||||
|
||||
let mut record = total_record("connection", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
// Provider 的传输和协议错误会统一保存为 upstream_unavailable,具体退出原因仍独立保存
|
||||
record.error_kind = Some("upstream_unavailable".to_owned());
|
||||
record.upstream_status_code = Some(200);
|
||||
for (reason, expected) in [
|
||||
// 本地通道、下游交付与连接池回收,不表示客户端取消应由代理承担责任
|
||||
("command_channel_closed", Proxy),
|
||||
("local_close", Proxy),
|
||||
("message_receiver_closed", Proxy),
|
||||
("client_cancelled", Proxy),
|
||||
("downstream_send_failed", Proxy),
|
||||
("downstream_connection_closed", Proxy),
|
||||
("downstream_rebound", Proxy),
|
||||
("pool_shutdown", Proxy),
|
||||
("max_age_expired", Proxy),
|
||||
("idle_timeout_expired", Proxy),
|
||||
("proxy_capacity_evicted", Proxy),
|
||||
("search_activation_required", Proxy),
|
||||
// 已收到明确的上游帧或终态,与无关闭帧的 EOF 区分
|
||||
("upstream_close", Upstream),
|
||||
("normal_close", Upstream),
|
||||
("unexpected_binary_event", Upstream),
|
||||
("incomplete_response", Upstream),
|
||||
("failed_response", Upstream),
|
||||
("eof", Connection),
|
||||
("unexpected_eof", Connection),
|
||||
("transport_error", Connection),
|
||||
("connection_reset", Connection),
|
||||
("connection_aborted", Connection),
|
||||
("broken_pipe", Connection),
|
||||
("tcp_reset", Connection),
|
||||
("reset_without_closing_handshake", Connection),
|
||||
("transport_timeout", Connection),
|
||||
("outbound_transport_error", Connection),
|
||||
("receive_idle_timeout", Connection),
|
||||
("pong_timeout", Connection),
|
||||
("ping_write_timeout", Connection),
|
||||
("pong_write_timeout", Connection),
|
||||
// stream_eof 也可能是本地收包任务退出,笼统退出摘要合并了多种底层失败
|
||||
("stream_eof", Unknown),
|
||||
("upstream_closed", Unknown),
|
||||
("upstream_receive_failed", Unknown),
|
||||
("exchange_failure", Unknown),
|
||||
("running", Unknown),
|
||||
("future_exit_reason", Unknown),
|
||||
] {
|
||||
record.upstream_connection_exit_reason = Some(reason.to_owned());
|
||||
assert_eq!(record.error_origin(), expected, "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_distinguishes_local_policy_from_connection_failure() {
|
||||
let mut record = total_record("local", Some("openai"), "unavailable", Utc::now());
|
||||
record.client_status_code = Some(503);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_kind = Some("budget_exhausted".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.error_kind = Some("transport".to_owned());
|
||||
record.upstream_connection_exit_reason = Some("unexpected_eof".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Connection);
|
||||
record.continuation_unavailable_reason = Some("latest_response_mismatch".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.continuation_unavailable_reason = Some("upstream_rejected".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.continuation_unavailable_reason = None;
|
||||
record.upstream_connection_exit_reason = Some("upstream_close".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ use gateway_admin::{
|
||||
ports::store::{AdminStoreResult, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
mod errors;
|
||||
|
||||
#[test]
|
||||
fn external_observability_range_accepts_exactly_366_days() {
|
||||
let end = Utc::now();
|
||||
@@ -1420,7 +1422,9 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_concurrent_per_account: self.max_concurrent_per_account,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -1435,9 +1439,10 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
audit_retention_days: 30,
|
||||
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
})
|
||||
@@ -1517,6 +1522,7 @@ fn total_record(
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
has_upstream_error: false,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
error_kind: None,
|
||||
@@ -1525,6 +1531,7 @@ fn total_record(
|
||||
upstream_connection_exit_reason: None,
|
||||
recovered: false,
|
||||
client_status_code: None,
|
||||
upstream_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: id.to_owned(),
|
||||
|
||||
@@ -422,6 +422,21 @@ fn loaded(account: &ProviderAccount) -> LoadedCredential {
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAccountStore for TestAccounts {
|
||||
async fn load_account_catalogs(
|
||||
&self,
|
||||
_: &gateway_core::routing::ProviderKind,
|
||||
) -> Result<Vec<gateway_core::account::StoredAccountCatalog>, gateway_core::error::StoreError>
|
||||
{
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn save_account_catalog(
|
||||
&self,
|
||||
_: &gateway_core::account::AccountCatalogObservation,
|
||||
_: &[u8],
|
||||
) -> Result<bool, gateway_core::error::StoreError> {
|
||||
unreachable!("fixture does not discover account catalogs")
|
||||
}
|
||||
|
||||
async fn create_account(&self, _account: NewProviderAccount) -> Result<(), StoreError> {
|
||||
panic!("unused account operation")
|
||||
}
|
||||
@@ -456,6 +471,31 @@ impl ProviderAccountStore for TestAccounts {
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_for_provider_accounts(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
account_ids: &[ProviderAccountId],
|
||||
) -> Result<Vec<ProviderAccount>, StoreError> {
|
||||
if account_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if Some(provider.as_str()) == self.failed_provider {
|
||||
return Err(StoreError::new(StoreErrorKind::Unavailable));
|
||||
}
|
||||
Ok(self
|
||||
.accounts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
account.provider() == provider
|
||||
&& account.enabled()
|
||||
&& account_ids.contains(account.id())
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn list_refresh_candidates(
|
||||
&self,
|
||||
_query: ProviderRefreshQuery,
|
||||
|
||||
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
|
||||
}
|
||||
|
||||
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
|
||||
let (release_os, release_architecture, target) = release_platform();
|
||||
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
|
||||
let artifacts = [
|
||||
("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("darwin", "amd64", "x86_64-apple-darwin"),
|
||||
("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
("windows", "amd64", "x86_64-pc-windows-msvc"),
|
||||
("windows", "arm64", "aarch64-pc-windows-msvc"),
|
||||
]
|
||||
.map(|(release_os, release_architecture, target)| {
|
||||
let os = if release_os == "darwin" {
|
||||
"macos"
|
||||
} else {
|
||||
release_os
|
||||
};
|
||||
let (architecture, _) = target.split_once('-').unwrap();
|
||||
serde_json::json!({
|
||||
"release_os": release_os,
|
||||
"release_architecture": release_architecture,
|
||||
"target": target,
|
||||
"os": os,
|
||||
"architecture": architecture,
|
||||
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
|
||||
"sha256": format!("{marker:01x}").repeat(64),
|
||||
})
|
||||
});
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"version": "1.0.0",
|
||||
"publisher": "test",
|
||||
"stability": "experimental",
|
||||
"artifacts": [{
|
||||
"release_os": release_os,
|
||||
"release_architecture": release_architecture,
|
||||
"target": target,
|
||||
"os": std::env::consts::OS,
|
||||
"architecture": std::env::consts::ARCH,
|
||||
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
|
||||
"sha256": format!("{marker:01x}").repeat(64),
|
||||
}]
|
||||
"artifacts": artifacts,
|
||||
})
|
||||
}
|
||||
|
||||
fn release_platform() -> (&'static str, &'static str, &'static str) {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
|
||||
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
|
||||
platform => panic!("unsupported test platform: {platform:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,13 @@ impl ProxyStore for TestProxies {
|
||||
guard: Box::new(ImportGuard(events.clone())),
|
||||
})
|
||||
}
|
||||
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
Err(super::unavailable("proxy"))
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
Ok(ProxyPage {
|
||||
items: self.records.clone(),
|
||||
total: self.records.len() as u64,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
@@ -114,6 +119,79 @@ impl ProxyStore for TestProxies {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_or_probe() {
|
||||
use gateway_admin::model::{AdminErrorKind, PageSize};
|
||||
use std::sync::Arc;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let records: Vec<_> = [true, false]
|
||||
.into_iter()
|
||||
.map(|enabled| ProxyRecord {
|
||||
id: format!("proxy_{enabled}"),
|
||||
name: "Login".into(),
|
||||
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(),
|
||||
revision: Revision::new(1).unwrap(),
|
||||
enabled,
|
||||
auto_location: false,
|
||||
location: None,
|
||||
detected_location: None,
|
||||
account_count: 0,
|
||||
last_test_at: Some(now),
|
||||
last_test: Some(ProxyTestResult {
|
||||
location: Default::default(),
|
||||
success: false,
|
||||
latency_ms: 1,
|
||||
exit_ip: None,
|
||||
exit_ipv4: None,
|
||||
exit_ipv6: None,
|
||||
message: "fixture failure".into(),
|
||||
}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.collect();
|
||||
let access = gateway_admin::initialize_plugin_proxies(Arc::new(TestProxies {
|
||||
records: records.clone(),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(
|
||||
access.resolve("proxy_true").await.unwrap(),
|
||||
records[0].proxy
|
||||
);
|
||||
assert_eq!(
|
||||
access.resolve("proxy_false").await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(access.resolve("missing").await.is_err());
|
||||
for id in ["", "bad\n", &"x".repeat(129)] {
|
||||
assert_eq!(
|
||||
access.resolve(id).await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
}
|
||||
let query = || ProxyListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(10).unwrap(),
|
||||
search: String::new(),
|
||||
};
|
||||
let page = access.list(query()).await.unwrap();
|
||||
assert_eq!(page.items.len(), 2);
|
||||
assert!(!page.items[1].enabled);
|
||||
for invalid in [
|
||||
ProxyListQuery { page: 0, ..query() },
|
||||
ProxyListQuery {
|
||||
search: "bad\n".into(),
|
||||
..query()
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
access.list(invalid).await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for TestProxies {
|
||||
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
|
||||
@@ -203,6 +281,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: cached.map(str::to_owned),
|
||||
|
||||
@@ -333,7 +333,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_concurrent_per_account: 1,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -349,9 +351,10 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
account_error_protection:
|
||||
gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Ok(SystemUpdateDetail {
|
||||
@@ -60,6 +60,7 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
update_supported: false,
|
||||
unsupported_reason: Some("source build".to_owned()),
|
||||
warning: None,
|
||||
retry_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ impl SystemOperations for PreflightingSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!()
|
||||
|
||||
@@ -276,6 +276,7 @@ impl UpdateAccountRequest {
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountConnectionUpdateRequest {
|
||||
pub protocol: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub transport: String,
|
||||
pub api_key: Option<String>,
|
||||
@@ -283,6 +284,13 @@ pub struct AccountConnectionUpdateRequest {
|
||||
|
||||
impl AccountConnectionUpdateRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self
|
||||
.protocol
|
||||
.as_deref()
|
||||
.is_some_and(|protocol| !matches!(protocol, "responses" | "messages"))
|
||||
{
|
||||
return Err(WireValidationError::new("connection.protocol"));
|
||||
}
|
||||
if let Some(base_url) = &self.base_url {
|
||||
require_text(base_url, 2048, "connection.baseUrl")?;
|
||||
}
|
||||
@@ -302,6 +310,9 @@ impl AccountConnectionUpdateRequest {
|
||||
fn into_document(self) -> ProviderDocument {
|
||||
let mut material =
|
||||
Map::from_iter([("transport".to_owned(), Value::String(self.transport))]);
|
||||
if let Some(protocol) = self.protocol {
|
||||
material.insert("protocol".to_owned(), Value::String(protocol));
|
||||
}
|
||||
if let Some(base_url) = self.base_url {
|
||||
material.insert("base_url".to_owned(), Value::String(base_url));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(super) fn account_page_data(
|
||||
summary: AccountSummaryView {
|
||||
total: result.summary.total,
|
||||
normal: result.summary.normal,
|
||||
quota_paused: result.summary.quota_paused,
|
||||
quota_exhausted: result.summary.quota_exhausted,
|
||||
rate_limited: result.summary.rate_limited,
|
||||
disabled: result.summary.disabled,
|
||||
@@ -116,6 +117,8 @@ pub(super) fn account_view(
|
||||
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
|
||||
quota.protection_rules = protection_rules;
|
||||
AccountView {
|
||||
quota_limits: account.quota_limits,
|
||||
auto_use_quota_credits: account.auto_use_quota_credits,
|
||||
upstream,
|
||||
capabilities: capabilities.into(),
|
||||
id: account.id.clone(),
|
||||
@@ -271,6 +274,7 @@ pub(super) fn account_quota_view(
|
||||
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
|
||||
(
|
||||
AccountQuotaView {
|
||||
paused_windows: quota.paused_windows,
|
||||
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
|
||||
refreshed_at_relative: quota
|
||||
.observed_at
|
||||
@@ -305,7 +309,7 @@ pub(crate) fn quota_window_view(
|
||||
limit_id,
|
||||
limit_name,
|
||||
role,
|
||||
local_usage_attribution: _,
|
||||
local_usage_attribution,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at,
|
||||
@@ -317,6 +321,8 @@ pub(crate) fn quota_window_view(
|
||||
.as_ref()
|
||||
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
|
||||
AccountQuotaWindowView {
|
||||
account_wide: local_usage_attribution
|
||||
== gateway_admin::model::provider_credentials::QuotaLocalUsageAttribution::AccountWide,
|
||||
source_account_id: upstream.as_ref().and_then(|value| value.account_id.clone()),
|
||||
amount: upstream
|
||||
.as_ref()
|
||||
|
||||
@@ -61,6 +61,8 @@ pub struct ListQuery {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
@@ -117,7 +119,9 @@ impl BatchUpdateAccountsRequest {
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.enabled.is_none()
|
||||
if self.quota_limits.is_none()
|
||||
&& self.auto_use_quota_credits.is_none()
|
||||
&& self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
&& self.group_ids.is_none()
|
||||
@@ -133,6 +137,8 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
quota_limits: self.quota_limits,
|
||||
auto_use_quota_credits: self.auto_use_quota_credits,
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
@@ -256,6 +262,7 @@ pub struct AccountPageData {
|
||||
pub struct AccountSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
@@ -266,6 +273,8 @@ pub struct AccountSummaryView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub quota_limits: gateway_core::account::AccountQuotaLimits,
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub upstream: Option<super::AccountUpstreamInfoView>,
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
@@ -318,6 +327,8 @@ pub struct AccountCapacityView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapabilitiesView {
|
||||
pub quota_limits_supported: bool,
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
pub profile: bool,
|
||||
@@ -336,6 +347,8 @@ impl From<gateway_admin::model::accounts::ProviderAccountCapabilities> for Accou
|
||||
subscription: value.subscription,
|
||||
avatar: value.avatar,
|
||||
reset_credits: value.reset_credits,
|
||||
quota_limits_supported: value.quota_limits_supported,
|
||||
quota_credits: value.quota_credits,
|
||||
consume_reset_credit: value.consume_reset_credit,
|
||||
}
|
||||
}
|
||||
@@ -386,6 +399,7 @@ pub use crate::admin::account_groups::AccountGroupRefView;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub paused_windows: Vec<String>,
|
||||
pub refreshed_at: Option<String>,
|
||||
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub refreshed_at_display: String,
|
||||
@@ -413,6 +427,7 @@ pub struct AccountQuotaCreditsView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaWindowView {
|
||||
pub account_wide: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_account_id: Option<String>,
|
||||
pub amount: Option<super::AccountQuotaAmountView>,
|
||||
@@ -1094,7 +1109,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
DomainConnectionTestEvent::Started { model } => serde_json::json!({
|
||||
"type": "test_start",
|
||||
"model": model,
|
||||
"text": "正在连接上游 Responses"
|
||||
"text": "正在连接上游"
|
||||
}),
|
||||
DomainConnectionTestEvent::Request {
|
||||
model,
|
||||
@@ -1123,6 +1138,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
DomainConnectionTestEvent::Failed {
|
||||
source,
|
||||
gateway_error_code,
|
||||
proxy_hint,
|
||||
send_state,
|
||||
message,
|
||||
provider_error_code,
|
||||
@@ -1134,6 +1150,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
"type": "error",
|
||||
"source": source.as_str(),
|
||||
"gatewayErrorCode": gateway_error_code.as_str(),
|
||||
"proxyHint": proxy_hint.map(gateway_core::error::ProxyConnectionHint::as_str),
|
||||
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
|
||||
"error": message,
|
||||
"providerErrorCode": provider_error_code,
|
||||
@@ -1229,6 +1246,7 @@ pub struct AccountResetCreditAutomationRequest {
|
||||
pub struct AccountResetCreditAutomationData {
|
||||
pub enabled: bool,
|
||||
pub lead_minutes: u32,
|
||||
pub weekly_trigger_remaining_percent: Option<u32>,
|
||||
pub next_discovery_at_display: Option<String>,
|
||||
pub next_consume_at_display: Option<String>,
|
||||
pub last_result: Option<String>,
|
||||
@@ -1249,6 +1267,7 @@ impl
|
||||
Self {
|
||||
enabled: status.enabled,
|
||||
lead_minutes: status.lead_minutes,
|
||||
weekly_trigger_remaining_percent: status.weekly_trigger_remaining_percent,
|
||||
next_discovery_at_display: status
|
||||
.next_discovery_at
|
||||
.as_ref()
|
||||
|
||||
@@ -415,14 +415,14 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
|
||||
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
|
||||
daily_resets_at_display: record
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.daily_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
|
||||
daily_resets_at: record.budget.daily_resets_at().map(DateTime::from),
|
||||
weekly_resets_at_display: record
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.weekly_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
|
||||
weekly_resets_at: record.budget.weekly_resets_at().map(DateTime::from),
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at,
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
|
||||
@@ -258,6 +258,9 @@ pub(crate) fn usage_list_record_view(
|
||||
.clone()
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
UsageListRecordView {
|
||||
error_origin: record.error_origin().as_str(),
|
||||
client_status_code: record.client_status_code,
|
||||
upstream_status_code: record.upstream_status_code,
|
||||
error_code: record.error_code,
|
||||
error_message: record.error_message,
|
||||
error_kind: record.error_kind,
|
||||
@@ -362,6 +365,7 @@ pub(crate) fn usage_record_view(
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
let metadata = provider_metadata_fields(record.provider_metadata_json.as_deref());
|
||||
UsageRecordView {
|
||||
error_origin: record.error_origin().as_str(),
|
||||
upstream_error: record
|
||||
.upstream_error
|
||||
.map(|error| super::wire::UsageUpstreamErrorView {
|
||||
@@ -1038,6 +1042,7 @@ pub(crate) fn dashboard_view(
|
||||
pool_summary: DashboardPoolSummaryView {
|
||||
total: provider_accounts.total,
|
||||
normal: provider_accounts.normal,
|
||||
quota_paused: provider_accounts.quota_paused,
|
||||
quota_exhausted: provider_accounts.quota_exhausted,
|
||||
rate_limited: provider_accounts.rate_limited,
|
||||
disabled: provider_accounts.disabled,
|
||||
|
||||
@@ -86,6 +86,9 @@ pub struct ImageBillingView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageListRecordView {
|
||||
pub error_origin: &'static str,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
@@ -143,6 +146,7 @@ pub struct UsageUpstreamErrorView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordView {
|
||||
pub error_origin: &'static str,
|
||||
pub upstream_error: Option<UsageUpstreamErrorView>,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub error_code: Option<String>,
|
||||
@@ -268,7 +272,7 @@ pub struct UsageAttemptView {
|
||||
pub transport: String,
|
||||
pub send_state: String,
|
||||
pub outcome: String,
|
||||
pub downstream_committed: bool,
|
||||
pub downstream_committed: Option<bool>,
|
||||
pub status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub failure_class: Option<String>,
|
||||
@@ -456,6 +460,7 @@ pub struct DashboardAccountUsageView {
|
||||
pub struct DashboardPoolSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 插件管理页面、资源、授权回调与页面模型请求的 HTTP 边界
|
||||
|
||||
use crate::openai::generation::{
|
||||
GenerationHttpRequest, ResponseAuthorization, execute_prepared_generation,
|
||||
};
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
@@ -35,10 +39,7 @@ use crate::{
|
||||
auth::SessionState,
|
||||
openai::{
|
||||
error::{gateway_error_response, protocol_error_response},
|
||||
responses::{
|
||||
ResponseAuthorization, ResponsesHttpRequest, decode_request_with_body,
|
||||
execute_prepared_responses,
|
||||
},
|
||||
responses::decode_request_with_body,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,10 +141,10 @@ async fn model_responses(
|
||||
};
|
||||
let authorization: Arc<dyn ResponseAuthorization> =
|
||||
Arc::new(PluginModelsAuthorization { state, target });
|
||||
let response = execute_prepared_responses(
|
||||
let response = execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
ResponsesHttpRequest {
|
||||
GenerationHttpRequest {
|
||||
peer_address: parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
@@ -158,6 +159,7 @@ async fn model_responses(
|
||||
middleware_body,
|
||||
},
|
||||
Some(authorization),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(plugin_response(response))
|
||||
|
||||
@@ -44,7 +44,9 @@ pub struct RuntimeSettingsFields {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub key_queue_timeout_seconds: u32,
|
||||
pub account_queue_timeout_seconds: u32,
|
||||
pub switch_account_on_queue_failure: bool,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
@@ -59,8 +61,10 @@ pub struct RuntimeSettingsFields {
|
||||
pub audit_retention_days: u64,
|
||||
pub account_error_protection: gateway_core::account::AccountErrorProtection,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_cron: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reset_credit_on_low_weekly_quota: bool,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
@@ -751,7 +755,9 @@ impl RuntimeSettingsFields {
|
||||
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
key_queue_timeout_seconds: self.key_queue_timeout_seconds,
|
||||
account_queue_timeout_seconds: self.account_queue_timeout_seconds,
|
||||
switch_account_on_queue_failure: self.switch_account_on_queue_failure,
|
||||
max_retry_after_seconds: self.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: self.openai_account_affinity,
|
||||
@@ -769,8 +775,9 @@ impl RuntimeSettingsFields {
|
||||
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
|
||||
account_error_protection: self.account_error_protection,
|
||||
account_warmup_enabled: self.account_warmup_enabled,
|
||||
account_warmup_schedule_time: self.account_warmup_schedule_time,
|
||||
account_warmup_cron: self.account_warmup_cron,
|
||||
account_warmup_model: self.account_warmup_model,
|
||||
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
@@ -791,7 +798,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
key_queue_timeout_seconds: settings.key_queue_timeout_seconds,
|
||||
account_queue_timeout_seconds: settings.account_queue_timeout_seconds,
|
||||
switch_account_on_queue_failure: settings.switch_account_on_queue_failure,
|
||||
max_retry_after_seconds: settings.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: settings.openai_account_affinity,
|
||||
@@ -806,8 +815,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
audit_retention_days: u64::from(settings.audit_retention_days),
|
||||
account_error_protection: settings.account_error_protection,
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_cron: settings.account_warmup_cron,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
reset_credit_on_low_weekly_quota: settings.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: settings
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
@@ -822,9 +832,10 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
|
||||
"account_warmup_model" => "accountWarmupModel",
|
||||
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
|
||||
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
|
||||
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
|
||||
"account_warmup_cron" => "accountWarmupCron",
|
||||
"audit_retention_days" => "auditRetentionDays",
|
||||
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
|
||||
"key_queue_timeout_seconds" => "keyQueueTimeoutSeconds",
|
||||
"account_queue_timeout_seconds" => "accountQueueTimeoutSeconds",
|
||||
"max_account_rotations" => "maxAccountRotations",
|
||||
"max_concurrent_per_account" => "maxConcurrentPerAccount",
|
||||
"max_waiting_per_account" => "maxWaitingPerAccount",
|
||||
|
||||
@@ -20,8 +20,9 @@ use axum::{
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
|
||||
SystemRestartPlan, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -34,7 +35,10 @@ use super::{
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
#[serde(default)]
|
||||
refresh: bool,
|
||||
#[serde(default)]
|
||||
summary_only: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
@@ -44,10 +48,16 @@ impl UpdateDetailQuery {
|
||||
self.channel
|
||||
}
|
||||
|
||||
/// 是否强制从发布源刷新
|
||||
/// 摘要只读取发布索引;手动刷新有独立的最短间隔
|
||||
#[must_use]
|
||||
pub fn refresh(&self) -> bool {
|
||||
self.refresh.unwrap_or(false)
|
||||
pub const fn check(&self) -> SystemUpdateCheck {
|
||||
if self.summary_only {
|
||||
SystemUpdateCheck::Summary
|
||||
} else if self.refresh {
|
||||
SystemUpdateCheck::Refresh
|
||||
} else {
|
||||
SystemUpdateCheck::Detail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +126,12 @@ struct SystemUpdateDetailView {
|
||||
update_supported: bool,
|
||||
unsupported_reason: Option<String>,
|
||||
warning: Option<String>,
|
||||
retry_at: Option<String>,
|
||||
retry_at_display: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
fn from(detail: SystemUpdateDetail) -> Self {
|
||||
impl From<(SystemUpdateDetail, crate::time::TimePresenter)> for SystemUpdateDetailView {
|
||||
fn from((detail, time): (SystemUpdateDetail, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
|
||||
build_type_label: build_type_label(&detail.build_type),
|
||||
@@ -135,6 +147,8 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
update_supported: detail.update_supported,
|
||||
unsupported_reason: detail.unsupported_reason,
|
||||
warning: detail.warning,
|
||||
retry_at: detail.retry_at.map(|at| at.to_rfc3339()),
|
||||
retry_at_display: detail.retry_at.map(|at| time.label(at, "%m-%d %H:%M:%S")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,12 +298,15 @@ where
|
||||
let detail = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_detail(query.refresh(), query.channel())
|
||||
.update_detail(query.check(), query.channel())
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemUpdateDetailView::from(detail)),
|
||||
AdminEnvelope::ok(SystemUpdateDetailView::from((
|
||||
detail,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,11 @@ pub const fn gateway_error_contract(
|
||||
"invalid_request_error",
|
||||
"invalid_request",
|
||||
),
|
||||
GatewayErrorKind::PeerRoutingLoop => (
|
||||
StatusCode::LOOP_DETECTED,
|
||||
"invalid_request_error",
|
||||
gateway_protocol::peer::ROUTING_LOOP_CODE,
|
||||
),
|
||||
GatewayErrorKind::Unsupported => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
|
||||
@@ -139,16 +139,16 @@ pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter
|
||||
daily_used_usd: key.budget.daily_used_usd.canonical(),
|
||||
daily_resets_at_display: key
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.daily_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
|
||||
daily_resets_at: key.budget.daily_resets_at().map(DateTime::from),
|
||||
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
|
||||
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
|
||||
weekly_resets_at_display: key
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.weekly_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
|
||||
weekly_resets_at: key.budget.weekly_resets_at().map(DateTime::from),
|
||||
},
|
||||
summary: metrics(
|
||||
&value.overview.requests,
|
||||
|
||||
@@ -137,6 +137,7 @@ pub enum ApiConfigError {
|
||||
|
||||
/// 完成组装的唯一 API router
|
||||
pub struct ApiBundle {
|
||||
peer_node: HeaderValue,
|
||||
router: Router,
|
||||
settings: middleware::SettingsSource,
|
||||
middleware: Option<middleware::PlanSource>,
|
||||
@@ -169,6 +170,7 @@ impl ApiBundle {
|
||||
self.timeout,
|
||||
self.request_id_header.clone(),
|
||||
self.settings.clone(),
|
||||
self.peer_node.clone(),
|
||||
),
|
||||
settings: self.settings.clone(),
|
||||
})
|
||||
@@ -181,12 +183,15 @@ impl ApiBundle {
|
||||
self.timeout,
|
||||
self.request_id_header,
|
||||
self.settings,
|
||||
self.peer_node,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装客户端、管理端、健康检查和静态资源路由
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub fn initialize(
|
||||
peer_node: uuid::Uuid,
|
||||
mut config: ApiConfig,
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
admin: AdminServices,
|
||||
@@ -250,6 +255,8 @@ pub fn initialize(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderName::from_static("x-api-key"),
|
||||
HeaderName::from_static("anthropic-version"),
|
||||
HeaderName::from_static("anthropic-beta"),
|
||||
request_id_header.clone(),
|
||||
])
|
||||
.allow_credentials(true),
|
||||
@@ -297,6 +304,13 @@ pub fn initialize(
|
||||
))
|
||||
.with_state(state);
|
||||
Ok(ApiBundle {
|
||||
peer_node: HeaderValue::from_bytes(
|
||||
peer_node
|
||||
.simple()
|
||||
.encode_lower(&mut uuid::Uuid::encode_buffer())
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("UUID encodes as ASCII"),
|
||||
settings,
|
||||
router,
|
||||
middleware: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 路由外层只适配 HTTP 值和正文,不解释插件协议或业务策略
|
||||
|
||||
pub(crate) mod headers;
|
||||
mod peer_route;
|
||||
pub(crate) mod websocket;
|
||||
|
||||
use std::{
|
||||
@@ -110,6 +111,7 @@ pub(crate) fn wrap(
|
||||
timeout: Option<Duration>,
|
||||
request_id_header: HeaderName,
|
||||
settings: SettingsSource,
|
||||
peer_node: HeaderValue,
|
||||
) -> Router {
|
||||
// Axum 的 route layer 在匹配后运行;外层 fallback service 才能让改写后的 URI 重新匹配内层路由
|
||||
Router::new().fallback_service(tower::service_fn(move |mut request: Request<Body>| {
|
||||
@@ -136,7 +138,10 @@ pub(crate) fn wrap(
|
||||
.and_then(|source| source(snapshot.as_deref()))
|
||||
});
|
||||
let request_id_header = request_id_header.clone();
|
||||
async move { Ok::<_, Infallible>(handle(router, request, plan, request_id_header).await) }
|
||||
let peer_node = peer_node.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(handle(router, request, plan, request_id_header, peer_node).await)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -145,13 +150,23 @@ async fn handle(
|
||||
mut request: Request<Body>,
|
||||
plan: Option<FrozenMiddlewarePlan>,
|
||||
request_id_header: HeaderName,
|
||||
peer_node: HeaderValue,
|
||||
) -> Response {
|
||||
let started = Instant::now();
|
||||
let inherited = request.extensions_mut().remove::<contract::Context>();
|
||||
if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) {
|
||||
request.extensions_mut().insert(plan.clone());
|
||||
}
|
||||
let context = inherited.unwrap_or_else(|| {
|
||||
let context = if let Some(context) = inherited {
|
||||
request
|
||||
.headers_mut()
|
||||
.remove(gateway_protocol::peer::ROUTE_HEADER);
|
||||
context
|
||||
} else {
|
||||
let path = match peer_route::enter(request.headers_mut(), peer_node) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return error.response(request.uri().path()),
|
||||
};
|
||||
let request_id = request
|
||||
.headers()
|
||||
.get(&request_id_header)
|
||||
@@ -164,11 +179,12 @@ async fn handle(
|
||||
request_id,
|
||||
parent_call_id: None,
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope::default()
|
||||
.with_request(crate::request_context::capture(&request)),
|
||||
.with_request(crate::request_context::capture(&request))
|
||||
.with_peer_route(path),
|
||||
cancellation: CancellationToken::new(),
|
||||
plan: plan.clone(),
|
||||
}
|
||||
});
|
||||
};
|
||||
let context = contract::Context {
|
||||
plan: plan.clone(),
|
||||
..context
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! 入口消费互联推理路径,拒绝回到本部署的请求
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use gateway_protocol::peer::{ROUTE_HEADER, ROUTING_LOOP_CODE};
|
||||
|
||||
pub(super) enum Error {
|
||||
Invalid,
|
||||
Loop,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(super) fn response(self, path: &str) -> Response {
|
||||
let (status, message, code) = match self {
|
||||
Self::Invalid => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid peer routing path.",
|
||||
"invalid_peer_route",
|
||||
),
|
||||
Self::Loop => (
|
||||
StatusCode::LOOP_DETECTED,
|
||||
"Peer routing loop detected.",
|
||||
ROUTING_LOOP_CODE,
|
||||
),
|
||||
};
|
||||
let (_, mut json) = crate::client_error::openai_error_response(
|
||||
status,
|
||||
message,
|
||||
"invalid_request_error",
|
||||
code,
|
||||
);
|
||||
if matches!(path, "/v1/messages" | "/v1/messages/count_tokens") {
|
||||
json.0["type"] = serde_json::json!("error");
|
||||
}
|
||||
(status, json).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn enter(headers: &mut HeaderMap, node: HeaderValue) -> Result<HeaderValue, Error> {
|
||||
if !headers.contains_key(ROUTE_HEADER) {
|
||||
return Ok(node);
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
for value in headers.get_all(ROUTE_HEADER) {
|
||||
for part in value.to_str().map_err(|_| Error::Invalid)?.split(',') {
|
||||
let id = part.trim();
|
||||
if id.len() != 32
|
||||
|| !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if id.as_bytes() == node.as_bytes() {
|
||||
return Err(Error::Loop);
|
||||
}
|
||||
path.extend_from_slice(id.as_bytes());
|
||||
path.push(b',');
|
||||
}
|
||||
}
|
||||
// 消费线协议头;内部调用继承执行作用域,上游重试不能重复追加本节点
|
||||
headers.remove(ROUTE_HEADER);
|
||||
path.extend_from_slice(node.as_bytes());
|
||||
HeaderValue::from_maybe_shared(bytes::Bytes::from(path)).map_err(|_| Error::Invalid)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 客户端协议的 Bearer API key 认证
|
||||
//! 数据面客户端 Bearer 与 x-api-key 认证
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, header::AUTHORIZATION},
|
||||
@@ -87,8 +87,9 @@ impl ClientApiKeyAuthError {
|
||||
pub(crate) async fn authenticate_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
accepted: ClientKeyHeaders,
|
||||
) -> Result<AuthenticatedClient, ClientAccessError> {
|
||||
let request = client_authentication_request(headers)?;
|
||||
let request = client_authentication_request(headers, accepted)?;
|
||||
let client = service.authenticate(request).await?;
|
||||
if let Some(identified) = identify_codex_client(headers) {
|
||||
client
|
||||
@@ -104,19 +105,38 @@ pub(crate) async fn verify_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
|
||||
let request = client_authentication_request(headers)?;
|
||||
let request = client_authentication_request(headers, ClientKeyHeaders::Bearer)?;
|
||||
service.verify(request).await
|
||||
}
|
||||
|
||||
/// 入口声明其认证头合同,所有形式仍进入同一 Client Key 认证链路
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ClientKeyHeaders {
|
||||
Bearer,
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
fn client_authentication_request(
|
||||
headers: &HeaderMap,
|
||||
accepted: ClientKeyHeaders,
|
||||
) -> Result<ClientAuthenticationRequest, ClientApiKeyAuthError> {
|
||||
let raw = headers
|
||||
.get(AUTHORIZATION)
|
||||
.ok_or(ClientApiKeyAuthError::MissingAuthorization)?
|
||||
.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
|
||||
let request = ClientAuthenticationRequest::new(raw.to_owned())
|
||||
let raw = if let Some(authorization) = headers.get(AUTHORIZATION) {
|
||||
authorization
|
||||
.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
|
||||
.to_owned()
|
||||
} else if matches!(accepted, ClientKeyHeaders::Anthropic)
|
||||
&& let Some(key) = headers.get("x-api-key")
|
||||
{
|
||||
format!(
|
||||
"Bearer {}",
|
||||
key.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
|
||||
)
|
||||
} else {
|
||||
return Err(ClientApiKeyAuthError::MissingAuthorization);
|
||||
};
|
||||
let request = ClientAuthenticationRequest::new(raw)
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
|
||||
let request = request.with_request_context(
|
||||
crate::middleware::current().and_then(|context| context.extensions.shared_request()),
|
||||
|
||||
@@ -11,12 +11,13 @@ use axum::{
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{gateway_error_response, protocol_error_response},
|
||||
responses::{
|
||||
DecodedResponsesRequest, HttpResponseAdapter, RequestDecodeError, ResponsesHttpRequest,
|
||||
decode_request_with_headers, decompress_request_body, execute_prepared_response,
|
||||
generation::{
|
||||
DecodedGenerationRequest, GenerationHttpRequest, HttpResponseAdapter,
|
||||
execute_prepared_generation,
|
||||
},
|
||||
responses::{RequestDecodeError, decode_request_with_headers, decompress_request_body},
|
||||
router::CHAT_COMPLETIONS_PATH,
|
||||
};
|
||||
|
||||
@@ -34,7 +35,7 @@ pub(in crate::openai) async fn chat_completions(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers).await {
|
||||
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -68,10 +69,10 @@ pub(in crate::openai) async fn chat_completions(
|
||||
}),
|
||||
stream_error: chat_error_frame,
|
||||
};
|
||||
execute_prepared_response(
|
||||
execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
ResponsesHttpRequest {
|
||||
GenerationHttpRequest {
|
||||
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
ingress_id,
|
||||
headers,
|
||||
@@ -88,7 +89,7 @@ fn decode_request(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum_body_bytes: usize,
|
||||
) -> Result<(DecodedResponsesRequest, Bytes, bool), RequestDecodeError> {
|
||||
) -> Result<(DecodedGenerationRequest, Bytes, bool), RequestDecodeError> {
|
||||
let body = decompress_request_body(body, headers, maximum_body_bytes)?;
|
||||
let converted = convert_request(&body)?;
|
||||
let body =
|
||||
|
||||
@@ -89,7 +89,7 @@ where
|
||||
)
|
||||
.await;
|
||||
let response = match result {
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson, "openai").await,
|
||||
Err(error) => error_response(error),
|
||||
};
|
||||
if !execution_entered.load(Ordering::Relaxed) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! 生成与计数协议共用的解码事实和 HTTP 执行边界
|
||||
|
||||
pub(crate) mod http;
|
||||
mod request;
|
||||
mod response;
|
||||
mod validation;
|
||||
|
||||
pub(crate) use http::{
|
||||
GenerationHttpRequest, HttpResponseAdapter, ResponseAuthorization, execute_prepared_generation,
|
||||
};
|
||||
pub use request::{ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use gateway_core::{
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
operation::OperationKind,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GenerationProtocol {
|
||||
Responses,
|
||||
Messages,
|
||||
MessageTokenCount,
|
||||
}
|
||||
|
||||
impl GenerationProtocol {
|
||||
pub(crate) fn from_request(request: &DecodedGenerationRequest) -> Self {
|
||||
if request.operation().kind() == OperationKind::CountTokens {
|
||||
Self::MessageTokenCount
|
||||
} else if request.operation().protocol() == "anthropic" {
|
||||
Self::Messages
|
||||
} else {
|
||||
Self::Responses
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Responses => "openai",
|
||||
Self::Messages | Self::MessageTokenCount => "anthropic",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn endpoint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Responses => super::router::RESPONSES_PATH,
|
||||
Self::Messages => super::router::MESSAGES_PATH,
|
||||
Self::MessageTokenCount => super::router::MESSAGE_TOKENS_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn operation(self) -> OperationKind {
|
||||
match self {
|
||||
Self::Responses | Self::Messages => OperationKind::Generate,
|
||||
Self::MessageTokenCount => OperationKind::CountTokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(
|
||||
self,
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum: usize,
|
||||
) -> Result<DecodedGenerationRequest, GatewayError> {
|
||||
match self {
|
||||
Self::Responses => super::responses::decode_request_with_headers(
|
||||
body, headers, maximum,
|
||||
)
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::InvalidRequest,
|
||||
"Request middleware produced an invalid Responses request.",
|
||||
)
|
||||
}),
|
||||
Self::Messages | Self::MessageTokenCount => {
|
||||
super::messages::decode_request(body, headers, maximum, self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 生成与计数入口共用的已解码操作和交付元数据
|
||||
|
||||
use gateway_core::operation::{Operation, ProviderSessionState};
|
||||
use std::{fmt, net::IpAddr};
|
||||
|
||||
/// 客户端声明的 continuation 意图
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ContinuationIntent {
|
||||
/// 不使用先前响应
|
||||
None,
|
||||
/// 使用当前调用方可见的 OpenAI response ID
|
||||
PreviousResponseId(String),
|
||||
}
|
||||
|
||||
impl ContinuationIntent {
|
||||
/// 返回待 history owner 解析的 response ID
|
||||
#[must_use]
|
||||
pub fn previous_response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::PreviousResponseId(value) => Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContinuationIntent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::None => formatter.write_str("None"),
|
||||
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler、Router 和 history owner 使用的请求元数据
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct GenerationRequestMetadata {
|
||||
pub(in crate::openai) requested_model: String,
|
||||
pub(in crate::openai) stream: bool,
|
||||
pub(in crate::openai) store: bool,
|
||||
pub(in crate::openai) continuation: ContinuationIntent,
|
||||
pub(in crate::openai) client_ip: Option<IpAddr>,
|
||||
pub(in crate::openai) user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl GenerationRequestMetadata {
|
||||
/// 返回客户端原始模型名
|
||||
#[must_use]
|
||||
pub fn requested_model(&self) -> &str {
|
||||
&self.requested_model
|
||||
}
|
||||
|
||||
/// 返回客户端是否请求 SSE
|
||||
#[must_use]
|
||||
pub const fn stream(&self) -> bool {
|
||||
self.stream
|
||||
}
|
||||
|
||||
/// 返回客户端 storage intent
|
||||
#[must_use]
|
||||
pub const fn store(&self) -> bool {
|
||||
self.store
|
||||
}
|
||||
|
||||
/// 返回 continuation intent
|
||||
#[must_use]
|
||||
pub const fn continuation(&self) -> &ContinuationIntent {
|
||||
&self.continuation
|
||||
}
|
||||
|
||||
/// 返回从 HTTP 连接边界解析出的客户端地址
|
||||
#[must_use]
|
||||
pub const fn client_ip(&self) -> Option<IpAddr> {
|
||||
self.client_ip
|
||||
}
|
||||
|
||||
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent
|
||||
#[must_use]
|
||||
pub fn user_agent(&self) -> Option<&str> {
|
||||
self.user_agent.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GenerationRequestMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GenerationRequestMetadata")
|
||||
.field("requested_model", &self.requested_model)
|
||||
.field("stream", &self.stream)
|
||||
.field("store", &self.store)
|
||||
.field("continuation", &self.continuation)
|
||||
.field("client_ip", &self.client_ip)
|
||||
.field("has_user_agent", &self.user_agent.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次成功解码的生成或计数请求
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DecodedGenerationRequest {
|
||||
pub(in crate::openai) operation: Operation,
|
||||
pub(in crate::openai) metadata: GenerationRequestMetadata,
|
||||
}
|
||||
|
||||
impl DecodedGenerationRequest {
|
||||
pub(crate) fn with_middleware_capabilities(
|
||||
mut self,
|
||||
request: &gateway_core::engine::middleware::MiddlewareRequest,
|
||||
) -> Result<Self, gateway_core::engine::middleware::MiddlewareError> {
|
||||
self.operation = request.apply_capabilities(self.operation)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态
|
||||
#[must_use]
|
||||
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
|
||||
self.operation = self.operation.with_provider_session_state(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回协议无关 operation
|
||||
#[must_use]
|
||||
pub const fn operation(&self) -> &Operation {
|
||||
&self.operation
|
||||
}
|
||||
|
||||
/// 返回 handler 元数据
|
||||
#[must_use]
|
||||
pub const fn metadata(&self) -> &GenerationRequestMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// 拆分为 operation 与元数据
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Operation, GenerationRequestMetadata) {
|
||||
(self.operation, self.metadata)
|
||||
}
|
||||
|
||||
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实
|
||||
#[must_use]
|
||||
pub fn with_client_context(
|
||||
mut self,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
) -> Self {
|
||||
self.metadata.client_ip = client_ip;
|
||||
self.metadata.user_agent = user_agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DecodedGenerationRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DecodedGenerationRequest")
|
||||
.field("operation", &self.operation)
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 原生协议出口保留 wire,只在非流式交付时聚合消息正文
|
||||
|
||||
use axum::body::Bytes;
|
||||
use gateway_core::{error::GatewayError, event::ProviderEvent};
|
||||
use gateway_protocol::{
|
||||
anthropic::MessagesAccumulator,
|
||||
openai::sse::{
|
||||
DONE_SSE_FRAME, encode_sse_event_with_metadata, response_failed_sse_event_with_id,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::GenerationProtocol;
|
||||
use crate::openai::{
|
||||
error::gateway_error_contract,
|
||||
messages,
|
||||
responses::{OpenAiResponsesEncoder, ResponseEncodeError},
|
||||
};
|
||||
|
||||
pub(super) enum GenerationEncoder {
|
||||
Responses(OpenAiResponsesEncoder),
|
||||
Messages {
|
||||
response_id: Option<String>,
|
||||
completed: bool,
|
||||
failed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl GenerationEncoder {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(OpenAiResponsesEncoder::new()),
|
||||
GenerationProtocol::Messages | GenerationProtocol::MessageTokenCount => {
|
||||
Self::Messages {
|
||||
response_id: None,
|
||||
completed: false,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.push_sse(event),
|
||||
Self::Messages {
|
||||
response_id,
|
||||
completed,
|
||||
failed,
|
||||
} => {
|
||||
let Some(wire) = event
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == "anthropic")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(raw) = wire.raw_json_body() {
|
||||
let events = serde_json::from_slice(raw).ok().and_then(|value| {
|
||||
gateway_protocol::anthropic::message_to_stream_events(value).ok()
|
||||
});
|
||||
let Some(events) = events else {
|
||||
*failed = true;
|
||||
return vec![messages::stream_error(
|
||||
&json!({"error":{"message":"The upstream message is invalid."}}),
|
||||
)];
|
||||
};
|
||||
*response_id = events
|
||||
.first()
|
||||
.and_then(|value| value.pointer("/message/id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
*completed = true;
|
||||
return events
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
Bytes::from(encode_sse_event_with_metadata(
|
||||
value["type"].as_str().unwrap_or_default(),
|
||||
&value.to_string(),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let kind = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
match kind {
|
||||
Some("message_start") => {
|
||||
*response_id = wire
|
||||
.data()
|
||||
.pointer("/message/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
Some("message_stop") => *completed = true,
|
||||
Some("error") => *failed = true,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(raw) = wire.raw_sse_frame() {
|
||||
return vec![raw.clone()];
|
||||
}
|
||||
vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
kind.unwrap_or_default(),
|
||||
&wire.data().to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_completed(&self) -> bool {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.is_completed(),
|
||||
Self::Messages { completed, .. } => *completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_wire_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.has_wire_failure(),
|
||||
Self::Messages { failed, .. } => *failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error_frame(&self, error: &GatewayError) -> Bytes {
|
||||
let (status, error_type, code) = gateway_error_contract(error.kind());
|
||||
match self {
|
||||
Self::Responses(encoder) => Bytes::from(response_failed_sse_event_with_id(
|
||||
encoder.response_id(),
|
||||
error.client_error_type().unwrap_or(error_type),
|
||||
crate::client_error::client_error_code(error.client_error_code().unwrap_or(code)),
|
||||
error.client_message(),
|
||||
)),
|
||||
Self::Messages { .. } => {
|
||||
let value = messages::error_payload(
|
||||
&json!({"error":{"message":error.client_message()}}),
|
||||
status,
|
||||
);
|
||||
messages::stream_error(&value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerationProtocol {
|
||||
pub(super) fn terminal_bytes(self) -> Bytes {
|
||||
match self {
|
||||
Self::Responses => Bytes::from_static(DONE_SSE_FRAME.as_bytes()),
|
||||
// Messages 的 message_stop 已在 wire 中交付,空帧只确认宿主交付终结
|
||||
Self::Messages | Self::MessageTokenCount => Bytes::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn collect(self, events: &[ProviderEvent]) -> Result<Vec<u8>, ResponseEncodeError> {
|
||||
if self == Self::Responses {
|
||||
return serde_json::to_vec(&crate::openai::responses::response::collect_response(
|
||||
events,
|
||||
)?)
|
||||
.map_err(|_| ResponseEncodeError::Serialization);
|
||||
}
|
||||
let mut accumulator = MessagesAccumulator::default();
|
||||
let mut response = None;
|
||||
for wire in events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.filter(|wire| wire.protocol() == self.wire())
|
||||
{
|
||||
if let Some(raw) = wire.raw_json_body() {
|
||||
return Ok(raw.to_vec());
|
||||
}
|
||||
let value = wire.data();
|
||||
if self == Self::MessageTokenCount {
|
||||
if value.get("input_tokens").and_then(Value::as_u64).is_some() {
|
||||
response = Some(value.clone());
|
||||
}
|
||||
} else if value.get("type").and_then(Value::as_str) == Some("message") {
|
||||
response = Some(value.clone());
|
||||
} else if wire.has_json_data()
|
||||
&& let Some(message) = accumulator
|
||||
.push(value)
|
||||
.map_err(|_| ResponseEncodeError::InvalidOutputItems)?
|
||||
{
|
||||
response = Some(message);
|
||||
}
|
||||
}
|
||||
serde_json::to_vec(&response.ok_or(ResponseEncodeError::MissingWireTerminal)?)
|
||||
.map_err(|_| ResponseEncodeError::Serialization)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 出口校验按协议维护边界事实,不在流式路径保存生成正文
|
||||
|
||||
use gateway_core::event::ProviderEvent;
|
||||
use gateway_protocol::openai::sse::parse_sse_events;
|
||||
use serde_json::Value;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::GenerationProtocol;
|
||||
use crate::openai::responses::validation::{
|
||||
ResponseValidationFacts, ResponsesDeliveryValidator, validate_buffered_response,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) enum GenerationValidationFacts {
|
||||
Responses(ResponseValidationFacts),
|
||||
Messages(Arc<Mutex<Option<String>>>),
|
||||
Count,
|
||||
}
|
||||
|
||||
impl GenerationValidationFacts {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(ResponseValidationFacts::default()),
|
||||
GenerationProtocol::Messages => Self::Messages(Arc::default()),
|
||||
GenerationProtocol::MessageTokenCount => Self::Count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_event(&self, event: &ProviderEvent) {
|
||||
match self {
|
||||
Self::Responses(facts) => facts.observe_event(event),
|
||||
Self::Messages(id) => {
|
||||
if let Some(value) = event
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == "anthropic")
|
||||
.map(|wire| wire.data())
|
||||
&& let Some(value) = value
|
||||
.pointer("/message/id")
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
&& let Ok(mut id) = id.lock()
|
||||
{
|
||||
*id = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
Self::Count => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_buffered(&self, events: &[ProviderEvent], bytes: &[u8]) {
|
||||
if let Self::Responses(facts) = self {
|
||||
facts.observe_buffered(events, bytes);
|
||||
} else {
|
||||
for event in events {
|
||||
self.observe_event(event);
|
||||
}
|
||||
if let Self::Messages(id) = self
|
||||
&& let Ok(value) = serde_json::from_slice::<Value>(bytes)
|
||||
&& let Some(value) = value.get("id").and_then(Value::as_str)
|
||||
&& let Ok(mut id) = id.lock()
|
||||
{
|
||||
*id = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Responses(facts) => facts.response_id(),
|
||||
Self::Messages(id) => id.lock().ok().and_then(|id| id.clone()),
|
||||
Self::Count => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_buffered(&self, bytes: &[u8]) -> Result<(), ()> {
|
||||
match self {
|
||||
Self::Responses(facts) => validate_buffered_response(facts, bytes).map_err(|_| ()),
|
||||
Self::Messages(_) => {
|
||||
let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
|
||||
let id = value.get("id").and_then(Value::as_str).ok_or(())?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("message")
|
||||
|| !value.get("content").is_some_and(Value::is_array)
|
||||
|| self.response_id().is_some_and(|expected| expected != id)
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Count => serde_json::from_slice::<Value>(bytes)
|
||||
.ok()
|
||||
.and_then(|value| value.get("input_tokens").and_then(Value::as_u64))
|
||||
.map(|_| ())
|
||||
.ok_or(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum GenerationDeliveryValidator {
|
||||
Responses(ResponsesDeliveryValidator),
|
||||
Messages { id: Option<String>, finished: bool },
|
||||
}
|
||||
|
||||
impl GenerationDeliveryValidator {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(ResponsesDeliveryValidator::default()),
|
||||
_ => Self::Messages {
|
||||
id: None,
|
||||
finished: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Responses(validator) => validator.response_id(),
|
||||
Self::Messages { id, .. } => id.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_frame(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
transformed: bool,
|
||||
facts: &GenerationValidationFacts,
|
||||
) -> Result<(), ()> {
|
||||
match (self, facts) {
|
||||
(Self::Responses(validator), GenerationValidationFacts::Responses(facts)) => validator
|
||||
.validate_frame(bytes, transformed, facts)
|
||||
.map_err(|_| ()),
|
||||
(Self::Messages { id, finished }, GenerationValidationFacts::Messages(_)) => {
|
||||
if bytes.is_empty() {
|
||||
return if *finished { Ok(()) } else { Err(()) };
|
||||
}
|
||||
for event in
|
||||
parse_sse_events(std::str::from_utf8(bytes).map_err(|_| ())?).map_err(|_| ())?
|
||||
{
|
||||
let value: Value = match serde_json::from_str(&event.data) {
|
||||
Ok(value) => value,
|
||||
Err(_) if !transformed => continue,
|
||||
Err(_) => return Err(()),
|
||||
};
|
||||
let kind = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if *finished {
|
||||
return Err(());
|
||||
}
|
||||
match kind {
|
||||
"message_start" => {
|
||||
let next = value
|
||||
.pointer("/message/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(())?;
|
||||
if id.is_some()
|
||||
|| (transformed
|
||||
&& facts.response_id().is_some_and(|expected| expected != next))
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
*id = Some(next.to_owned());
|
||||
}
|
||||
"message_stop" => {
|
||||
if id.is_none() {
|
||||
return Err(());
|
||||
}
|
||||
*finished = true;
|
||||
}
|
||||
"error" => *finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_delivery(&self) -> Result<(), ()> {
|
||||
match self {
|
||||
Self::Responses(validator) => validator.finish_delivery().map_err(|_| ()),
|
||||
Self::Messages { finished: true, .. } => Ok(()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use crate::ApiState;
|
||||
use crate::middleware::headers::encode_headers;
|
||||
use crate::openai::middleware::RequestInput;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
endpoint::provider_endpoint_response,
|
||||
responses::{OpenAiRequestHeaders, request_client_context},
|
||||
router::{IMAGE_EDITS_PATH, IMAGE_GENERATIONS_PATH},
|
||||
@@ -73,7 +73,7 @@ async fn handle_image_request(
|
||||
endpoint: &'static str,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ use super::{
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{engine_error_response, gateway_error_response},
|
||||
middleware::PendingExecution,
|
||||
responses::request_client_context,
|
||||
@@ -42,7 +42,7 @@ pub(crate) async fn live_call(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -116,7 +116,7 @@ pub(crate) async fn hangup(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ use super::LiveErrorShape;
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::runtime_unavailable_response,
|
||||
live::{
|
||||
filter_protocol_headers, live_error_response, offered_subprotocols,
|
||||
@@ -110,7 +110,7 @@ async fn open_sideband(
|
||||
return upgrade_required_response();
|
||||
};
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Messages 与输入计数的 HTTP 入口和原生错误表达
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::Response,
|
||||
};
|
||||
use gateway_core::{
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
operation::{GenerateRequest, Operation, ProtocolPayload, RawJsonPayload, TokenCountRequest},
|
||||
};
|
||||
use gateway_protocol::{anthropic, openai::sse::encode_sse_event};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::gateway_error_response,
|
||||
generation::{
|
||||
ContinuationIntent, DecodedGenerationRequest, GenerationHttpRequest, GenerationProtocol,
|
||||
GenerationRequestMetadata, execute_prepared_generation,
|
||||
},
|
||||
responses::{OpenAiRequestHeaders, decompress_request_body},
|
||||
};
|
||||
use crate::ApiState;
|
||||
|
||||
pub(super) async fn messages(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
execute(
|
||||
state,
|
||||
connect_info,
|
||||
ingress_id,
|
||||
headers,
|
||||
body,
|
||||
GenerationProtocol::Messages,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn count_tokens(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
execute(
|
||||
state,
|
||||
connect_info,
|
||||
ingress_id,
|
||||
headers,
|
||||
body,
|
||||
GenerationProtocol::MessageTokenCount,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
state: ApiState,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
mut headers: HeaderMap,
|
||||
body: Bytes,
|
||||
protocol: GenerationProtocol,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return project_error_response(client_access_error_response(error)).await,
|
||||
};
|
||||
let maximum = client.snapshot().responses_max_decompressed_body_bytes();
|
||||
let body = match decompress_request_body(&body, &headers, maximum) {
|
||||
Ok(body) => Bytes::copy_from_slice(&body),
|
||||
Err(error) => {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
&error.protocol_body().error.message,
|
||||
);
|
||||
}
|
||||
};
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
let decoded = match decode_request(&body, &headers, maximum, protocol) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => return project_error_response(gateway_error_response(&error)).await,
|
||||
};
|
||||
let prepared = match service.execution().prepare_execution(client).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return project_error_response(gateway_error_response(&error)).await,
|
||||
};
|
||||
execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
GenerationHttpRequest {
|
||||
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
ingress_id,
|
||||
headers,
|
||||
decoded,
|
||||
middleware_body: body,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn decode_request(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum: usize,
|
||||
protocol: GenerationProtocol,
|
||||
) -> Result<DecodedGenerationRequest, GatewayError> {
|
||||
let invalid = |message| GatewayError::new(GatewayErrorKind::InvalidRequest, message);
|
||||
let body = decompress_request_body(body, headers, maximum)
|
||||
.map_err(|_| invalid("Request body could not be decoded."))?;
|
||||
let object = serde_json::from_slice::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.ok_or_else(|| invalid("Request body must be a JSON object."))?;
|
||||
let counted = protocol == GenerationProtocol::MessageTokenCount;
|
||||
let validation = if counted {
|
||||
anthropic::validate_count_request(&object)
|
||||
} else {
|
||||
anthropic::validate_request(&object)
|
||||
};
|
||||
validation.map_err(|error| {
|
||||
invalid(match error {
|
||||
anthropic::TranslationError::InvalidRequest(message) => message,
|
||||
_ => "Messages request is invalid.",
|
||||
})
|
||||
})?;
|
||||
let model = object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let stream = !counted
|
||||
&& object
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let context = OpenAiRequestHeaders::from_headers(headers).session_context();
|
||||
let operation = if counted {
|
||||
let payload = RawJsonPayload::new(protocol.wire(), Bytes::copy_from_slice(&body))
|
||||
.map_err(|_| invalid("Request protocol is invalid."))?
|
||||
.with_context(context);
|
||||
Operation::CountTokens(TokenCountRequest::from_raw_json(payload))
|
||||
} else {
|
||||
let payload = ProtocolPayload::json_object(protocol.wire(), object)
|
||||
.map_err(|_| invalid("Request protocol is invalid."))?
|
||||
.with_context(context);
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
};
|
||||
Ok(DecodedGenerationRequest {
|
||||
operation,
|
||||
metadata: GenerationRequestMetadata {
|
||||
requested_model: model,
|
||||
stream,
|
||||
store: false,
|
||||
continuation: ContinuationIntent::None,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn error_type(status: StatusCode) -> &'static str {
|
||||
match status.as_u16() {
|
||||
400 | 405 | 422 => "invalid_request_error",
|
||||
401 => "authentication_error",
|
||||
403 => "permission_error",
|
||||
404 => "not_found_error",
|
||||
413 => "request_too_large",
|
||||
429 => "rate_limit_error",
|
||||
503 | 529 => "overloaded_error",
|
||||
_ => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error_payload(value: &Value, status: StatusCode) -> Value {
|
||||
if value.get("type").and_then(Value::as_str) == Some("error") && value.get("error").is_some() {
|
||||
return value.clone();
|
||||
}
|
||||
let error = value
|
||||
.pointer("/response/error")
|
||||
.or_else(|| value.get("error"))
|
||||
.unwrap_or(value);
|
||||
// 转接 Responses 的互联拒绝时保留机器码,下一节点需能识别相同的环路
|
||||
if gateway_protocol::peer::is_routing_loop(
|
||||
Some(status.as_u16()),
|
||||
error.get("code").and_then(Value::as_str),
|
||||
) {
|
||||
return json!({"type": "error", "error": error});
|
||||
}
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("The gateway could not complete the request.");
|
||||
json!({"type":"error", "error":{"type":error_type(status), "message":message}})
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, kind: &str, message: &str) -> Response {
|
||||
let mut response = Response::new(Body::from(
|
||||
json!({"type":"error", "error":{"type":kind,"message":message}}).to_string(),
|
||||
));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) async fn project_error_response(response: Response) -> Response {
|
||||
if response.status().is_success() {
|
||||
return response;
|
||||
}
|
||||
let (mut parts, body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let value = serde_json::from_slice(&bytes)
|
||||
.unwrap_or_else(|_| json!({"error":{"message":String::from_utf8_lossy(&bytes)}}));
|
||||
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
parts
|
||||
.headers
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
let projected = error_payload(&value, parts.status);
|
||||
Response::from_parts(parts, Body::from(projected.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn stream_error(value: &Value) -> Bytes {
|
||||
Bytes::from(encode_sse_event(
|
||||
"error",
|
||||
&error_payload(value, StatusCode::INTERNAL_SERVER_ERROR).to_string(),
|
||||
))
|
||||
}
|
||||
@@ -94,9 +94,10 @@ impl ExpectedBody {
|
||||
pub(crate) async fn into_http_response(
|
||||
response: MiddlewareResponse,
|
||||
expected: ExpectedBody,
|
||||
expected_protocol: &str,
|
||||
) -> Response {
|
||||
let (protocol, status, headers, mut body, _) = response.into_parts();
|
||||
if protocol != "openai" {
|
||||
if protocol != expected_protocol {
|
||||
return fail_before_commit(body, MiddlewareError::InvalidState).await;
|
||||
}
|
||||
let head = response_head(status, headers);
|
||||
|
||||
@@ -64,6 +64,7 @@ where
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let protocol = input.protocol.clone();
|
||||
let expected_protocol = protocol.clone();
|
||||
let result = invoke_request(
|
||||
execution,
|
||||
prepared,
|
||||
@@ -79,7 +80,9 @@ where
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
|
||||
Ok(response) => {
|
||||
into_http_response(response, ExpectedBody::SingleJson, &expected_protocol).await
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ pub mod auth;
|
||||
mod chat_completions;
|
||||
mod endpoint;
|
||||
pub mod error;
|
||||
pub(crate) mod generation;
|
||||
pub mod images;
|
||||
pub(crate) mod live;
|
||||
mod messages;
|
||||
pub(crate) mod middleware;
|
||||
pub mod models;
|
||||
mod peer_link;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 模型目录 HTTP adapter
|
||||
//! 按显式协议头投影模型目录,Codex 目录保留独立合同
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{model_not_found_response, openai_error_response},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsQuery {
|
||||
client_version: Option<String>,
|
||||
after_id: Option<String>,
|
||||
before_id: Option<String>,
|
||||
limit: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /v1/models`
|
||||
@@ -38,28 +41,37 @@ pub(crate) async fn models(
|
||||
Query(query): Query<ModelsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
Err(error) => {
|
||||
return model_protocol_response(client_access_error_response(error), anthropic).await;
|
||||
}
|
||||
};
|
||||
|
||||
middleware::query_response(
|
||||
service.execution(),
|
||||
client,
|
||||
RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers),
|
||||
move |client| models_response(state, client, query),
|
||||
)
|
||||
.await
|
||||
let mut input = RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers);
|
||||
if anthropic {
|
||||
input.protocol = "anthropic".to_owned();
|
||||
}
|
||||
let response = middleware::query_response(service.execution(), client, input, move |client| {
|
||||
models_response(state, client, query, anthropic)
|
||||
})
|
||||
.await;
|
||||
model_protocol_response(response, anthropic).await
|
||||
}
|
||||
|
||||
async fn models_response(
|
||||
state: ApiState,
|
||||
client: AuthenticatedClient,
|
||||
query: ModelsQuery,
|
||||
anthropic: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
|
||||
if anthropic {
|
||||
return anthropic_models_response(service.public_models(&client), &query);
|
||||
}
|
||||
if let Some(version) = query
|
||||
.client_version
|
||||
.as_deref()
|
||||
@@ -147,28 +159,37 @@ pub(crate) async fn model_detail(
|
||||
headers: HeaderMap,
|
||||
Path(model_id): Path<String>,
|
||||
) -> Response {
|
||||
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
Err(error) => {
|
||||
return model_protocol_response(client_access_error_response(error), anthropic).await;
|
||||
}
|
||||
};
|
||||
middleware::query_response(
|
||||
let mut input = RequestInput::query(
|
||||
format!("{}/{model_id}", super::router::MODELS_PATH),
|
||||
Some(model_id.clone()),
|
||||
&headers,
|
||||
);
|
||||
if anthropic {
|
||||
input.protocol = "anthropic".to_owned();
|
||||
}
|
||||
let response = middleware::query_response(
|
||||
service.execution(),
|
||||
client,
|
||||
RequestInput::query(
|
||||
format!("{}/{model_id}", super::router::MODELS_PATH),
|
||||
Some(model_id.clone()),
|
||||
&headers,
|
||||
),
|
||||
move |client| async move { model_detail_response(&state, &client, model_id) },
|
||||
input,
|
||||
move |client| async move { model_detail_response(&state, &client, model_id, anthropic) },
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
model_protocol_response(response, anthropic).await
|
||||
}
|
||||
|
||||
fn model_detail_response(
|
||||
state: &ApiState,
|
||||
client: &AuthenticatedClient,
|
||||
model_id: String,
|
||||
anthropic: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
@@ -180,11 +201,76 @@ fn model_detail_response(
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(openai_model_json(public_model.as_str())),
|
||||
Json(if anthropic {
|
||||
anthropic_model_json(public_model.as_str())
|
||||
} else {
|
||||
openai_model_json(public_model.as_str())
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn model_protocol_response(response: Response, anthropic: bool) -> Response {
|
||||
if anthropic {
|
||||
super::messages::project_error_response(response).await
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_models_response(models: Vec<String>, query: &ModelsQuery) -> Response {
|
||||
let limit = match query.limit.as_deref().unwrap_or("20").parse::<usize>() {
|
||||
Ok(limit @ 1..=1000) => limit,
|
||||
_ => {
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Model list limit must be between 1 and 1000.",
|
||||
"invalid_request_error",
|
||||
"invalid_limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let cursor = |value: &Option<String>| -> Result<Option<usize>, ()> {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|id| models.iter().position(|model| model == id).ok_or(()))
|
||||
.transpose()
|
||||
};
|
||||
let (Ok(after), Ok(before)) = (cursor(&query.after_id), cursor(&query.before_id)) else {
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"The model pagination cursor was not found.",
|
||||
"invalid_request_error",
|
||||
"invalid_cursor",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let start = after.map_or(0, |index| index + 1);
|
||||
let end = before.unwrap_or(models.len());
|
||||
let range = if before.is_some() {
|
||||
start.max(end.saturating_sub(limit))..end
|
||||
} else {
|
||||
start..end.min(start.saturating_add(limit))
|
||||
};
|
||||
let data = models
|
||||
.get(range.clone())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|model| anthropic_model_json(model))
|
||||
.collect::<Vec<_>>();
|
||||
let has_more = if before.is_some() {
|
||||
range.start > start
|
||||
} else {
|
||||
range.end < end
|
||||
};
|
||||
Json(json!({"first_id":data.first().and_then(|model| model.get("id")), "last_id":data.last().and_then(|model| model.get("id")), "has_more":has_more, "data":data})).into_response()
|
||||
}
|
||||
|
||||
fn anthropic_model_json(id: &str) -> Value {
|
||||
json!({"id":id, "type":"model", "display_name":id, "created_at":"1970-01-01T00:00:00Z", "capabilities":null, "max_input_tokens":null, "max_tokens":null, "line":null, "lifecycle":"active", "deprecated_at":null, "retires_at":null})
|
||||
}
|
||||
|
||||
fn openai_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::peer_link::{PeerLinkExport, PeerModelState as ModelState};
|
||||
use gateway_core::metering::Decimal;
|
||||
use gateway_core::{engine::budget::ClientBudgetWindow, metering::Decimal};
|
||||
use gateway_protocol::peer::{
|
||||
OBJECT, PeerBudgetWindow, PeerComponentStatus, PeerKeyLimits, PeerKeyUsage, PeerLink,
|
||||
PeerModelAvailability, PeerModelCoverage, PeerModelState, PeerPlanCount, PeerPool,
|
||||
PeerPoolStates, PeerQuota, PeerQuotaAccount, PeerQuotaWindow, PeerUsage, SCHEMA_VERSION,
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
let mut capabilities = vec!["key_usage".to_owned(), "key_limits".to_owned()];
|
||||
@@ -88,19 +87,17 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
budget_window(
|
||||
"daily",
|
||||
"日额度",
|
||||
86400,
|
||||
export.budget.limits.daily_usd,
|
||||
export.budget.daily_used_usd,
|
||||
export.budget.daily_resets_at,
|
||||
export.budget.daily_window,
|
||||
export.budget_access.daily_limit_reached,
|
||||
),
|
||||
budget_window(
|
||||
"weekly",
|
||||
"周额度",
|
||||
604800,
|
||||
export.budget.limits.weekly_usd,
|
||||
export.budget.weekly_used_usd,
|
||||
export.budget.weekly_resets_at,
|
||||
export.budget.weekly_window,
|
||||
export.budget_access.weekly_limit_reached,
|
||||
),
|
||||
],
|
||||
@@ -133,21 +130,24 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
fn budget_window(
|
||||
key: &str,
|
||||
label: &str,
|
||||
window_seconds: u64,
|
||||
total: Decimal,
|
||||
used: Decimal,
|
||||
resets_at: Option<SystemTime>,
|
||||
window: Option<ClientBudgetWindow>,
|
||||
limit_reached: bool,
|
||||
) -> PeerBudgetWindow {
|
||||
let limited = total != Decimal::ZERO;
|
||||
let window = window.filter(|w| w.start < w.end);
|
||||
PeerBudgetWindow {
|
||||
key: key.to_owned(),
|
||||
label: Some(label.to_owned()),
|
||||
window_seconds: Some(window_seconds),
|
||||
window_seconds: window.and_then(|w| {
|
||||
let duration = w.end.duration_since(w.start).ok()?;
|
||||
(duration.subsec_nanos() == 0).then_some(duration.as_secs())
|
||||
}),
|
||||
limit_reached: Some(limit_reached),
|
||||
total: limited.then(|| total.canonical()),
|
||||
used: used.canonical(),
|
||||
remaining: limited.then(|| total.saturating_sub(used).canonical()),
|
||||
reset_at: resets_at.map(|time| DateTime::<Utc>::from(time).to_rfc3339()),
|
||||
reset_at: window.map(|w| DateTime::<Utc>::from(w.end).to_rfc3339()),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,17 +18,18 @@ use tokio::sync::oneshot;
|
||||
|
||||
use crate::openai::middleware::{SSE_KEEPALIVE_FRAME, SSE_KEEPALIVE_INTERVAL};
|
||||
|
||||
pub(super) async fn response(
|
||||
pub(in crate::openai) async fn response(
|
||||
response: impl Future<Output = Response> + Send + 'static,
|
||||
admitted: oneshot::Receiver<ResponseCommit>,
|
||||
) -> Response {
|
||||
response_with_encoder(response, admitted, None).await
|
||||
response_with_encoder(response, admitted, None, true).await
|
||||
}
|
||||
|
||||
pub(super) async fn response_with_encoder(
|
||||
pub(in crate::openai) async fn response_with_encoder(
|
||||
response: impl Future<Output = Response> + Send + 'static,
|
||||
admitted: oneshot::Receiver<ResponseCommit>,
|
||||
error_encoder: Option<fn(&Value) -> Bytes>,
|
||||
append_done: bool,
|
||||
) -> Response {
|
||||
let mut response = Box::pin(response);
|
||||
// 鉴权、模型门禁和准入失败仍保留原 HTTP 状态,不提前建立 SSE
|
||||
@@ -48,7 +49,7 @@ pub(super) async fn response_with_encoder(
|
||||
if !commit.record(Some(StatusCode::OK.as_u16())) {
|
||||
return response.await;
|
||||
}
|
||||
let pending = PendingResponse::Waiting(response, error_encoder);
|
||||
let pending = PendingResponse::Waiting(response, error_encoder, append_done);
|
||||
let stream = stream::once(async { Ok::<_, axum::Error>(SSE_KEEPALIVE_FRAME) })
|
||||
.chain(stream::try_unfold(pending, PendingResponse::next));
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
@@ -66,7 +67,11 @@ pub(super) async fn response_with_encoder(
|
||||
}
|
||||
|
||||
enum PendingResponse {
|
||||
Waiting(BoxFuture<'static, Response>, Option<fn(&Value) -> Bytes>),
|
||||
Waiting(
|
||||
BoxFuture<'static, Response>,
|
||||
Option<fn(&Value) -> Bytes>,
|
||||
bool,
|
||||
),
|
||||
Streaming(BodyDataStream),
|
||||
}
|
||||
|
||||
@@ -74,7 +79,7 @@ impl PendingResponse {
|
||||
async fn next(mut self) -> Result<Option<(Bytes, Self)>, axum::Error> {
|
||||
loop {
|
||||
match &mut self {
|
||||
Self::Waiting(response, error_encoder) => {
|
||||
Self::Waiting(response, error_encoder, append_done) => {
|
||||
// 心跳只轮询同一个 future,不取消重建首帧等待或插件调用
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
@@ -93,7 +98,7 @@ impl PendingResponse {
|
||||
let body = if sse {
|
||||
response.into_body()
|
||||
} else {
|
||||
stream_failure(response, *error_encoder).await
|
||||
stream_failure(response, *error_encoder, *append_done).await
|
||||
};
|
||||
// 正式响应的正文已经带有交付期心跳,后续只转交,不叠加定时器
|
||||
self = Self::Streaming(body.into_data_stream());
|
||||
@@ -110,7 +115,11 @@ impl PendingResponse {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) -> Bytes>) -> Body {
|
||||
async fn stream_failure(
|
||||
response: Response,
|
||||
error_encoder: Option<fn(&Value) -> Bytes>,
|
||||
append_done: bool,
|
||||
) -> Body {
|
||||
let status = response.status();
|
||||
let retry_after = response
|
||||
.headers()
|
||||
@@ -157,6 +166,8 @@ async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) ->
|
||||
Some(encode) => encode(&data).to_vec(),
|
||||
None => encode_sse_event("response.failed", &data.to_string()).into_bytes(),
|
||||
};
|
||||
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
|
||||
if append_done {
|
||||
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
|
||||
}
|
||||
Body::from(event)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user