mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 00:06:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,58 @@
|
||||
---
|
||||
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) |
|
||||
| 开发独立网关插件 | [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 已通过”代替自审结论。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
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。 -->
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,14 @@ 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("插件额度事实查询暂不可用"))
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -515,9 +515,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 +548,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 +559,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>;
|
||||
|
||||
|
||||
@@ -124,9 +124,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 +255,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()
|
||||
|
||||
@@ -95,6 +95,20 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
let account = self.get_runtime(account_id).await?;
|
||||
self.provider(&account.provider_kind)?
|
||||
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "plugin quota facts"))
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,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,
|
||||
|
||||
@@ -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),
|
||||
@@ -1035,7 +1036,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 +1051,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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -278,7 +278,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 +291,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -1456,7 +1458,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 +1473,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 +1520,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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -510,12 +525,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)
|
||||
{
|
||||
@@ -585,13 +595,22 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
|
||||
|
||||
@@ -629,6 +648,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| {
|
||||
@@ -650,7 +671,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 +683,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 +719,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 +802,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 +824,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 +850,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 +874,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,
|
||||
|
||||
@@ -1062,25 +1062,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 +1531,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 +1657,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() {
|
||||
|
||||
@@ -2118,9 +2118,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 {
|
||||
|
||||
@@ -235,6 +235,37 @@ pub enum AccountScheduleDecision {
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// 重试策略只能收窄宿主决定;不能自行创造恢复路径。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetryDecision {
|
||||
Delegate,
|
||||
Stop,
|
||||
Retry,
|
||||
}
|
||||
|
||||
/// 协调器计算的安全失败事实;无凭据、原始错误或请求正文。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryFacts {
|
||||
pub attempt_index: NonZeroU32,
|
||||
pub provider: ProviderKind,
|
||||
pub model: Option<String>,
|
||||
pub error_kind: crate::error::ProviderErrorKind,
|
||||
pub upstream_status: Option<u16>,
|
||||
pub send_state: super::UpstreamSendState,
|
||||
pub remaining_routing_attempts: u32,
|
||||
pub remaining_deadline: std::time::Duration,
|
||||
pub retry_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryInput {
|
||||
pub request_id: super::ModelRequestId,
|
||||
pub client_key_id: ClientApiKeyId,
|
||||
pub account_group_ids: Arc<[AccountGroupId]>,
|
||||
pub extension_scope: ExtensionCallScope,
|
||||
pub facts: RetryFacts,
|
||||
}
|
||||
|
||||
/// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("request policy call failed")]
|
||||
@@ -253,6 +284,13 @@ pub enum AccountPolicyError {
|
||||
|
||||
/// 一个发布代次的不可变请求策略计划。
|
||||
pub trait RequestPolicyPlan: Send + Sync + fmt::Debug {
|
||||
fn retry_decision(
|
||||
&self,
|
||||
_input: RetryInput,
|
||||
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
|
||||
Box::pin(async { Ok(RetryDecision::Delegate) })
|
||||
}
|
||||
|
||||
fn route_model(
|
||||
&self,
|
||||
input: ModelRouteInput,
|
||||
@@ -288,6 +326,25 @@ impl fmt::Debug for RequestPolicyContext {
|
||||
}
|
||||
|
||||
impl RequestPolicyContext {
|
||||
pub async fn retry_decision(&self, facts: RetryFacts) -> RetryDecision {
|
||||
self.plan
|
||||
.retry_decision(RetryInput {
|
||||
request_id: self.request_id.clone(),
|
||||
client_key_id: self.client_key_id.clone(),
|
||||
account_group_ids: self.account_group_ids.clone(),
|
||||
extension_scope: self.extension_scope.clone(),
|
||||
facts,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
request_id = self.request_id.as_str(),
|
||||
"重试策略调用失败,使用宿主决定"
|
||||
);
|
||||
RetryDecision::Delegate
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
plan: Arc<dyn RequestPolicyPlan>,
|
||||
|
||||
@@ -479,6 +479,9 @@ impl MiddlewareNext for ProviderMiddlewareNext {
|
||||
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
|
||||
Box::pin(async move {
|
||||
let fallback_protocol = self.operation.protocol().to_owned();
|
||||
if request.has_capability_declaration() {
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
let (protocol, headers, body) = request.into_parts();
|
||||
validate_attempt_headers(&headers)?;
|
||||
let operation = self
|
||||
|
||||
@@ -336,7 +336,10 @@ impl CoreControlPlaneStartup {
|
||||
match self.publisher.refresh().await {
|
||||
Ok(_) => {}
|
||||
// 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求。
|
||||
Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {}
|
||||
Err(
|
||||
routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable
|
||||
| routing::snapshot::RuntimeSnapshotCompileError::InvalidExtensionModels,
|
||||
) => {}
|
||||
Err(_) => return Err(CoreError::SnapshotUnavailable),
|
||||
}
|
||||
Ok(CoreControlPlaneBundle {
|
||||
|
||||
@@ -374,6 +374,7 @@ struct GeneratePayload {
|
||||
protocol_payload: ProtocolPayload,
|
||||
provider_session_state: Option<ProviderSessionState>,
|
||||
source_requirements: Option<CapabilityRequirements>,
|
||||
middleware_requirements: Option<(CapabilityRequirements, CapabilityRequirements)>,
|
||||
}
|
||||
|
||||
impl GenerateRequest {
|
||||
@@ -388,6 +389,7 @@ impl GenerateRequest {
|
||||
protocol_payload,
|
||||
provider_session_state: None,
|
||||
source_requirements: None,
|
||||
middleware_requirements: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -487,6 +489,11 @@ impl GenerateRequest {
|
||||
}
|
||||
let mut requirements = CapabilityRequirements::new(OperationKind::Generate)
|
||||
.with_requested_output_tokens(self.max_output_tokens());
|
||||
if let Some((_, inherited)) = &self.payload.middleware_requirements {
|
||||
for feature in inherited.features() {
|
||||
requirements = requirements.require(*feature);
|
||||
}
|
||||
}
|
||||
if self
|
||||
.body()
|
||||
.get("tools")
|
||||
@@ -837,6 +844,31 @@ pub enum Operation {
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub(crate) fn with_middleware_requirements(
|
||||
self,
|
||||
original: CapabilityRequirements,
|
||||
inherited: CapabilityRequirements,
|
||||
) -> Result<Self, OperationError> {
|
||||
let Self::Generate(mut request) = self else {
|
||||
return Err(OperationError::EmptyField {
|
||||
field: "middleware capabilities",
|
||||
});
|
||||
};
|
||||
Arc::make_mut(&mut request.payload).middleware_requirements = Some((original, inherited));
|
||||
Ok(Self::Generate(request))
|
||||
}
|
||||
|
||||
/// 请求转换前的需求仅供诊断,选路始终使用有效上游需求。
|
||||
#[must_use]
|
||||
pub fn original_capability_requirements(&self) -> CapabilityRequirements {
|
||||
if let Self::Generate(request) = self
|
||||
&& let Some((original, _)) = &request.payload.middleware_requirements
|
||||
{
|
||||
return original.clone();
|
||||
}
|
||||
self.capability_requirements()
|
||||
}
|
||||
|
||||
/// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 快照编译与客户端读取需要的 Provider 目录合同,不暴露执行或账号选择能力。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::account::ProviderAccountId;
|
||||
use crate::identity::ProviderKind;
|
||||
use crate::operation::RawJsonPayload;
|
||||
|
||||
@@ -11,6 +12,15 @@ use super::{
|
||||
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
|
||||
};
|
||||
|
||||
/// 扩展集合持有的直接模型别名;执行与元数据仍归属内置 Provider。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContributedModelAlias {
|
||||
pub owner: String,
|
||||
pub id: PublicModelId,
|
||||
pub provider: ProviderKind,
|
||||
pub target: UpstreamModelId,
|
||||
}
|
||||
|
||||
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModelDescriptor {
|
||||
@@ -54,6 +64,7 @@ pub struct ProviderModelCapabilities {
|
||||
upstream_model: UpstreamModelId,
|
||||
capabilities: ModelCapabilities,
|
||||
presentation: Option<ModelPresentation>,
|
||||
catalog_accounts: Option<BTreeSet<ProviderAccountId>>,
|
||||
}
|
||||
|
||||
impl ProviderModelCapabilities {
|
||||
@@ -63,6 +74,7 @@ impl ProviderModelCapabilities {
|
||||
upstream_model,
|
||||
capabilities,
|
||||
presentation: None,
|
||||
catalog_accounts: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +84,19 @@ impl ProviderModelCapabilities {
|
||||
self
|
||||
}
|
||||
|
||||
/// 声明发现此模型的账号;仅用于目录可见性,不作为推理能力白名单。
|
||||
#[must_use]
|
||||
pub fn with_catalog_accounts(mut self, accounts: BTreeSet<ProviderAccountId>) -> Self {
|
||||
self.catalog_accounts = Some(accounts);
|
||||
self
|
||||
}
|
||||
|
||||
/// 未提供账号来源的 Provider 沿用 Provider 级目录;空集合表示无可展示来源。
|
||||
#[must_use]
|
||||
pub const fn catalog_accounts(&self) -> Option<&BTreeSet<ProviderAccountId>> {
|
||||
self.catalog_accounts.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn upstream_model(&self) -> &UpstreamModelId {
|
||||
&self.upstream_model
|
||||
|
||||
@@ -9,9 +9,9 @@ pub use crate::account::scope::{
|
||||
};
|
||||
pub use crate::identity::ProviderKind;
|
||||
pub use catalog::{
|
||||
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
|
||||
ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor,
|
||||
PublicModelDescriptor,
|
||||
ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
|
||||
ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent,
|
||||
ProviderModelDescriptor, PublicModelDescriptor,
|
||||
};
|
||||
pub use snapshot::RuntimeSnapshot;
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ use super::{
|
||||
|
||||
const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
|
||||
|
||||
type ModelCatalogAccounts = BTreeMap<ProviderKind, BTreeMap<String, BTreeSet<ProviderAccountId>>>;
|
||||
|
||||
/// Store 在一个一致性读取中提供的调度设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotSettingsFacts {
|
||||
@@ -40,6 +42,7 @@ pub struct SnapshotSettingsFacts {
|
||||
concurrency_wait_timeout_seconds: u32,
|
||||
responses_max_decompressed_body_bytes: u64,
|
||||
request_interval_ms: u64,
|
||||
smart_scheduling: crate::account::SmartSchedulingConfig,
|
||||
rotation_strategy: String,
|
||||
model_mappings: BTreeMap<String, String>,
|
||||
min_codex_desktop_version: Option<String>,
|
||||
@@ -47,6 +50,15 @@ pub struct SnapshotSettingsFacts {
|
||||
}
|
||||
|
||||
impl SnapshotSettingsFacts {
|
||||
#[must_use]
|
||||
pub const fn with_smart_scheduling(
|
||||
mut self,
|
||||
config: crate::account::SmartSchedulingConfig,
|
||||
) -> Self {
|
||||
self.smart_scheduling = config;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self {
|
||||
self.pricing = Arc::new(pricing);
|
||||
@@ -112,6 +124,7 @@ impl SnapshotSettingsFacts {
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
request_interval_ms,
|
||||
smart_scheduling: crate::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: rotation_strategy.into(),
|
||||
model_mappings,
|
||||
min_codex_desktop_version,
|
||||
@@ -303,6 +316,8 @@ pub enum RuntimeSnapshotCompileError {
|
||||
RevisionChanged,
|
||||
#[error("runtime snapshot contains invalid frozen data")]
|
||||
InvalidData,
|
||||
#[error("extension model aliases conflict with the provider catalog or model mappings")]
|
||||
InvalidExtensionModels,
|
||||
#[error("provider model catalog changed while the snapshot was compiling")]
|
||||
CatalogChanged,
|
||||
}
|
||||
@@ -420,13 +435,15 @@ impl RuntimeSnapshotCompiler {
|
||||
{
|
||||
return Err(RuntimeSnapshotCompileError::RevisionChanged);
|
||||
}
|
||||
return Ok(snapshot
|
||||
let snapshot = snapshot
|
||||
.with_provider_catalog_generations(if cached.is_some() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
observed_generations
|
||||
})
|
||||
.with_extensions(extensions));
|
||||
.with_extensions(extensions);
|
||||
snapshot.validate_extension_models()?;
|
||||
return Ok(snapshot);
|
||||
}
|
||||
}
|
||||
Err(RuntimeSnapshotCompileError::CatalogChanged)
|
||||
@@ -441,9 +458,13 @@ async fn compile_runtime_snapshot(
|
||||
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。
|
||||
let mut provider_models = Vec::new();
|
||||
let mut catalog_accounts = BTreeMap::new();
|
||||
let mut exhaustive_provider_catalogs = BTreeSet::new();
|
||||
for provider in &provider_kinds {
|
||||
if let Some(previous) = previous {
|
||||
if let Some(accounts) = previous.model_catalog_accounts.get(provider) {
|
||||
catalog_accounts.insert(provider.clone(), accounts.clone());
|
||||
}
|
||||
if previous.exhaustive_provider_catalogs.contains(provider) {
|
||||
exhaustive_provider_catalogs.insert(provider.clone());
|
||||
}
|
||||
@@ -470,6 +491,12 @@ async fn compile_runtime_snapshot(
|
||||
exhaustive_provider_catalogs.insert(provider.clone());
|
||||
}
|
||||
provider_models.extend(models.into_iter().map(|model| {
|
||||
if let Some(accounts) = model.catalog_accounts() {
|
||||
catalog_accounts
|
||||
.entry(provider.clone())
|
||||
.or_default()
|
||||
.insert(model.upstream_model().as_str().to_owned(), accounts.clone());
|
||||
}
|
||||
let compiled = ProviderModel::new(
|
||||
provider.clone(),
|
||||
model.upstream_model().clone(),
|
||||
@@ -576,6 +603,7 @@ async fn compile_runtime_snapshot(
|
||||
AccountConcurrency::new(facts.settings.max_concurrent_per_account),
|
||||
Duration::from_millis(facts.settings.request_interval_ms),
|
||||
)
|
||||
.with_smart_scheduling(facts.settings.smart_scheduling)
|
||||
.with_queue(ConcurrencyQueuePolicy {
|
||||
max_waiting: facts.settings.max_waiting_per_account,
|
||||
timeout: queue_timeout,
|
||||
@@ -661,6 +689,7 @@ async fn compile_runtime_snapshot(
|
||||
.with_model_mappings(model_mappings)
|
||||
.with_account_directory(account_directory)
|
||||
.with_exhaustive_provider_catalogs(exhaustive_provider_catalogs)
|
||||
.with_model_catalog_accounts(catalog_accounts)
|
||||
.with_min_codex_client_versions(min_client_versions)
|
||||
})
|
||||
}
|
||||
@@ -682,6 +711,7 @@ pub struct RuntimeSnapshot {
|
||||
model_mappings: Arc<BTreeMap<String, String>>,
|
||||
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
|
||||
exhaustive_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
|
||||
model_catalog_accounts: Arc<ModelCatalogAccounts>,
|
||||
account_directory: Arc<RuntimeAccountDirectory>,
|
||||
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
|
||||
min_codex_client_versions: CodexClientMinVersions,
|
||||
@@ -701,6 +731,47 @@ impl RuntimeSnapshot {
|
||||
pub const fn extensions(&self) -> Option<&crate::runtime::extensions::ExtensionSetReference> {
|
||||
self.extensions.as_ref()
|
||||
}
|
||||
|
||||
fn model_aliases(&self) -> &[super::ContributedModelAlias] {
|
||||
self.extensions
|
||||
.as_ref()
|
||||
.map_or(&[], |set| set.model_aliases())
|
||||
}
|
||||
|
||||
fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> {
|
||||
self.model_aliases()
|
||||
.iter()
|
||||
.find(|alias| alias.id.as_str() == model)
|
||||
}
|
||||
|
||||
fn validate_extension_models(&self) -> Result<(), RuntimeSnapshotCompileError> {
|
||||
let mut ids = BTreeSet::new();
|
||||
for alias in self.model_aliases() {
|
||||
let valid = ids.insert(&alias.id)
|
||||
&& self.providers.contains(&alias.provider)
|
||||
&& !self.model_mappings.contains_key(alias.id.as_str())
|
||||
&& !self.model_mappings.contains_key(alias.target.as_str())
|
||||
&& !self
|
||||
.model_mappings
|
||||
.values()
|
||||
.any(|target| target == alias.id.as_str())
|
||||
&& self.model_alias(alias.target.as_str()).is_none()
|
||||
&& !self.provider_models.values().any(|models| {
|
||||
models
|
||||
.keys()
|
||||
.any(|model| model.as_str() == alias.id.as_str())
|
||||
})
|
||||
&& self
|
||||
.provider_models
|
||||
.get(&alias.provider)
|
||||
.is_some_and(|models| models.contains_key(&alias.target));
|
||||
if !valid {
|
||||
tracing::warn!(owner = %alias.owner, model = %alias.id, "插件模型别名与宿主目录或映射冲突,拒绝发布候选快照");
|
||||
return Err(RuntimeSnapshotCompileError::InvalidExtensionModels);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[must_use]
|
||||
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
|
||||
self.pricing = pricing;
|
||||
@@ -823,6 +894,7 @@ impl RuntimeSnapshot {
|
||||
model_mappings: Arc::new(BTreeMap::new()),
|
||||
provider_catalog_generations: Arc::new(BTreeMap::new()),
|
||||
exhaustive_provider_catalogs: Arc::new(exhaustive_provider_catalogs),
|
||||
model_catalog_accounts: Arc::default(),
|
||||
account_directory: Arc::new(RuntimeAccountDirectory::default()),
|
||||
client_policies: Arc::new(client_policy_map),
|
||||
min_codex_client_versions: CodexClientMinVersions::default(),
|
||||
@@ -853,6 +925,11 @@ impl RuntimeSnapshot {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_model_catalog_accounts(mut self, accounts: ModelCatalogAccounts) -> Self {
|
||||
self.model_catalog_accounts = Arc::new(accounts);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn all_account_scope(&self) -> Arc<FrozenAccountScope> {
|
||||
Arc::new(FrozenAccountScope::new(
|
||||
@@ -901,6 +978,12 @@ impl RuntimeSnapshot {
|
||||
.keys()
|
||||
.filter_map(|model| PublicModelId::new(model.clone()).ok()),
|
||||
);
|
||||
models.extend(
|
||||
self.model_aliases()
|
||||
.iter()
|
||||
.filter(|alias| &alias.provider == provider)
|
||||
.map(|alias| alias.id.clone()),
|
||||
);
|
||||
models.into_iter().collect()
|
||||
}
|
||||
|
||||
@@ -930,6 +1013,15 @@ impl RuntimeSnapshot {
|
||||
profiles.insert(public_model, presentation.clone());
|
||||
}
|
||||
}
|
||||
for alias in self
|
||||
.model_aliases()
|
||||
.iter()
|
||||
.filter(|alias| &alias.provider == provider)
|
||||
{
|
||||
if let Some(presentation) = presentations.get(&alias.target) {
|
||||
profiles.insert(alias.id.clone(), presentation.clone());
|
||||
}
|
||||
}
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|(model, presentation)| super::PublicModelProfile::new(model, presentation))
|
||||
@@ -955,9 +1047,7 @@ impl RuntimeSnapshot {
|
||||
.flat_map(|provider| {
|
||||
self.public_models_for_provider(provider)
|
||||
.into_iter()
|
||||
.filter(|model| {
|
||||
scope.allows_provider_model(provider, &self.mapped_model(model.as_str()))
|
||||
})
|
||||
.filter(|model| self.catalog_model_allowed_for_scope(provider, model, scope))
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
@@ -973,9 +1063,7 @@ impl RuntimeSnapshot {
|
||||
let mut profiles = BTreeMap::new();
|
||||
for provider in scope.provider_kinds() {
|
||||
for profile in self.public_model_profiles_for_provider(provider) {
|
||||
if !scope
|
||||
.allows_provider_model(provider, &self.mapped_model(profile.model().as_str()))
|
||||
{
|
||||
if !self.catalog_model_allowed_for_scope(provider, profile.model(), scope) {
|
||||
continue;
|
||||
}
|
||||
profiles
|
||||
@@ -999,6 +1087,12 @@ impl RuntimeSnapshot {
|
||||
if !self.providers.contains(provider) {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.model_alias(public_model.as_str())
|
||||
.is_some_and(|alias| &alias.provider != provider)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.exhaustive_provider_catalogs.contains(provider) {
|
||||
return true;
|
||||
}
|
||||
@@ -1016,12 +1110,35 @@ impl RuntimeSnapshot {
|
||||
) -> bool {
|
||||
scope.provider_kinds().iter().any(|provider| {
|
||||
self.contains_public_model_for_provider(public_model, provider)
|
||||
&& scope.allows_provider_model(provider, &self.mapped_model(public_model.as_str()))
|
||||
&& self.catalog_model_allowed_for_scope(provider, public_model, scope)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn catalog_model_allowed_for_scope(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
public_model: &PublicModelId,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> bool {
|
||||
let upstream_model = self.mapped_model(public_model.as_str());
|
||||
match self
|
||||
.model_catalog_accounts
|
||||
.get(provider)
|
||||
.and_then(|models| models.get(&upstream_model))
|
||||
{
|
||||
Some(accounts) => accounts.iter().any(|account| {
|
||||
scope.account_provider(account) == Some(provider)
|
||||
&& scope.allows_model(account, &upstream_model)
|
||||
}),
|
||||
None => scope.allows_provider_model(provider, &upstream_model),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mapped_model(&self, requested: &str) -> String {
|
||||
if let Some(alias) = self.model_alias(requested) {
|
||||
return alias.target.as_str().to_owned();
|
||||
}
|
||||
let original = requested;
|
||||
let mut current = original.to_owned();
|
||||
let mut seen = BTreeSet::new();
|
||||
@@ -1122,6 +1239,12 @@ impl RuntimeSnapshot {
|
||||
if !self.providers.contains(provider) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.model_alias(public_model.as_str())
|
||||
.is_some_and(|alias| &alias.provider != provider)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if context
|
||||
.required_provider
|
||||
.as_ref()
|
||||
@@ -1132,7 +1255,9 @@ impl RuntimeSnapshot {
|
||||
}
|
||||
let requested_model = public_model.as_str();
|
||||
let mapped_model = self.mapped_model(requested_model);
|
||||
let upstream_model = if self.model_mappings.contains_key(requested_model) {
|
||||
let upstream_model = if self.model_mappings.contains_key(requested_model)
|
||||
|| self.model_alias(requested_model).is_some()
|
||||
{
|
||||
UpstreamModelId::new(mapped_model)
|
||||
} else {
|
||||
UpstreamModelId::from_client_wire(mapped_model)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 可发布能力集合的中立身份与保活合同;不解释包格式、进程或管理配置。
|
||||
//! 可发布能力集合的中立身份、静态目录与保活合同;不解释包格式、进程或管理配置。
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::routing::ConfigRevision;
|
||||
use crate::routing::{ConfigRevision, ContributedModelAlias};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ExtensionSetId(String);
|
||||
@@ -32,6 +32,11 @@ impl ExtensionSetId {
|
||||
pub trait ExtensionSetLease: Send + Sync {
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// 与集合一起冻结的目录事实,读取不调用插件进程。
|
||||
fn model_aliases(&self) -> &[ContributedModelAlias] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// 局部故障已有请求级处理计划时,集合仍可提供服务。
|
||||
fn can_serve(&self) -> bool {
|
||||
self.is_ready()
|
||||
@@ -61,6 +66,11 @@ impl ExtensionSetReference {
|
||||
pub fn can_serve(&self) -> bool {
|
||||
self.lease.can_serve()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model_aliases(&self) -> &[ContributedModelAlias] {
|
||||
self.lease.model_aliases()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ExtensionSetReference {
|
||||
|
||||
@@ -69,6 +69,7 @@ pub struct RuntimeSnapshotUnavailable;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RuntimeSnapshotHandle {
|
||||
current: Arc<RwLock<Option<Arc<RuntimeSnapshot>>>>,
|
||||
publications: Arc<SyncMutex<Vec<futures::channel::mpsc::Sender<()>>>>,
|
||||
}
|
||||
|
||||
impl RuntimeSnapshotHandle {
|
||||
@@ -76,15 +77,38 @@ impl RuntimeSnapshotHandle {
|
||||
pub fn new(initial: RuntimeSnapshot) -> Self {
|
||||
Self {
|
||||
current: Arc::new(RwLock::new(Some(Arc::new(initial)))),
|
||||
publications: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish(&self, snapshot: RuntimeSnapshot) {
|
||||
*write_unpoisoned(&self.current) = Some(Arc::new(snapshot));
|
||||
self.notify_publication();
|
||||
}
|
||||
|
||||
pub fn suspend(&self) {
|
||||
*write_unpoisoned(&self.current) = None;
|
||||
self.notify_publication();
|
||||
}
|
||||
|
||||
/// 合并发布与暂停通知;订阅者收到后读取当前快照,不依赖逐条投递。
|
||||
pub fn subscribe_publications(&self) -> futures::channel::mpsc::Receiver<()> {
|
||||
let (sender, receiver) = futures::channel::mpsc::channel(0);
|
||||
self.publications
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(sender);
|
||||
receiver
|
||||
}
|
||||
|
||||
fn notify_publication(&self) {
|
||||
self.publications
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.retain_mut(|sender| match sender.try_send(()) {
|
||||
Ok(()) => true,
|
||||
Err(error) => error.is_full(),
|
||||
});
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -3,6 +3,7 @@ mod model_access;
|
||||
mod proxy;
|
||||
mod selection;
|
||||
mod selection_diagnostics;
|
||||
mod smart_scheduling;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
use gateway_core::account::{
|
||||
AccountSelector, PreferredAccountSelection, RotationStrategy, SmartSchedulingConfig,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use futures::FutureExt;
|
||||
use gateway_core::concurrency::{
|
||||
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
|
||||
};
|
||||
|
||||
use super::{candidate_with_concurrency, context, weighted_candidate};
|
||||
|
||||
#[test]
|
||||
fn smart_config_rejects_invalid_weights_and_incomplete_wire_values() {
|
||||
for value in [-0.1, 10.1, 0.01, f64::NAN, f64::INFINITY] {
|
||||
for dimension in 0..6 {
|
||||
let mut weights = [1.0; 6];
|
||||
weights[dimension] = value;
|
||||
assert!(SmartSchedulingConfig::new(weights, false).is_err());
|
||||
}
|
||||
}
|
||||
assert!(SmartSchedulingConfig::new([0.0; 6], true).is_err());
|
||||
for value in [json!({}), json!(null), json!({"loadWeight": 1.0})] {
|
||||
assert!(serde_json::from_value::<SmartSchedulingConfig>(value).is_err());
|
||||
}
|
||||
let config = SmartSchedulingConfig::new([0.0, 0.1, 9.9, 10.0, 1.2, 2.3], true).unwrap();
|
||||
let encoded = serde_json::to_value(config).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SmartSchedulingConfig>(encoded).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
|
||||
let mut context = context(RotationStrategy::Smart);
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 1.0, 0.0], false).unwrap(),
|
||||
);
|
||||
let mut candidates = [
|
||||
candidate_with_concurrency("acct_unknown", 0, 2),
|
||||
candidate_with_concurrency("acct_past", 0, 2),
|
||||
candidate_with_concurrency("acct_now", 0, 2),
|
||||
candidate_with_concurrency("acct_near", 0, 2),
|
||||
candidate_with_concurrency("acct_far", 0, 2),
|
||||
];
|
||||
candidates[1].signals.quota_reset_at = Some(context.now - Duration::from_secs(1));
|
||||
candidates[2].signals.quota_reset_at = Some(context.now);
|
||||
candidates[3].signals.quota_reset_at = Some(context.now + Duration::from_secs(3600));
|
||||
candidates[4].signals.quota_reset_at = Some(context.now + Duration::from_secs(86_400));
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_near"
|
||||
);
|
||||
let trace = gateway_core::diagnostics::TraceContext::new("req_reset_scores");
|
||||
trace.account_selection(&candidates, &context, None);
|
||||
let snapshot = trace.snapshot().unwrap();
|
||||
let scores = snapshot["events"][0]["data"]["candidates"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
for (candidate, expected) in scores.iter().zip([0.0, 0.0, 0.0, 0.5, 0.04]) {
|
||||
assert_eq!(candidate["smartScore"].as_f64().unwrap(), expected);
|
||||
}
|
||||
candidates[3].signals.in_flight = 2;
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_far"
|
||||
);
|
||||
candidates[3].signals.in_flight = 0;
|
||||
context.now += Duration::from_secs(3600);
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_far"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_weight_balances_live_pressure_and_health_only_when_enabled_for_smart() {
|
||||
for (strategy, queue_weight, expects_shortest) in [
|
||||
(RotationStrategy::Smart, 0.0, true),
|
||||
(RotationStrategy::Smart, 0.2, false),
|
||||
(RotationStrategy::Smart, 2.0, true),
|
||||
(RotationStrategy::RoundRobin, 0.2, true),
|
||||
(RotationStrategy::QuotaResetPriority, 0.2, true),
|
||||
(RotationStrategy::Sticky, 0.2, true),
|
||||
] {
|
||||
let mut candidates = [
|
||||
candidate_with_concurrency("acct_healthy", 2, 2),
|
||||
candidate_with_concurrency("acct_shortest", 2, 2),
|
||||
];
|
||||
candidates[1].signals.failure_rate_basis_points = Some(6000);
|
||||
let mut context = context(strategy);
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([0.0, 0.0, 1.0, 0.0, 0.0, queue_weight], false).unwrap(),
|
||||
);
|
||||
let queue = ConcurrencyWaitQueue::default();
|
||||
let head = queue
|
||||
.enqueue(
|
||||
&[candidates[0].account.id().clone()],
|
||||
2,
|
||||
Instant::now() + Duration::from_secs(2),
|
||||
)
|
||||
.unwrap();
|
||||
let budget = ConcurrencyWaitBudget::default();
|
||||
let policy = ConcurrencyQueuePolicy {
|
||||
max_waiting: 2,
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let mut waiting = CapacityWait::new(
|
||||
&queue,
|
||||
policy,
|
||||
SystemTime::now() + Duration::from_secs(2),
|
||||
&budget,
|
||||
);
|
||||
let keys = AccountSelector.wait_candidates(&candidates, &context);
|
||||
assert!(
|
||||
AccountSelector
|
||||
.wait_for_capacity(&mut waiting, &keys, &candidates, &context)
|
||||
.now_or_never()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
queue.has_waiters(candidates[1].account.id()),
|
||||
expects_shortest,
|
||||
"{strategy:?}, {queue_weight}"
|
||||
);
|
||||
// 新请求不能绕过已经选定的队列,取消请求同步归还位置。
|
||||
let newcomer = CapacityWait::new(
|
||||
&queue,
|
||||
policy,
|
||||
SystemTime::now() + Duration::from_secs(2),
|
||||
&budget,
|
||||
);
|
||||
assert!(!newcomer.can_try(candidates[usize::from(expects_shortest)].account.id()));
|
||||
drop(waiting);
|
||||
assert!(!queue.has_waiters(candidates[1].account.id()));
|
||||
drop(head);
|
||||
assert!(!queue.has_waiters(candidates[0].account.id()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_weight_does_not_widen_immediate_selection_tolerance() {
|
||||
let mut candidates = [
|
||||
candidate_with_concurrency("acct_a", 0, 2),
|
||||
candidate_with_concurrency("acct_b", 0, 2),
|
||||
];
|
||||
candidates[0].signals.quota_remaining_rank = Some(10000);
|
||||
candidates[1].signals.quota_remaining_rank = Some(9000);
|
||||
let mut context = context(RotationStrategy::Smart);
|
||||
context.round_robin_cursor = 1;
|
||||
for queue_weight in [0.0, 10.0] {
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([0.0, 1.0, 0.0, 0.0, 0.0, queue_weight], false).unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_a"
|
||||
);
|
||||
}
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 0.0, 1.0], false).unwrap(),
|
||||
);
|
||||
// 仅开启排队系数时,立即可用的同权重账号按既有游标轮换。
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_smart_weight_controls_its_signal_without_relaxing_capacity() {
|
||||
let mut candidates = [
|
||||
candidate_with_concurrency("acct_idle", 0, 10),
|
||||
candidate_with_concurrency("acct_healthy", 5, 10),
|
||||
];
|
||||
candidates[0].signals.quota_remaining_rank = Some(1_000);
|
||||
candidates[1].signals.quota_remaining_rank = Some(9_000);
|
||||
candidates[0].signals.failure_rate_basis_points = Some(5_000);
|
||||
candidates[1].signals.failure_rate_basis_points = Some(0);
|
||||
candidates[0].signals.first_output_latency_ms = Some(30_000);
|
||||
candidates[1].signals.first_output_latency_ms = Some(1_000);
|
||||
for dimension in 0..4 {
|
||||
let mut weights = [0.0; 6];
|
||||
weights[dimension] = 1.0;
|
||||
let mut context = context(RotationStrategy::Smart);
|
||||
context.policy = context
|
||||
.policy
|
||||
.with_smart_scheduling(SmartSchedulingConfig::new(weights, false).unwrap());
|
||||
let selected = AccountSelector.select(&candidates, &context).unwrap();
|
||||
assert_eq!(
|
||||
selected.candidate().account.id().as_str(),
|
||||
if dimension == 0 {
|
||||
"acct_idle"
|
||||
} else {
|
||||
"acct_healthy"
|
||||
}
|
||||
);
|
||||
let mut saturated = candidates.clone();
|
||||
saturated[1].signals.in_flight = 10;
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&saturated, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str(),
|
||||
"acct_idle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proportional_coefficients_preserve_near_best_rotation_and_diagnostics() {
|
||||
let mut candidates = [
|
||||
candidate_with_concurrency("acct_a", 0, 100),
|
||||
candidate_with_concurrency("acct_b", 0, 100),
|
||||
candidate_with_concurrency("acct_c", 0, 100),
|
||||
];
|
||||
// 默认额度权重 0.8 下,9375 正好位于容差边界,9374 刚好超出。
|
||||
for (candidate, quota) in candidates.iter_mut().zip([10_000, 9_375, 9_374]) {
|
||||
candidate.signals.quota_remaining_rank = Some(quota);
|
||||
}
|
||||
for scale in [1.0, 2.0, 5.0] {
|
||||
let mut context = context(RotationStrategy::Smart);
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([scale, 0.8 * scale, scale, 0.5 * scale, 0.0, 0.0], false)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut ids = Vec::new();
|
||||
for cursor in 0..4 {
|
||||
candidates.reverse();
|
||||
context.round_robin_cursor = cursor;
|
||||
ids.push(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.candidate()
|
||||
.account
|
||||
.id()
|
||||
.as_str()
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
assert_eq!(ids, ["acct_a", "acct_b", "acct_a", "acct_b"]);
|
||||
let trace = gateway_core::diagnostics::TraceContext::new("req_scaled_weights");
|
||||
trace.account_selection(&candidates, &context, None);
|
||||
let snapshot = trace.snapshot().unwrap();
|
||||
let tolerance = snapshot["events"][0]["data"]["smartScoreTolerance"]
|
||||
.as_f64()
|
||||
.unwrap();
|
||||
assert!((tolerance - 0.05 * scale).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switchback_is_opt_in_and_only_affects_smart_soft_affinity() {
|
||||
let mut candidates = [
|
||||
weighted_candidate("acct_affinity", 10, 0),
|
||||
weighted_candidate("acct_primary", 100, 0),
|
||||
];
|
||||
for strategy in [
|
||||
RotationStrategy::Smart,
|
||||
RotationStrategy::Sticky,
|
||||
RotationStrategy::RoundRobin,
|
||||
RotationStrategy::QuotaResetPriority,
|
||||
] {
|
||||
let mut context = context(strategy);
|
||||
context.preferred_account = Some(candidates[0].account.id().clone());
|
||||
context.preferred_account_overrides_weight = true;
|
||||
for enabled in [false, true] {
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], enabled).unwrap(),
|
||||
);
|
||||
let selected = AccountSelector.select(&candidates, &context).unwrap();
|
||||
let should_switch = enabled && strategy == RotationStrategy::Smart;
|
||||
assert_eq!(
|
||||
selected.candidate().account.id(),
|
||||
candidates[usize::from(should_switch)].account.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut context = context(RotationStrategy::Smart);
|
||||
context.policy = context.policy.with_smart_scheduling(
|
||||
SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], true).unwrap(),
|
||||
);
|
||||
context.preferred_account_overrides_weight = true;
|
||||
context.preferred_account = Some(candidates[0].account.id().clone());
|
||||
candidates[1].signals.in_flight = 3;
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.preferred(),
|
||||
PreferredAccountSelection::Hit
|
||||
);
|
||||
candidates[1].signals.in_flight = 0;
|
||||
context
|
||||
.excluded_accounts
|
||||
.insert(candidates[1].account.id().clone());
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.preferred(),
|
||||
PreferredAccountSelection::Hit
|
||||
);
|
||||
context.excluded_accounts.clear();
|
||||
candidates[0] = weighted_candidate("acct_affinity", 100, 2);
|
||||
assert_eq!(
|
||||
AccountSelector
|
||||
.select(&candidates, &context)
|
||||
.unwrap()
|
||||
.preferred(),
|
||||
PreferredAccountSelection::Hit
|
||||
);
|
||||
}
|
||||
@@ -159,3 +159,75 @@ fn creating_a_waiter_does_not_start_the_budget_before_queueing() {
|
||||
waiting.wait(&["account"]).await.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_priority_reads_live_counts_and_preserves_limits_and_fifo() {
|
||||
block_on(async {
|
||||
let queue = ConcurrencyWaitQueue::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let head = queue.enqueue(&["a"], 2, deadline).unwrap();
|
||||
let mut observed = Vec::new();
|
||||
let tail = queue
|
||||
.enqueue_with_priority(&["a", "b"], 2, deadline, |key, count| {
|
||||
observed.push((*key, count));
|
||||
if *key == "a" { 10.0 } else { 0.0 }
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(observed, [("a", 1), ("b", 0)]);
|
||||
assert_eq!(*tail.key(), "a");
|
||||
assert!(tail.turn().now_or_never().is_none());
|
||||
// 满队列不参与评分,不能因偏好而突破上限。
|
||||
let fallback = queue
|
||||
.enqueue_with_priority(&["a", "b"], 2, deadline, |key, count| {
|
||||
assert_eq!((*key, count), ("b", 0));
|
||||
0.0
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(*fallback.key(), "b");
|
||||
drop(head);
|
||||
tail.turn().await.unwrap();
|
||||
drop((tail, fallback));
|
||||
let tie = queue
|
||||
.enqueue_with_priority(&["b", "a"], 2, deadline, |_, _| 1.0)
|
||||
.unwrap();
|
||||
assert_eq!(*tie.key(), "b");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_scores_keep_existing_position_until_account_becomes_ineligible() {
|
||||
block_on(async {
|
||||
let queue = ConcurrencyWaitQueue::default();
|
||||
let budget = ConcurrencyWaitBudget::default();
|
||||
let policy = ConcurrencyQueuePolicy {
|
||||
max_waiting: 1,
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let mut waiting = CapacityWait::new(
|
||||
&queue,
|
||||
policy,
|
||||
SystemTime::now() + Duration::from_secs(2),
|
||||
&budget,
|
||||
);
|
||||
waiting
|
||||
.wait_with_priority(&["a", "b"], |key, _| if *key == "a" { 1.0 } else { 0.0 })
|
||||
.await
|
||||
.unwrap();
|
||||
waiting
|
||||
.wait_with_priority(&["a", "b"], |_, _| {
|
||||
panic!("existing ticket must not be rescored")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(queue.has_waiters(&"a"));
|
||||
assert!(!queue.has_waiters(&"b"));
|
||||
waiting
|
||||
.wait_with_priority(&["b"], |_, _| 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!queue.has_waiters(&"a"));
|
||||
assert!(queue.has_waiters(&"b"));
|
||||
drop(waiting);
|
||||
assert!(!queue.has_waiters(&"b"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -723,6 +723,162 @@ struct FixedRoutingPolicy {
|
||||
inputs: Mutex<Vec<ModelRouteInput>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RetryTestPolicy {
|
||||
decision: gateway_core::engine::policy::RetryDecision,
|
||||
facts: Arc<Mutex<Vec<gateway_core::engine::policy::RetryFacts>>>,
|
||||
}
|
||||
|
||||
impl RequestPolicyPlan for RetryTestPolicy {
|
||||
fn route_model(
|
||||
&self,
|
||||
_: ModelRouteInput,
|
||||
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
|
||||
Box::pin(async { Ok(ModelRouteDecision::Unhandled) })
|
||||
}
|
||||
fn schedule_account(
|
||||
&self,
|
||||
_: AccountScheduleInput,
|
||||
) -> BoxFuture<'static, Result<AccountScheduleDecision, RequestPolicyFault>> {
|
||||
Box::pin(async { Ok(AccountScheduleDecision::Delegate) })
|
||||
}
|
||||
fn retry_decision(
|
||||
&self,
|
||||
input: gateway_core::engine::policy::RetryInput,
|
||||
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
|
||||
{
|
||||
self.facts.lock().unwrap().push(input.facts);
|
||||
let decision = self.decision;
|
||||
Box::pin(async move { Ok(decision) })
|
||||
}
|
||||
}
|
||||
|
||||
struct RetryTestProvider {
|
||||
error: ProviderError,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for RetryTestProvider {
|
||||
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![])
|
||||
}
|
||||
async fn execute(
|
||||
self: Arc<Self>,
|
||||
request: ProviderRequest,
|
||||
_: AttemptContext,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) > 0 {
|
||||
return Err(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
let candidate = request.candidate();
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
candidate.provider().clone(),
|
||||
candidate.upstream_model().cloned().unwrap(),
|
||||
ProviderAccountId::new("acct_openai").unwrap(),
|
||||
UpstreamTransport::new("http").unwrap(),
|
||||
);
|
||||
Ok(ProviderStream::new(
|
||||
metadata,
|
||||
futures::stream::iter([Err(self.error.clone())]),
|
||||
(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
|
||||
use gateway_core::engine::policy::RetryDecision;
|
||||
for (decision, send_state, replay_safe, expected_allowed, expected_calls) in [
|
||||
(
|
||||
RetryDecision::Stop,
|
||||
UpstreamSendState::NotSent,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
),
|
||||
(
|
||||
RetryDecision::Retry,
|
||||
UpstreamSendState::NotSent,
|
||||
true,
|
||||
true,
|
||||
2,
|
||||
),
|
||||
(
|
||||
RetryDecision::Retry,
|
||||
UpstreamSendState::Ambiguous,
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
),
|
||||
(
|
||||
RetryDecision::Retry,
|
||||
UpstreamSendState::Sent,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
),
|
||||
] {
|
||||
block_on(async {
|
||||
let error = ProviderError::new(ProviderErrorKind::Transport, send_state);
|
||||
let error = if replay_safe {
|
||||
error.with_replay_safe()
|
||||
} else {
|
||||
error
|
||||
};
|
||||
let provider = Arc::new(RetryTestProvider {
|
||||
error,
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let facts = Arc::new(Mutex::new(vec![]));
|
||||
let generation = super::extensions::reference("retry-policy");
|
||||
let policies = RequestPolicyExtensionIndex::default();
|
||||
let _owner = policies
|
||||
.register(
|
||||
generation.id().clone(),
|
||||
Arc::new(RetryTestPolicy {
|
||||
decision,
|
||||
facts: facts.clone(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(request_policy_snapshot(
|
||||
generation,
|
||||
&[ProviderKind::new("openai").unwrap()],
|
||||
)),
|
||||
Arc::new(TrackingExecutionStore::default()),
|
||||
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
|
||||
Arc::new(Admissions::default()),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(RecordingClientApiKeyUsage::default()),
|
||||
)
|
||||
.with_request_policies(policies);
|
||||
let mut started = service
|
||||
.start(request(&service, ClientTransport::HttpJson))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(started.session.collect_uncommitted().await.is_err());
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), expected_calls);
|
||||
let observed = facts.lock().unwrap();
|
||||
assert_eq!(observed[0].retry_allowed, expected_allowed);
|
||||
assert_eq!(observed[0].send_state, send_state);
|
||||
assert_eq!(observed[0].remaining_routing_attempts, 31);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestPolicyPlan for FixedRoutingPolicy {
|
||||
fn route_model(
|
||||
&self,
|
||||
@@ -2957,11 +3113,34 @@ impl Provider for NativeCatalogProvider {
|
||||
|
||||
#[test]
|
||||
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
|
||||
#[derive(Debug)]
|
||||
struct Aliases(Vec<gateway_core::routing::ContributedModelAlias>);
|
||||
impl gateway_core::runtime::extensions::ExtensionSetLease for Aliases {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
for fail in [false, true] {
|
||||
let snapshot = start_snapshot().with_model_mappings(BTreeMap::from([
|
||||
("alias".to_owned(), "gpt-start".to_owned()),
|
||||
("missing-alias".to_owned(), "unavailable-model".to_owned()),
|
||||
]));
|
||||
let snapshot = start_snapshot()
|
||||
.with_model_mappings(BTreeMap::from([
|
||||
("alias".to_owned(), "gpt-start".to_owned()),
|
||||
("missing-alias".to_owned(), "unavailable-model".to_owned()),
|
||||
]))
|
||||
.with_extensions(Some(ExtensionSetReference::new(
|
||||
gateway_core::runtime::extensions::ExtensionSetId::new("catalog-aliases".into())
|
||||
.unwrap(),
|
||||
Arc::new(Aliases(vec![
|
||||
gateway_core::routing::ContributedModelAlias {
|
||||
owner: "catalog-plugin".into(),
|
||||
id: PublicModelId::new("plugin-alias").unwrap(),
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
target: UpstreamModelId::new("gpt-start").unwrap(),
|
||||
},
|
||||
])),
|
||||
)));
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
Arc::new(TrackingExecutionStore::default()),
|
||||
@@ -2993,9 +3172,13 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
pairs.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
|
||||
["gpt-start", "alias"]
|
||||
["gpt-start", "alias", "plugin-alias"]
|
||||
);
|
||||
assert_eq!(pairs[0].1, pairs[1].1, "Core preserves alias payload bytes");
|
||||
assert_eq!(
|
||||
pairs[0].1, pairs[2].1,
|
||||
"插件别名使用相同原生对象,不伪造字段"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,138 @@ fn operation() -> Operation {
|
||||
))
|
||||
}
|
||||
|
||||
fn body_operation(body: &Value) -> Operation {
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_declaration_preserves_original_semantics_and_recomputes_upstream_needs() {
|
||||
use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature};
|
||||
let original = json!({"model":"gpt-test","input":"answer", "tools":[{"type":"function"}],
|
||||
"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}});
|
||||
let mut converted = original.clone();
|
||||
converted.as_object_mut().unwrap().remove("text");
|
||||
converted["input"] = json!(
|
||||
"Return an object matching the supplied schema; the middleware validates the response."
|
||||
);
|
||||
let request = MiddlewareRequest::new(
|
||||
"openai",
|
||||
vec![],
|
||||
serde_json::to_vec(&original).unwrap().into(),
|
||||
)
|
||||
.replace_parts(
|
||||
"openai".into(),
|
||||
vec![],
|
||||
serde_json::to_vec(&converted).unwrap().into(),
|
||||
Some(MiddlewareCapabilityDeclaration {
|
||||
handled: [Feature::JsonSchema].into(),
|
||||
required: [Feature::Reasoning].into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let operation = request
|
||||
.apply_capabilities(body_operation(&converted))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
operation.original_capability_requirements(),
|
||||
body_operation(&original).capability_requirements()
|
||||
);
|
||||
assert_eq!(
|
||||
operation.capability_requirements().features(),
|
||||
&[Feature::Tools, Feature::Reasoning].into()
|
||||
);
|
||||
assert_eq!(
|
||||
request.body().as_ref(),
|
||||
serde_json::to_vec(&converted).unwrap()
|
||||
);
|
||||
|
||||
// 后续普通正文修改也不能删掉未承担的原始语义,或藏起实际新增的需求。
|
||||
converted.as_object_mut().unwrap().remove("tools");
|
||||
converted["input"] = json!([{"type":"input_image","image_url":"synthetic"}]);
|
||||
let request = request
|
||||
.replace_parts(
|
||||
"openai".into(),
|
||||
vec![],
|
||||
serde_json::to_vec(&converted).unwrap().into(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let operation = request
|
||||
.apply_capabilities(body_operation(&converted))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
operation.capability_requirements().features(),
|
||||
&[Feature::Tools, Feature::Reasoning, Feature::Vision].into()
|
||||
);
|
||||
let changed = operation
|
||||
.replace_middleware_wire(
|
||||
"openai",
|
||||
serde_json::to_vec(&json!({"input":"answer","text":{"format":{"type":"json_schema"}}}))
|
||||
.unwrap()
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
changed
|
||||
.capability_requirements()
|
||||
.features()
|
||||
.contains(&Feature::JsonSchema)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_declaration_cannot_erase_remaining_or_invented_features_or_native_continuation() {
|
||||
use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature};
|
||||
let source = json!({"tools":[{"type":"function"}],"previous_response_id":"resp-original"});
|
||||
let request = MiddlewareRequest::new(
|
||||
"openai",
|
||||
vec![],
|
||||
serde_json::to_vec(&source).unwrap().into(),
|
||||
);
|
||||
for (handled, body) in [
|
||||
(Feature::Tools, source),
|
||||
(Feature::JsonSchema, json!({"input":"removed"})),
|
||||
(Feature::NativeContinuation, json!({"input":"removed"})),
|
||||
] {
|
||||
assert!(
|
||||
request
|
||||
.clone()
|
||||
.replace_parts(
|
||||
"openai".into(),
|
||||
vec![],
|
||||
serde_json::to_vec(&body).unwrap().into(),
|
||||
Some(MiddlewareCapabilityDeclaration {
|
||||
handled: [handled].into(),
|
||||
required: Default::default()
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let converted = json!({"input":"tools converted"});
|
||||
let request = request
|
||||
.replace_parts(
|
||||
"openai".into(),
|
||||
vec![],
|
||||
serde_json::to_vec(&converted).unwrap().into(),
|
||||
Some(MiddlewareCapabilityDeclaration {
|
||||
handled: [Feature::Tools].into(),
|
||||
required: Default::default(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
request
|
||||
.apply_capabilities(body_operation(&converted))
|
||||
.unwrap()
|
||||
.capability_requirements()
|
||||
.features()
|
||||
.contains(&Feature::NativeContinuation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
|
||||
futures::executor::block_on(async {
|
||||
|
||||
@@ -169,6 +169,52 @@ fn delegated_scheduler_keeps_builtin_affinity() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_switchback_does_not_override_explicit_plugin_choices_or_their_telemetry() {
|
||||
block_on(async {
|
||||
let mut candidates = [candidate("acct_a"), candidate("acct_b")];
|
||||
candidates[0].account = candidates[0]
|
||||
.account
|
||||
.clone()
|
||||
.with_scheduling(None, AccountWeight::new(10).unwrap());
|
||||
let mut selection_context = context();
|
||||
selection_context.policy = AccountSelectionPolicy::new(
|
||||
RotationStrategy::Smart,
|
||||
NonZeroU32::new(2).unwrap(),
|
||||
Duration::ZERO,
|
||||
)
|
||||
.with_smart_scheduling(
|
||||
gateway_core::account::SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], true)
|
||||
.unwrap(),
|
||||
);
|
||||
for chosen in [Some("acct_a"), Some("acct_b"), None] {
|
||||
let selected = policy(chosen)
|
||||
.select_account(
|
||||
NonZeroU32::new(1).unwrap(),
|
||||
&ProviderKind::new("openai").unwrap(),
|
||||
None,
|
||||
&candidates,
|
||||
&selection_context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected.candidate().account.id().as_str(),
|
||||
chosen.unwrap_or("acct_b")
|
||||
);
|
||||
let expected = match chosen {
|
||||
Some("acct_a") => PreferredAccountSelection::Hit,
|
||||
Some(_) => PreferredAccountSelection::OverriddenByPolicy,
|
||||
None => PreferredAccountSelection::Blocked(
|
||||
gateway_core::account::AccountSchedulingBlocker::LowerWeight,
|
||||
),
|
||||
};
|
||||
assert_eq!(selected.preferred(), expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_cannot_select_outside_the_hard_bound_candidate_pool() {
|
||||
block_on(async {
|
||||
|
||||
@@ -13,9 +13,9 @@ use gateway_core::routing::snapshot::{
|
||||
SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderCatalogGeneration,
|
||||
ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind, ProviderModelCapabilities,
|
||||
PublicModelId, UpstreamModelId,
|
||||
ConfigRevision, ContributedModelAlias, ModelCapabilities, ModelPresentation,
|
||||
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind,
|
||||
ProviderModelCapabilities, PublicModelId, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::runtime::extensions::{
|
||||
ExtensionPreparationError, ExtensionPreparationPort, ExtensionSetId, ExtensionSetLease,
|
||||
@@ -93,6 +93,134 @@ struct CountingExtensionPreparation {
|
||||
drops: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
struct AliasLease(Vec<ContributedModelAlias>);
|
||||
|
||||
impl ExtensionSetLease for AliasLease {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn model_aliases(&self) -> &[ContributedModelAlias] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
struct AliasPreparation(ExtensionSetReference);
|
||||
|
||||
impl ExtensionPreparationPort for AliasPreparation {
|
||||
fn prepare(
|
||||
&self,
|
||||
_: ConfigRevision,
|
||||
) -> BoxFuture<'_, Result<ExtensionSetReference, ExtensionPreparationError>> {
|
||||
Box::pin(async move { Ok(self.0.clone()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn alias(id: &str, target: &str) -> ContributedModelAlias {
|
||||
ContributedModelAlias {
|
||||
owner: "test-plugin".into(),
|
||||
id: PublicModelId::new(id).unwrap(),
|
||||
provider: ProviderKind::new("alpha").unwrap(),
|
||||
target: UpstreamModelId::new(target).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_aliases(
|
||||
aliases: Vec<ContributedModelAlias>,
|
||||
) -> Result<gateway_core::routing::RuntimeSnapshot, RuntimeSnapshotCompileError> {
|
||||
let facts = SnapshotFacts::new(
|
||||
revision(1),
|
||||
revision(1),
|
||||
SnapshotSettingsFacts::new(
|
||||
3,
|
||||
50,
|
||||
"smart",
|
||||
BTreeMap::from([("configured".into(), "upstream-model".into())]),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
vec![],
|
||||
vec![],
|
||||
vec![SnapshotProviderAccountFacts::new(
|
||||
gateway_core::account::ProviderAccountId::new("acct_alias").unwrap(),
|
||||
"alpha",
|
||||
)],
|
||||
vec![],
|
||||
);
|
||||
block_on(
|
||||
RuntimeSnapshotCompiler::new(
|
||||
Arc::new(TestSnapshotStore::new(Ok(facts))),
|
||||
Arc::new(PublishingCatalog {
|
||||
generation: AtomicU64::new(1),
|
||||
queries: AtomicUsize::new(1),
|
||||
}),
|
||||
)
|
||||
.with_extensions(Arc::new(AliasPreparation(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("alias-generation".into()).unwrap(),
|
||||
Arc::new(AliasLease(aliases)),
|
||||
))))
|
||||
.compile(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributed_alias_uses_the_same_target_for_listing_profiles_and_routing() {
|
||||
let snapshot = compile_aliases(vec![alias("plugin-model", "upstream-model")]).unwrap();
|
||||
let model = PublicModelId::new("plugin-model").unwrap();
|
||||
let provider = ProviderKind::new("alpha").unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.public_models_for_scope(&snapshot.all_account_scope())
|
||||
.contains(&model)
|
||||
);
|
||||
assert!(snapshot.contains_public_model_for_scope(&model, &snapshot.all_account_scope()));
|
||||
let profiles = snapshot.public_model_profiles_for_provider(&provider);
|
||||
let alias_profile = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.model() == &model)
|
||||
.unwrap();
|
||||
let native_profile = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.model().as_str() == "upstream-model")
|
||||
.unwrap();
|
||||
assert_eq!(alias_profile.presentation(), native_profile.presentation());
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&model,
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.candidates()[0].provider(), &provider);
|
||||
assert_eq!(
|
||||
plan.candidates()[0].upstream_model().unwrap().as_str(),
|
||||
"upstream-model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_publication() {
|
||||
for aliases in [
|
||||
vec![alias("upstream-model", "upstream-model")],
|
||||
vec![alias("configured", "upstream-model")],
|
||||
vec![alias("plugin-model", "configured")],
|
||||
vec![alias("plugin-model", "missing")],
|
||||
vec![
|
||||
alias("plugin-model", "other"),
|
||||
alias("other", "plugin-model"),
|
||||
],
|
||||
vec![
|
||||
alias("plugin-model", "upstream-model"),
|
||||
alias("plugin-model", "upstream-model"),
|
||||
],
|
||||
] {
|
||||
assert_eq!(
|
||||
compile_aliases(aliases).unwrap_err(),
|
||||
RuntimeSnapshotCompileError::InvalidExtensionModels
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPreparationPort for CountingExtensionPreparation {
|
||||
fn prepare(
|
||||
&self,
|
||||
@@ -234,6 +362,49 @@ fn compiler_accepts_unlimited_default_account_concurrency() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
|
||||
use gateway_core::account::SmartSchedulingConfig;
|
||||
let configs = [
|
||||
SmartSchedulingConfig::default(),
|
||||
SmartSchedulingConfig::new([0.0, 2.0, 1.0, 0.5, 1.2, 2.3], true).unwrap(),
|
||||
];
|
||||
let mut plans = Vec::new();
|
||||
for (index, config) in configs.into_iter().enumerate() {
|
||||
let facts = SnapshotFacts::new(
|
||||
revision(index as u64 + 1),
|
||||
revision(index as u64 + 1),
|
||||
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
|
||||
.with_smart_scheduling(config),
|
||||
vec![],
|
||||
vec![],
|
||||
vec![SnapshotProviderAccountFacts::new(
|
||||
gateway_core::account::ProviderAccountId::new("acct_config").unwrap(),
|
||||
"alpha",
|
||||
)],
|
||||
vec![],
|
||||
);
|
||||
let compiler = RuntimeSnapshotCompiler::new(
|
||||
Arc::new(TestSnapshotStore::new(Ok(facts))),
|
||||
Arc::new(TestCatalog::Unavailable),
|
||||
);
|
||||
let snapshot = block_on(compiler.compile()).unwrap();
|
||||
plans.push(
|
||||
snapshot
|
||||
.plan(
|
||||
&PublicModelId::new("any-model").unwrap(),
|
||||
&super::operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for (plan, config) in plans.iter().zip(configs) {
|
||||
assert_eq!(plan.account_selection_policy().smart_scheduling(), config);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdrawn_provider_keeps_accounts_without_becoming_a_route_or_expanding_group_scope() {
|
||||
use gateway_core::{
|
||||
|
||||
@@ -168,19 +168,30 @@ fn committed_account_change_should_publish_without_waiting_for_provider_catalog(
|
||||
block_on(async {
|
||||
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(9, true))));
|
||||
let catalog = Arc::new(TestCatalog::default());
|
||||
catalog
|
||||
.models
|
||||
.lock()
|
||||
.expect("models lock")
|
||||
.push(ProviderModelCapabilities::new(
|
||||
catalog.models.lock().expect("models lock").push(
|
||||
ProviderModelCapabilities::new(
|
||||
UpstreamModelId::new("listed-model").expect("model"),
|
||||
ModelCapabilities::new(Default::default(), None),
|
||||
));
|
||||
)
|
||||
.with_catalog_accounts(std::collections::BTreeSet::from([
|
||||
ProviderAccountId::new("acct_audit_removed").expect("source account"),
|
||||
])),
|
||||
);
|
||||
let compiler = Arc::new(catalog_compiler(store.clone(), catalog.clone()));
|
||||
let initial = compiler.compile().await.expect("initial snapshot");
|
||||
let provider = ProviderKind::new("audit").expect("provider");
|
||||
let initial_models = initial.public_models_for_provider(&provider);
|
||||
let handle = RuntimeSnapshotHandle::new(initial);
|
||||
let initial_scope = initial
|
||||
.client_policies()
|
||||
.next()
|
||||
.unwrap()
|
||||
.account_scope()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
initial.public_models_for_scope(&initial_scope),
|
||||
initial_models
|
||||
);
|
||||
let handle = RuntimeSnapshotHandle::new(initial.clone());
|
||||
let publisher = RuntimeSnapshotPublisher::new(
|
||||
compiler,
|
||||
handle.clone(),
|
||||
@@ -200,6 +211,16 @@ fn committed_account_change_should_publish_without_waiting_for_provider_catalog(
|
||||
initial_models
|
||||
);
|
||||
assert_eq!(catalog.queries.load(Ordering::SeqCst), 1);
|
||||
let committed_scope = committed.client_policies().next().unwrap().account_scope();
|
||||
assert!(
|
||||
committed
|
||||
.public_models_for_scope(committed_scope)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
initial.public_models_for_scope(&initial_scope),
|
||||
initial_models
|
||||
);
|
||||
assert_ne!(
|
||||
committed.provider_catalog_generations(),
|
||||
&catalog.catalog_generations(),
|
||||
|
||||
@@ -26,6 +26,7 @@ use super::{
|
||||
pub(super) struct CachedRelease {
|
||||
result: Result<ResolvedRelease, AdminError>,
|
||||
until: Instant,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -57,7 +58,9 @@ impl HttpPluginDistribution {
|
||||
mut query: GithubReleaseQuery,
|
||||
credentials: &[SourceCredential],
|
||||
egress: Option<&PluginDistributionEgress>,
|
||||
refresh: bool,
|
||||
) -> Result<ResolvedRelease, AdminError> {
|
||||
let requested_at = Instant::now();
|
||||
validate_repository(&query.repository)?;
|
||||
query.repository.make_ascii_lowercase();
|
||||
let url = self.release_url(&query)?;
|
||||
@@ -91,7 +94,11 @@ impl HttpPluginDistribution {
|
||||
tokio::time::timeout(Duration::from_secs(60), async {
|
||||
// 同一来源共用锁和结果;取消请求释放锁,下一位查询者可重新发起。
|
||||
let mut cached = slot.lock().await;
|
||||
if let Some(entry) = cached.as_ref().filter(|entry| entry.until > Instant::now()) {
|
||||
// 显式查询刷新成功结果;等待同一次请求的调用者仍共享结果,失败保留短缓存限流。
|
||||
if let Some(entry) = cached.as_ref().filter(|entry| {
|
||||
entry.until > Instant::now()
|
||||
&& (!refresh || entry.fetched_at >= requested_at || entry.result.is_err())
|
||||
}) {
|
||||
return entry.result.clone();
|
||||
}
|
||||
let _permit = self
|
||||
@@ -105,9 +112,11 @@ impl HttpPluginDistribution {
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
let fetched_at = Instant::now();
|
||||
*cached = Some(CachedRelease {
|
||||
result: result.clone(),
|
||||
until: Instant::now() + ttl,
|
||||
until: fetched_at + ttl,
|
||||
fetched_at,
|
||||
});
|
||||
result
|
||||
})
|
||||
@@ -213,7 +222,7 @@ impl HttpPluginDistribution {
|
||||
credentials: &[SourceCredential],
|
||||
egress: Option<&PluginDistributionEgress>,
|
||||
) -> Result<(Url, Option<String>, PluginSource, bool), AdminError> {
|
||||
let release = self.release(query, credentials, egress).await?;
|
||||
let release = self.release(query, credentials, egress, false).await?;
|
||||
let selected = release
|
||||
.assets
|
||||
.iter()
|
||||
|
||||
@@ -153,7 +153,7 @@ impl PluginDistribution for HttpPluginDistribution {
|
||||
egress: Option<PluginDistributionEgress>,
|
||||
) -> Result<PluginRelease, AdminError> {
|
||||
Ok(self
|
||||
.release(query, &credentials, egress.as_ref())
|
||||
.release(query, &credentials, egress.as_ref(), true)
|
||||
.await?
|
||||
.view)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 安装文件指纹用于区分本进程安装的候选与外部重新部署,历史版本号不代表磁盘事实。
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use super::swap::backup_path_for;
|
||||
use super::{OperationError, SystemUpdateConfig, conflict, internal};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReleaseFiles {
|
||||
binary: String,
|
||||
web: String,
|
||||
official_plugins: String,
|
||||
}
|
||||
|
||||
impl ReleaseFiles {
|
||||
pub(crate) fn installed(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
|
||||
Self::read(
|
||||
&config.executable_path()?,
|
||||
config.web_dist_dir()?,
|
||||
&config.official_plugins_dir()?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn backup(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
|
||||
Self::read(
|
||||
&backup_path_for(&config.executable_path()?),
|
||||
&backup_path_for(config.web_dist_dir()?),
|
||||
&backup_path_for(&config.official_plugins_dir()?),
|
||||
)
|
||||
}
|
||||
|
||||
fn read(binary: &Path, web: &Path, plugins: &Path) -> Result<Self, OperationError> {
|
||||
if !web.join("index.html").is_file()
|
||||
|| !plugins.join(super::OFFICIAL_PLUGIN_MANIFEST).is_file()
|
||||
{
|
||||
return Err(conflict("安装文件不完整,请核对部署后重试"));
|
||||
}
|
||||
let mut binary_hash = Sha256::new();
|
||||
hash_file(binary, &mut binary_hash).map_err(read_error)?;
|
||||
Ok(Self {
|
||||
binary: hex::encode(binary_hash.finalize()),
|
||||
web: hash_directory(web).map_err(read_error)?,
|
||||
official_plugins: hash_directory(plugins).map_err(read_error)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_error(error: io::Error) -> OperationError {
|
||||
tracing::warn!(error = %error, "核对系统安装文件失败");
|
||||
internal("无法核对安装文件,请检查文件完整性与读取权限")
|
||||
}
|
||||
|
||||
fn hash_directory(path: &Path) -> io::Result<String> {
|
||||
let mut hash = Sha256::new();
|
||||
hash_tree(path, &mut hash)?;
|
||||
Ok(hex::encode(hash.finalize()))
|
||||
}
|
||||
|
||||
fn hash_tree(path: &Path, hash: &mut Sha256) -> io::Result<()> {
|
||||
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
entries.sort_by_key(fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let name = entry.file_name();
|
||||
let name = name.as_encoded_bytes();
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name);
|
||||
if entry.file_type()?.is_dir() {
|
||||
hash.update(b"directory");
|
||||
hash_tree(&entry.path(), hash)?;
|
||||
hash.update(b"end-directory");
|
||||
} else {
|
||||
hash.update(b"file");
|
||||
hash_file(&entry.path(), hash)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_file(path: &Path, hash: &mut Sha256) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"发行文件必须为普通文件",
|
||||
));
|
||||
}
|
||||
hash.update(metadata.len().to_le_bytes());
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod archive;
|
||||
mod download;
|
||||
mod installation;
|
||||
mod process;
|
||||
mod release;
|
||||
mod state;
|
||||
@@ -17,8 +18,9 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateEventLevel, SystemUpdateStatus, SystemVersion,
|
||||
SystemOperationAccepted, SystemOperationKind, SystemUpdateChannel as UpdateChannel,
|
||||
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
use gateway_admin::ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate,
|
||||
@@ -32,14 +34,15 @@ use crate::config::ConfigError;
|
||||
|
||||
use self::archive::extract_release;
|
||||
use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum};
|
||||
use self::installation::ReleaseFiles;
|
||||
use self::process::{environment_value, spawn_replacement};
|
||||
use self::release::{
|
||||
ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest,
|
||||
select_archive, validate_update_target, version_channel,
|
||||
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
|
||||
validate_update_target, version_channel,
|
||||
};
|
||||
use self::state::{
|
||||
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
|
||||
recover_interrupted, set_running,
|
||||
reconcile_installation, recover_interrupted, set_running,
|
||||
};
|
||||
use self::swap::{replace_release_files, rollback_official_plugins_dir, rollback_release};
|
||||
|
||||
@@ -233,23 +236,28 @@ pub struct ProcessSystemOperations {
|
||||
config: Arc<SystemUpdateConfig>,
|
||||
operation_lock: Arc<AsyncMutex<()>>,
|
||||
release_cache: Arc<ReleaseCache>,
|
||||
running_files: Arc<Result<ReleaseFiles, OperationError>>,
|
||||
}
|
||||
|
||||
impl ProcessSystemOperations {
|
||||
#[must_use]
|
||||
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
|
||||
// 组合根在接收请求前创建服务,记录本次启动的发行文件,后续替换不能改写这份事实。
|
||||
let running_files = Arc::new(ReleaseFiles::installed(&config));
|
||||
Self {
|
||||
cancellation,
|
||||
events: Arc::new(UpdateEvents::default()),
|
||||
config: Arc::new(config),
|
||||
operation_lock: Arc::new(AsyncMutex::new(())),
|
||||
release_cache: Arc::new(ReleaseCache::default()),
|
||||
running_files,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<UpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
let operation_lock = Arc::clone(&self.operation_lock)
|
||||
@@ -264,13 +272,14 @@ impl ProcessSystemOperations {
|
||||
return Err(conflict(reason));
|
||||
}
|
||||
let target = confirmed_target(target_version)?;
|
||||
if let Err(error) = validate_update_target(&self.config.version, &target) {
|
||||
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
|
||||
let selected = self.resolve_channel(channel)?;
|
||||
if let Err(error) = validate_update_target(&self.config.version, &target, selected) {
|
||||
self.events
|
||||
.error_terminal(None, Some("preflight"), error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
|
||||
let status = read_status(&self.config.update_state_file, &self.config.version)?;
|
||||
let status = self.reconcile_installation()?;
|
||||
if status.need_restart {
|
||||
return Err(conflict("更新已完成,请先重启服务"));
|
||||
}
|
||||
@@ -295,14 +304,14 @@ impl ProcessSystemOperations {
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
|
||||
result = service.perform_update_inner(&target, &operation_id, preflight.as_ref()) => result,
|
||||
result = service.perform_update_inner(&target, selected, &operation_id, preflight.as_ref()) => result,
|
||||
};
|
||||
if let Err(error) = operation.complete(&result) {
|
||||
tracing::warn!(error = %error, "系统更新终态落盘失败");
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(()) => service.events.success_terminal(
|
||||
Ok(_) => service.events.success_terminal(
|
||||
Some(&operation_id),
|
||||
Some("done"),
|
||||
"更新文件已替换,等待服务重启生效",
|
||||
@@ -320,9 +329,10 @@ impl ProcessSystemOperations {
|
||||
async fn perform_update_inner(
|
||||
&self,
|
||||
target: &str,
|
||||
channel: UpdateChannel,
|
||||
operation_id: &str,
|
||||
preflight: &dyn SystemUpdatePreflight,
|
||||
) -> Result<(), OperationError> {
|
||||
) -> Result<ReleaseFiles, OperationError> {
|
||||
let repository = self
|
||||
.config
|
||||
.update_repository
|
||||
@@ -337,10 +347,11 @@ impl ProcessSystemOperations {
|
||||
&self.config.github_api_base,
|
||||
repository,
|
||||
&self.config.version,
|
||||
channel,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
|
||||
let detail = detail_from_release(&self.config, &release);
|
||||
let detail = detail_from_release(&self.config, &release, channel);
|
||||
if detail.latest_version != target {
|
||||
return Err(conflict("远端最新版本已变化,请重新确认"));
|
||||
}
|
||||
@@ -362,7 +373,7 @@ impl ProcessSystemOperations {
|
||||
version: &str,
|
||||
operation_id: &str,
|
||||
preflight: &dyn SystemUpdatePreflight,
|
||||
) -> Result<(), OperationError> {
|
||||
) -> Result<ReleaseFiles, OperationError> {
|
||||
self.events.info(
|
||||
Some(operation_id),
|
||||
Some("asset"),
|
||||
@@ -461,24 +472,46 @@ impl ProcessSystemOperations {
|
||||
if let Err(error) = preflight.confirm_revision(plugin_revision).await {
|
||||
return Err(applied.rollback(error));
|
||||
}
|
||||
let files = ReleaseFiles::installed(&self.config)?;
|
||||
applied.commit();
|
||||
self.events
|
||||
.success(Some(operation_id), Some("replace"), "应用文件替换完成");
|
||||
Ok(())
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn resolve_channel(
|
||||
&self,
|
||||
requested: Option<UpdateChannel>,
|
||||
) -> Result<UpdateChannel, OperationError> {
|
||||
let channel = requested.unwrap_or_else(|| {
|
||||
version_channel(&self.config.version).unwrap_or(UpdateChannel::Stable)
|
||||
});
|
||||
if !update_policy(&self.config, channel)
|
||||
.available_channels
|
||||
.contains(&channel)
|
||||
{
|
||||
return Err(conflict("当前发行版本不支持此更新通道"));
|
||||
}
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
fn reconcile_installation(&self) -> Result<SystemUpdateStatus, OperationError> {
|
||||
let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?;
|
||||
reconcile_installation(&self.config, running_files)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for ProcessSystemOperations {
|
||||
async fn version(&self) -> Result<SystemVersion, OperationError> {
|
||||
let detail = self.update_detail(false).await?;
|
||||
let detail = self.update_detail(false, None).await?;
|
||||
Ok(SystemVersion {
|
||||
version: self.config.version.clone(),
|
||||
git_sha: self.config.git_sha.clone(),
|
||||
build_time: self.config.build_time.clone(),
|
||||
deployment_mode: self.config.deployment_mode.clone(),
|
||||
update_channel: version_channel(&self.config.version)
|
||||
.map(|channel| channel.label().to_owned())
|
||||
.map(|channel| channel.as_str().to_owned())
|
||||
.unwrap_or_else(|| "unknown".to_owned()),
|
||||
latest_version: detail.latest_version,
|
||||
has_update: detail.has_update,
|
||||
@@ -487,11 +520,21 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, OperationError> {
|
||||
match self.release_cache.detail(&self.config, refresh).await {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<UpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, OperationError> {
|
||||
let channel = self.resolve_channel(channel)?;
|
||||
match self
|
||||
.release_cache
|
||||
.detail(&self.config, refresh, channel)
|
||||
.await
|
||||
{
|
||||
Ok(detail) => Ok(detail),
|
||||
Err(error) => Ok(base_update_detail(
|
||||
&self.config,
|
||||
channel,
|
||||
self.config.update_support_error(),
|
||||
Some(error.to_string()),
|
||||
)),
|
||||
@@ -520,9 +563,10 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<UpdateChannel>,
|
||||
preflight: Arc<dyn SystemUpdatePreflight>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
self.start_update(target_version, preflight)
|
||||
self.start_update(target_version, channel, preflight)
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
|
||||
@@ -531,8 +575,15 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
&self.config.update_state_file,
|
||||
&self.config.update_lock_file,
|
||||
)?;
|
||||
if self.config.update_support_error().is_none() {
|
||||
match OperationFileLock::acquire(&self.config.update_lock_file) {
|
||||
Ok(_lock) => return self.reconcile_installation(),
|
||||
Err(error) if error.kind() == SystemOperationErrorKind::Conflict => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
read_status(&self.config.update_state_file, &self.config.version)
|
||||
read_status(&self.config.update_state_file, false)
|
||||
}
|
||||
|
||||
async fn rollback(
|
||||
@@ -548,7 +599,8 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
}
|
||||
let operation_id = operation_id("rollback");
|
||||
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
|
||||
let target_version = read_status(&self.config.update_state_file, &self.config.version)?
|
||||
let target_version = self
|
||||
.reconcile_installation()?
|
||||
.previous_version
|
||||
.ok_or_else(|| conflict("没有可用于回滚的上一版本"))?;
|
||||
let release_manifest =
|
||||
@@ -592,6 +644,7 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
&operation_id,
|
||||
SystemOperationKind::Rollback,
|
||||
None,
|
||||
None,
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)?;
|
||||
match &result {
|
||||
@@ -700,12 +753,26 @@ impl Drop for AppliedReleaseGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_policy(config: &SystemUpdateConfig, channel: UpdateChannel) -> SystemUpdatePolicy {
|
||||
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
|
||||
SystemUpdatePolicy {
|
||||
channel,
|
||||
available_channels: if version_channel(&config.version) == Some(Experimental) {
|
||||
vec![Experimental]
|
||||
} else {
|
||||
vec![Stable, Rc, Beta, Alpha]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn base_update_detail(
|
||||
config: &SystemUpdateConfig,
|
||||
channel: UpdateChannel,
|
||||
unsupported_reason: Option<String>,
|
||||
warning: Option<String>,
|
||||
) -> SystemUpdateDetail {
|
||||
SystemUpdateDetail {
|
||||
policy: update_policy(config, channel),
|
||||
current_version: config.version.clone(),
|
||||
latest_version: config.version.clone(),
|
||||
has_update: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user