mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3915aa259b | ||
|
|
d310286e33 | ||
|
|
ea4e3059db | ||
|
|
3bf7030fba | ||
|
|
5d5965c6ca | ||
|
|
0710ed1f7c | ||
|
|
4634d8dc6d | ||
|
|
8a0759905e | ||
|
|
e830b2ef48 | ||
|
|
ae8da00459 | ||
|
|
7bbea42fba | ||
|
|
2e0669d462 | ||
|
|
2df9494e99 | ||
|
|
0534dd8f26 | ||
|
|
525f3d51c1 | ||
|
|
33cc427b86 | ||
|
|
d1bf90d5fd | ||
|
|
eb23a0fb3b | ||
|
|
ca90f2b586 | ||
|
|
cfcf7c486a | ||
|
|
b981e42342 | ||
|
|
02177af921 | ||
|
|
2d20f03bcb | ||
|
|
c12c600b42 | ||
|
|
1c6b5a8f10 | ||
|
|
589e1bc999 | ||
|
|
2956f07723 | ||
|
|
bb76399e59 | ||
|
|
66a3474baf | ||
|
|
5e7196cfbf | ||
|
|
a340bec472 | ||
|
|
5782c71f73 | ||
|
|
999fcc1770 | ||
|
|
b920dcec83 | ||
|
|
21c0711a66 | ||
|
|
f770ba127d | ||
|
|
66719f22e2 | ||
|
|
00a1956df7 |
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库开发与问题审查入口。用户要求“开发/实现这个功能”“直接改”“修复一下”“看下报错”“审查这个问题/代码”“自审当前改动”“调整页面”“更新文档”,或继续修改本仓库的 Rust、Vue、协议、存储、部署和 CI 时主动使用,无需点名技能。负责定位、方案、自审与验证;提交或审查 PR 另配合 cpr-github-pr。Use for implementation, debugging, code review and documentation in this repository, not generic advice for unrelated projects.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
|
||||
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 插件 SDK、Runtime 或宿主能力扩展 | 先执行下方「插件架构边界」,结合 [系统架构](../../../docs/architecture.md) 定位原生能力归属 |
|
||||
| 开发独立网关插件 | 先执行下方「插件架构边界」,再使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [cpr-github-issue](../cpr-github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [cpr-release](../cpr-release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
|
||||
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
|
||||
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
|
||||
|
||||
## 后端任务
|
||||
|
||||
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
|
||||
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
|
||||
|
||||
## 插件架构边界
|
||||
|
||||
**底线:宿主可以暴露底层能力,但不能替插件完成业务。** 开发或审查插件 SDK、Runtime、宿主接口及独立网关插件时,必须先明确宿主与插件各自的职责;具体架构依据见 [系统架构](../../../docs/architecture.md)。
|
||||
|
||||
- 宿主提供独立、可组合的受控原生能力,负责授权、资源隔离、事务和审计,复用已有服务与状态归属。
|
||||
插件自身的触发条件、跨资源业务关联、策略和流程编排由插件实现;不能将某个 Issue 的使用场景直接做成宿主的组合流程,或为其增加专属业务状态与补偿逻辑。
|
||||
- 开发独立网关插件时,在插件内通过公开 SDK 组合用户要求的业务。发现能力缺口时,先界定可独立使用的基础接口,不能把整个插件业务搬进宿主。
|
||||
- 基础接口必须说明输入输出、权限、事务、失败及重复调用语义;这些合同不应夹带对插件业务工作流或私有数据模型的要求。
|
||||
- 自审完整调用链、协议、测试与文档,检查是否混入插件业务判断、自动串联或专属状态;仅更换通用名称不算消除业务耦合。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定或评估方案。结合上表读取所需文档,先找可复用的同类实现,再按授权修改或审查所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 复核相对目标基线的完整差异及上下游:开发时清理本次引入的职责越界、重复逻辑、冗余状态和无依据的分支;仅审查时报告问题与依据。页面与后端分别按上述任务入口执行,插件相关变更还须逐项核对「插件架构边界」,不等待 PR 审查代做开发自审。
|
||||
6. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
7. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。用户要求提交或更新 PR 时,进入 `cpr-github-pr` 流程整理最终证据与正文。
|
||||
|
||||
发布任务按 `cpr-release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-dev-guide"
|
||||
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
|
||||
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: github-issue
|
||||
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
|
||||
name: cpr-github-issue
|
||||
description: 整理、创建和补充 GitHub Issue。用户说“提/开/提交一个 Issue”“报个 bug 到仓库”“反馈给上游”“把问题或功能建议整理成 Issue”“补充这个 Issue”时主动使用;已有 Issue 反馈上下文中的“提交吧”“补上复现”也适用。处理缺陷、功能与协作规则反馈,区分草拟和发布。Use for drafting, filing and updating issues; standalone “审查问题/看下报错/修一下” belongs to cpr-dev-guide, not automatic Issue submission.
|
||||
---
|
||||
|
||||
# 整理与提交 Issue
|
||||
@@ -16,6 +16,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
|
||||
@@ -54,6 +55,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
|
||||
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
|
||||
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
|
||||
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-issue"
|
||||
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
|
||||
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: github-pr
|
||||
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
|
||||
name: cpr-github-pr
|
||||
description: 准备、提交、更新与审查 GitHub PR。用户说“提交 PR/提个 PR/发起拉取请求”“把改动放到这个 PR”“改下 PR 描述”“看下/review/审查这个 PR”“处理审查意见”“这个 PR 能合并吗”,或提供 /pull/ 链接、PR 编号并要求处理时主动使用;已有 PR 上下文中的“提交吧”“继续改”也适用。包含问题核实、自审、页面截图、AI 模型披露与验证。Use for pull request preparation, creation, updates and review; plain git commits or issue debugging without PR context do not create a PR.
|
||||
---
|
||||
|
||||
# 创建与审查 PR
|
||||
# 提交、更新与审查 PR
|
||||
|
||||
## 先确定模式和仓库
|
||||
|
||||
@@ -11,6 +11,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
|
||||
| --- | --- |
|
||||
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
|
||||
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
|
||||
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
|
||||
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
|
||||
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
|
||||
|
||||
@@ -27,6 +28,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
|
||||
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
|
||||
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
|
||||
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
|
||||
|
||||
## 创建和审查前的核对
|
||||
|
||||
@@ -46,7 +48,13 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
|
||||
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
|
||||
|
||||
## 创建 PR
|
||||
## 页面证据与 AI 披露
|
||||
|
||||
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
|
||||
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
|
||||
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
|
||||
|
||||
## 创建或更新 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
@@ -55,19 +63,19 @@ PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
|
||||
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
@@ -95,11 +103,12 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-pr"
|
||||
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: plugin-dev
|
||||
description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查和打包 Codex Proxy RS 网关插件。用户说“写/做一个网关插件”“给插件加能力或页面”“改 plugin.json”“接入公开 SDK”“插件不生效/接入报错”“打包插件”时主动使用,覆盖能力选择、SDK 处理器、管理页面和安装包验证。Use for gateway plugin authoring and debugging; host Runtime/installer changes use cpr-dev-guide, not this workflow, and OpenAI .codex-plugin or Vue plugins are outside its scope.
|
||||
---
|
||||
|
||||
# Codex Proxy 插件开发
|
||||
# cpr-plugin-dev
|
||||
|
||||
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
|
||||
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
|
||||
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
|
||||
|
||||
## 先建立正确的模型
|
||||
|
||||
@@ -25,7 +26,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
|
||||
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
|
||||
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
@@ -35,6 +36,9 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
| --- | --- | --- |
|
||||
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
|
||||
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
|
||||
| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 |
|
||||
| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 |
|
||||
| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next |
|
||||
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
|
||||
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
|
||||
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
@@ -50,6 +54,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
@@ -59,13 +64,14 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
|
||||
### 只有需要页面时才创建前端
|
||||
|
||||
- 沿用已有插件页面与 [管理端主题](../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../CONTRIBUTING.md#界面验证) 提供实际截图
|
||||
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
|
||||
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
|
||||
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-plugin-dev"
|
||||
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
+13
-3
@@ -8,8 +8,8 @@
|
||||
| --- | --- |
|
||||
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
|
||||
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
|
||||
| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground |
|
||||
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
@@ -72,13 +72,23 @@ cpr-plugin package \
|
||||
```
|
||||
|
||||
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
|
||||
目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
目标平台指运行宿主的服务器/容器,不是访问管理端的浏览器所在电脑;平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
|
||||
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
|
||||
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
|
||||
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 发布与安装来源
|
||||
|
||||
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
|
||||
|
||||
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
|
||||
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
|
||||
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
|
||||
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
|
||||
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
name: cpr-release
|
||||
description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个版本/正式发版”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“发布 alpha/beta/rc/exp/实验版”“打发行 tag”“重试失败发版”时主动使用;已有发版上下文中的“发布吧”“继续发布”也适用。Use for release planning, notes, publishing and recovery; deployment/restarts and ordinary Git tag cleanup are not release requests.
|
||||
---
|
||||
|
||||
# 发版
|
||||
@@ -19,6 +19,7 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
|
||||
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
需要修复本仓库代码、工作流或文档时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备或更新相关 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md),不以发版步骤替代开发自审。
|
||||
|
||||
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
|
||||
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
|
||||
@@ -45,9 +46,9 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
|
||||
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
|
||||
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
|
||||
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
|
||||
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-release"
|
||||
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
|
||||
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: dev-guide
|
||||
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
|
||||
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "dev-guide"
|
||||
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
|
||||
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-issue"
|
||||
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
|
||||
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-pr"
|
||||
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
|
||||
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Codex Proxy 插件开发"
|
||||
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
|
||||
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "release"
|
||||
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
|
||||
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
|
||||
@@ -0,0 +1,20 @@
|
||||
## 问题与改动
|
||||
|
||||
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
|
||||
|
||||
## 自审与验证
|
||||
|
||||
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
|
||||
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
|
||||
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
|
||||
|
||||
## 页面截图
|
||||
|
||||
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
|
||||
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
|
||||
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
|
||||
|
||||
## AI 使用
|
||||
|
||||
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
|
||||
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
|
||||
@@ -146,6 +146,21 @@ jobs:
|
||||
working-directory: backend
|
||||
run: cargo test --test main --locked
|
||||
|
||||
- name: Store tests with Redis ACL authentication
|
||||
working-directory: backend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
redis_acl_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$redis_acl_password"
|
||||
docker exec "${{ job.services.redis.id }}" redis-cli -e --no-auth-warning \
|
||||
-u "$CPR_TEST_REDIS_URL" --user default ACL SETUSER cpr_acl reset on \
|
||||
">$redis_acl_password" '~*' '&*' '+@all' >/dev/null
|
||||
docker exec --env REDISCLI_AUTH="$redis_acl_password" "${{ job.services.redis.id }}" \
|
||||
redis-cli -e --user cpr_acl ACL SETUSER default off >/dev/null
|
||||
export CPR_TEST_REDIS_URL="redis://cpr_acl:${redis_acl_password}@127.0.0.1:6379"
|
||||
cargo test -p gateway-store --test main --locked
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source: ${{ steps.filter.outputs.source }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -45,6 +46,13 @@ jobs:
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/**'
|
||||
source:
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
@@ -72,3 +80,60 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
|
||||
- name: Check UI source integration
|
||||
run: |
|
||||
pnpm --dir frontend build:source
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
- name: Check local SDK integration
|
||||
env:
|
||||
RUST_MIN_STACK: 16777216
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
|
||||
run: |
|
||||
sdk_check_dir="$RUNNER_TEMP/plugin-source"
|
||||
mkdir -p "$sdk_check_dir/backend"
|
||||
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
|
||||
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
|
||||
- name: Ensure source checks preserve lockfiles
|
||||
run: |
|
||||
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
|
||||
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.vite/
|
||||
/frontend/.pnpm-store/
|
||||
/frontend/.pnpm-cache/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "modules/ui"]
|
||||
path = modules/ui
|
||||
url = https://github.com/zyycn/codex-proxy-ui.git
|
||||
[submodule "modules/plugins"]
|
||||
path = modules/plugins
|
||||
url = https://github.com/zyycn/codex-proxy-plugins.git
|
||||
@@ -1,6 +1,17 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
|
||||
按用户意图和当前会话主动加载并执行仓库技能,不要求用户写出 `$技能名`:
|
||||
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
|
||||
| 提交/提个 PR、准备或更新 PR、提交到已有 PR、看下 PR、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
|
||||
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入。
|
||||
任务进入新阶段时补载对应技能,不能只读取文件而跳过其中的问题核实、自审、验证与交付步骤。协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
|
||||
@@ -19,10 +30,13 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
|
||||
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
@@ -36,8 +50,8 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
|
||||
+30
-14
@@ -31,8 +31,8 @@
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
|
||||
@@ -41,17 +41,25 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
|
||||
| [plugin-dev](.agents/skills/plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$plugin-dev 开发一个添加响应头的插件` |
|
||||
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
|
||||
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
|
||||
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
|
||||
| [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) | 项目开发与文档导航 | `$cpr-dev-guide 修复当前问题` |
|
||||
| [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$cpr-plugin-dev 开发一个添加响应头的插件` |
|
||||
| [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md) | 准备、创建、更新或审查 PR | `$cpr-github-pr 创建当前分支的 PR` / `$cpr-github-pr 审查 PR #编号` |
|
||||
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
|
||||
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
|
||||
### AI 使用披露
|
||||
|
||||
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
|
||||
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
|
||||
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
|
||||
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
@@ -99,7 +107,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
@@ -110,13 +118,21 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
|
||||
|
||||
开发前查找同类页面及同级 `codex-proxy-ui/src/components` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
|
||||
|
||||
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
|
||||
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
|
||||
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
|
||||
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
|
||||
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
|
||||
@@ -132,15 +148,15 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。普通开发与生产构建都使用 UI 编译产物,源码联调通过 `@codex-proxy/ui/vite` 显式开启。
|
||||
|
||||
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级标签时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
|
||||
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级依赖时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
|
||||
|
||||
```bash
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
```
|
||||
|
||||
需要修改组件库时,按 [UI 源码联调指南](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/guide/development.md) 建立临时本地链接;提交前恢复发布依赖并核对锁文件,不能将开发机目录写入发行依赖。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
|
||||
需要联合修改组件库或插件时,使用 [源码联调](docs/development.md) 中的子模块与开发入口;联调只临时替换解析路径,不改写正式依赖或锁文件。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1830,7 +1830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.4.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -187,6 +187,14 @@ async fn launch(
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
@@ -231,6 +239,14 @@ async fn launch(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_key_ports(&plugin_keys)
|
||||
);
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
@@ -322,6 +338,7 @@ async fn launch(
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。
|
||||
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
|
||||
|
||||
fn source_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"manifestVersion":3,
|
||||
"manifestVersion":1,
|
||||
"name":"request-workbench",
|
||||
"displayName":"Request Workbench",
|
||||
"publisher":"codex-proxy",
|
||||
|
||||
@@ -527,7 +527,7 @@ pub fn initialize_plugin_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口。
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
providers: ports::provider::ProviderAdminRegistry,
|
||||
@@ -539,6 +539,15 @@ pub fn initialize_plugin_client_keys(
|
||||
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
|
||||
}
|
||||
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_resources(
|
||||
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
|
||||
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
|
||||
@@ -223,6 +223,16 @@ pub struct AccountPage {
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
pub capacity: AccountCapacity,
|
||||
}
|
||||
|
||||
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountCapacity {
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲。
|
||||
pub used_slots: Option<u64>,
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
|
||||
@@ -10,7 +10,10 @@ use gateway_core::{
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
use super::{
|
||||
AdminModelError, Revision, account_groups::AccountGroupRef,
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
pub type ProviderRequestProfileOverrides =
|
||||
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
|
||||
@@ -219,6 +222,21 @@ pub struct ResetClientKeyBudget {
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
pub id: ClientApiKeyId,
|
||||
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientKeyBudgetMutationOrigin {
|
||||
Admin,
|
||||
Plugin(PluginResourceOwner),
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_resources;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider_credentials;
|
||||
|
||||
@@ -1154,7 +1154,6 @@ pub struct DiagnosticsItem {
|
||||
pub non_completion_rate: f64,
|
||||
pub retry_count: u64,
|
||||
pub retry_rate: f64,
|
||||
pub impact_score: f64,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
//! 插件可见的 Client Key 非秘密资源投影。
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyFacts {
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyCursor {
|
||||
pub name: String,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
|
||||
|
||||
use super::Revision;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginResourceOwner {
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub revision: Revision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedResource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub struct ResourceMutation<T> {
|
||||
/// 没有事实变化时不递增配置 revision,也不触发发布。
|
||||
pub revision: Option<Revision>,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
pub struct GroupMembersChange {
|
||||
pub resource_key: String,
|
||||
pub add: Vec<String>,
|
||||
pub remove: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupMembersChanged {
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
|
||||
pub struct ManagedKeyConfig {
|
||||
pub name: String,
|
||||
pub limits: gateway_core::policy::RateLimits,
|
||||
pub budget: gateway_core::engine::budget::ClientBudgetLimits,
|
||||
}
|
||||
@@ -1227,6 +1227,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capacity: super::accounts::AccountCapacity,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct RuntimeSettings {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -73,6 +74,7 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SystemUpdateChannel {
|
||||
Stable,
|
||||
Alpha,
|
||||
Beta,
|
||||
Rc,
|
||||
#[serde(rename = "exp")]
|
||||
Experimental,
|
||||
}
|
||||
|
||||
impl SystemUpdateChannel {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Beta => "beta",
|
||||
Self::Rc => "rc",
|
||||
Self::Experimental => "exp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 返回的本次检查通道及当前构建允许选择的范围。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemUpdatePolicy {
|
||||
pub channel: SystemUpdateChannel,
|
||||
pub available_channels: Vec<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
/// 当前运行版本及更新检查摘要。
|
||||
///
|
||||
@@ -23,6 +57,7 @@ pub struct SystemVersion {
|
||||
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateDetail {
|
||||
pub policy: SystemUpdatePolicy,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_management;
|
||||
pub mod plugin_release;
|
||||
pub mod plugin_resources;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
|
||||
@@ -26,6 +26,22 @@ pub trait PluginAccountAccess: Send + Sync {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<PluginAccountCredential, AdminError>;
|
||||
|
||||
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
|
||||
async fn get_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
|
||||
}
|
||||
|
||||
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
Err(AdminError::unavailable("插件额度观测刷新暂不可用"))
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
//! 插件发现 Client Key 公开身份的窄端口。
|
||||
//! 插件 Client Key 非秘密目录与受控预算管理端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::model::{
|
||||
AdminError,
|
||||
plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
AdminError, MutationContext,
|
||||
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
|
||||
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 插件自有资源的管理入口与原子持久化端口。
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{CreateAccountGroup, NewAccountGroup},
|
||||
client_keys::NewClientKey,
|
||||
plugin_resources::{
|
||||
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
|
||||
PluginResourceOwner, ResourceMutation,
|
||||
},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginResourceAccess: Send + Sync {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
command: CreateAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError>;
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
groups: Vec<String>,
|
||||
command: ManagedKeyConfig,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError>;
|
||||
async fn change_members(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: GroupMembersChange,
|
||||
context: &MutationContext,
|
||||
) -> Result<GroupMembersChanged, AdminError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginResourceStore: Send + Sync {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
command: NewAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
groups: Vec<String>,
|
||||
command: NewClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
|
||||
async fn change_members(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: GroupMembersChange,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<GroupMembersChanged>>;
|
||||
}
|
||||
@@ -22,8 +22,9 @@ use crate::model::{
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
|
||||
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
@@ -332,10 +333,19 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>>;
|
||||
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
@@ -515,9 +525,15 @@ pub struct AdminStorePorts {
|
||||
backup: BackupStorePorts,
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
|
||||
self.plugin_resources.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
|
||||
Arc::clone(&self.plugins)
|
||||
@@ -542,6 +558,7 @@ impl AdminStorePorts {
|
||||
backup: BackupStorePorts,
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -552,6 +569,7 @@ impl AdminStorePorts {
|
||||
backup,
|
||||
plugins,
|
||||
plugin_state,
|
||||
plugin_resources,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ use futures::Stream;
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
|
||||
@@ -89,6 +89,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
@@ -96,6 +97,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ impl DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: stored.capacity,
|
||||
capabilities: provider
|
||||
.account_capabilities(account_id, &stored.account.authentication_kind),
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
@@ -402,6 +403,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
.iter()
|
||||
.map(|item| item.account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
|
||||
let in_flight = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match self.account_runtime.account_runtime(&ids).await {
|
||||
Ok(runtime) => runtime.in_flight,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "account capacity projection is unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let rolling_usage = self
|
||||
.accounts
|
||||
.load_account_usage(rolling_range, &ids)
|
||||
@@ -472,6 +485,12 @@ impl AccountsService for DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: crate::model::accounts::AccountCapacity {
|
||||
used_slots: in_flight
|
||||
.as_ref()
|
||||
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
|
||||
..item.capacity
|
||||
},
|
||||
capabilities,
|
||||
plan_type_display: providers.resolve_account_plan(
|
||||
item.account.provider_kind.as_str(),
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use gateway_core::{
|
||||
engine::budget::ClientBudgetStatus, policy::ClientApiKeyId, runtime::SnapshotControl,
|
||||
};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -13,10 +14,10 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
|
||||
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
|
||||
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
@@ -27,6 +28,7 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Client Key 管理服务。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
async fn create(
|
||||
@@ -49,10 +51,18 @@ pub trait ClientKeyService: Send + Sync {
|
||||
context: &MutationContext,
|
||||
command: DeleteClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
}
|
||||
|
||||
@@ -79,14 +89,48 @@ impl DefaultClientKeyService {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
|
||||
self.store
|
||||
.get_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
|
||||
}
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
|
||||
self.get(id).await.map(|key| key.budget)
|
||||
}
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
if command.daily_limit_usd.is_none() && command.weekly_limit_usd.is_none() {
|
||||
return Err(AdminError::invalid("至少指定一个预算上限"));
|
||||
}
|
||||
let id = command.id.clone();
|
||||
if let Some(revision) = self
|
||||
.store
|
||||
.update_client_key_budget_limits(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
{
|
||||
publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
let id = command.id.clone();
|
||||
self.store
|
||||
.reset_client_key_budget(command, context)
|
||||
.reset_client_key_budget(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
Ok(id)
|
||||
@@ -124,9 +168,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
let plaintext = if let Some(key) = command.custom_key {
|
||||
key.expose_for_auth().to_owned()
|
||||
} else {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
generate_key()
|
||||
};
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
@@ -257,3 +299,10 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
Err(AdminError::invalid("Client API Key 游标不合法"))
|
||||
}
|
||||
}
|
||||
|
||||
// 原生与插件创建共用相同的密钥生成规则。
|
||||
pub(super) fn generate_key() -> String {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod plugin_accounts;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_resources;
|
||||
pub(crate) mod plugin_update;
|
||||
pub mod plugins;
|
||||
pub mod proxies;
|
||||
|
||||
@@ -342,21 +342,13 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
let mut items = items
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let impact_score = diagnostic_impact_score(
|
||||
item.request_count,
|
||||
total_requests,
|
||||
error_rate,
|
||||
non_completion_rate,
|
||||
retry_rate,
|
||||
item.first_token_p95_ms,
|
||||
);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
@@ -372,19 +364,12 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
non_completion_rate,
|
||||
retry_count: item.retry_count,
|
||||
retry_rate,
|
||||
impact_score,
|
||||
estimated_cost: usd_cost(&item.costs),
|
||||
attempt_count: item.attempt_count,
|
||||
total_tokens: item.total_tokens,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.impact_score
|
||||
.total_cmp(&left.impact_score)
|
||||
.then_with(|| right.request_count.cmp(&left.request_count))
|
||||
});
|
||||
Ok(DiagnosticsResult { dimension, items })
|
||||
}
|
||||
|
||||
@@ -724,25 +709,6 @@ fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
|
||||
value.as_str().parse().ok()
|
||||
}
|
||||
|
||||
fn diagnostic_impact_score(
|
||||
request_count: u64,
|
||||
total_requests: u64,
|
||||
error_rate: f64,
|
||||
non_completion_rate: f64,
|
||||
retry_rate: f64,
|
||||
first_token_p95_ms: Option<u64>,
|
||||
) -> f64 {
|
||||
let request_share = rate_or_zero(request_count, total_requests);
|
||||
let slow_score = first_token_p95_ms
|
||||
.map_or(0.0, |value| value as f64 / 30_000.0)
|
||||
.min(1.0);
|
||||
error_rate * 0.35
|
||||
+ non_completion_rate * 0.25
|
||||
+ retry_rate.min(1.0) * 0.20
|
||||
+ request_share * 0.10
|
||||
+ slow_score * 0.10
|
||||
}
|
||||
|
||||
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
costs
|
||||
.iter()
|
||||
|
||||
@@ -45,6 +45,22 @@ impl DefaultPluginAccountAccess {
|
||||
}
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
let account = self.get_runtime(account_id).await?;
|
||||
self.provider(&account.provider_kind)?
|
||||
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "plugin quota facts"))
|
||||
}
|
||||
|
||||
fn provider(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -95,6 +111,20 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
self.quota(account_id, false).await
|
||||
}
|
||||
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
self.quota(account_id, true).await
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。
|
||||
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
|
||||
ClientKeySort, ClientKeySortField, SortDirection,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
|
||||
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
|
||||
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
|
||||
PluginClientKeyPage,
|
||||
},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
},
|
||||
ports::plugin_client_keys::PluginClientKeyAccess,
|
||||
use_case::client_keys::ClientKeyService,
|
||||
@@ -31,6 +35,49 @@ impl DefaultPluginClientKeyAccess {
|
||||
|
||||
#[async_trait]
|
||||
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
|
||||
let key = self.service.get(id).await?;
|
||||
Ok(PluginClientKeyFacts {
|
||||
id: key.id,
|
||||
enabled: key.enabled,
|
||||
group_ids: key.groups.into_iter().map(|group| group.id).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
|
||||
self.service.budget(id).await
|
||||
}
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
self.service
|
||||
.update_budget_limits(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError> {
|
||||
self.service
|
||||
.reset_budget(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
query: PluginClientKeyListQuery,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{CreateAccountGroup, NewAccountGroup},
|
||||
client_keys::NewClientKey,
|
||||
plugin_resources::{
|
||||
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
|
||||
PluginResourceOwner, ResourceMutation,
|
||||
},
|
||||
},
|
||||
ports::plugin_resources::{PluginResourceAccess, PluginResourceStore},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId, runtime::SnapshotControl};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct DefaultPluginResourceAccess {
|
||||
pub store: Arc<dyn PluginResourceStore>,
|
||||
pub snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultPluginResourceAccess {
|
||||
async fn publish<T>(
|
||||
&self,
|
||||
result: crate::ports::store::AdminStoreResult<ResourceMutation<T>>,
|
||||
) -> Result<T, AdminError> {
|
||||
let result = result.map_err(|error| super::map_store_error(error, "plugin resource"))?;
|
||||
if let Some(revision) = result.revision {
|
||||
super::publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
}
|
||||
Ok(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginResourceAccess for DefaultPluginResourceAccess {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
command: CreateAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError> {
|
||||
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
|
||||
self.publish(
|
||||
self.store
|
||||
.ensure_group(
|
||||
owner,
|
||||
resource_key,
|
||||
NewAccountGroup {
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
color: command.color,
|
||||
disable_fast: command.disable_fast,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
groups: Vec<String>,
|
||||
command: ManagedKeyConfig,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError> {
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Key ID 失败"))?;
|
||||
self.publish(
|
||||
self.store
|
||||
.ensure_key(
|
||||
owner,
|
||||
resource_key,
|
||||
groups,
|
||||
NewClientKey {
|
||||
id,
|
||||
name: command.name,
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: command.limits,
|
||||
budget: command.budget,
|
||||
request_profile_overrides: Default::default(),
|
||||
plaintext: super::client_keys::generate_key(),
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn change_members(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: GroupMembersChange,
|
||||
context: &MutationContext,
|
||||
) -> Result<GroupMembersChanged, AdminError> {
|
||||
self.publish(self.store.change_members(owner, command, context).await)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,11 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
|
||||
.filter(|(capability, _)| {
|
||||
!matches!(
|
||||
capability.as_str(),
|
||||
"frontend_authentication" | "management" | "command_line"
|
||||
"frontend_authentication"
|
||||
| "management"
|
||||
| "command_line"
|
||||
| "model_catalog"
|
||||
| "maintenance"
|
||||
)
|
||||
})
|
||||
.flat_map(|(_, contribution)| {
|
||||
@@ -390,6 +394,8 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
|
||||
order: 0,
|
||||
failure_policy: if stage == "observation" {
|
||||
PluginFailurePolicy::Observe
|
||||
} else if stage == "retry" {
|
||||
PluginFailurePolicy::Delegate
|
||||
} else {
|
||||
PluginFailurePolicy::Reject
|
||||
},
|
||||
|
||||
@@ -7,7 +7,10 @@ use async_trait::async_trait;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
system::{
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
},
|
||||
},
|
||||
ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
|
||||
@@ -19,11 +22,16 @@ use crate::{
|
||||
#[async_trait]
|
||||
pub trait SystemService: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, AdminError>;
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError>;
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
@@ -55,9 +63,13 @@ impl SystemService for DefaultSystemService {
|
||||
self.operations.version().await.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError> {
|
||||
self.operations
|
||||
.update_detail(refresh)
|
||||
.update_detail(refresh, channel)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
@@ -69,12 +81,13 @@ impl SystemService for DefaultSystemService {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemOperationAccepted, AdminError> {
|
||||
let target_version = target_version
|
||||
.map(|version| version.trim().to_owned())
|
||||
.filter(|version| !version.is_empty());
|
||||
self.operations
|
||||
.perform_update(target_version, Arc::clone(&self.preflight))
|
||||
.perform_update(target_version, channel, Arc::clone(&self.preflight))
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
@@ -55,7 +58,8 @@ use gateway_admin::{
|
||||
ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry,
|
||||
},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore,
|
||||
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreErrorKind,
|
||||
AdminStoreResult, SettingsStore,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -668,6 +672,12 @@ impl FakeAccountStore {
|
||||
last_error_message: account.last_error_message.clone(),
|
||||
};
|
||||
AccountPageItem {
|
||||
capacity: gateway_admin::model::accounts::AccountCapacity {
|
||||
used_slots: None,
|
||||
total_slots: account
|
||||
.concurrency_limit
|
||||
.map(|limit| u64::from(limit.get())),
|
||||
},
|
||||
account,
|
||||
projection: resolve_account_status(&facts, std::time::SystemTime::now()),
|
||||
}
|
||||
@@ -1105,6 +1115,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
@@ -3404,3 +3415,136 @@ async fn unregistered_provider_accounts_have_no_live_management_capabilities() {
|
||||
);
|
||||
assert_eq!(page.items[0].capabilities, Default::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_quota_refresh_uses_native_provider_without_changing_configuration() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let access = gateway_admin::initialize_plugin_accounts(
|
||||
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
|
||||
store,
|
||||
Arc::new(RecordingPluginAccountPublication(events.clone())),
|
||||
);
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
access.get_quota(&id).await.unwrap();
|
||||
access.refresh_quota(&id).await.unwrap();
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
vec![
|
||||
ProviderQuotaRequest {
|
||||
account_id: id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None
|
||||
},
|
||||
ProviderQuotaRequest {
|
||||
account_id: id,
|
||||
refresh: true,
|
||||
rolling_usage: None
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(!recorded(&events).contains(&"snapshot.publish_committed"));
|
||||
}
|
||||
|
||||
struct CapacityRuntime {
|
||||
counts: Option<BTreeMap<String, u64>>,
|
||||
fail: bool,
|
||||
reads: Mutex<Vec<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for CapacityRuntime {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn account_runtime(&self, ids: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
self.reads.lock().unwrap().push(ids.to_vec());
|
||||
if self.fail {
|
||||
return Err(store_unavailable());
|
||||
}
|
||||
Ok(AccountRuntimeSnapshot {
|
||||
in_flight: self.counts.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(&self, _: &[String]) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &gateway_admin::model::accounts::AccountFreeze,
|
||||
_: Option<chrono::DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavailable() {
|
||||
for (counts, fail, expected) in [
|
||||
(
|
||||
Some(BTreeMap::from([("acct_test".to_owned(), 3)])),
|
||||
false,
|
||||
Some(3),
|
||||
),
|
||||
(Some(BTreeMap::new()), false, Some(0)),
|
||||
(None, false, None),
|
||||
(None, true, None),
|
||||
] {
|
||||
for empty in [false, true] {
|
||||
let runtime = Arc::new(CapacityRuntime {
|
||||
counts: counts.clone(),
|
||||
fail,
|
||||
reads: Mutex::default(),
|
||||
});
|
||||
let mut account = account_record("openai");
|
||||
account.concurrency_limit = gateway_core::account::AccountConcurrencyLimit::new(5);
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
if empty {
|
||||
store.set_accounts(Vec::new());
|
||||
}
|
||||
let services = super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.account_runtime(runtime.clone())
|
||||
.settings(Arc::new(StaticSettingsStore))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if empty {
|
||||
assert!(page.items.is_empty());
|
||||
assert!(runtime.reads.lock().unwrap().is_empty());
|
||||
} else {
|
||||
assert_eq!(
|
||||
*runtime.reads.lock().unwrap(),
|
||||
[vec!["acct_test".to_owned()]]
|
||||
);
|
||||
assert_eq!(page.items[0].capacity.used_slots, expected);
|
||||
assert_eq!(page.items[0].capacity.total_slots, Some(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,19 @@ struct TestClientKeyStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
self.resets.lock().unwrap().push(command);
|
||||
@@ -147,7 +157,11 @@ async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
|
||||
};
|
||||
let result = services
|
||||
.client_keys()
|
||||
.reset_budget(&mutation_context(), command.clone())
|
||||
.reset_budget(
|
||||
&mutation_context(),
|
||||
command.clone(),
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin::Admin,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, command.id);
|
||||
|
||||
@@ -38,6 +38,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -258,6 +258,7 @@ impl AdminHarness {
|
||||
self.backup,
|
||||
self.plugin_store,
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
plugin_preparation: Arc::new(plugins::TestPluginPorts),
|
||||
@@ -643,9 +644,19 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>> {
|
||||
Err(unavailable("client key budget limits"))
|
||||
}
|
||||
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
Err(unavailable("client key budget reset"))
|
||||
@@ -1035,7 +1046,11 @@ impl SystemOperations for UnavailableSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
@@ -1046,6 +1061,7 @@ impl SystemOperations for UnavailableSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
|
||||
@@ -502,7 +502,31 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
);
|
||||
assert_eq!(diagnostics.items[0].request_share, 0.75);
|
||||
assert_eq!(diagnostics.items[1].request_share, 0.25);
|
||||
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
let now = Utc::now();
|
||||
let range = observation_range(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let success = diagnostic("popular", 100);
|
||||
let mut failure = diagnostic("single_failure", 1);
|
||||
failure.success_count = 0;
|
||||
failure.failure_count = 1;
|
||||
store.replace_diagnostics(vec![success, failure]);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("diagnostics");
|
||||
|
||||
assert_eq!(result.items[0].key, "popular");
|
||||
assert_eq!(result.items[1].key, "single_failure");
|
||||
assert_eq!(result.items[0].error_count, 0);
|
||||
assert_eq!(result.items[1].error_count, 1);
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -821,6 +845,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -225,7 +225,11 @@ impl SystemOperations for PreflightingSystem {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
@@ -236,6 +240,7 @@ impl SystemOperations for PreflightingSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
let revision = preflight.validate(self.candidate.clone()).await?;
|
||||
@@ -291,7 +296,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
|
||||
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.expect("compatible update");
|
||||
}
|
||||
@@ -309,7 +314,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -325,7 +330,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
|
||||
assert!(
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some("1.2.0".into()))
|
||||
.perform_update(Some("1.2.0".into()), None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -401,3 +401,46 @@ impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts {
|
||||
Err(AdminError::invalid("unused plugin fixture"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl gateway_admin::ports::plugin_resources::PluginResourceStore for TestPluginPorts {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: String,
|
||||
_: gateway_admin::model::account_groups::NewAccountGroup,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::ManagedResource,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: String,
|
||||
_: Vec<String>,
|
||||
_: gateway_admin::model::client_keys::NewClientKey,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::ManagedResource,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
async fn change_members(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: gateway_admin::model::plugin_resources::GroupMembersChange,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::GroupMembersChanged,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -13,6 +13,7 @@ use gateway_admin::{
|
||||
#[derive(Default)]
|
||||
struct RecordingSystemOperations {
|
||||
target: Mutex<Option<Option<String>>>,
|
||||
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -24,6 +25,7 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
build_time: "unknown".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
update_channel: "stable".to_owned(),
|
||||
|
||||
latest_version: "1.0.0".to_owned(),
|
||||
has_update: false,
|
||||
update_cached: false,
|
||||
@@ -31,8 +33,16 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Ok(SystemUpdateDetail {
|
||||
policy: gateway_admin::model::system::SystemUpdatePolicy {
|
||||
channel: gateway_admin::model::system::SystemUpdateChannel::Stable,
|
||||
available_channels: vec![gateway_admin::model::system::SystemUpdateChannel::Stable],
|
||||
},
|
||||
current_version: "1.0.0".to_owned(),
|
||||
latest_version: "1.0.0".to_owned(),
|
||||
has_update: false,
|
||||
@@ -54,9 +64,11 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
*self.target.lock().expect("target") = Some(target_version.clone());
|
||||
*self.channel.lock().expect("channel") = channel;
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id: "operation-update".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
@@ -111,9 +123,29 @@ async fn system_update_should_normalize_blank_target_to_latest() {
|
||||
.await;
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some(" ".to_owned()))
|
||||
.perform_update(Some(" ".to_owned()), None)
|
||||
.await
|
||||
.expect("perform update");
|
||||
|
||||
assert_eq!(*operations.target.lock().expect("target"), Some(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_should_forward_the_confirmed_channel() {
|
||||
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.system(operations.clone())
|
||||
.build()
|
||||
.await;
|
||||
let channel = gateway_admin::model::system::SystemUpdateChannel::Beta;
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some(" 1.2.0-beta.1 ".into()), Some(channel))
|
||||
.await
|
||||
.expect("accepted");
|
||||
assert_eq!(
|
||||
*operations.target.lock().expect("target"),
|
||||
Some(Some("1.2.0-beta.1".into()))
|
||||
);
|
||||
assert_eq!(*operations.channel.lock().expect("channel"), Some(channel));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
|
||||
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
let AccountDirectoryItem {
|
||||
account,
|
||||
capacity,
|
||||
capabilities,
|
||||
plan_type_display,
|
||||
projection,
|
||||
@@ -109,6 +110,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
error_message: projection.error_message,
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
capacity: AccountCapacityView {
|
||||
used_slots: capacity.used_slots,
|
||||
total_slots: capacity.total_slots,
|
||||
},
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_endpoint: account
|
||||
|
||||
@@ -290,6 +290,7 @@ pub struct AccountView {
|
||||
pub error_message: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<u32>,
|
||||
pub capacity: AccountCapacityView,
|
||||
pub weight: u16,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub access_token_expires_at: Option<String>,
|
||||
@@ -304,6 +305,13 @@ pub struct AccountView {
|
||||
pub usage: AccountUsageView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapacityView {
|
||||
pub used_slots: Option<u64>,
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapabilitiesView {
|
||||
|
||||
@@ -977,6 +977,7 @@ where
|
||||
.reset_budget(
|
||||
&auth.context().mutation_context(),
|
||||
payload.into_command().map_err(map_wire_error)?,
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin::Admin,
|
||||
)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
|
||||
@@ -1294,7 +1294,6 @@ pub(crate) fn diagnostics_view(
|
||||
non_completion_rate: item.non_completion_rate,
|
||||
retry_count: item.retry_count,
|
||||
retry_rate: item.retry_rate,
|
||||
impact_score: item.impact_score,
|
||||
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
|
||||
attempt_count: item.attempt_count,
|
||||
total_tokens: item.total_tokens,
|
||||
|
||||
@@ -758,7 +758,6 @@ pub struct DiagnosticItemView {
|
||||
pub non_completion_rate: f64,
|
||||
pub retry_count: u64,
|
||||
pub retry_rate: f64,
|
||||
pub impact_score: f64,
|
||||
pub estimated_cost: Option<String>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
|
||||
@@ -37,6 +37,7 @@ pub type ProviderRequestProfileUpdates =
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
|
||||
pub provider_request_profiles: ProviderRequestProfiles,
|
||||
/// 固定兼容字段;值始终从 provider_request_profiles 派生。
|
||||
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
@@ -53,6 +54,7 @@ pub struct RuntimeSettingsView {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -95,6 +97,7 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -227,6 +230,7 @@ impl UpdateRuntimeSettingsRequest {
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: self.smart_scheduling,
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
min_codex_desktop_version: self.min_codex_desktop_version,
|
||||
@@ -274,6 +278,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
|
||||
@@ -20,8 +20,8 @@ use axum::{
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
|
||||
SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -35,9 +35,15 @@ use super::{
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
impl UpdateDetailQuery {
|
||||
#[must_use]
|
||||
pub const fn channel(&self) -> Option<SystemUpdateChannel> {
|
||||
self.channel
|
||||
}
|
||||
|
||||
/// 是否强制从发布源刷新。
|
||||
#[must_use]
|
||||
pub fn refresh(&self) -> bool {
|
||||
@@ -50,6 +56,7 @@ impl UpdateDetailQuery {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRequest {
|
||||
target_version: String,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
impl UpdateRequest {
|
||||
@@ -95,6 +102,7 @@ impl From<SystemVersion> for SystemVersionView {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemUpdateDetailView {
|
||||
policy: SystemUpdatePolicy,
|
||||
current_version: String,
|
||||
latest_version: String,
|
||||
has_update: bool,
|
||||
@@ -115,6 +123,7 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
Self {
|
||||
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
|
||||
build_type_label: build_type_label(&detail.build_type),
|
||||
policy: detail.policy,
|
||||
current_version: detail.current_version,
|
||||
latest_version: detail.latest_version,
|
||||
has_update: detail.has_update,
|
||||
@@ -263,7 +272,7 @@ where
|
||||
let detail = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_detail(query.refresh())
|
||||
.update_detail(query.refresh(), query.channel())
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
@@ -299,10 +308,13 @@ async fn perform_update<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (target, channel) = payload.map_or((None, None), |AdminJson(value)| {
|
||||
(Some(value.target_version), value.channel)
|
||||
});
|
||||
let result = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.perform_update(payload.map(|AdminJson(value)| value.into_target_version()))
|
||||
.perform_update(target, channel)
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
let SystemOperationAccepted::Update {
|
||||
|
||||
@@ -54,11 +54,11 @@ where
|
||||
input,
|
||||
Box::new(move |prepared, request| {
|
||||
Box::pin(async move {
|
||||
let (protocol, headers, body) = request_parts(request)?;
|
||||
let (protocol, headers, body) = request_parts(request.clone())?;
|
||||
if protocol != "openai" {
|
||||
return Err(MiddlewareError::Rejected);
|
||||
}
|
||||
let operation = decode(body, &headers)?;
|
||||
let operation = request.apply_capabilities(decode(body, &headers)?)?;
|
||||
let started = match service
|
||||
.start_prepared_provider_endpoint(
|
||||
prepared, operation, client_ip, user_agent, endpoint,
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(crate) async fn execute_prepared_responses(
|
||||
input,
|
||||
Box::new(move |prepared, request| {
|
||||
Box::pin(async move {
|
||||
let (protocol, request_headers, request_body) = request_parts(request)?;
|
||||
let (protocol, request_headers, request_body) = request_parts(request.clone())?;
|
||||
if protocol != OPENAI_PROTOCOL {
|
||||
return Err(MiddlewareError::Rejected);
|
||||
}
|
||||
@@ -174,7 +174,8 @@ pub(crate) async fn execute_prepared_responses(
|
||||
maximum_body_bytes,
|
||||
)
|
||||
.map_err(|_| MiddlewareError::Rejected)?
|
||||
.with_client_context(client_ip, user_agent);
|
||||
.with_client_context(client_ip, user_agent)
|
||||
.with_middleware_capabilities(&request)?;
|
||||
if decoded.metadata().stream() != stream {
|
||||
return Err(MiddlewareError::Rejected);
|
||||
}
|
||||
|
||||
@@ -272,6 +272,14 @@ pub struct DecodedResponsesRequest {
|
||||
}
|
||||
|
||||
impl DecodedResponsesRequest {
|
||||
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 {
|
||||
@@ -576,6 +584,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
| "chatgpt-project-id"
|
||||
| "openai-organization"
|
||||
| "openai-project"
|
||||
| "x-openai-account-routing-override"
|
||||
| "x-openai-fedramp"
|
||||
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
|
||||
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
|
||||
| "originator"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 下游客户端 WebSocket 的单 owner pump 与有界收发边界。
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fmt,
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -14,7 +15,7 @@ use futures::{Sink, SinkExt, Stream, StreamExt};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot},
|
||||
sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
time::{Instant, timeout},
|
||||
};
|
||||
@@ -48,6 +49,21 @@ pub enum ConnectionEvent {
|
||||
Exited(PumpExitReason),
|
||||
}
|
||||
|
||||
/// 接收队列与活动期间暂存的请求共用容量;移动帧不释放它占用的名额。
|
||||
pub(super) struct PendingConnectionEvent {
|
||||
pub(super) event: ConnectionEvent,
|
||||
_permit: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
impl PendingConnectionEvent {
|
||||
fn exited(reason: PumpExitReason) -> Self {
|
||||
Self {
|
||||
event: ConnectionEvent::Exited(reason),
|
||||
_permit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FramePhase {
|
||||
@@ -213,7 +229,8 @@ pub struct ResponsesWebSocketConnection {
|
||||
opened_at: Instant,
|
||||
expired: Arc<AtomicBool>,
|
||||
commands: Option<mpsc::Sender<ConnectionCommand>>,
|
||||
incoming: mpsc::Receiver<ConnectionEvent>,
|
||||
incoming: mpsc::Receiver<PendingConnectionEvent>,
|
||||
deferred: VecDeque<PendingConnectionEvent>,
|
||||
exited: oneshot::Receiver<PumpExitReason>,
|
||||
pump_task: Option<JoinHandle<()>>,
|
||||
stats: Arc<ConnectionStats>,
|
||||
@@ -251,17 +268,43 @@ impl ResponsesWebSocketConnection {
|
||||
|
||||
/// 等待下一个需要业务层处理的客户端事件。
|
||||
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
|
||||
if self.exit_reason.is_none() {
|
||||
match self.exited.try_recv() {
|
||||
Ok(reason) => {
|
||||
self.exit_reason = Some(reason);
|
||||
}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
|
||||
self.exit_reason = Some(PumpExitReason::PumpStopped);
|
||||
}
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
if let Some(reason) = self.exit_reason {
|
||||
return Some(ConnectionEvent::Exited(reason));
|
||||
}
|
||||
if let Some(event) = self.deferred.pop_front() {
|
||||
return Some(event.event);
|
||||
}
|
||||
self.next_active_event().await.map(|event| event.event)
|
||||
}
|
||||
|
||||
/// 活动响应期间只消费新到的帧,已排队的下一轮请求继续保持串行。
|
||||
pub(super) async fn next_active_event(&mut self) -> Option<PendingConnectionEvent> {
|
||||
if let Some(reason) = self.exit_reason {
|
||||
return Some(PendingConnectionEvent::exited(reason));
|
||||
}
|
||||
let event = tokio::select! {
|
||||
biased;
|
||||
reason = &mut self.exited => {
|
||||
Some(ConnectionEvent::Exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
|
||||
Some(PendingConnectionEvent::exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
|
||||
}
|
||||
event = self.incoming.recv() => event,
|
||||
};
|
||||
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
|
||||
if let Some(PendingConnectionEvent {
|
||||
event: ConnectionEvent::Exited(reason),
|
||||
..
|
||||
}) = event.as_ref()
|
||||
{
|
||||
self.exit_reason.get_or_insert(*reason);
|
||||
} else if event.is_none() {
|
||||
self.exit_reason.get_or_insert(PumpExitReason::PumpStopped);
|
||||
@@ -269,6 +312,10 @@ impl ResponsesWebSocketConnection {
|
||||
event
|
||||
}
|
||||
|
||||
pub(super) fn defer(&mut self, event: PendingConnectionEvent) {
|
||||
self.deferred.push_back(event);
|
||||
}
|
||||
|
||||
/// 等待连接退出,不消费留给后续串行请求的业务帧。
|
||||
pub async fn wait_for_exit(&mut self) -> PumpExitReason {
|
||||
if let Some(reason) = self.exit_reason {
|
||||
@@ -494,6 +541,7 @@ where
|
||||
expired,
|
||||
commands: Some(command_tx),
|
||||
incoming: incoming_rx,
|
||||
deferred: VecDeque::new(),
|
||||
exited: exit_rx,
|
||||
pump_task: Some(pump_task),
|
||||
stats,
|
||||
@@ -506,7 +554,7 @@ where
|
||||
async fn run_pump<S, E>(
|
||||
mut socket: S,
|
||||
mut commands: mpsc::Receiver<ConnectionCommand>,
|
||||
incoming: mpsc::Sender<ConnectionEvent>,
|
||||
incoming: mpsc::Sender<PendingConnectionEvent>,
|
||||
exited: oneshot::Sender<PumpExitReason>,
|
||||
connection_id: Arc<str>,
|
||||
cancellation: CancellationToken,
|
||||
@@ -518,6 +566,7 @@ async fn run_pump<S, E>(
|
||||
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin,
|
||||
E: fmt::Display,
|
||||
{
|
||||
let event_slots = Arc::new(Semaphore::new(INBOUND_EVENT_BUFFER));
|
||||
let deadline = tokio::time::sleep_until(opened_at + config.max_age);
|
||||
tokio::pin!(deadline);
|
||||
let mut deadline_elapsed = false;
|
||||
@@ -536,11 +585,9 @@ async fn run_pump<S, E>(
|
||||
expired.store(true, Ordering::Release);
|
||||
// 满队列本身已能唤醒空闲协调层;到期标志仍阻止启动下一轮。
|
||||
// 不能因无法追加 Expired 通知而中断正在收尾的响应。
|
||||
match incoming.try_send(ConnectionEvent::Expired) {
|
||||
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
break PumpExitReason::CoordinatorDropped;
|
||||
}
|
||||
match emit_incoming(&incoming, &event_slots, ConnectionEvent::Expired) {
|
||||
Ok(()) | Err(PumpExitReason::InboundOverload) => {}
|
||||
Err(reason) => break reason,
|
||||
}
|
||||
}
|
||||
command = commands.recv() => {
|
||||
@@ -608,12 +655,12 @@ async fn run_pump<S, E>(
|
||||
}
|
||||
match message {
|
||||
Some(Ok(Message::Text(payload))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
|
||||
if let Err(reason) = emit_incoming(&incoming, &event_slots, ConnectionEvent::Text(payload.to_string())) {
|
||||
break reason;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
|
||||
if let Err(reason) = emit_incoming(&incoming, &event_slots, ConnectionEvent::Binary) {
|
||||
break reason;
|
||||
}
|
||||
}
|
||||
@@ -662,11 +709,20 @@ async fn run_pump<S, E>(
|
||||
}
|
||||
|
||||
fn emit_incoming(
|
||||
incoming: &mpsc::Sender<ConnectionEvent>,
|
||||
incoming: &mpsc::Sender<PendingConnectionEvent>,
|
||||
slots: &Arc<Semaphore>,
|
||||
event: ConnectionEvent,
|
||||
) -> Result<(), PumpExitReason> {
|
||||
incoming.try_send(event).map_err(|error| match error {
|
||||
mpsc::error::TrySendError::Full(_) => PumpExitReason::InboundOverload,
|
||||
mpsc::error::TrySendError::Closed(_) => PumpExitReason::CoordinatorDropped,
|
||||
})
|
||||
let permit = Arc::clone(slots)
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| PumpExitReason::InboundOverload)?;
|
||||
incoming
|
||||
.try_send(PendingConnectionEvent {
|
||||
event,
|
||||
_permit: Some(permit),
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
mpsc::error::TrySendError::Full(_) => PumpExitReason::InboundOverload,
|
||||
mpsc::error::TrySendError::Closed(_) => PumpExitReason::CoordinatorDropped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use gateway_core::engine::middleware::{
|
||||
MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader,
|
||||
MiddlewareResponse,
|
||||
};
|
||||
use gateway_core::engine::response_control::ResponseControl;
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::ProviderResponseHeader;
|
||||
use gateway_core::operation::ProviderSessionState;
|
||||
@@ -22,8 +23,10 @@ use crate::openai::responses::validation::{ResponseValidationFacts, ResponsesDel
|
||||
|
||||
use super::{
|
||||
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
|
||||
connection::{FramePhase, ResponsesWebSocketConnection, WriteContext},
|
||||
protocol::{error_event, initial_engine_error_event, response_metadata_event},
|
||||
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
|
||||
protocol::{
|
||||
decode_response_interrupt, error_event, initial_engine_error_event, response_metadata_event,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -144,6 +147,7 @@ pub(super) async fn forward_response(
|
||||
replay: &mut ConnectionReplaySnapshot,
|
||||
capture: ReplayCaptureHandle,
|
||||
validation: ResponseValidationFacts,
|
||||
response_control: ResponseControl,
|
||||
) -> ForwardOutcome {
|
||||
let (protocol, status, headers, mut body, _) = response.into_parts();
|
||||
if protocol != "openai" || StatusCode::from_u16(status).is_err() {
|
||||
@@ -156,7 +160,9 @@ pub(super) async fn forward_response(
|
||||
ProviderResponseHeader::new(name, value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let first = match next_body_input(connection, body.as_mut()).await {
|
||||
let first = match next_body_input(connection, body.as_mut(), &response_control, &request_id)
|
||||
.await
|
||||
{
|
||||
BodyInput::Frame(Ok(Some(frame))) => frame,
|
||||
BodyInput::Frame(Ok(None)) => {
|
||||
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
|
||||
@@ -200,10 +206,33 @@ pub(super) async fn forward_response(
|
||||
loop {
|
||||
let frame = match current.take() {
|
||||
Some(frame) => frame,
|
||||
None => match next_body_input(connection, body.as_mut()).await {
|
||||
BodyInput::Frame(Ok(Some(frame))) => frame,
|
||||
BodyInput::Frame(Ok(None)) if terminal_seen && body.is_finalized() => {
|
||||
if delivery_validator.finish_websocket_delivery().is_err() {
|
||||
None => {
|
||||
match next_body_input(connection, body.as_mut(), &response_control, &request_id)
|
||||
.await
|
||||
{
|
||||
BodyInput::Frame(Ok(Some(frame))) => frame,
|
||||
BodyInput::Frame(Ok(None)) if terminal_seen && body.is_finalized() => {
|
||||
if delivery_validator.finish_websocket_delivery().is_err() {
|
||||
return fail_body(
|
||||
connection,
|
||||
body,
|
||||
MiddlewareError::InvalidState,
|
||||
&request_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
{
|
||||
let captured = capture
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(response_id) = captured.response_id.clone() {
|
||||
replay.commit(response_id, captured.provider_state.clone());
|
||||
}
|
||||
}
|
||||
body.close().await;
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
BodyInput::Frame(Ok(None)) => {
|
||||
return fail_body(
|
||||
connection,
|
||||
body,
|
||||
@@ -212,29 +241,15 @@ pub(super) async fn forward_response(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
{
|
||||
let captured = capture
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(response_id) = captured.response_id.clone() {
|
||||
replay.commit(response_id, captured.provider_state.clone());
|
||||
}
|
||||
BodyInput::Frame(Err(error)) => {
|
||||
return fail_body(connection, body, error, &request_id).await;
|
||||
}
|
||||
BodyInput::Disconnect => {
|
||||
detach_body(body);
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
body.close().await;
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
BodyInput::Frame(Ok(None)) => {
|
||||
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id)
|
||||
.await;
|
||||
}
|
||||
BodyInput::Frame(Err(error)) => {
|
||||
return fail_body(connection, body, error, &request_id).await;
|
||||
}
|
||||
BodyInput::Disconnect => {
|
||||
detach_body(body);
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
if terminal_seen || validate_frame(&frame).is_err() {
|
||||
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
|
||||
@@ -293,11 +308,45 @@ enum BodyInput {
|
||||
async fn next_body_input(
|
||||
connection: &mut ResponsesWebSocketConnection,
|
||||
body: &mut dyn MiddlewareBody,
|
||||
response_control: &ResponseControl,
|
||||
request_id: &Arc<str>,
|
||||
) -> BodyInput {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = connection.wait_for_exit() => BodyInput::Disconnect,
|
||||
frame = body.next_frame() => BodyInput::Frame(frame),
|
||||
// 处理控制帧时继续持有同一个读取 future,不能取消正在加工正文的中间件。
|
||||
let frame = body.next_frame();
|
||||
tokio::pin!(frame);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = connection.next_active_event() => {
|
||||
let Some(event) = event else { return BodyInput::Disconnect; };
|
||||
match &event.event {
|
||||
ConnectionEvent::Text(payload) => {
|
||||
let error = match decode_response_interrupt(payload) {
|
||||
Ok(Some(response_id)) => response_control.interrupt(&response_id).err().map(|_| {
|
||||
super::super::RequestDecodeError::InvalidValue { field: "response_id".to_owned() }.protocol_body()
|
||||
}),
|
||||
Ok(None) => {
|
||||
connection.defer(event);
|
||||
continue;
|
||||
}
|
||||
Err(error) => Some(error.protocol_body()),
|
||||
};
|
||||
if let Some(error) = error
|
||||
&& send_protocol_error(connection, StatusCode::BAD_REQUEST, error, request_id).await == ForwardOutcome::Disconnect
|
||||
{
|
||||
return BodyInput::Disconnect;
|
||||
}
|
||||
}
|
||||
ConnectionEvent::Expired => {
|
||||
// 允许当前响应收尾,外层仍会在下一轮准入前关闭过期连接。
|
||||
}
|
||||
ConnectionEvent::Binary => {
|
||||
connection.defer(event);
|
||||
}
|
||||
ConnectionEvent::Exited(_) => return BodyInput::Disconnect,
|
||||
}
|
||||
}
|
||||
frame = &mut frame => return BodyInput::Frame(frame),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
}
|
||||
};
|
||||
let request_id = Arc::<str>::from(prepared.request_id().to_string());
|
||||
let response_control = prepared.response_control();
|
||||
let capture = new_replay_capture();
|
||||
let validation = ResponseValidationFacts::default();
|
||||
let input = HttpMiddlewareInput {
|
||||
@@ -278,7 +279,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
input,
|
||||
Box::new(move |prepared, request| {
|
||||
Box::pin(async move {
|
||||
let (protocol, headers, body) = request_parts(request)?;
|
||||
let (protocol, headers, body) = request_parts(request.clone())?;
|
||||
if protocol != "openai" {
|
||||
return Err(MiddlewareError::Rejected);
|
||||
}
|
||||
@@ -291,7 +292,9 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
let decoded = decode_response_create_with_context(payload, &request_headers)
|
||||
.map_err(|_| MiddlewareError::Rejected)?
|
||||
.with_client_context(client_ip, user_agent_for_terminal);
|
||||
let decoded = replay_for_terminal.prepare(decoded);
|
||||
let decoded = replay_for_terminal
|
||||
.prepare(decoded)
|
||||
.with_middleware_capabilities(&request)?;
|
||||
let started = service_for_terminal
|
||||
.start_prepared_response(
|
||||
prepared,
|
||||
@@ -341,6 +344,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
|
||||
&mut replay,
|
||||
capture,
|
||||
validation,
|
||||
response_control,
|
||||
)
|
||||
.await
|
||||
== ForwardOutcome::Disconnect
|
||||
|
||||
@@ -31,6 +31,44 @@ pub fn decode_response_create_with_context(
|
||||
decode_response_create_inner(payload, request_headers)
|
||||
}
|
||||
|
||||
/// 中断只引用活动响应;普通创建帧仍交给原有解码与串行准入路径。
|
||||
pub(super) fn decode_response_interrupt(
|
||||
payload: &str,
|
||||
) -> Result<Option<String>, ResponseCreateFrameError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FrameType {
|
||||
#[serde(rename = "type")]
|
||||
message_type: Option<String>,
|
||||
}
|
||||
// 非控制帧仍在原来的串行请求边界校验,不能抢先拒绝排队的普通请求。
|
||||
// 只投影类型,避免为排队的大型 response.create 再构造完整 JSON 树。
|
||||
let Ok(frame) = serde_json::from_str::<FrameType>(payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if frame.message_type.as_deref() != Some("response.interrupt") {
|
||||
return Ok(None);
|
||||
}
|
||||
let value: Value =
|
||||
serde_json::from_str(payload).map_err(|_| ResponseCreateFrameError::InvalidJson)?;
|
||||
if value.get("mode").and_then(Value::as_str) != Some("discard_partial_items") {
|
||||
return Err(ResponseCreateFrameError::Request(
|
||||
RequestDecodeError::InvalidValue {
|
||||
field: "mode".to_owned(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let id = value
|
||||
.get("response_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ResponseCreateFrameError::Request(RequestDecodeError::InvalidValue {
|
||||
field: "response_id".to_owned(),
|
||||
})
|
||||
})?;
|
||||
Ok(Some(id.to_owned()))
|
||||
}
|
||||
|
||||
fn decode_response_create_inner(
|
||||
payload: &str,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
|
||||
@@ -337,7 +337,39 @@ mod provider {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_public_errors_survive_import_and_refresh_http_handlers() {
|
||||
async fn unsupported_quota_should_preserve_account_capacity_wire() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (used_slots, total_slots) in [(Some(3), Some(5)), (Some(0), None), (None, Some(10))] {
|
||||
let mut stored = account("openai");
|
||||
stored.capacity = gateway_admin::model::accounts::AccountCapacity {
|
||||
used_slots,
|
||||
total_slots,
|
||||
};
|
||||
*fixture.account.lock().unwrap() = Some(stored);
|
||||
let mut request = request(
|
||||
Method::GET,
|
||||
"/api/admin/accounts/quota?accountId=acct_error_test",
|
||||
Body::empty(),
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
|
||||
let response = app(fixture.state()).oneshot(request).await.unwrap();
|
||||
let (status, _, body) = response_json(response).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
body["data"]["account"]["capacity"],
|
||||
json!({
|
||||
"usedSlots": used_slots,
|
||||
"totalSlots": total_slots,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_public_errors_survive_import_credential_and_quota_refresh_handlers() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for provider in ["openai", "xai"] {
|
||||
@@ -383,6 +415,10 @@ mod provider {
|
||||
"/api/admin/accounts/import",
|
||||
json!({"provider": provider, "data": {"accessToken": "at-synthetic-test"}}),
|
||||
),
|
||||
(
|
||||
"/api/admin/accounts/quota/refresh",
|
||||
json!({"accountId": "acct_error_test"}),
|
||||
),
|
||||
] {
|
||||
let response = send(&fixture, path, body).await;
|
||||
assert_eq!(response.headers()["x-request-id"], "req_admin_errors");
|
||||
@@ -468,6 +504,10 @@ mod provider {
|
||||
last_error_message: None,
|
||||
};
|
||||
AccountPageItem {
|
||||
capacity: gateway_admin::model::accounts::AccountCapacity {
|
||||
used_slots: None,
|
||||
total_slots: None,
|
||||
},
|
||||
account: AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
|
||||
@@ -169,6 +169,7 @@ impl AdminTestFixture {
|
||||
gateway_admin::ports::backup::BackupStorePorts::disabled(),
|
||||
plugin_ports.clone(),
|
||||
plugin_ports.clone(),
|
||||
plugin_ports.clone(),
|
||||
);
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
|
||||
@@ -556,6 +557,7 @@ impl SettingsStore for MemorySettingsStore {
|
||||
max_waiting_per_account: command.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: command.smart_scheduling,
|
||||
rotation_strategy: command.rotation_strategy,
|
||||
min_codex_desktop_version: command.min_codex_desktop_version,
|
||||
min_codex_cli_version: command.min_codex_cli_version,
|
||||
@@ -861,9 +863,23 @@ fn mutation(
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for MemoryClientKeyStore {
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>> {
|
||||
Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget update",
|
||||
))
|
||||
}
|
||||
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
use gateway_admin::model::client_keys::ClientKeyBudgetPeriod;
|
||||
@@ -1024,6 +1040,9 @@ impl AccountStore for UnusedStore {
|
||||
_: TimeRange,
|
||||
_: &[String],
|
||||
) -> AdminStoreResult<Vec<AccountUsage>> {
|
||||
if self.account.lock().expect("account").is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(unavailable("account usage"))
|
||||
}
|
||||
|
||||
@@ -1409,7 +1428,12 @@ impl ProviderAdmin for UnusedProvider {
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
Err(self
|
||||
.error
|
||||
.lock()
|
||||
.expect("provider error")
|
||||
.clone()
|
||||
.unwrap_or_else(unsupported_provider))
|
||||
}
|
||||
|
||||
async fn models(
|
||||
@@ -1456,7 +1480,11 @@ impl SystemOperations for UnusedSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
@@ -1467,6 +1495,7 @@ impl SystemOperations for UnusedSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
@@ -1513,6 +1542,7 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -595,6 +595,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.await
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert!(value["data"]["items"][0].get("impactScore").is_none());
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["items"][0]["key"],
|
||||
|
||||
@@ -1008,3 +1008,46 @@ async fn version_plan_requires_admin_and_an_explicit_target() {
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl gateway_admin::ports::plugin_resources::PluginResourceStore for TestPluginPorts {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: String,
|
||||
_: gateway_admin::model::account_groups::NewAccountGroup,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::ManagedResource,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: String,
|
||||
_: Vec<String>,
|
||||
_: gateway_admin::model::client_keys::NewClientKey,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::ManagedResource,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
async fn change_members(
|
||||
&self,
|
||||
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
|
||||
_: gateway_admin::model::plugin_resources::GroupMembersChange,
|
||||
_: &gateway_admin::model::MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::plugin_resources::ResourceMutation<
|
||||
gateway_admin::model::plugin_resources::GroupMembersChanged,
|
||||
>,
|
||||
> {
|
||||
unreachable!("resource port is not used by this fixture")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn update_body() -> Value {
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
"usageRetentionDays": 32,
|
||||
@@ -70,6 +71,76 @@ fn update_body() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_intact() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let mut body = update_body();
|
||||
let custom = json!({"loadWeight": 2.0, "quotaWeight": 0.0, "healthWeight": 1.0, "latencyWeight": 0.5, "resetWeight": 1.2, "queueWeight": 2.3, "preferHigherWeight": true});
|
||||
body["smartScheduling"] = custom.clone();
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body.clone()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = response_json(response).await;
|
||||
assert_eq!(response["data"]["smartScheduling"], custom);
|
||||
assert_eq!(
|
||||
response["data"]["smartSchedulingDefaults"],
|
||||
json!(gateway_core::account::SmartSchedulingConfig::default())
|
||||
);
|
||||
let mut invalid_values = vec![
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"loadWeight":0,"quotaWeight":0,"healthWeight":0,"latencyWeight":0,"resetWeight":0,"queueWeight":0,"preferHigherWeight":true}),
|
||||
json!({"loadWeight":0.01,"quotaWeight":1,"healthWeight":1,"latencyWeight":1,"resetWeight":0,"queueWeight":0,"preferHigherWeight":false}),
|
||||
];
|
||||
for field in ["resetWeight", "queueWeight"] {
|
||||
for value in [json!(-0.1), json!(10.1), json!(0.01), json!(null)] {
|
||||
let mut invalid = custom.clone();
|
||||
invalid[field] = value;
|
||||
invalid_values.push(invalid);
|
||||
}
|
||||
let mut missing = custom.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
invalid_values.push(missing);
|
||||
}
|
||||
for invalid in invalid_values {
|
||||
body["smartScheduling"] = invalid;
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body.clone()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
body.as_object_mut().unwrap().remove("smartScheduling");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response_json(response).await["data"]["smartScheduling"],
|
||||
custom
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
let mut body = update_body();
|
||||
@@ -149,6 +220,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::RoundRobin,
|
||||
min_codex_desktop_version: Some("26.825.6671".to_owned()),
|
||||
min_codex_cli_version: Some("0.40.0".to_owned()),
|
||||
@@ -193,6 +265,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
|
||||
"smartSchedulingDefaults": gateway_core::account::SmartSchedulingConfig::default(),
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
"usageRetentionDays": 32,
|
||||
@@ -257,6 +331,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: request.smart_scheduling,
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
.expect("fixture rotation strategy"),
|
||||
min_codex_desktop_version: request.min_codex_desktop_version,
|
||||
@@ -290,6 +365,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
expected_fields.insert("openaiClientProfile".to_owned());
|
||||
expected_fields.insert("xaiClientProfile".to_owned());
|
||||
expected_fields.insert("updatedAt".to_owned());
|
||||
expected_fields.insert("smartSchedulingDefaults".to_owned());
|
||||
|
||||
assert_eq!(response_fields, expected_fields);
|
||||
}
|
||||
|
||||
@@ -125,8 +125,29 @@ impl SystemOperations for ProgressSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
use gateway_admin::model::system::{SystemUpdateChannel, SystemUpdatePolicy};
|
||||
Ok(SystemUpdateDetail {
|
||||
policy: SystemUpdatePolicy {
|
||||
channel: channel.unwrap_or(SystemUpdateChannel::Stable),
|
||||
available_channels: vec![SystemUpdateChannel::Stable, SystemUpdateChannel::Beta],
|
||||
},
|
||||
current_version: "0.1.0".into(),
|
||||
latest_version: "0.2.0".into(),
|
||||
has_update: true,
|
||||
deployment_mode: "docker".into(),
|
||||
build_type: "release".into(),
|
||||
release_url: None,
|
||||
notes: None,
|
||||
cached: false,
|
||||
update_supported: true,
|
||||
unsupported_reason: None,
|
||||
warning: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
@@ -147,6 +168,7 @@ impl SystemOperations for ProgressSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
@@ -186,3 +208,39 @@ impl SystemOperations for ProgressSystem {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_detail_should_validate_and_forward_the_temporary_channel() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let router = app(fixture.state());
|
||||
for (authenticated, query, expected) in [
|
||||
(false, "channel=beta", StatusCode::UNAUTHORIZED),
|
||||
(true, "channel=nightly", StatusCode::BAD_REQUEST),
|
||||
(true, "channel=beta&extra=true", StatusCode::BAD_REQUEST),
|
||||
(true, "channel=beta", StatusCode::OK),
|
||||
(true, "", StatusCode::OK),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri(format!("/api/admin/system/update/detail?{query}"))
|
||||
.header("x-request-id", "req_channel_check");
|
||||
if authenticated {
|
||||
request = request.header(header::COOKIE, "cpr_session=valid-session");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).expect("request"))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), expected);
|
||||
if expected == StatusCode::OK {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
|
||||
.expect("json");
|
||||
assert_eq!(
|
||||
body["data"]["policy"]["channel"],
|
||||
if query.is_empty() { "stable" } else { "beta" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/openai/responses/request.rs",
|
||||
"tests/openai/responses/websocket/connection.rs",
|
||||
"tests/openai/responses/websocket/forward.rs",
|
||||
"tests/openai/responses/websocket/interrupt.rs",
|
||||
"tests/openai/responses/websocket/mod.rs",
|
||||
"tests/openai/responses/websocket/protocol.rs",
|
||||
"tests/openai/router.rs",
|
||||
|
||||
@@ -313,6 +313,10 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
HeaderValue::from_static("client-attestation"),
|
||||
);
|
||||
headers.insert("x-oai-is", HeaderValue::from_static("client-is"));
|
||||
for name in ["x-openai-account-routing-override", "x-openai-fedramp"] {
|
||||
headers.append(name, HeaderValue::from_static("first"));
|
||||
headers.append(name, HeaderValue::from_static("second"));
|
||||
}
|
||||
headers.insert(
|
||||
"x-oai-is-update",
|
||||
HeaderValue::from_static("client-is-update"),
|
||||
@@ -381,6 +385,8 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
"cookie",
|
||||
"chatgpt-account-id",
|
||||
"chatgpt-project-id",
|
||||
"x-openai-account-routing-override",
|
||||
"x-openai-fedramp",
|
||||
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
|
||||
"user-agent",
|
||||
"originator",
|
||||
|
||||
@@ -916,6 +916,33 @@ async fn queued_binary_frame_should_be_rejected_after_the_active_response_finish
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_deferred_during_active_response_share_the_inbound_queue_limit() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
for _ in 0..33 {
|
||||
if socket
|
||||
.send(ClientMessage::Text(
|
||||
json!({"type":"response.create","model":"model-a","input":"queued"})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
trace.cancellation_observed.notified(),
|
||||
)
|
||||
.await
|
||||
.expect("queue overload cancels the active execution");
|
||||
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
|
||||
assert!(trace.cancelled.load(Ordering::Acquire));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_close_should_cancel_active_execution_without_starting_queued_requests() {
|
||||
let (trace, mut socket, server) = start_active_response().await;
|
||||
|
||||
@@ -522,6 +522,24 @@ async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_s
|
||||
assert!(error["headers"].get("retry-after").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_continuation_recovery_delivers_the_official_client_replay_signal() {
|
||||
let provider = ProviderError::new(
|
||||
ProviderErrorKind::ContinuationRecoveryRequired,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Previous response was not found. Retrying the full request.",
|
||||
Some("previous_response_not_found".to_owned()),
|
||||
Some("invalid_request_error".to_owned()),
|
||||
));
|
||||
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
|
||||
assert_eq!(error["type"], "error");
|
||||
assert_eq!(error["status"], 400);
|
||||
assert_eq!(error["error"]["code"], "previous_response_not_found");
|
||||
assert_eq!(error["error"]["type"], "invalid_request_error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locally_exhausted_account_pool_sends_one_official_usage_limit_error() {
|
||||
let provider = ProviderError::new(
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::*;
|
||||
use gateway_core::engine::response_control::{ResponseControl, ResponseInterruptError};
|
||||
|
||||
#[derive(Default)]
|
||||
struct InterruptProvider {
|
||||
calls: AtomicUsize,
|
||||
controls: Mutex<Vec<ResponseControl>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for InterruptProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"openai"
|
||||
}
|
||||
fn catalog_generation(&self) -> ProviderCatalogGeneration {
|
||||
ProviderCatalogGeneration::default()
|
||||
}
|
||||
async fn query_model_capabilities(
|
||||
&self,
|
||||
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
self: Arc<Self>,
|
||||
request: ProviderRequest,
|
||||
context: AttemptContext,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
let number = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let id = format!("resp_interrupt_{number}");
|
||||
let control = context
|
||||
.response_control()
|
||||
.expect("root control propagated through Core")
|
||||
.clone();
|
||||
let active = control.activate(id.clone()).unwrap();
|
||||
self.controls.lock().unwrap().push(control);
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().unwrap().clone(),
|
||||
ProviderAccountId::new("acct_api_test").unwrap(),
|
||||
UpstreamTransport::new("websocket").unwrap(),
|
||||
);
|
||||
let body = futures::stream::unfold(
|
||||
(0, Some(active), id),
|
||||
|(phase, mut active, id)| async move {
|
||||
let (event_type, status, event) = match phase {
|
||||
0 => (
|
||||
"response.created",
|
||||
"in_progress",
|
||||
GatewayEvent::Started(ResponseMeta::new(&id, "model-a")),
|
||||
),
|
||||
1 => {
|
||||
active.as_ref().unwrap().requested().await;
|
||||
drop(active.take());
|
||||
(
|
||||
"response.incomplete",
|
||||
"incomplete",
|
||||
GatewayEvent::Completed(ResponseMeta::new(&id, "model-a")),
|
||||
)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut response = json!({"id":id,"model":"model-a","status":status,"output":[]});
|
||||
if phase == 1 {
|
||||
response["incomplete_details"] = json!({"reason":"interrupted"});
|
||||
}
|
||||
let event = ProviderEvent::canonical_with_wire(
|
||||
vec![event],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some(event_type.to_owned()),
|
||||
json!({
|
||||
"type":event_type, "response":response
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
Some((Ok(event), (phase + 1, active, id)))
|
||||
},
|
||||
);
|
||||
Ok(ProviderStream::new(metadata, body, ()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InterruptAdmissions {
|
||||
active: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ClientAdmissionPort for InterruptAdmissions {
|
||||
fn abandon(&self, _: &ClientApiKeyId, _: &ModelRequestId) {
|
||||
self.active.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
fn admit(
|
||||
&self,
|
||||
_: ClientAdmissionRequest,
|
||||
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
|
||||
Box::pin(async {
|
||||
self.active.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(ClientAdmissionDecision::Granted)
|
||||
})
|
||||
}
|
||||
fn release<'a>(
|
||||
&'a self,
|
||||
_: &'a ClientApiKeyId,
|
||||
_: &'a ModelRequestId,
|
||||
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
|
||||
Box::pin(async {
|
||||
self.active.fetch_sub(1, Ordering::SeqCst);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
fn restore(
|
||||
&self,
|
||||
_: ClientAdmissionRecovery,
|
||||
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
|
||||
Box::pin(async { unreachable!("no recovery in live connection test") })
|
||||
}
|
||||
}
|
||||
|
||||
type TestSocket =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
struct Server(tokio::task::JoinHandle<()>);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(socket: &mut TestSocket, value: Value) {
|
||||
socket
|
||||
.send(ClientMessage::Text(value.to_string().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn next(socket: &mut TestSocket) -> Value {
|
||||
loop {
|
||||
let message = tokio::time::timeout(Duration::from_secs(5), socket.next())
|
||||
.await
|
||||
.expect("response deadline")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(message.to_text().unwrap()).unwrap();
|
||||
if value["type"] != "response.metadata" {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
|
||||
let provider = Arc::new(InterruptProvider::default());
|
||||
let admissions = Arc::new(InterruptAdmissions::default());
|
||||
let execution = Arc::new(DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot("sk_ws_interrupt", "openai")),
|
||||
Arc::new(SettlementPorts::default()),
|
||||
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
|
||||
admissions.clone(),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(IgnoredClientApiKeyUsage),
|
||||
));
|
||||
let app = api_router(execution).await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let _server = Server(tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap()
|
||||
}));
|
||||
let mut request = format!("ws://{address}/v1/responses")
|
||||
.into_client_request()
|
||||
.unwrap();
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer sk_ws_interrupt"),
|
||||
);
|
||||
let (mut socket, _) = connect_async(request).await.unwrap();
|
||||
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
|
||||
let interrupt =
|
||||
|id: &str, mode: &str| json!({"type":"response.interrupt","response_id":id,"mode":mode});
|
||||
send(
|
||||
&mut socket,
|
||||
interrupt("resp_interrupt_0", "discard_partial_items"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(next(&mut socket).await["type"], "error");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
send(&mut socket, create.clone()).await;
|
||||
assert_eq!(next(&mut socket).await["type"], "response.created");
|
||||
send(&mut socket, create).await;
|
||||
for invalid in [
|
||||
interrupt("resp_other", "discard_partial_items"),
|
||||
interrupt("resp_interrupt_0", "unknown"),
|
||||
interrupt("", "discard_partial_items"),
|
||||
] {
|
||||
send(&mut socket, invalid).await;
|
||||
let error = next(&mut socket).await;
|
||||
assert_eq!(error["type"], "error");
|
||||
assert_eq!(error["status"], 400);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
send(
|
||||
&mut socket,
|
||||
interrupt("resp_interrupt_0", "discard_partial_items"),
|
||||
)
|
||||
.await;
|
||||
let terminal = next(&mut socket).await;
|
||||
assert_eq!(terminal["type"], "response.incomplete");
|
||||
assert_eq!(
|
||||
terminal["response"]["incomplete_details"]["reason"],
|
||||
"interrupted"
|
||||
);
|
||||
assert_eq!(
|
||||
next(&mut socket).await["response"]["id"],
|
||||
"resp_interrupt_1"
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
|
||||
send(
|
||||
&mut socket,
|
||||
interrupt("resp_interrupt_0", "discard_partial_items"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(next(&mut socket).await["type"], "error");
|
||||
assert_eq!(
|
||||
provider.controls.lock().unwrap()[0].interrupt("resp_interrupt_0"),
|
||||
Err(ResponseInterruptError::Unavailable)
|
||||
);
|
||||
socket.close(None).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while admissions.active.load(Ordering::SeqCst) != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("disconnect releases active execution");
|
||||
assert_eq!(
|
||||
provider.controls.lock().unwrap()[1].interrupt("resp_interrupt_1"),
|
||||
Err(ResponseInterruptError::Unavailable)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod connection;
|
||||
mod forward;
|
||||
mod interrupt;
|
||||
mod protocol;
|
||||
|
||||
use std::sync::{
|
||||
|
||||
@@ -93,6 +93,7 @@ impl SystemOperations for VersionSystem {
|
||||
build_time: "internal-build-time".to_owned(),
|
||||
deployment_mode: "binary".to_owned(),
|
||||
update_channel: "release".to_owned(),
|
||||
|
||||
latest_version: "3.8.0".to_owned(),
|
||||
has_update: true,
|
||||
update_cached: true,
|
||||
@@ -100,7 +101,11 @@ impl SystemOperations for VersionSystem {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!("client route must not request update details")
|
||||
}
|
||||
|
||||
@@ -111,6 +116,7 @@ impl SystemOperations for VersionSystem {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not perform updates")
|
||||
|
||||
@@ -13,10 +13,12 @@ mod proxy;
|
||||
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
|
||||
pub mod scope;
|
||||
mod selection;
|
||||
mod smart_scheduling;
|
||||
mod store;
|
||||
|
||||
pub use error::CredentialError;
|
||||
pub use model::*;
|
||||
pub(crate) use selection::smart_score;
|
||||
pub use selection::*;
|
||||
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
|
||||
pub use smart_scheduling::{SmartSchedulingConfig, SmartSchedulingConfigError};
|
||||
pub use store::ProviderAccountStore;
|
||||
|
||||
@@ -1053,6 +1053,7 @@ pub struct LoadedCredential {
|
||||
/// Provider 已计算好时间边界的有界 OAuth refresh 候选查询。
|
||||
///
|
||||
/// Store 只负责按持久事实筛选和稳定排序,不拥有提前量或恢复窗口语义。
|
||||
/// 调度启停不影响凭据续期;停用账号仍按 Provider 的凭据状态与时间边界刷新。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderRefreshQuery {
|
||||
provider: ProviderKind,
|
||||
@@ -1117,7 +1118,6 @@ impl ProviderRefreshQuery {
|
||||
#[must_use]
|
||||
pub fn contains(&self, account: &ProviderAccount) -> bool {
|
||||
account.provider() == &self.provider
|
||||
&& account.enabled()
|
||||
&& account.has_refresh_token()
|
||||
&& matches!(
|
||||
account.credential_state(),
|
||||
|
||||
@@ -6,10 +6,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::concurrency::ConcurrencyQueuePolicy;
|
||||
use crate::concurrency::{CapacityWait, ConcurrencyQueuePolicy, QueueRejection};
|
||||
use crate::identity::ProviderKind;
|
||||
|
||||
use super::{AccountConcurrency, AccountStatus, ProviderAccount, ProviderAccountId};
|
||||
use super::{
|
||||
AccountConcurrency, AccountStatus, ProviderAccount, ProviderAccountId, SmartSchedulingConfig,
|
||||
};
|
||||
|
||||
/// `runtime_settings.rotation_strategy` 的稳定值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -47,6 +49,7 @@ impl RotationStrategy {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSelectionPolicy {
|
||||
strategy: RotationStrategy,
|
||||
smart_scheduling: SmartSchedulingConfig,
|
||||
max_concurrent_per_account: AccountConcurrency,
|
||||
request_interval: Duration,
|
||||
queue_policy: ConcurrencyQueuePolicy,
|
||||
@@ -61,6 +64,7 @@ impl AccountSelectionPolicy {
|
||||
) -> Self {
|
||||
Self {
|
||||
strategy,
|
||||
smart_scheduling: SmartSchedulingConfig::default(),
|
||||
max_concurrent_per_account: max_concurrent_per_account.into(),
|
||||
request_interval,
|
||||
queue_policy: ConcurrencyQueuePolicy {
|
||||
@@ -86,6 +90,17 @@ impl AccountSelectionPolicy {
|
||||
self.strategy
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_smart_scheduling(mut self, config: SmartSchedulingConfig) -> Self {
|
||||
self.smart_scheduling = config;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn smart_scheduling(self) -> SmartSchedulingConfig {
|
||||
self.smart_scheduling
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn max_concurrent_per_account(self) -> AccountConcurrency {
|
||||
self.max_concurrent_per_account
|
||||
@@ -464,6 +479,7 @@ pub enum PreferredAccountSelection {
|
||||
pub struct AccountSelection<'a> {
|
||||
candidate: &'a AccountCandidate,
|
||||
preferred: PreferredAccountSelection,
|
||||
policy_choice: bool,
|
||||
}
|
||||
|
||||
impl<'a> AccountSelection<'a> {
|
||||
@@ -476,6 +492,12 @@ impl<'a> AccountSelection<'a> {
|
||||
pub const fn preferred(self) -> PreferredAccountSelection {
|
||||
self.preferred
|
||||
}
|
||||
|
||||
/// 显式策略选号不受 Provider 的软亲和等待覆盖;委托内置选号不属于显式选择。
|
||||
#[must_use]
|
||||
pub const fn is_policy_choice(self) -> bool {
|
||||
self.policy_choice
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一 target 内唯一的账号排序器。
|
||||
@@ -510,12 +532,7 @@ impl AccountSelector {
|
||||
if self.scheduling_blocker(candidate, context).is_some() {
|
||||
return None;
|
||||
}
|
||||
let highest_weight = candidates
|
||||
.iter()
|
||||
.filter(|candidate| self.scheduling_blocker(candidate, context).is_none())
|
||||
.map(|candidate| candidate.account.weight())
|
||||
.max()?;
|
||||
let (preferred, _) = self.preferred_decision(candidates, context, highest_weight);
|
||||
let (preferred, _) = self.preferred_decision(candidates, context);
|
||||
let preferred = if preferred == PreferredAccountSelection::Hit
|
||||
&& context.preferred_account.as_ref() != Some(account_id)
|
||||
{
|
||||
@@ -526,6 +543,7 @@ impl AccountSelector {
|
||||
Some(AccountSelection {
|
||||
candidate,
|
||||
preferred,
|
||||
policy_choice: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -585,13 +603,23 @@ impl AccountSelector {
|
||||
.iter()
|
||||
.map(|candidate| candidate.account.weight())
|
||||
.max()?;
|
||||
let (preferred, preferred_candidate) =
|
||||
self.preferred_decision(candidates, context, highest_weight);
|
||||
let (mut preferred, preferred_candidate) = self.preferred_decision(candidates, context);
|
||||
if let Some(candidate) = preferred_candidate {
|
||||
return Some(AccountSelection {
|
||||
candidate,
|
||||
preferred: PreferredAccountSelection::Hit,
|
||||
});
|
||||
// 权重回切只裁决内置策略的软亲和,插件显式选号按实际选择记录结果。
|
||||
let prefer_higher_weight = context.policy.strategy() == RotationStrategy::Smart
|
||||
&& context.policy.smart_scheduling().prefer_higher_weight();
|
||||
if (!context.preferred_account_overrides_weight || prefer_higher_weight)
|
||||
&& candidate.account.weight() < highest_weight
|
||||
{
|
||||
preferred =
|
||||
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight);
|
||||
} else {
|
||||
return Some(AccountSelection {
|
||||
candidate,
|
||||
preferred: PreferredAccountSelection::Hit,
|
||||
policy_choice: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
|
||||
|
||||
@@ -629,6 +657,8 @@ impl AccountSelector {
|
||||
&eligible,
|
||||
context.policy.max_concurrent_per_account(),
|
||||
context.round_robin_cursor,
|
||||
context.policy.smart_scheduling(),
|
||||
context.now,
|
||||
)?,
|
||||
RotationStrategy::Sticky => {
|
||||
eligible.sort_by_key(|candidate| {
|
||||
@@ -643,6 +673,7 @@ impl AccountSelector {
|
||||
Some(AccountSelection {
|
||||
candidate,
|
||||
preferred,
|
||||
policy_choice: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -650,7 +681,6 @@ impl AccountSelector {
|
||||
&self,
|
||||
candidates: &'a [AccountCandidate],
|
||||
context: &AccountSelectionContext,
|
||||
highest_weight: super::AccountWeight,
|
||||
) -> (PreferredAccountSelection, Option<&'a AccountCandidate>) {
|
||||
let Some(preferred) = context.preferred_account.as_ref() else {
|
||||
return (PreferredAccountSelection::NotRequested, None);
|
||||
@@ -663,14 +693,6 @@ impl AccountSelector {
|
||||
};
|
||||
match self.scheduling_blocker(candidate, context) {
|
||||
Some(blocker) => (PreferredAccountSelection::Blocked(blocker), None),
|
||||
None if !context.preferred_account_overrides_weight
|
||||
&& candidate.account.weight() < highest_weight =>
|
||||
{
|
||||
(
|
||||
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight),
|
||||
None,
|
||||
)
|
||||
}
|
||||
None => (PreferredAccountSelection::Hit, Some(candidate)),
|
||||
}
|
||||
}
|
||||
@@ -707,6 +729,41 @@ impl AccountSelector {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Provider 先投影续写范围与资格,再将等待候选交给共用评分规则选择队列。
|
||||
pub async fn wait_for_capacity(
|
||||
&self,
|
||||
waiting: &mut CapacityWait<'_, ProviderAccountId>,
|
||||
keys: &[ProviderAccountId],
|
||||
candidates: &[AccountCandidate],
|
||||
context: &AccountSelectionContext,
|
||||
) -> Result<(), QueueRejection> {
|
||||
let config = context.policy.smart_scheduling();
|
||||
let queue_weight = config.weights()[5];
|
||||
if context.policy.strategy() != RotationStrategy::Smart || queue_weight == 0.0 {
|
||||
return waiting.wait(keys).await;
|
||||
}
|
||||
// 账号信号在队列锁外评分,锁内只读取实时队长并查表,避免扫描候选阻塞其他等待者。
|
||||
let scores = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
(
|
||||
candidate.account.id(),
|
||||
smart_score(
|
||||
candidate,
|
||||
context.policy.max_concurrent_per_account(),
|
||||
config,
|
||||
context.now,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
waiting
|
||||
.wait_with_priority(keys, |key, count| {
|
||||
scores[key] + queue_weight / (1.0 + count as f64)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn scheduling_blocker(
|
||||
&self,
|
||||
candidate: &AccountCandidate,
|
||||
@@ -755,14 +812,10 @@ impl AccountSelector {
|
||||
}
|
||||
}
|
||||
|
||||
const SMART_LOAD_WEIGHT: f64 = 1.0;
|
||||
const SMART_QUOTA_WEIGHT: f64 = 0.8;
|
||||
const SMART_FAILURE_WEIGHT: f64 = 1.0;
|
||||
const SMART_LATENCY_WEIGHT: f64 = 0.5;
|
||||
// 容忍 5 个百分点的单项负载/失败率差异,避免微小信号波动独占新会话。
|
||||
pub(crate) const SMART_SCORE_TOLERANCE: f64 = 0.05;
|
||||
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化。
|
||||
const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
|
||||
// 距重置一小时时得分减半;未知和已过期时间不提供重置奖励。
|
||||
const SMART_RESET_HALF_SCORE_SECONDS: f64 = 3_600.0;
|
||||
|
||||
fn capacity_utilization(
|
||||
candidate: &AccountCandidate,
|
||||
@@ -781,16 +834,23 @@ fn select_smart_candidate<'a>(
|
||||
candidates: &[&'a AccountCandidate],
|
||||
default_concurrency: AccountConcurrency,
|
||||
cursor: u64,
|
||||
config: SmartSchedulingConfig,
|
||||
now: SystemTime,
|
||||
) -> Option<&'a AccountCandidate> {
|
||||
let mut ranked = candidates
|
||||
.iter()
|
||||
.map(|candidate| (*candidate, smart_score(candidate, default_concurrency)))
|
||||
.map(|candidate| {
|
||||
(
|
||||
*candidate,
|
||||
smart_score(candidate, default_concurrency, config, now),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let best_score = ranked
|
||||
.iter()
|
||||
.map(|(_, score)| *score)
|
||||
.max_by(f64::total_cmp)?;
|
||||
ranked.retain(|(_, score)| best_score - score <= SMART_SCORE_TOLERANCE);
|
||||
ranked.retain(|(_, score)| best_score - score <= config.score_tolerance());
|
||||
// 轮换顺序保持稳定,避免分数轻微交错与 cursor 同步后仍反复命中同一账号。
|
||||
ranked.sort_unstable_by(|(left, _), (right, _)| left.account.id().cmp(right.account.id()));
|
||||
let index = (cursor % ranked.len() as u64) as usize;
|
||||
@@ -800,6 +860,8 @@ fn select_smart_candidate<'a>(
|
||||
pub(crate) fn smart_score(
|
||||
candidate: &AccountCandidate,
|
||||
default_concurrency: AccountConcurrency,
|
||||
config: SmartSchedulingConfig,
|
||||
now: SystemTime,
|
||||
) -> f64 {
|
||||
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
|
||||
let quota = candidate
|
||||
@@ -822,8 +884,26 @@ pub(crate) fn smart_score(
|
||||
SMART_LATENCY_HALF_SCORE_MS / (SMART_LATENCY_HALF_SCORE_MS + latency as f64)
|
||||
});
|
||||
|
||||
SMART_LOAD_WEIGHT * load
|
||||
+ SMART_QUOTA_WEIGHT * quota
|
||||
+ SMART_FAILURE_WEIGHT * failure
|
||||
+ SMART_LATENCY_WEIGHT * latency
|
||||
let reset = candidate
|
||||
.signals
|
||||
.quota_reset_at
|
||||
.and_then(|reset| reset.duration_since(now).ok())
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
.map_or(0.0, |remaining| {
|
||||
SMART_RESET_HALF_SCORE_SECONDS
|
||||
/ (SMART_RESET_HALF_SCORE_SECONDS + remaining.as_secs_f64())
|
||||
});
|
||||
let [
|
||||
load_weight,
|
||||
quota_weight,
|
||||
health_weight,
|
||||
latency_weight,
|
||||
reset_weight,
|
||||
_,
|
||||
] = config.weights();
|
||||
load_weight * load
|
||||
+ quota_weight * quota
|
||||
+ health_weight * failure
|
||||
+ latency_weight * latency
|
||||
+ reset_weight * reset
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 智能调度参数;固定十分位保证快照可精确比较,浮点数只用于 wire 与评分。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "SmartSchedulingValues", into = "SmartSchedulingValues")]
|
||||
pub struct SmartSchedulingConfig {
|
||||
weights: [u8; 6],
|
||||
prefer_higher_weight: bool,
|
||||
}
|
||||
|
||||
impl Default for SmartSchedulingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
weights: [10, 8, 10, 5, 0, 0],
|
||||
prefer_higher_weight: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SmartSchedulingConfigError {
|
||||
#[error("{0} must be between 0 and 10 with at most one decimal place")]
|
||||
InvalidWeight(&'static str),
|
||||
#[error("at least one smart scheduling weight must be positive")]
|
||||
EmptyWeights,
|
||||
}
|
||||
|
||||
impl SmartSchedulingConfig {
|
||||
/// 顺序为负载、剩余额度、健康、首输出延迟、额度重置和排队压力。
|
||||
pub fn new(
|
||||
weights: [f64; 6],
|
||||
prefer_higher_weight: bool,
|
||||
) -> Result<Self, SmartSchedulingConfigError> {
|
||||
let mut tenths = [0; 6];
|
||||
for (index, field) in [
|
||||
"loadWeight",
|
||||
"quotaWeight",
|
||||
"healthWeight",
|
||||
"latencyWeight",
|
||||
"resetWeight",
|
||||
"queueWeight",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let value = weights[index];
|
||||
if !value.is_finite()
|
||||
|| !(0.0..=10.0).contains(&value)
|
||||
|| (value * 10.0).round() / 10.0 != value
|
||||
{
|
||||
return Err(SmartSchedulingConfigError::InvalidWeight(field));
|
||||
}
|
||||
tenths[index] = (value * 10.0).round() as u8;
|
||||
}
|
||||
if tenths == [0; 6] {
|
||||
return Err(SmartSchedulingConfigError::EmptyWeights);
|
||||
}
|
||||
Ok(Self {
|
||||
weights: tenths,
|
||||
prefer_higher_weight,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn weights(self) -> [f64; 6] {
|
||||
self.weights.map(|weight| f64::from(weight) / 10.0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn prefer_higher_weight(self) -> bool {
|
||||
self.prefer_higher_weight
|
||||
}
|
||||
|
||||
pub(crate) fn score_tolerance(self) -> f64 {
|
||||
// 与系数同步缩放,避免仅放大相同比例的系数就改变近似最优候选集合。
|
||||
// 排队系数只参与入队选择,不能放大立即选号的候选容差。
|
||||
0.05 * f64::from(
|
||||
self.weights[..5]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(u16::from)
|
||||
.sum::<u16>(),
|
||||
) / 33.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SmartSchedulingValues {
|
||||
load_weight: f64,
|
||||
quota_weight: f64,
|
||||
health_weight: f64,
|
||||
latency_weight: f64,
|
||||
reset_weight: f64,
|
||||
queue_weight: f64,
|
||||
prefer_higher_weight: bool,
|
||||
}
|
||||
|
||||
impl TryFrom<SmartSchedulingValues> for SmartSchedulingConfig {
|
||||
type Error = SmartSchedulingConfigError;
|
||||
|
||||
fn try_from(value: SmartSchedulingValues) -> Result<Self, Self::Error> {
|
||||
Self::new(
|
||||
[
|
||||
value.load_weight,
|
||||
value.quota_weight,
|
||||
value.health_weight,
|
||||
value.latency_weight,
|
||||
value.reset_weight,
|
||||
value.queue_weight,
|
||||
],
|
||||
value.prefer_higher_weight,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SmartSchedulingConfig> for SmartSchedulingValues {
|
||||
fn from(value: SmartSchedulingConfig) -> Self {
|
||||
let [
|
||||
load_weight,
|
||||
quota_weight,
|
||||
health_weight,
|
||||
latency_weight,
|
||||
reset_weight,
|
||||
queue_weight,
|
||||
] = value.weights();
|
||||
Self {
|
||||
load_weight,
|
||||
quota_weight,
|
||||
health_weight,
|
||||
latency_weight,
|
||||
reset_weight,
|
||||
queue_weight,
|
||||
prefer_higher_weight: value.prefer_higher_weight,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,18 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
|
||||
keys: &[K],
|
||||
max_waiting: u32,
|
||||
deadline: Instant,
|
||||
) -> Result<WaitTicket<K>, QueueRejection> {
|
||||
self.enqueue_with_priority(keys, max_waiting, deadline, |_, waiting| -(waiting as f64))
|
||||
}
|
||||
|
||||
/// 在同一锁内读取队长并入队,分数越大越优先,同分沿用候选顺序。
|
||||
/// 回调只做内存评分,不得阻塞或再次访问本队列。
|
||||
pub fn enqueue_with_priority(
|
||||
&self,
|
||||
keys: &[K],
|
||||
max_waiting: u32,
|
||||
deadline: Instant,
|
||||
mut priority: impl FnMut(&K, usize) -> f64,
|
||||
) -> Result<WaitTicket<K>, QueueRejection> {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(QueueRejection::Timeout);
|
||||
@@ -95,9 +107,9 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let len = state.queues.get(key).map_or(0, VecDeque::len);
|
||||
(len < max_waiting as usize).then_some((key, len))
|
||||
(len < max_waiting as usize).then(|| (key, priority(key, len)))
|
||||
})
|
||||
.min_by_key(|(_, len)| *len)
|
||||
.min_by(|(_, left), (_, right)| right.total_cmp(left))
|
||||
.map(|(key, _)| key.clone())
|
||||
.ok_or(QueueRejection::Full)?;
|
||||
let waker = Arc::new(AtomicWaker::new());
|
||||
@@ -237,6 +249,16 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
|
||||
}
|
||||
|
||||
pub async fn wait(&mut self, keys: &[K]) -> Result<(), QueueRejection> {
|
||||
self.wait_with_priority(keys, |_, waiting| -(waiting as f64))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 只在首次入队或原账号不再合格时重新选队列,已取得的位置不因评分变化而移动。
|
||||
pub async fn wait_with_priority(
|
||||
&mut self,
|
||||
keys: &[K],
|
||||
priority: impl FnMut(&K, usize) -> f64,
|
||||
) -> Result<(), QueueRejection> {
|
||||
self.started_at.get_or_insert_with(Instant::now);
|
||||
let deadline = self
|
||||
.budget
|
||||
@@ -249,10 +271,12 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
|
||||
self.ticket = None;
|
||||
}
|
||||
if self.ticket.is_none() {
|
||||
self.ticket = Some(
|
||||
self.queue
|
||||
.enqueue(keys, self.policy.max_waiting, deadline)?,
|
||||
);
|
||||
self.ticket = Some(self.queue.enqueue_with_priority(
|
||||
keys,
|
||||
self.policy.max_waiting,
|
||||
deadline,
|
||||
priority,
|
||||
)?);
|
||||
}
|
||||
if let Some(ticket) = &self.ticket {
|
||||
ticket.retry().await?;
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use super::TraceContext;
|
||||
use crate::account::{
|
||||
AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, RotationStrategy,
|
||||
SMART_SCORE_TOLERANCE, smart_score,
|
||||
smart_score,
|
||||
};
|
||||
|
||||
// 单个 trace 事件最多 4 KiB;优先保留实际选中的账号。
|
||||
@@ -50,7 +50,7 @@ impl TraceContext {
|
||||
"failureRateBasisPoints": signals.failure_rate_basis_points,
|
||||
"firstOutputLatencyMs": signals.first_output_latency_ms,
|
||||
"smartScore": smart.then(|| smart_score(
|
||||
candidate, context.policy.max_concurrent_per_account()
|
||||
candidate, context.policy.max_concurrent_per_account(), context.policy.smart_scheduling(), context.now
|
||||
)),
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ impl TraceContext {
|
||||
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
|
||||
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
|
||||
"roundRobinCursor": context.round_robin_cursor,
|
||||
"smartScoreTolerance": smart.then_some(SMART_SCORE_TOLERANCE),
|
||||
"smartScoreTolerance": smart.then(|| context.policy.smart_scheduling().score_tolerance()),
|
||||
"candidateCount": candidates.len(),
|
||||
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
|
||||
"candidates": observations,
|
||||
|
||||
@@ -52,6 +52,7 @@ enum AccountSelection {
|
||||
}
|
||||
|
||||
pub(super) struct CoordinationExtensions {
|
||||
response_control: Option<super::response_control::ResponseControl>,
|
||||
continuation: Option<ContinuationBinding>,
|
||||
observation: Option<RequestObservationDispatch>,
|
||||
request_policy: Option<super::policy::RequestPolicyContext>,
|
||||
@@ -65,12 +66,21 @@ pub(super) struct CoordinationExtensions {
|
||||
}
|
||||
|
||||
impl CoordinationExtensions {
|
||||
pub(super) fn with_response_control(
|
||||
mut self,
|
||||
control: Option<super::response_control::ResponseControl>,
|
||||
) -> Self {
|
||||
self.response_control = control;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn new(
|
||||
continuation: Option<ContinuationBinding>,
|
||||
observation: Option<RequestObservationDispatch>,
|
||||
) -> Self {
|
||||
Self {
|
||||
continuation,
|
||||
response_control: None,
|
||||
observation,
|
||||
request_policy: None,
|
||||
execution_effects: None,
|
||||
@@ -230,6 +240,7 @@ where
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<ResponseExecutionSession<S>, EngineError> {
|
||||
let CoordinationExtensions {
|
||||
response_control,
|
||||
continuation,
|
||||
observation: request_observation,
|
||||
request_policy,
|
||||
@@ -295,6 +306,7 @@ where
|
||||
.fuse(),
|
||||
pending_request: Some(request),
|
||||
request_persisted: false,
|
||||
response_control,
|
||||
operation,
|
||||
plan,
|
||||
request_policy,
|
||||
@@ -409,6 +421,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
deadline_timer: Fuse<Delay>,
|
||||
pending_request: Option<NewModelRequest>,
|
||||
request_persisted: bool,
|
||||
response_control: Option<super::response_control::ResponseControl>,
|
||||
operation: Operation,
|
||||
plan: RoutingPlan,
|
||||
request_policy: Option<super::policy::RequestPolicyContext>,
|
||||
@@ -996,6 +1009,7 @@ where
|
||||
}
|
||||
let context = AttemptContext::new(
|
||||
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
|
||||
.with_response_control(self.response_control.clone())
|
||||
.with_request_profile(self.request_profiles.get(candidate.provider()).cloned())
|
||||
.with_disable_fast(self.plan.disable_fast())
|
||||
.with_pricing(self.plan.pricing())
|
||||
@@ -1062,25 +1076,45 @@ where
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
record_trace_error(&attempt_trace, &error);
|
||||
if self.prepare_unavailable_native_continuation_replay(&error) {
|
||||
return Ok(Some(PullOutcome::AttemptDiscarded));
|
||||
}
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
) && error.send_state() == UpstreamSendState::NotSent
|
||||
let continuation_retry =
|
||||
self.prepare_unavailable_native_continuation_replay(&error);
|
||||
let candidate_retry = !continuation_retry
|
||||
&& matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
)
|
||||
&& error.send_state() == UpstreamSendState::NotSent
|
||||
&& self.current_send_state() == UpstreamSendState::NotSent
|
||||
&& matches!(
|
||||
self.continuation_attempt,
|
||||
ContinuationAttempt::None | ContinuationAttempt::ReplayAny
|
||||
)
|
||||
&& self.advance_provider_candidate()
|
||||
{
|
||||
&& self.advance_provider_candidate();
|
||||
let retryable = self
|
||||
.apply_retry_policy(super::policy::RetryFacts {
|
||||
attempt_index: next_attempt,
|
||||
provider: candidate.provider().clone(),
|
||||
model: candidate
|
||||
.upstream_model()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
error_kind: error.kind(),
|
||||
upstream_status: error.upstream_status(),
|
||||
send_state: error.send_state(),
|
||||
remaining_routing_attempts: self
|
||||
.plan
|
||||
.max_attempts()
|
||||
.get()
|
||||
.saturating_sub(self.routing_attempts),
|
||||
remaining_deadline: Duration::ZERO,
|
||||
retry_allowed: continuation_retry || candidate_retry,
|
||||
})
|
||||
.await?;
|
||||
if retryable {
|
||||
return Ok(Some(PullOutcome::AttemptDiscarded));
|
||||
}
|
||||
if matches!(
|
||||
@@ -1511,6 +1545,35 @@ where
|
||||
|| account_rotation_retry
|
||||
|| transport_recovery.is_some();
|
||||
|
||||
let retryable = match self
|
||||
.apply_retry_policy(super::policy::RetryFacts {
|
||||
attempt_index: current.index,
|
||||
provider: current.metadata.provider().clone(),
|
||||
model: current
|
||||
.metadata
|
||||
.upstream_model()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
error_kind: error.kind(),
|
||||
upstream_status: error.upstream_status(),
|
||||
send_state: attempt_send_state,
|
||||
remaining_routing_attempts: self
|
||||
.plan
|
||||
.max_attempts()
|
||||
.get()
|
||||
.saturating_sub(self.routing_attempts),
|
||||
remaining_deadline: Duration::ZERO,
|
||||
retry_allowed: retryable,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(retryable) => retryable,
|
||||
Err(error) => {
|
||||
self.current = Some(current);
|
||||
self.finish_interruption(&error).await?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
self.trace.attempt(current.index.get()).record("retry.decided", json!({
|
||||
"retryable": retryable, "continuationRetry": continuation_retry,
|
||||
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
|
||||
@@ -1608,6 +1671,41 @@ where
|
||||
Err(provider_engine_error(error))
|
||||
}
|
||||
|
||||
async fn apply_retry_policy(
|
||||
&mut self,
|
||||
mut facts: super::policy::RetryFacts,
|
||||
) -> Result<bool, EngineError> {
|
||||
let Some(policy) = self.request_policy.clone() else {
|
||||
return Ok(facts.retry_allowed);
|
||||
};
|
||||
facts.remaining_deadline = self
|
||||
.deadline
|
||||
.duration_since(SystemTime::now())
|
||||
.unwrap_or_default();
|
||||
facts.retry_allowed &= !facts.remaining_deadline.is_zero()
|
||||
&& !self.cancellation.is_cancelled()
|
||||
&& !self.execution_effect_observed();
|
||||
let allowed = facts.retry_allowed;
|
||||
let decision = {
|
||||
let cancellation = self.cancellation.clone();
|
||||
let cancelled = cancellation.cancelled().fuse();
|
||||
let decision = policy.retry_decision(facts).fuse();
|
||||
let mut deadline_timer = &mut self.deadline_timer;
|
||||
pin_mut!(cancelled, decision);
|
||||
select_biased! {
|
||||
() = cancelled => Err(EngineError::Cancelled),
|
||||
() = deadline_timer => Err(EngineError::Deadline),
|
||||
decision = decision => Ok(decision),
|
||||
}
|
||||
}?;
|
||||
// 策略只收窄宿主已允许的恢复,返回后仍复核期限和外部副作用。
|
||||
Ok(allowed
|
||||
&& decision != super::policy::RetryDecision::Stop
|
||||
&& !self.execution_effect_observed()
|
||||
&& !self.cancellation.is_cancelled()
|
||||
&& SystemTime::now() < self.deadline)
|
||||
}
|
||||
|
||||
fn observe_atomic_terminal_events(&mut self, events: &mut [ProviderEvent]) {
|
||||
for event in events {
|
||||
if let Some(observation) = event.take_observation() {
|
||||
|
||||
@@ -157,6 +157,7 @@ pub struct StartExecution {
|
||||
/// 中间件短路时本值直接释放;调用 `next` 后必须原样交回
|
||||
/// [`ExecutionService::start_prepared`],不能重新认证为另一个 Key。
|
||||
pub struct PreparedRootExecution {
|
||||
response_control: super::response_control::ResponseControl,
|
||||
client: AuthenticatedClient,
|
||||
request_id: ModelRequestId,
|
||||
started_at: SystemTime,
|
||||
@@ -167,6 +168,11 @@ pub struct PreparedRootExecution {
|
||||
}
|
||||
|
||||
impl PreparedRootExecution {
|
||||
#[must_use]
|
||||
pub fn response_control(&self) -> super::response_control::ResponseControl {
|
||||
self.response_control.clone()
|
||||
}
|
||||
|
||||
fn new(client: AuthenticatedClient) -> Result<Self, GatewayError> {
|
||||
let started_at = SystemTime::now();
|
||||
let deadline_at = started_at
|
||||
@@ -178,6 +184,7 @@ impl PreparedRootExecution {
|
||||
let execution_effects_baseline = execution_effects.epoch();
|
||||
Ok(Self {
|
||||
client,
|
||||
response_control: super::response_control::ResponseControl::default(),
|
||||
request_id: new_request_id()?,
|
||||
started_at,
|
||||
deadline_at,
|
||||
@@ -292,6 +299,7 @@ struct PendingStartExecution {
|
||||
}
|
||||
|
||||
struct AuthorizedExecution {
|
||||
response_control: Option<super::response_control::ResponseControl>,
|
||||
account_scope: Arc<FrozenAccountScope>,
|
||||
deadline_at: SystemTime,
|
||||
cancellation: CancellationToken,
|
||||
@@ -894,6 +902,7 @@ impl DefaultExecutionService {
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let PreparedRootExecution {
|
||||
client,
|
||||
response_control,
|
||||
request_id,
|
||||
started_at,
|
||||
deadline_at,
|
||||
@@ -902,6 +911,7 @@ impl DefaultExecutionService {
|
||||
execution_effects_baseline,
|
||||
} = prepared;
|
||||
let authorization = AuthorizedExecution {
|
||||
response_control: Some(response_control),
|
||||
account_scope: Arc::clone(client.policy.account_scope()),
|
||||
deadline_at,
|
||||
cancellation,
|
||||
@@ -1155,6 +1165,7 @@ impl DefaultExecutionService {
|
||||
.map(|graph| Arc::clone(&graph.effects));
|
||||
let extensions =
|
||||
CoordinationExtensions::new(continuation, request_observation.clone())
|
||||
.with_response_control(authorization.response_control.clone())
|
||||
.with_request_policy(request_policy)
|
||||
.with_execution_effects(
|
||||
execution_effects,
|
||||
@@ -1511,6 +1522,7 @@ impl DefaultExecutionService {
|
||||
metadata.user_agent = None;
|
||||
let authorization = AuthorizedExecution {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
deadline_at: parent.deadline_at,
|
||||
cancellation: parent.cancellation.child_token(),
|
||||
extension_scope,
|
||||
@@ -1676,6 +1688,7 @@ impl DefaultExecutionService {
|
||||
},
|
||||
AuthorizedExecution {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
deadline_at: authority.deadline_at,
|
||||
cancellation: authority.cancellation.child_token(),
|
||||
extension_scope: authority.extension_scope.clone(),
|
||||
@@ -2118,9 +2131,10 @@ impl ExecutionService for DefaultExecutionService {
|
||||
.await?
|
||||
else {
|
||||
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
|
||||
if !scope.allows_provider_model(
|
||||
if !client.snapshot.catalog_model_allowed_for_scope(
|
||||
kind,
|
||||
&client.snapshot.mapped_model(profile.model().as_str()),
|
||||
profile.model(),
|
||||
scope,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Runtime/SDK 负责;身份、路由、账号租约、发送事实、重试、计量与结算仍由 Core
|
||||
//! 持有,不能通过本端口交给插件解释。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::{Arc, RwLock, Weak};
|
||||
@@ -23,7 +23,9 @@ use crate::error::{GatewayError, ProviderError};
|
||||
use crate::event::ProviderEvent;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::lifecycle::CancellationToken;
|
||||
use crate::operation::OperationKind;
|
||||
use crate::operation::{
|
||||
CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload,
|
||||
};
|
||||
use crate::policy::ClientApiKeyId;
|
||||
use crate::runtime::extensions::{ExtensionSetId, ExtensionSetReference};
|
||||
|
||||
@@ -95,13 +97,27 @@ pub struct MiddlewareRequest {
|
||||
protocol: String,
|
||||
headers: Vec<MiddlewareHeader>,
|
||||
body: Bytes,
|
||||
original_body: Bytes,
|
||||
original_protocol: String,
|
||||
capabilities: Option<MiddlewareCapabilityDeclaration>,
|
||||
}
|
||||
|
||||
/// 只声明具体功能的责任归属,实际正文中的需求始终继续生效。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MiddlewareCapabilityDeclaration {
|
||||
pub handled: BTreeSet<Feature>,
|
||||
pub required: BTreeSet<Feature>,
|
||||
}
|
||||
|
||||
impl MiddlewareRequest {
|
||||
#[must_use]
|
||||
pub fn new(protocol: impl Into<String>, headers: Vec<MiddlewareHeader>, body: Bytes) -> Self {
|
||||
let protocol = protocol.into();
|
||||
Self {
|
||||
protocol: protocol.into(),
|
||||
original_protocol: protocol.clone(),
|
||||
original_body: body.clone(),
|
||||
capabilities: None,
|
||||
protocol,
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
@@ -126,6 +142,78 @@ impl MiddlewareRequest {
|
||||
pub fn into_parts(self) -> (String, Vec<MiddlewareHeader>, Bytes) {
|
||||
(self.protocol, self.headers, self.body)
|
||||
}
|
||||
|
||||
/// 保留链入口的语义来源;新增声明必须对应当前这一层实际消除的功能字段。
|
||||
pub fn replace_parts(
|
||||
mut self,
|
||||
protocol: String,
|
||||
headers: Vec<MiddlewareHeader>,
|
||||
body: Bytes,
|
||||
declaration: Option<MiddlewareCapabilityDeclaration>,
|
||||
) -> Result<Self, MiddlewareError> {
|
||||
if let Some(declaration) = declaration {
|
||||
if self.protocol != "openai"
|
||||
|| protocol != "openai"
|
||||
|| self.original_protocol != "openai"
|
||||
|| declaration.handled.contains(&Feature::NativeContinuation)
|
||||
|| !declaration.handled.is_disjoint(&declaration.required)
|
||||
|| (declaration.handled.is_empty() && declaration.required.is_empty())
|
||||
{
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
let before = generate_requirements(&self.body)?;
|
||||
let after = generate_requirements(&body)?;
|
||||
if !declaration.handled.is_subset(before.features())
|
||||
|| !declaration.handled.is_disjoint(after.features())
|
||||
{
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
let accumulated = self.capabilities.get_or_insert_default();
|
||||
accumulated.handled.extend(declaration.handled);
|
||||
accumulated.required.extend(declaration.required);
|
||||
}
|
||||
self.protocol = protocol;
|
||||
self.headers = headers;
|
||||
self.body = body;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// API 在最终解码后应用;没有声明的透传路径不解析或重新编码正文。
|
||||
pub fn apply_capabilities(&self, operation: Operation) -> Result<Operation, MiddlewareError> {
|
||||
let Some(declaration) = &self.capabilities else {
|
||||
return Ok(operation);
|
||||
};
|
||||
if operation.kind() != OperationKind::Generate || operation.protocol() != "openai" {
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
let original = generate_requirements(&self.original_body)?;
|
||||
let mut effective = CapabilityRequirements::new(OperationKind::Generate);
|
||||
for feature in original
|
||||
.features()
|
||||
.difference(&declaration.handled)
|
||||
.chain(declaration.required.iter())
|
||||
{
|
||||
effective = effective.require(*feature);
|
||||
}
|
||||
operation
|
||||
.with_middleware_requirements(original, effective)
|
||||
.map_err(|_| MiddlewareError::InvalidState)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_capability_declaration(&self) -> bool {
|
||||
self.capabilities.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_requirements(body: &[u8]) -> Result<CapabilityRequirements, MiddlewareError> {
|
||||
let body = serde_json::from_slice(body).map_err(|_| MiddlewareError::InvalidState)?;
|
||||
let payload =
|
||||
ProtocolPayload::json_object("openai", body).map_err(|_| MiddlewareError::InvalidState)?;
|
||||
Ok(
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
.capability_requirements(),
|
||||
)
|
||||
}
|
||||
|
||||
impl fmt::Debug for MiddlewareRequest {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user