Compare commits

...
13 Commits
Author SHA1 Message Date
ZyyandCodex 589e1bc999 chore(release): v3.15.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:51:51 +08:00
ZyyandCodex 2956f07723 feat(system): add update channels and reconcile installation state
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:41:53 +08:00
ZyyandCodex bb76399e59 chore(release): v3.15.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:19:48 +08:00
ZyyandCodex 66a3474baf fix(openai): update desktop Core extraction and identity layout
Read bundled Core from the current CodexCLI.app layout and reject legacy
and launcher paths. Keep client identity aligned to the right edge on the
same row and to the left edge after wrapping on narrow cards.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:16:06 +08:00
ZyyandCodex 5e7196cfbf chore(release): v3.15.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 02:10:20 +08:00
ZyyandCodex a340bec472 docs: standardize cpr skills and contribution requirements
Namespace repository skills with the cpr prefix and clarify automatic task routing. Require UI screenshots and AI model disclosure in pull requests, preserve existing page designs, and strengthen backend self-review.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 02:06:40 +08:00
5782c71f73 feat(openai): support complete identities with official release updates (#272)
Reuse official daily release checks for complete TUI and Exec identities
without third-party catalogs. Keep settings and key overrides on a compact
preset and version-policy editor, with matching previews and loading states.
Prefill custom versions with the current user agent. Compare settings by
value, align toolbar icon backgrounds, and compact the key creation form.

Release transaction locks before rejecting stale proxy test results,
with a regression covering an immediate edit during connection cleanup.

Co-authored-by: Wei Hu <whuae@connect.ust.hk>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 01:35:43 +08:00
ZyyandCodex 999fcc1770 feat(dev): integrate source modules and plugin multi-select settings
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 23:59:13 +08:00
ZyyandCodex b920dcec83 feat(plugin): extend model, data and retry capabilities
Add model aliases, minimal read-only account and quota facts, bounded retry
policies and middleware capability declarations. Preserve unchanged payloads
and expose the new contracts in the SDK and plugin configuration UI.

Remove the ignored external observer example test.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 22:52:59 +08:00
ZyyandCodex 21c0711a66 chore(release): v3.14.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:26:04 +08:00
ZyyandCodex f770ba127d fix(plugin): separate body transport from buffer budgets
Define the initial plugin manifest and wire protocol without legacy branches.
Preserve large request payloads and isolate local RPC encoding failures.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:11:16 +08:00
ZyyandCodex 66719f22e2 docs(skills): clarify plugin development and release guidance
Align the visible skill name with plugin-dev and document published resources, host platform selection, configuration defaults, and release installation behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 07:11:27 +08:00
ZyyandCodex 00a1956df7 fix(plugins): clarify release asset selection
Show release assets with filename tooltips, size tags, and a compact selection state. Remove duplicate release version labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:53:10 +08:00
186 changed files with 6492 additions and 1603 deletions
+58
View File
@@ -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
@@ -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
-48
View File
@@ -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 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
+20
View File
@@ -0,0 +1,20 @@
## 问题与改动
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
## 自审与验证
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
## 页面截图
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
## AI 使用
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
+65
View File
@@ -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
View File
@@ -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
+17 -3
View File
@@ -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
View File
@@ -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 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
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 提交,不依赖浮动分支。
前端从仓库根目录执行:
+1 -1
View File
@@ -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) |
## 社区
+1 -1
View File
@@ -1830,7 +1830,7 @@ dependencies = [
[[package]]
name = "gateway-plugin-sdk"
version = "0.4.0"
version = "0.1.0"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -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` 合同。
## 安装
+1 -1
View File
@@ -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",
@@ -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,
@@ -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,
@@ -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>;
@@ -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,
@@ -377,7 +377,7 @@ 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"
)
})
.flat_map(|(_, contribution)| {
@@ -390,6 +390,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)
}
@@ -1035,7 +1035,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 +1050,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())
@@ -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()
);
@@ -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));
}
+16 -4
View File
@@ -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,
@@ -1456,7 +1456,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 +1471,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())
@@ -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" }
);
}
}
}
+7 -1
View File
@@ -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")
@@ -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() {
@@ -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
+4 -1
View File
@@ -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
@@ -11,6 +11,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 {
@@ -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;
@@ -303,6 +303,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 +422,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)
@@ -701,6 +705,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;
@@ -901,6 +946,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 +981,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))
@@ -999,6 +1059,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;
}
@@ -1022,6 +1088,9 @@ impl RuntimeSnapshot {
#[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 +1191,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 +1207,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 {
@@ -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 {
@@ -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,
@@ -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,
@@ -1,9 +1,10 @@
//! GitHub Release 发现、缓存、版本比较与下载信任边界。
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use gateway_admin::model::system::SystemUpdateDetail;
use gateway_admin::model::system::{SystemUpdateChannel as UpdateChannel, SystemUpdateDetail};
use serde::Deserialize;
use tokio::sync::Mutex;
@@ -12,27 +13,6 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const CACHE_TTL: Duration = Duration::from_secs(20 * 60);
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateChannel {
Stable,
Alpha,
Beta,
Rc,
Experimental,
}
impl UpdateChannel {
pub(crate) fn label(&self) -> &str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct GitHubRelease {
pub(crate) tag_name: String,
@@ -62,6 +42,7 @@ struct CachedRelease {
#[derive(Default)]
pub(crate) struct ReleaseCache {
entry: Mutex<Option<CachedRelease>>,
request_sequence: AtomicU64,
}
impl ReleaseCache {
@@ -69,9 +50,15 @@ impl ReleaseCache {
&self,
config: &SystemUpdateConfig,
refresh: bool,
channel: UpdateChannel,
) -> Result<SystemUpdateDetail, OperationError> {
if let Some(reason) = config.update_support_error() {
return Ok(super::base_update_detail(config, Some(reason), None));
return Ok(super::base_update_detail(
config,
channel,
Some(reason),
None,
));
}
let repository = config
.update_repository
@@ -79,26 +66,50 @@ impl ReleaseCache {
.ok_or_else(|| conflict("update repository is not configured"))?;
validate_repository(repository)?;
validate_api_base(&config.github_api_base).map_err(conflict)?;
let key = config.release_cache_key();
let key = format!("{}|{}", config.release_cache_key(), channel.as_str());
if !refresh && let Some(detail) = self.cached(&key).await {
return Ok(detail);
}
match fetch_latest(&config.github_api_base, repository, &config.version).await {
let sequence = self.request_sequence.fetch_add(1, Ordering::Relaxed) + 1;
match fetch_latest(
&config.github_api_base,
repository,
&config.version,
channel,
)
.await
{
Ok(release) => {
let detail = release.as_ref().map_or_else(
|| super::base_update_detail(config, config.update_support_error(), None),
|release| detail_from_release(config, release),
|| {
super::base_update_detail(
config,
channel,
config.update_support_error(),
None,
)
},
|release| detail_from_release(config, release, channel),
);
*self.entry.lock().await = Some(CachedRelease {
key,
detail: detail.clone(),
cached_at: Instant::now(),
});
let mut entry = self.entry.lock().await;
// 较慢的旧请求不能覆盖或清除新检查的结果,包括切走再切回同一通道。
if self.request_sequence.load(Ordering::Relaxed) == sequence {
*entry = Some(CachedRelease {
key,
detail: detail.clone(),
cached_at: Instant::now(),
});
}
Ok(detail)
}
Err(error) => {
// 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。
*self.entry.lock().await = None;
let mut entry = self.entry.lock().await;
if self.request_sequence.load(Ordering::Relaxed) == sequence
&& entry.as_ref().is_some_and(|cached| cached.key == key)
{
*entry = None;
}
Err(error)
}
}
@@ -120,6 +131,7 @@ pub(crate) async fn fetch_latest(
api_base: &str,
repository: &str,
current_version: &str,
channel: UpdateChannel,
) -> Result<Option<GitHubRelease>, OperationError> {
validate_api_base(api_base).map_err(conflict)?;
validate_repository(repository)?;
@@ -143,7 +155,7 @@ pub(crate) async fn fetch_latest(
.await?;
let last_page = releases.len() < 100;
for release in releases {
let Some(version) = eligible_release_version(&release, &current) else {
let Some(version) = eligible_release_version(&release, &current, channel) else {
continue;
};
if latest
@@ -163,11 +175,12 @@ pub(crate) async fn fetch_latest(
fn eligible_release_version(
release: &GitHubRelease,
current: &semver::Version,
channel: UpdateChannel,
) -> Option<semver::Version> {
let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?;
(!release.draft
&& release.prerelease != version.pre.is_empty()
&& (version == *current || update_target_allowed(current, &version)))
&& (version == *current || update_target_allowed(current, &version, channel)))
.then_some(version)
}
@@ -197,16 +210,18 @@ async fn fetch_release_json<T: serde::de::DeserializeOwned>(
pub(crate) fn detail_from_release(
config: &SystemUpdateConfig,
release: &GitHubRelease,
channel: UpdateChannel,
) -> SystemUpdateDetail {
let unsupported_reason = config.update_support_error();
let latest_version = normalize_version(&release.tag_name);
let has_update = validate_update_target(&config.version, &latest_version).is_ok();
let has_update = validate_update_target(&config.version, &latest_version, channel).is_ok();
if unsupported_reason.is_some()
|| (!has_update && latest_version != normalize_version(&config.version))
{
return super::base_update_detail(config, unsupported_reason, None);
return super::base_update_detail(config, channel, unsupported_reason, None);
}
SystemUpdateDetail {
policy: super::update_policy(config, channel),
current_version: config.version.clone(),
latest_version,
has_update,
@@ -366,9 +381,12 @@ pub(crate) fn version_channel(version: &str) -> Option<UpdateChannel> {
}
/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。
fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool {
fn update_target_allowed(
current: &semver::Version,
target: &semver::Version,
selected: UpdateChannel,
) -> bool {
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
if current.major != target.major || !target.cmp_precedence(current).is_gt() {
return false;
}
@@ -377,30 +395,34 @@ fn update_target_allowed(current: &semver::Version, target: &semver::Version) ->
else {
return false;
};
if current_channel == Stable {
return target_channel == Stable;
}
// 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。
if (current.minor, current.patch) != (target.minor, target.patch) {
return false;
if current_channel == Experimental || selected == Experimental || target_channel == Experimental
{
return current_channel == Experimental
&& selected == Experimental
&& target_channel == Experimental
&& (current.minor, current.patch) == (target.minor, target.patch);
}
matches!(
(current_channel, target_channel),
(Alpha, Alpha | Beta | Rc | Stable)
| (Beta, Beta | Rc | Stable)
(selected, target_channel),
(Stable, Stable)
| (Rc, Rc | Stable)
| (Experimental, Experimental)
| (Beta, Beta | Rc | Stable)
| (Alpha, Alpha | Beta | Rc | Stable)
)
}
pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> {
pub(crate) fn validate_update_target(
current: &str,
target: &str,
channel: UpdateChannel,
) -> Result<(), OperationError> {
let current = semver::Version::parse(&normalize_version(current))
.map_err(|_| conflict("当前版本格式无效"))?;
let target = semver::Version::parse(&normalize_version(target))
.map_err(|_| invalid("target version is invalid"))?;
if !update_target_allowed(&current, &target) {
if !update_target_allowed(&current, &target, channel) {
return Err(conflict(
"当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级",
"所选通道不允许此目标,或目标涉及跨实验线、跨大版本、降级",
));
}
Ok(())
@@ -12,7 +12,8 @@ use gateway_admin::model::system::{
};
use serde::{Deserialize, Serialize};
use super::{OperationError, conflict, internal};
use super::installation::ReleaseFiles;
use super::{OperationError, SystemUpdateConfig, conflict, internal};
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -20,6 +21,10 @@ struct PersistedState {
previous_version: Option<String>,
current_version: Option<String>,
#[serde(default)]
current_files: Option<ReleaseFiles>,
#[serde(default)]
previous_files: Option<ReleaseFiles>,
#[serde(default)]
operation: PersistedOperation,
}
@@ -190,13 +195,14 @@ impl UpdateOperation {
pub(crate) fn complete(
&mut self,
result: &Result<(), OperationError>,
result: &Result<ReleaseFiles, OperationError>,
) -> Result<(), OperationError> {
finish(
&self.path,
&self.operation_id,
SystemOperationKind::Update,
result.as_ref().ok().map(|()| self.target.clone()),
result.as_ref().ok().map(|_| self.target.clone()),
result.as_ref().ok().cloned(),
result.as_ref().err().map(ToString::to_string),
)?;
self.completed = true;
@@ -212,6 +218,7 @@ impl Drop for UpdateOperation {
&self.operation_id,
SystemOperationKind::Update,
None,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)
{
@@ -237,6 +244,7 @@ pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), O
&operation_id,
kind.into(),
None,
None,
Some("更新任务已中断,请重新发起更新".to_owned()),
)?;
}
@@ -281,6 +289,7 @@ pub(crate) fn finish(
operation_id: &str,
kind: SystemOperationKind,
version: Option<String>,
files: Option<ReleaseFiles>,
error: Option<String>,
) -> Result<(), OperationError> {
let mut state = read_persisted(path)?;
@@ -299,11 +308,14 @@ pub(crate) fn finish(
SystemOperationKind::Update => {
state.previous_version = state.current_version.take();
state.current_version = version.clone();
state.previous_files = state.current_files.take();
state.current_files = files;
}
SystemOperationKind::Rollback => {
let current = state.current_version.take();
state.current_version = state.previous_version.take();
state.previous_version = current;
std::mem::swap(&mut state.current_files, &mut state.previous_files);
}
SystemOperationKind::Restart => {}
}
@@ -315,15 +327,12 @@ pub(crate) fn finish(
pub(crate) fn read_status(
path: &Path,
running_version: &str,
need_restart: bool,
) -> Result<SystemUpdateStatus, OperationError> {
let state = read_persisted(path)?;
let operation = state.operation;
Ok(SystemUpdateStatus {
need_restart: matches!(operation.status, PersistedStatus::Succeeded)
&& state.current_version.as_deref().is_some_and(|version| {
version.trim_start_matches('v') != running_version.trim_start_matches('v')
}),
need_restart,
previous_version: state.previous_version,
current_version: state.current_version,
operation: SystemOperationState {
@@ -339,6 +348,52 @@ pub(crate) fn read_status(
})
}
/// 调用方必须持有进程内锁及文件锁,避免将安装中间态当成手动部署。
pub(crate) fn reconcile_installation(
config: &SystemUpdateConfig,
running: &ReleaseFiles,
) -> Result<SystemUpdateStatus, OperationError> {
let path = &config.update_state_file;
let mut state = read_persisted(path)?;
let installed = ReleaseFiles::installed(config)?;
let need_restart = &installed != running;
if need_restart
&& (state.current_files.as_ref() != Some(&installed) || state.current_version.is_none())
{
return Err(conflict(
"运行期间安装文件被外部修改,请核对部署并重启服务后重试",
));
}
let mut changed = false;
if !need_restart
&& (state.current_files.as_ref() != Some(&installed)
|| state.current_version.as_deref() != Some(config.version.as_str()))
{
// 保留操作历史,但外部部署的文件不能继续使用旧操作推断版本。
state.current_version = Some(config.version.clone());
state.current_files = Some(installed);
changed = true;
}
if state.previous_version.is_some() || state.previous_files.is_some() {
let backup_valid = state.previous_files.as_ref().is_some_and(|expected| {
ReleaseFiles::backup(config)
.as_ref()
.is_ok_and(|actual| actual == expected)
});
if !backup_valid {
// 无法证明旧备份完整时撤销回滚资格,不删除用户的备份文件。
state.previous_version = None;
state.previous_files = None;
changed = true;
}
}
if changed {
write_persisted(path, &state)?;
}
// 指纹证明磁盘仍是本进程安装的候选,回滚到旧版本也必须等待重启。
read_status(path, need_restart)
}
pub(crate) fn default_temp_dir(state_file: &Path) -> PathBuf {
state_file
.parent()
@@ -393,7 +393,7 @@ fn copy_dir_all(from: &Path, to: &Path) -> io::Result<()> {
Ok(())
}
fn backup_path_for(path: &Path) -> PathBuf {
pub(crate) fn backup_path_for(path: &Path) -> PathBuf {
let mut backup = path.as_os_str().to_os_string();
backup.push(".backup");
PathBuf::from(backup)
@@ -0,0 +1,292 @@
use super::*;
use gateway_admin::model::system::SystemUpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
#[tokio::test]
async fn temporary_channel_should_filter_future_releases_without_changing_running_channel() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let releases: Vec<_> = ["1.1.0", "1.2.0-rc.1", "1.3.0-beta.1", "1.4.0-alpha.1", "1.9.0-exp.9", "2.0.0"]
.into_iter().map(|version| serde_json::json!({"tag_name": format!("v{version}"), "prerelease": version.contains('-')})).collect();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(releases))
.mount(&server)
.await;
let service = fixture.service(&server);
for (channel, target) in [
(Stable, "1.1.0"),
(Rc, "1.2.0-rc.1"),
(Beta, "1.3.0-beta.1"),
(Alpha, "1.4.0-alpha.1"),
(Stable, "1.1.0"),
] {
let detail = service
.update_detail(false, Some(channel))
.await
.expect("detail");
assert_eq!(detail.latest_version, target);
assert_eq!(detail.policy.channel, channel);
assert_eq!(
detail.policy.available_channels,
vec![Stable, Rc, Beta, Alpha]
);
assert!(detail.has_update);
let cached = service
.update_detail(false, Some(channel))
.await
.expect("cached");
assert!(cached.cached);
assert_eq!(cached.latest_version, target);
}
let version = service.version().await.expect("default check");
assert_eq!(version.update_channel, "stable");
assert_eq!(version.latest_version, "1.1.0");
assert!(!fixture.state().exists(), "检查通道不能写入实例偏好");
let requests = server.received_requests().await.expect("requests");
assert_eq!(requests.len(), 5);
assert!(
requests
.iter()
.all(|request| request.url.path().ends_with("/releases"))
);
}
#[tokio::test]
async fn reopening_should_infer_running_channel_and_switching_to_stable_should_not_downgrade() {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.0.0", Vec::new()))
.mount(&server)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-beta.1".into();
let service = ProcessSystemOperations::new(CancellationToken::new(), config.clone());
let temporary = service
.update_detail(true, Some(Stable))
.await
.expect("view stable");
assert_eq!(temporary.policy.channel, Stable);
assert!(!temporary.has_update);
assert_eq!(temporary.latest_version, "1.1.0-beta.1");
assert_eq!(
service
.update_detail(false, None)
.await
.expect("reopened")
.policy
.channel,
Beta
);
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config.clone());
assert_eq!(
restarted
.update_detail(false, None)
.await
.expect("restarted")
.policy
.channel,
Beta
);
config.version = "1.1.0".into();
let promoted = ProcessSystemOperations::new(CancellationToken::new(), config);
assert_eq!(
promoted
.update_detail(false, None)
.await
.expect("now stable")
.policy
.channel,
Stable
);
assert!(!fixture.state().exists());
}
#[tokio::test]
async fn install_should_use_confirmed_channel_and_infer_new_default_only_after_restart() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
"1.1.0-beta.1",
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let error = service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Stable),
Arc::new(AllowingUpdatePreflight),
)
.await
.expect_err("target outside confirmed channel");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(
server
.received_requests()
.await
.expect("requests")
.is_empty()
);
service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Beta),
Arc::new(AllowingUpdatePreflight),
)
.await
.expect("accept selected target");
let status = wait_for_update(&service).await;
assert_eq!(status.operation.status, SystemOperationStatus::Succeeded);
assert!(status.need_restart);
assert_eq!(
service
.version()
.await
.expect("still running stable")
.update_channel,
"stable"
);
assert!(
service
.perform_update(
Some("1.1.0-beta.1".into()),
Some(Alpha),
Arc::new(AllowingUpdatePreflight)
)
.await
.is_err()
);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-beta.1".into();
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config);
assert!(
!restarted
.update_status()
.await
.expect("restarted")
.need_restart
);
assert_eq!(
restarted
.update_detail(false, None)
.await
.expect("new default")
.policy
.channel,
Beta
);
}
#[tokio::test]
async fn temporary_channels_should_keep_experimental_isolation() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
assert!(
service
.update_detail(true, Some(Experimental))
.await
.is_err()
);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0-exp.1".into();
config.build_type = "experimental".into();
let experimental = ProcessSystemOperations::new(CancellationToken::new(), config);
for channel in [Stable, Rc, Beta, Alpha] {
assert!(
experimental
.update_detail(true, Some(channel))
.await
.is_err()
);
}
assert!(
server
.received_requests()
.await
.expect("requests")
.is_empty()
);
}
#[tokio::test]
async fn slower_check_should_not_overwrite_newer_cached_candidate() {
let server = MockServer::start().await;
let fixture = Fixture::new();
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.1.0", Vec::new()).set_delay(Duration::from_millis(150)))
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repository/releases"))
.respond_with(release_response("1.2.0", Vec::new()))
.mount(&server)
.await;
let service = fixture.service(&server);
let first_service = service.clone();
let first = tokio::spawn(async move { first_service.update_detail(true, None).await });
tokio::time::timeout(Duration::from_secs(1), async {
while server
.received_requests()
.await
.expect("requests")
.is_empty()
{
tokio::task::yield_now().await;
}
})
.await
.expect("first request sent");
assert_eq!(
service
.update_detail(true, None)
.await
.expect("new check")
.latest_version,
"1.2.0"
);
assert_eq!(
first
.await
.expect("join")
.expect("old check")
.latest_version,
"1.1.0"
);
let cached = service.update_detail(false, None).await.expect("cache");
assert!(cached.cached);
assert_eq!(cached.latest_version, "1.2.0");
}
#[tokio::test]
async fn inferred_prerelease_policy_should_follow_future_minor_and_patch_releases() {
for (current, target) in [
("3.12.0-alpha.1", "3.13.0-alpha.2"),
("3.12.0-beta.1", "3.12.1-rc.1"),
("3.12.0-rc.1", "3.13.0"),
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(&server, target, ArchiveKind::Safe, ChecksumKind::Valid)
.await;
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true, None).await.expect("check");
assert!(detail.has_update);
assert_eq!(detail.latest_version, target);
assert_eq!(
complete_update(&service, target).await.operation.status,
SystemOperationStatus::Succeeded
);
}
}
@@ -0,0 +1,148 @@
use super::*;
#[tokio::test]
async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换。
fixture.write_executable("manually-installed-binary");
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
.expect("remove image backup");
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0".into();
config.deployment_mode = "docker".into();
let redeployed = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = redeployed.update_status().await.expect("reconciled");
assert!(!status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("1.1.0"));
assert!(status.previous_version.is_none());
assert_eq!(
status.operation.target_version.as_deref(),
Some(TARGET_VERSION)
);
assert!(
redeployed
.update_detail(true, None)
.await
.expect("check")
.has_update
);
assert_eq!(
complete_update(&redeployed, TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Succeeded
);
}
#[tokio::test]
async fn rollback_after_restart_should_require_restart_even_for_a_lower_version() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = TARGET_VERSION.into();
let restarted = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = restarted.update_status().await.expect("new process");
assert!(!status.need_restart);
assert_eq!(status.previous_version.as_deref(), Some("1.0.0"));
restarted
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.expect("rollback");
let status = restarted.update_status().await.expect("pending rollback");
assert!(status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("1.0.0"));
assert!(
!fixture
.service(&server)
.update_status()
.await
.expect("rollback restarted")
.need_restart
);
}
#[tokio::test]
async fn external_change_during_runtime_should_not_be_reported_as_a_successful_installation() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
service.update_status().await.expect("baseline");
fs::write(fixture.web().join("index.html"), "unexpected-web").expect("external replacement");
assert_eq!(
service
.update_status()
.await
.expect_err("mixed deployment")
.kind(),
SystemOperationErrorKind::Conflict
);
assert!(
service
.perform_test_update(Some(TARGET_VERSION.into()))
.await
.is_err()
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
);
}
#[tokio::test]
async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
fs::write(
fixture.root.path().join("codex-proxy-rs.backup"),
"unrelated-backup",
)
.expect("changed backup");
let status = service
.update_status()
.await
.expect("pending with invalid backup");
assert!(status.need_restart);
assert!(status.previous_version.is_none());
assert!(
service
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.is_err()
);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
);
}
@@ -1,3 +1,6 @@
mod channels;
mod installation;
use std::fs;
use std::path::PathBuf;
use std::sync::{
@@ -61,7 +64,8 @@ impl TestSystemUpdate for ProcessSystemOperations {
&self,
target: Option<String>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
SystemOperations::perform_update(self, target, Arc::new(AllowingUpdatePreflight)).await
SystemOperations::perform_update(self, target, None, Arc::new(AllowingUpdatePreflight))
.await
}
}
@@ -349,6 +353,7 @@ async fn update_should_restore_all_files_when_plugin_revision_changes_during_swa
SystemOperations::perform_update(
&service,
Some(TARGET_VERSION.to_owned()),
None,
Arc::new(ChangingRevisionPreflight {
confirmations: AtomicUsize::new(0),
}),
@@ -496,9 +501,15 @@ async fn update_detail_should_report_refresh_failure_and_clear_previous_update()
let fixture = Fixture::new();
fixture.mount_release_once(&server, TARGET_VERSION).await;
let service = fixture.service(&server);
service.update_detail(true).await.expect("prime cache");
service
.update_detail(true, None)
.await
.expect("prime cache");
let detail = service.update_detail(true).await.expect("failure detail");
let detail = service
.update_detail(true, None)
.await
.expect("failure detail");
assert!(!detail.has_update);
assert!(detail.warning.is_some());
assert!(!detail.cached);
@@ -541,7 +552,7 @@ async fn update_detail_should_offer_latest_non_draft_release_in_current_channel(
config.version = "1.1.0-beta.1".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "1.1.0-beta.2");
assert!(detail.has_update);
}
@@ -554,7 +565,10 @@ async fn update_detail_should_reject_untrusted_github_api_base() {
fixture.config("https://api.github.example/repos"),
);
let detail = service.update_detail(true).await.expect("safe rejection");
let detail = service
.update_detail(true, None)
.await
.expect("safe rejection");
assert!(!detail.update_supported);
assert!(detail.warning.is_some() || detail.unsupported_reason.is_some());
}
@@ -567,7 +581,7 @@ async fn update_detail_should_withhold_updates_for_source_builds() {
config.build_type = "source".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "1.0.0");
assert!(!detail.update_supported);
assert!(detail.unsupported_reason.is_some());
@@ -610,7 +624,7 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re
config.build_type = "experimental".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "3.10.0-exp.2");
assert!(!detail.has_update);
assert!(detail.update_supported);
@@ -620,7 +634,10 @@ async fn experimental_build_should_ignore_newer_stable_and_other_experimental_re
let version = service.version().await.expect("version");
assert!(!version.has_update);
assert_eq!(version.update_channel, "exp");
let cached = service.update_detail(false).await.expect("cached detail");
let cached = service
.update_detail(false, None)
.await
.expect("cached detail");
assert!(cached.cached);
assert!(!cached.has_update);
}
@@ -657,7 +674,7 @@ async fn experimental_update_should_find_highest_same_channel_version_across_pag
config.build_type = "experimental".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.latest_version, "3.10.0-exp.10");
assert_eq!(detail.notes.as_deref(), Some("exp.10 notes"));
assert!(detail.has_update);
@@ -716,8 +733,6 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing
("experimental", "3.10.0-exp.2", "3.10.1-exp.3"),
("experimental", "3.10.0-exp.2", "3.10.0-exp.1"),
("release", "3.10.0-beta.2", "3.10.0-alpha.3"),
("release", "3.10.0-rc.2", "3.11.0-rc.3"),
("release", "3.10.0-rc.2", "3.11.0"),
("release", "3.10.0", "3.10.0+new"),
("release", "3.10.0", "3.9.0"),
("experimental", "3.10.0-exp.2", "3.11.0-beta.1"),
@@ -737,7 +752,7 @@ async fn update_should_reject_cross_channel_targets_before_fetching_or_replacing
.await
.expect_err("cross-channel update");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
assert!(error.to_string().contains("跨通道"));
assert!(error.to_string().contains("所选通道"));
assert!(
server
.received_requests()
@@ -784,7 +799,7 @@ async fn update_checks_should_follow_stage_promotion_and_experimental_isolation(
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert_eq!(detail.has_update, allowed, "{current} -> {target}");
assert_eq!(
detail.latest_version,
@@ -833,7 +848,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst
}
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("current release");
let detail = service
.update_detail(true, None)
.await
.expect("current release");
assert_eq!(detail.latest_version, current);
assert!(!detail.has_update);
assert!(detail.update_supported);
@@ -842,7 +860,10 @@ async fn update_detail_should_keep_current_release_notes_without_allowing_reinst
Some("## 当前版本\n\n- 修复更新状态")
);
assert_eq!(detail.release_url.as_deref(), Some(release_url.as_str()));
let cached = service.update_detail(false).await.expect("cached release");
let cached = service
.update_detail(false, None)
.await
.expect("cached release");
assert!(cached.cached);
assert_eq!(cached.notes, detail.notes);
assert_eq!(cached.release_url, detail.release_url);
@@ -886,7 +907,7 @@ async fn update_detail_should_prefer_available_update_notes_over_current_release
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert!(detail.has_update);
@@ -923,7 +944,7 @@ async fn update_detail_should_find_current_release_notes_across_pages() {
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert!(!detail.has_update);
@@ -950,7 +971,7 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes(
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update);
assert_eq!(detail.latest_version, current);
assert!(detail.notes.is_none());
@@ -959,11 +980,8 @@ async fn update_detail_should_not_use_draft_or_mislabeled_current_release_notes(
}
#[tokio::test]
async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata() {
async fn update_checks_should_ignore_downgrades_unknown_stages_and_build_metadata() {
for (current, target) in [
("3.12.0-alpha.1", "3.13.0-alpha.2"),
("3.12.0-beta.1", "3.12.1-rc.1"),
("3.12.0-rc.1", "3.13.0"),
("3.12.0-beta.2", "3.12.0-beta.1"),
("3.12.0", "3.12.0+new-build"),
("3.12.0-beta.1+aaa", "3.12.0-beta.1+zzz"),
@@ -979,7 +997,7 @@ async fn update_checks_should_ignore_other_cycles_downgrades_and_build_metadata(
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = current.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update, "{current} -> {target}");
assert_eq!(detail.latest_version, current);
assert!(detail.notes.is_none());
@@ -1003,7 +1021,7 @@ async fn update_checks_should_fail_closed_for_unknown_current_channels() {
config.version = current.to_owned();
config.build_type = build_type.to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(!detail.has_update);
assert!(!detail.update_supported);
assert!(detail.unsupported_reason.is_some());
@@ -1036,7 +1054,7 @@ async fn stable_update_should_select_allowed_version_below_newer_major_releases(
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "3.11.0".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let detail = service.update_detail(true).await.expect("detail");
let detail = service.update_detail(true, None).await.expect("detail");
assert!(detail.has_update);
assert_eq!(detail.latest_version, "3.11.10");
assert_eq!(detail.notes.as_deref(), Some("stable notes"));
@@ -1060,7 +1078,7 @@ async fn prerelease_updates_should_install_later_stages_and_stable_release() {
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
assert!(
service
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail")
.has_update
@@ -1095,9 +1113,12 @@ async fn update_detail_should_use_cached_release_when_not_refreshed() {
let fixture = Fixture::new();
fixture.mount_release_once(&server, TARGET_VERSION).await;
let service = fixture.service(&server);
service.update_detail(true).await.expect("prime cache");
service
.update_detail(true, None)
.await
.expect("prime cache");
assert!(service.update_detail(false).await.is_ok());
assert!(service.update_detail(false, None).await.is_ok());
}
#[tokio::test]
@@ -1110,7 +1131,7 @@ async fn update_detail_should_withhold_cross_major_release() {
let detail = fixture
.service(&server)
.update_detail(true)
.update_detail(true, None)
.await
.expect("detail");
assert_eq!(detail.latest_version, "1.0.0");
@@ -1569,27 +1590,19 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
}
#[tokio::test]
async fn update_status_should_read_local_update_state() {
async fn update_status_should_reconcile_manual_deployment_without_reusing_legacy_success() {
let fixture = Fixture::new();
fs::write(
fixture.state(),
r#"{"previousVersion":"1.0.0","currentVersion":"2.0.0","operation":{"operationId":"x","kind":"update","status":"succeeded","targetVersion":"2.0.0","message":"done","error":null,"startedAt":"2026-07-19T00:00:00Z","finishedAt":"2026-07-19T00:01:00Z"}}"#,
)
.expect("state");
let service = ProcessSystemOperations::new(
CancellationToken::new(),
fixture.config("https://api.github.com/repos"),
);
assert_eq!(
service
.update_status()
.await
.expect("status")
.previous_version
.as_deref(),
Some("1.0.0")
);
fs::write(fixture.state(), r#"{"previousVersion":"3.14.0","currentVersion":"3.14.1","operation":{"operationId":"legacy","kind":"update","status":"succeeded","targetVersion":"3.14.1"}}"#).expect("legacy state");
let mut config = fixture.config("https://api.github.com/repos");
config.version = "3.15.0".to_owned();
config.deployment_mode = "docker".to_owned();
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let status = service.update_status().await.expect("status");
assert!(!status.need_restart);
assert_eq!(status.current_version.as_deref(), Some("3.15.0"));
assert!(status.previous_version.is_none());
assert_eq!(status.operation.target_version.as_deref(), Some("3.14.1"));
assert_eq!(status.operation.status, SystemOperationStatus::Succeeded);
}
#[tokio::test]
@@ -1,12 +1,14 @@
{
"schema_version": 1,
"manifest_schema_versions": [3],
"protocol_versions": [4],
"manifest_schema_versions": [1],
"protocol_versions": [1],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [1] },
{ "capability": "scheduler", "versions": [1] },
{ "capability": "model_router", "versions": [1] },
{ "capability": "middleware", "versions": [1] },
{ "capability": "model_catalog", "versions": [1] },
{ "capability": "retry_policy", "versions": [1] },
{ "capability": "middleware", "versions": [1, 2] },
{ "capability": "request_lifecycle", "versions": [1] },
{ "capability": "web_socket_observer", "versions": [1] },
{ "capability": "usage", "versions": [1] },
@@ -17,6 +19,7 @@
"network",
"models",
"accounts",
"data",
"requests",
"public_endpoints"
]
@@ -0,0 +1,78 @@
use std::{collections::BTreeSet, time::Duration};
use gateway_admin::model::{
AdminError,
plugins::instances::{PluginCapabilityBinding, PluginInstance},
};
use gateway_core::routing::{ContributedModelAlias, ProviderKind, PublicModelId, UpstreamModelId};
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::catalog::ModelCatalogRegistration};
use crate::RpcSession;
pub(crate) async fn prepare(
manifest: &Manifest,
instance: &PluginInstance,
session: &RpcSession,
) -> Result<Vec<ContributedModelAlias>, AdminError> {
if !manifest.contributes.contains_key(&Capability::ModelCatalog) {
return Ok(Vec::new());
}
validate_bindings(manifest, &instance.bindings)?;
let reply = session
.call(
"model_catalog.register",
session.context(Stage::Registration, Duration::from_secs(5)),
serde_json::json!({}),
Vec::new(),
)
.await
.map_err(|_| AdminError::invalid("插件模型目录注册失败"))?;
if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 {
return Err(AdminError::invalid("插件模型目录信封无效或过大"));
}
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
if registration.models.len() > 256 {
return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名"));
}
let mut ids = BTreeSet::new();
registration
.models
.into_iter()
.map(|model| {
if !matches!(model.provider.as_str(), "openai" | "xai") || model.id == model.model {
return Err(AdminError::invalid(
"模型别名必须指向内置 Provider 的不同上游模型",
));
}
let id = PublicModelId::new(model.id)
.map_err(|_| AdminError::invalid("模型别名 ID 无效"))?;
if !ids.insert(id.clone()) {
return Err(AdminError::invalid(format!("插件模型别名重复:{id}")));
}
Ok(ContributedModelAlias {
owner: instance.id.clone(),
id,
provider: ProviderKind::new(model.provider)
.map_err(|_| AdminError::invalid("模型别名 Provider 无效"))?,
target: UpstreamModelId::new(model.model)
.map_err(|_| AdminError::invalid("模型别名目标无效"))?,
})
})
.collect()
}
pub(crate) fn validate_bindings(
manifest: &Manifest,
bindings: &[PluginCapabilityBinding],
) -> Result<(), AdminError> {
// 目录随实例完整发布;不接受请求级绑定,以免展示与执行的可见范围分叉。
if let Some(declaration) = manifest.contributes.get(&Capability::ModelCatalog)
&& bindings
.iter()
.any(|binding| binding.contribution == declaration.id)
{
return Err(AdminError::invalid("模型目录随实例发布,不支持请求级绑定"));
}
Ok(())
}
@@ -45,9 +45,6 @@ impl PluginCommandSession {
}
let payload = serde_json::to_vec(&invocation)
.map_err(|_| AdminError::invalid("插件命令参数无法编码"))?;
if payload.len() > self.limits.maximum_frame_bytes.saturating_sub(4096) {
return Err(AdminError::invalid("插件命令参数超过帧预算").into());
}
let mut saved_accounts = 0;
let result = tokio::select! {
biased;
@@ -97,7 +94,8 @@ impl PluginCommand {
)
.await
.map_err(rpc_failure)?;
if reply.result != serde_json::json!({}) || reply.payload.len() > limits.maximum_frame_bytes
if reply.result != serde_json::json!({})
|| reply.payload.len() > limits.maximum_buffered_body_bytes
{
return Err("结果信封无效");
}
@@ -63,7 +63,7 @@ impl ManagementEntry {
.iter()
.find(|route| route.method == request.method && route.path == request.path)
.ok_or_else(|| AdminError::not_found("插件管理路由不存在"))?;
let maximum_body = MAXIMUM_BODY_BYTES.min(limits.maximum_frame_bytes.saturating_sub(4096));
let maximum_body = MAXIMUM_BODY_BYTES.min(limits.maximum_buffered_body_bytes);
if request.body.len() > maximum_body
|| request.query.len() > 8192
|| request.query.bytes().any(|byte| byte.is_ascii_control())
@@ -112,8 +112,7 @@ fn decode_response(
.map_err(|_| AdminError::unavailable("插件管理响应元数据无效"))?;
if !(200..=599).contains(&response.status)
|| !allowed_content_types.contains(&response.content_type)
|| reply.payload.len()
> MAXIMUM_BODY_BYTES.min(limits.maximum_frame_bytes.saturating_sub(4096))
|| reply.payload.len() > MAXIMUM_BODY_BYTES.min(limits.maximum_buffered_body_bytes)
|| (matches!(response.status, 204 | 304) && !reply.payload.is_empty())
{
return Err(AdminError::unavailable(
@@ -1,3 +1,4 @@
pub(super) mod catalog;
pub(super) mod command_line;
pub(super) mod frontend_authentication;
pub(super) mod management;
@@ -166,7 +166,7 @@ impl PluginObserverPlan {
mut entries: Vec<ObserverEntry>,
maximum_calls: usize,
maximum_call_timeout: Duration,
maximum_frame_bytes: usize,
maximum_buffered_body_bytes: usize,
) -> Option<Arc<Self>> {
if entries.is_empty() {
return None;
@@ -178,8 +178,8 @@ impl PluginObserverPlan {
&right.instance_id,
))
});
let maximum_websocket_payload_bytes = maximum_frame_bytes
.saturating_sub(OBSERVATION_ENVELOPE_RESERVE.min(maximum_frame_bytes));
let maximum_websocket_payload_bytes = maximum_buffered_body_bytes
.saturating_sub(OBSERVATION_ENVELOPE_RESERVE.min(maximum_buffered_body_bytes));
Some(Arc::new(Self {
entries: entries.into(),
slots: Arc::new(Semaphore::new(maximum_calls)),
@@ -60,7 +60,6 @@ impl MiddlewarePlan for PluginRequestPolicyPlan {
context,
terminal: Some(next),
timeout: self.middleware_timeout,
maximum_payload_bytes: self.maximum_payload_bytes,
})
.run(request)
}
@@ -73,7 +72,6 @@ struct MiddlewareChainNext {
context: MiddlewareContext,
terminal: Option<Box<dyn MiddlewareNext>>,
timeout: Duration,
maximum_payload_bytes: usize,
}
impl MiddlewareNext for MiddlewareChainNext {
@@ -97,7 +95,6 @@ impl MiddlewareNext for MiddlewareChainNext {
context: self.context.clone(),
terminal: self.terminal.take(),
timeout: self.timeout,
maximum_payload_bytes: self.maximum_payload_bytes,
});
invoke_middleware(
&self.entries[entry_index],
@@ -105,7 +102,6 @@ impl MiddlewareNext for MiddlewareChainNext {
request,
next,
self.timeout,
self.maximum_payload_bytes,
)
.await
})
@@ -118,7 +114,6 @@ async fn invoke_middleware(
request: MiddlewareRequest,
next: Box<dyn MiddlewareNext>,
maximum_timeout: Duration,
maximum_payload_bytes: usize,
) -> Result<MiddlewareResponse, MiddlewareError> {
let Some(invocation_ports) = &entry.invocation else {
return if entry.failure_policy
@@ -137,14 +132,17 @@ async fn invoke_middleware(
if remaining.is_zero() || context.cancellation().is_cancelled() {
return Err(MiddlewareError::Fault);
}
let invocation = MiddlewareInvocation::new(&context, request, next, entry.requests_authorized);
let invocation = MiddlewareInvocation::new(
&context,
request,
next,
entry.requests_authorized,
entry.capability_version,
);
let (protocol, headers, body_visible, payload) = match invocation.request_projection() {
Ok(projection) => projection,
Err(_) => return recover_invalid(entry, &invocation).await,
};
if payload.len() > maximum_payload_bytes {
return recover_invalid(entry, &invocation).await;
}
let mut call_context = invocation_ports.session.context(
match entry.mount {
MiddlewareMount::Request => Stage::Request,
@@ -1,4 +1,5 @@
mod middleware;
mod retry;
mod route_schedule;
use std::{fmt, sync::Arc, time::Duration};
@@ -17,6 +18,7 @@ const POLICY_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) enum PolicyEntry {
Router(ModelRouterEntry),
Scheduler(AccountSchedulerEntry),
Retry(RetryEntry),
Middleware(MiddlewareEntry),
}
@@ -26,6 +28,14 @@ struct PolicyInvocation {
callbacks: Arc<PluginCallbacks>,
}
pub(crate) struct RetryEntry {
order: i32,
plugin_id: String,
instance_id: String,
invocation: Option<PolicyInvocation>,
scope: BindingScope,
}
pub(crate) struct ModelRouterEntry {
order: i32,
plugin_id: String,
@@ -46,6 +56,7 @@ pub(crate) struct AccountSchedulerEntry {
}
pub(crate) struct MiddlewareEntry {
capability_version: u32,
order: i32,
plugin_id: String,
instance_id: String,
@@ -62,12 +73,16 @@ pub(crate) fn validate_bindings(
) -> Result<(), AdminError> {
let mut has_router = false;
let mut has_scheduler = false;
let mut has_retry = false;
let mut middleware_stages = std::collections::BTreeSet::new();
for binding in bindings {
let capability = crate::contribution::resolve(manifest, binding)?.capability;
if !matches!(
capability,
Capability::ModelRouter | Capability::Scheduler | Capability::Middleware
Capability::ModelRouter
| Capability::Scheduler
| Capability::Middleware
| Capability::RetryPolicy
) {
continue;
}
@@ -80,6 +95,16 @@ pub(crate) fn validate_bindings(
return Err(AdminError::invalid("数据面绑定的故障策略无效"));
}
match capability {
Capability::RetryPolicy => {
if std::mem::replace(&mut has_retry, true)
|| stage != Stage::Retry
|| binding.failure_policy != PluginFailurePolicy::Delegate
{
return Err(AdminError::invalid(
"重试策略只允许一次 retry 绑定,故障策略必须为 delegate",
));
}
}
Capability::ModelRouter => {
if std::mem::replace(&mut has_router, true) || stage != Stage::Routing {
return Err(AdminError::invalid(
@@ -140,7 +165,10 @@ pub(crate) fn compile_entries(
};
matches!(
capability,
Capability::ModelRouter | Capability::Scheduler | Capability::Middleware
Capability::ModelRouter
| Capability::Scheduler
| Capability::Middleware
| Capability::RetryPolicy
)
.then(|| {
compile_entry(
@@ -148,6 +176,7 @@ pub(crate) fn compile_entries(
instance_id,
binding,
capability,
manifest.contributes[&capability].version,
Some(invocation.clone()),
permissions.contains(&Permission::Requests),
)
@@ -167,6 +196,7 @@ pub(crate) fn unavailable_entries(
let capability = match binding.stage.as_str() {
"routing" => Capability::ModelRouter,
"scheduling" => Capability::Scheduler,
"retry" => Capability::RetryPolicy,
"request" | "attempt" => Capability::Middleware,
_ => return None,
};
@@ -178,6 +208,7 @@ pub(crate) fn unavailable_entries(
&instance.id,
binding,
capability,
1,
None,
false,
))
@@ -190,11 +221,19 @@ fn compile_entry(
instance_id: &str,
binding: &PluginCapabilityBinding,
capability: Capability,
capability_version: u32,
invocation: Option<PolicyInvocation>,
requests_authorized: bool,
) -> Result<PolicyEntry, AdminError> {
let scope = BindingScope::compile(binding)?;
Ok(match capability {
Capability::RetryPolicy => PolicyEntry::Retry(RetryEntry {
order: binding.order,
plugin_id: plugin_id.to_owned(),
instance_id: instance_id.to_owned(),
invocation,
scope,
}),
Capability::ModelRouter => PolicyEntry::Router(ModelRouterEntry {
order: binding.order,
plugin_id: plugin_id.to_owned(),
@@ -213,6 +252,7 @@ fn compile_entry(
requests_authorized,
}),
Capability::Middleware => PolicyEntry::Middleware(MiddlewareEntry {
capability_version,
order: binding.order,
plugin_id: plugin_id.to_owned(),
instance_id: instance_id.to_owned(),
@@ -233,10 +273,10 @@ fn compile_entry(
pub(crate) struct PluginRequestPolicyPlan {
routers: Arc<[ModelRouterEntry]>,
schedulers: Arc<[AccountSchedulerEntry]>,
retries: Arc<[RetryEntry]>,
middleware: Arc<[MiddlewareEntry]>,
policy_timeout: Duration,
middleware_timeout: Duration,
maximum_payload_bytes: usize,
}
impl fmt::Debug for PluginRequestPolicyPlan {
@@ -245,6 +285,7 @@ impl fmt::Debug for PluginRequestPolicyPlan {
.debug_struct("PluginRequestPolicyPlan")
.field("router_count", &self.routers.len())
.field("scheduler_count", &self.schedulers.len())
.field("retry_count", &self.retries.len())
.field("middleware_count", &self.middleware.len())
.finish_non_exhaustive()
}
@@ -254,19 +295,24 @@ impl PluginRequestPolicyPlan {
pub(crate) fn compile(
entries: Vec<PolicyEntry>,
maximum_call_timeout: Duration,
maximum_frame_bytes: usize,
) -> Result<Option<Arc<Self>>, AdminError> {
let mut routers = Vec::new();
let mut schedulers = Vec::new();
let mut retries = Vec::new();
let mut middleware = Vec::new();
for entry in entries {
match entry {
PolicyEntry::Router(entry) => routers.push(entry),
PolicyEntry::Scheduler(entry) => schedulers.push(entry),
PolicyEntry::Retry(entry) => retries.push(entry),
PolicyEntry::Middleware(entry) => middleware.push(entry),
}
}
if routers.is_empty() && schedulers.is_empty() && middleware.is_empty() {
if routers.is_empty()
&& schedulers.is_empty()
&& middleware.is_empty()
&& retries.is_empty()
{
return Ok(None);
}
routers.sort_by(|left, right| {
@@ -276,6 +322,13 @@ impl PluginRequestPolicyPlan {
&right.instance_id,
))
});
retries.sort_by(|left, right| {
(left.order, &left.plugin_id, &left.instance_id).cmp(&(
right.order,
&right.plugin_id,
&right.instance_id,
))
});
for (index, left) in schedulers.iter().enumerate() {
if schedulers[index + 1..]
.iter()
@@ -301,16 +354,16 @@ impl PluginRequestPolicyPlan {
Ok(Some(Arc::new(Self {
routers: routers.into(),
schedulers: schedulers.into(),
retries: retries.into(),
middleware: middleware.into(),
policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT),
middleware_timeout: maximum_call_timeout,
maximum_payload_bytes: maximum_frame_bytes,
})))
}
#[must_use]
pub(crate) fn has_request_policy(&self) -> bool {
!self.routers.is_empty() || !self.schedulers.is_empty()
!self.routers.is_empty() || !self.schedulers.is_empty() || !self.retries.is_empty()
}
#[must_use]
@@ -0,0 +1,107 @@
use std::{sync::Arc, time::Duration};
use futures::future::BoxFuture;
use gateway_core::{
engine::policy::{RequestPolicyFault, RetryDecision, RetryInput},
upstream::UpstreamSendState,
};
use gateway_plugin_sdk::{
SendState, Stage,
call::policy::{RetryAction, RetryDecision as WireDecision, RetryDecisionRequest},
};
use super::RetryEntry;
pub(super) fn decide(
entries: Arc<[RetryEntry]>,
input: RetryInput,
timeout: Duration,
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
Box::pin(async move {
let started = std::time::Instant::now();
for entry in entries.iter().filter(|entry| {
!input.extension_scope.contains(&entry.instance_id)
&& entry.scope.matches_processing(
&input.client_key_id,
&input.account_group_ids,
Some(&input.facts.provider),
input.facts.model.as_deref(),
)
}) {
// 整条委托链共享预算,不能每个插件重新取得完整等待时间。
let remaining = timeout
.min(input.facts.remaining_deadline)
.saturating_sub(started.elapsed());
if remaining.is_zero() {
break;
}
match call(entry, &input, remaining).await {
Ok(RetryDecision::Delegate) => {}
Ok(decision) => return Ok(decision),
Err(()) => tracing::warn!(
plugin_id = entry.plugin_id,
instance_id = entry.instance_id,
request_id = input.request_id.as_str(),
"插件重试策略无效或调用失败,委托后续策略"
),
}
}
Ok(RetryDecision::Delegate)
})
}
async fn call(
entry: &RetryEntry,
input: &RetryInput,
timeout: Duration,
) -> Result<RetryDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let mut allowed_actions = vec![RetryAction::Stop];
if input.facts.retry_allowed {
allowed_actions.push(RetryAction::Retry);
}
let request = RetryDecisionRequest {
request_id: input.request_id.as_str().to_owned(),
attempt_index: input.facts.attempt_index.get(),
provider: input.facts.provider.as_str().to_owned(),
model: input.facts.model.clone(),
error_kind: input.facts.error_kind.as_str().to_owned(),
upstream_status: input.facts.upstream_status,
send_state: match input.facts.send_state {
UpstreamSendState::NotSent => SendState::NotSent,
UpstreamSendState::Sent => SendState::Sent,
UpstreamSendState::Ambiguous => SendState::Ambiguous,
},
remaining_routing_attempts: input.facts.remaining_routing_attempts,
remaining_deadline_ms: u64::try_from(input.facts.remaining_deadline.as_millis())
.unwrap_or(u64::MAX),
allowed_actions,
};
let mut context = invocation.session.context(Stage::Retry, timeout);
context.request_id = Some(request.request_id.clone());
context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index));
let reply = invocation
.session
.call(
"policy.retry_decision",
context,
serde_json::to_value(request).map_err(|_| ())?,
vec![],
)
.await
.map_err(|_| ())?;
if !reply.payload.is_empty()
|| reply
.result
.as_object()
.is_none_or(|fields| fields.len() != 1)
{
return Err(());
}
match serde_json::from_value::<WireDecision>(reply.result).map_err(|_| ())? {
WireDecision::Delegate => Ok(RetryDecision::Delegate),
WireDecision::Stop => Ok(RetryDecision::Stop),
WireDecision::Retry if input.facts.retry_allowed => Ok(RetryDecision::Retry),
WireDecision::Retry => Err(()),
}
}
@@ -1,7 +1,4 @@
use std::{
io,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use futures::future::BoxFuture;
@@ -34,18 +31,25 @@ const MAX_HEADER_TOTAL_BYTES: usize = 64 * 1024;
const OPAQUE_HEADERS_KEY: &str = "opaque_request_headers";
impl RequestPolicyPlan for PluginRequestPolicyPlan {
fn retry_decision(
&self,
input: gateway_core::engine::policy::RetryInput,
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
{
super::retry::decide(self.retries.clone(), input, self.policy_timeout)
}
fn route_model(
&self,
input: ModelRouteInput,
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
let routers = std::sync::Arc::clone(&self.routers);
let timeout = self.policy_timeout;
let maximum_payload_bytes = self.maximum_payload_bytes;
Box::pin(async move {
for entry in routers.iter().filter(|entry| {
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_route(&input)
}) {
let result = route_with_entry(entry, &input, timeout, maximum_payload_bytes).await;
let result = route_with_entry(entry, &input, timeout).await;
match result {
Ok(ModelRouteDecision::Unhandled) => continue,
Ok(decision) => return Ok(decision),
@@ -103,12 +107,11 @@ async fn route_with_entry(
entry: &ModelRouterEntry,
input: &ModelRouteInput,
timeout: Duration,
maximum_payload_bytes: usize,
) -> Result<ModelRouteDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let projection = operation_projection(input.operation())?;
let payload = if entry.requests_authorized {
projection.body.to_bytes(maximum_payload_bytes)?
projection.body.to_bytes()?
} else {
Vec::new()
};
@@ -264,15 +267,10 @@ enum OperationBody<'a> {
}
impl OperationBody<'_> {
fn to_bytes(&self, maximum: usize) -> Result<Vec<u8>, ()> {
fn to_bytes(&self) -> Result<Vec<u8>, ()> {
match self {
Self::Raw(body) if body.len() <= maximum => Ok(body.to_vec()),
Self::Raw(_) => Err(()),
Self::Object(body) => {
let mut writer = LimitedWriter::new(maximum);
serde_json::to_writer(&mut writer, body).map_err(|_| ())?;
Ok(writer.into_inner())
}
Self::Raw(body) => Ok(body.to_vec()),
Self::Object(body) => serde_json::to_vec(body).map_err(|_| ()),
}
}
}
@@ -444,42 +442,3 @@ fn system_time_millis(value: Option<SystemTime>) -> Option<u64> {
.try_into()
.ok()
}
struct LimitedWriter {
bytes: Vec<u8>,
maximum: usize,
}
impl LimitedWriter {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
}
}
fn into_inner(self) -> Vec<u8> {
self.bytes
}
}
impl io::Write for LimitedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if self
.bytes
.len()
.checked_add(bytes.len())
.is_none_or(|length| length > self.maximum)
{
return Err(io::Error::other(
"request body exceeds plugin payload limit",
));
}
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
@@ -0,0 +1,119 @@
use std::sync::Arc;
use gateway_admin::model::{
PageSize, plugins::instances::PluginPermissionGrant,
provider_credentials::PluginAccountListQuery,
};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use gateway_plugin_sdk::{CallContext, PluginFault, Stage, call::data};
use super::{
accounts::{PluginAccountPortSlot, map_admin_error},
denied, invalid,
};
use crate::RpcReply;
pub(super) struct PluginData {
accounts: Arc<PluginAccountPortSlot>,
authorized: bool,
}
impl PluginData {
pub(super) fn new(
accounts: Arc<PluginAccountPortSlot>,
grants: &[PluginPermissionGrant],
) -> Self {
Self {
accounts,
authorized: grants.iter().any(|grant| grant.permission == "data"),
}
}
pub(super) async fn call(
&self,
context: &CallContext,
method: &str,
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
// 管理范围的只读授权不继承到客户端请求链,避免借数据查询扩大当前 Key 的范围。
if !self.authorized || !matches!(context.stage, Stage::Management | Stage::CommandLine) {
return Err(denied());
}
if params != serde_json::json!({}) {
return Err(invalid());
}
let accounts = self.accounts.upgrade().map_err(map_admin_error)?;
let payload = match method {
data::ACCOUNTS_LIST => {
let query: data::AccountFactsQuery =
serde_json::from_slice(payload).map_err(|_| invalid())?;
let page = accounts
.list(PluginAccountListQuery {
provider_kind: query
.provider_id
.map(ProviderKind::new)
.transpose()
.map_err(|_| invalid())?,
cursor: query
.cursor
.map(ProviderAccountId::new)
.transpose()
.map_err(|_| invalid())?,
limit: PageSize::new(query.limit).map_err(|_| invalid())?,
})
.await
.map_err(map_admin_error)?;
serde_json::to_vec(&data::AccountFactsPage {
schema_version: 1,
accounts: page
.accounts
.into_iter()
.map(|account| data::AccountFacts {
account_id: account.id,
provider_id: account.provider_kind.as_str().to_owned(),
group_ids: account
.groups
.into_iter()
.map(|group| group.id.as_str().to_owned())
.collect(),
enabled: account.enabled,
updated_at_ms: account.updated_at.timestamp_millis(),
})
.collect(),
next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()),
})
}
data::QUOTA_GET => {
let query: data::QuotaFactsQuery =
serde_json::from_slice(payload).map_err(|_| invalid())?;
let account_id = ProviderAccountId::new(query.account_id).map_err(|_| invalid())?;
let quota = accounts
.get_quota(&account_id)
.await
.map_err(map_admin_error)?;
serde_json::to_vec(&data::QuotaFacts {
schema_version: 1,
account_id: account_id.as_str().to_owned(),
observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()),
windows: quota
.windows
.into_iter()
.map(|window| data::QuotaWindowFacts {
key: window.key,
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()),
})
.collect(),
})
}
_ => return Err(denied()),
}
.map_err(|_| invalid())?;
Ok(RpcReply {
result: serde_json::json!({}),
payload,
})
}
}
@@ -28,8 +28,22 @@ use body::BodyResource;
pub(crate) use body::MiddlewareBodyAuthority;
use headers::{HeaderDirection, apply_header_mutations, project_headers, validate_headers};
fn request_feature(
feature: gateway_plugin_sdk::call::middleware::RequestFeature,
) -> gateway_core::operation::Feature {
use gateway_core::operation::Feature;
use gateway_plugin_sdk::call::middleware::RequestFeature as Wire;
match feature {
Wire::Tools => Feature::Tools,
Wire::Vision => Feature::Vision,
Wire::Reasoning => Feature::Reasoning,
Wire::JsonSchema => Feature::JsonSchema,
}
}
pub(crate) struct MiddlewareInvocation {
requests_authorized: bool,
capabilities_allowed: bool,
transport: ClientTransport,
state: Mutex<InvocationState>,
downstream_error: Arc<Mutex<Option<MiddlewareError>>>,
@@ -82,9 +96,13 @@ impl MiddlewareInvocation {
request: MiddlewareRequest,
next: Box<dyn MiddlewareNext>,
requests_authorized: bool,
capability_version: u32,
) -> Arc<Self> {
Arc::new(Self {
requests_authorized,
capabilities_allowed: capability_version >= 2
&& context.mount() == gateway_core::engine::middleware::MiddlewareMount::Request
&& context.operation() == Some(gateway_core::operation::OperationKind::Generate),
transport: context.transport(),
state: Mutex::new(InvocationState {
closed: false,
@@ -258,7 +276,41 @@ impl MiddlewareInvocation {
request: MiddlewareNextRequest,
payload: Vec<u8>,
) -> Result<MiddlewareRequest, PluginFault> {
let (mut protocol, mut headers, original_body) = original.into_parts();
let (mut protocol, mut headers, original_body) = original.clone().into_parts();
let declaration = request
.capabilities
.map(|declaration| {
if !self.requests_authorized || !self.capabilities_allowed {
return Err(denied());
}
if request.body != MiddlewareRequestBody::Replace {
return Err(invalid());
}
let handled = declaration
.handled
.iter()
.copied()
.map(request_feature)
.collect::<std::collections::BTreeSet<_>>();
let required = declaration
.required
.iter()
.copied()
.map(request_feature)
.collect::<std::collections::BTreeSet<_>>();
if handled.len() != declaration.handled.len()
|| required.len() != declaration.required.len()
{
return Err(invalid());
}
Ok(
gateway_core::engine::middleware::MiddlewareCapabilityDeclaration {
handled,
required,
},
)
})
.transpose()?;
if let Some(replacement) = request.protocol {
if !self.requests_authorized {
return Err(denied());
@@ -282,7 +334,9 @@ impl MiddlewareInvocation {
MiddlewareRequestBody::Replace if self.requests_authorized => Bytes::from(payload),
MiddlewareRequestBody::Replace => return Err(denied()),
};
Ok(MiddlewareRequest::new(protocol, headers, body))
original
.replace_parts(protocol, headers, body, declaration)
.map_err(|_| invalid())
}
async fn call_body_read(
@@ -1,5 +1,6 @@
mod accounts;
mod affinity;
mod data;
mod http;
mod keys;
mod log;
@@ -62,6 +63,7 @@ impl PluginCallbackPorts {
pub(crate) struct PluginCallbacks {
accounts: Arc<accounts::PluginAccounts>,
data: Arc<data::PluginData>,
keys: Arc<PluginClientKeyPortSlot>,
models_authorized: bool,
affinity: Arc<affinity::PluginAffinity>,
@@ -123,6 +125,7 @@ impl PluginCallbacks {
ports: PluginCallbackPorts,
) -> Result<Self, AdminError> {
Ok(Self {
data: Arc::new(data::PluginData::new(ports.accounts.clone(), grants)),
accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts, grants)),
keys: ports.keys,
models_authorized: grants.iter().any(|grant| grant.permission == "models"),
@@ -371,6 +374,7 @@ impl CallbackHandler for PluginCallbacks {
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let data = self.data.clone();
let keys = self.keys.clone();
let models_authorized = self.models_authorized;
let models = self.models.clone();
@@ -437,6 +441,13 @@ impl CallbackHandler for PluginCallbacks {
let scope = Some(scope)
.filter(|scope| scope.authorizes(&context))
.ok_or_else(denied)?;
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::ACCOUNTS_LIST
| gateway_plugin_sdk::call::data::QUOTA_GET
) {
return data.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
"host.auth.list" | "host.auth.get" | "host.auth.get_runtime" | "host.auth.save"
@@ -146,7 +146,7 @@ impl PluginRuntime {
};
let callbacks = Arc::new(PluginCallbacks::new(
&grants,
self.config.rpc_limits.maximum_frame_bytes,
self.config.rpc_limits.maximum_buffered_body_bytes,
&manifest,
self.log_slots.clone(),
private_state.clone(),
@@ -233,6 +233,8 @@ impl PluginRuntime {
session.clone(),
callbacks.clone(),
)?);
let model_aliases =
crate::adapter::catalog::prepare(&manifest, &instance, &session).await?;
sessions.push(PreparedInstance {
instance_id,
artifact_sha256: instance.artifact_sha256,
@@ -247,6 +249,7 @@ impl PluginRuntime {
management,
policy_entries,
authentication_entries,
model_aliases,
})
}
}
@@ -125,6 +125,7 @@ pub(super) struct PreparedSet {
shutting_down: Arc<AtomicBool>,
commands: Vec<Arc<crate::adapter::command_line::PluginCommand>>,
management: Vec<crate::adapter::management::ManagementEntry>,
model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
}
#[derive(Default)]
@@ -136,6 +137,7 @@ struct PreparedContributions {
policy_entries: Vec<crate::adapter::policy::PolicyEntry>,
authentication_entries:
Vec<crate::adapter::frontend_authentication::FrontendAuthenticationEntry>,
model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
}
impl PreparedContributions {
@@ -144,6 +146,7 @@ impl PreparedContributions {
self.observer_entries.extend(other.observer_entries);
self.commands.extend(other.commands);
self.management.extend(other.management);
self.model_aliases.extend(other.model_aliases);
self.policy_entries.extend(other.policy_entries);
self.authentication_entries
.extend(other.authentication_entries);
@@ -171,6 +174,10 @@ impl ExtensionSetLease for PreparedSet {
self.sessions_ready() && self.can_serve()
}
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
&self.model_aliases
}
fn can_serve(&self) -> bool {
// 发布的是可用能力和故障绑定组成的完整计划;单个进程退出不能使原生转发失效。
!self.shutting_down.load(Ordering::Acquire)
@@ -423,6 +430,24 @@ impl PluginRuntime {
Ok(candidate) => contributions.append(candidate),
Err(error) if required_revision == Some(instance.revision) => return Err(error),
Err(error) => {
// 目录没有拒绝请求用的 binding,不能在恢复失败时悄悄撤销仍启用的别名。
// 不复制其他候选的注册结果;让发布事务保留调用方持有的旧有效快照。
let has_retained_catalog = self
.prepared
.lock()
.await
.values()
.filter_map(Weak::upgrade)
.any(|set| {
set.model_aliases
.iter()
.any(|alias| alias.owner == instance.id)
});
if has_retained_catalog {
return Err(AdminError::unavailable(
"已启用的模型目录插件未就绪,请修复或停用后重试",
));
}
// 恢复失败只隔离该实例;绑定的拒绝策略仍保留,不能绕过认证或必需处理。
contributions
.policy_entries
@@ -443,14 +468,24 @@ impl PluginRuntime {
management,
policy_entries,
authentication_entries,
model_aliases,
} = contributions;
let mut alias_owners = BTreeMap::new();
for alias in &model_aliases {
if let Some(previous) = alias_owners.insert(&alias.id, &alias.owner) {
return Err(AdminError::invalid(format!(
"模型别名 {} 在插件实例 {} 与 {} 之间冲突",
alias.id, previous, alias.owner
)));
}
}
let id = ExtensionSetId::new(uuid::Uuid::new_v4().to_string())
.map_err(|_| AdminError::internal("插件集合 ID 无效"))?;
let observers = crate::adapter::observer::PluginObserverPlan::compile(
observer_entries,
self.config.rpc_limits.maximum_calls,
self.config.rpc_limits.maximum_call_timeout,
self.config.rpc_limits.maximum_frame_bytes,
self.config.rpc_limits.maximum_buffered_body_bytes,
)
.map(|plan| {
self.observers
@@ -461,7 +496,6 @@ impl PluginRuntime {
let policy_plan = crate::adapter::policy::PluginRequestPolicyPlan::compile(
policy_entries,
self.config.rpc_limits.maximum_call_timeout,
self.config.rpc_limits.maximum_frame_bytes,
)?;
let policies = policy_plan
.as_ref()
@@ -502,6 +536,7 @@ impl PluginRuntime {
commands,
management,
failures,
model_aliases,
shutting_down: self.shutting_down.clone(),
});
self.prepared
@@ -825,6 +860,7 @@ impl PluginPreparation for PluginRuntime {
let (_, _, state) = super::configuration::validate(&instance, package.manifest())?;
crate::adapter::observer::validate_bindings(package.manifest(), &instance.bindings)?;
crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?;
crate::adapter::catalog::validate_bindings(package.manifest(), &instance.bindings)?;
crate::adapter::frontend_authentication::validate_bindings(
package.manifest(),
&instance.bindings,
@@ -2,19 +2,15 @@ use std::sync::Arc;
use gateway_plugin_sdk::{
ErrorCode, Frame, Message, Permission, PluginFault, Stage,
client::{read_frame, write_frame},
client::{read_frame, validate_frame, write_frame},
};
use tokio::{
io::{AsyncRead, AsyncWrite},
sync::{mpsc, oneshot},
};
use super::session::{CallbackHandler, RpcError, RpcLimits, RpcReply, Shared};
use super::session::{CallbackHandler, RpcError, RpcReply, Shared};
#[expect(
clippy::too_many_arguments,
reason = "双向资源在一个入口交给独立读写任务,不引入重复连接状态"
)]
pub(super) fn start<W, R>(
writer: W,
reader: R,
@@ -23,19 +19,12 @@ pub(super) fn start<W, R>(
shared: Arc<Shared>,
callbacks: Arc<dyn CallbackHandler>,
permissions: Vec<Permission>,
limits: RpcLimits,
) where
W: AsyncWrite + Unpin + Send + 'static,
R: AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(write_loop(
writer,
data,
control,
Arc::clone(&shared),
limits.maximum_frame_bytes,
));
tokio::spawn(read_loop(reader, shared, callbacks, permissions, limits));
tokio::spawn(write_loop(writer, data, control, Arc::clone(&shared)));
tokio::spawn(read_loop(reader, shared, callbacks, permissions));
}
async fn write_loop<W: AsyncWrite + Unpin>(
@@ -43,7 +32,6 @@ async fn write_loop<W: AsyncWrite + Unpin>(
mut data: mpsc::Receiver<Frame>,
mut control: mpsc::Receiver<Frame>,
shared: Arc<Shared>,
maximum: usize,
) {
let mut stopped = shared.stopped.subscribe();
loop {
@@ -73,7 +61,7 @@ async fn write_loop<W: AsyncWrite + Unpin>(
let written = tokio::select! {
biased;
_ = stopped.changed() => return,
result = write_frame(&mut writer, &frame, maximum) => result,
result = write_frame(&mut writer, &frame) => result,
};
if written.is_err() {
shared.fail(RpcError::Closed);
@@ -87,7 +75,6 @@ async fn read_loop<R: AsyncRead + Unpin>(
shared: Arc<Shared>,
callbacks: Arc<dyn CallbackHandler>,
permissions: Vec<Permission>,
limits: RpcLimits,
) {
let mut stopped = shared.stopped.subscribe();
let mut last_callback = 0;
@@ -98,7 +85,7 @@ async fn read_loop<R: AsyncRead + Unpin>(
let frame = tokio::select! {
biased;
_ = stopped.changed() => return,
frame = read_frame(&mut reader, limits.maximum_frame_bytes) => frame,
frame = read_frame(&mut reader) => frame,
};
let Ok(frame) = frame else {
shared.fail(RpcError::Closed);
@@ -148,7 +135,7 @@ async fn read_loop<R: AsyncRead + Unpin>(
return;
}
let result = handler.call(context, method, params, frame.payload).await;
let reply = match result {
let mut reply = match result {
Ok(reply) => Frame {
message: Message::Result {
id,
@@ -158,6 +145,16 @@ async fn read_loop<R: AsyncRead + Unpin>(
},
Err(error) => Frame::control(Message::Error { id, error }),
};
// 宿主回调的局部编码错误只能结束该调用,不能关闭共享写通道。
if validate_frame(&reply).is_err() {
reply = Frame::control(Message::Error {
id,
error: PluginFault::new(
ErrorCode::Fault,
"host callback response cannot be encoded",
),
});
}
response.send_control(reply);
drop(permit);
});
@@ -211,6 +208,10 @@ fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> b
) {
return true;
}
// 失败后的策略不得产生额外出站调用或读取账号凭据。
if stage == Stage::Retry {
return false;
}
if matches!(
method,
gateway_plugin_sdk::call::middleware::NEXT_METHOD
@@ -234,6 +235,11 @@ fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> b
Permission::Accounts
}
"host.affinity.lookup" => Permission::Requests,
"host.data.accounts.list" | "host.data.quota.get"
if matches!(stage, Stage::Management | Stage::CommandLine) =>
{
Permission::Data
}
_ => return false,
};
permissions.contains(&permission)
@@ -11,7 +11,7 @@ use futures::future::BoxFuture;
use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor};
use gateway_plugin_sdk::{
CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, PluginFault,
client::{read_frame, write_frame},
client::{read_frame, validate_frame, write_frame},
};
use tokio::sync::{Mutex as AsyncMutex, Semaphore, mpsc, oneshot, watch};
@@ -21,7 +21,8 @@ use super::dispatch;
#[derive(Debug, Clone, Copy)]
pub struct RpcLimits {
pub maximum_frame_bytes: usize,
/// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限。
pub maximum_buffered_body_bytes: usize,
pub maximum_calls: usize,
pub maximum_callbacks: usize,
pub handshake_timeout: Duration,
@@ -32,7 +33,7 @@ pub struct RpcLimits {
impl Default for RpcLimits {
fn default() -> Self {
Self {
maximum_frame_bytes: 1024 * 1024,
maximum_buffered_body_bytes: 1024 * 1024,
maximum_calls: 16,
maximum_callbacks: 16,
handshake_timeout: Duration::from_secs(5),
@@ -457,8 +458,8 @@ impl RpcSession {
|| limits.maximum_calls > 256
|| limits.maximum_callbacks == 0
|| limits.maximum_callbacks > 256
|| limits.maximum_frame_bytes < 1024
|| limits.maximum_frame_bytes > 16 * 1024 * 1024
|| limits.maximum_buffered_body_bytes < 1024
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|| limits.handshake_timeout.is_zero()
|| limits.maximum_call_timeout.is_zero()
{
@@ -467,6 +468,11 @@ impl RpcSession {
let manifest = package.package().manifest();
let plugin_id = manifest.plugin_id().map_err(|_| RpcError::Handshake)?;
if handshake.protocol_version != PROTOCOL_VERSION
|| manifest
.package
.as_ref()
.map(|package| package.protocol_version)
!= Some(handshake.protocol_version)
|| handshake.plugin_id != plugin_id
|| handshake.artifact_sha256 != package.package().digest()
|| handshake.contributes != manifest.contributes
@@ -516,18 +522,22 @@ impl RpcSession {
&Frame::control(Message::Hello {
handshake: handshake.clone(),
}),
limits.maximum_frame_bytes,
)
.await
.map_err(|_| RpcError::Handshake)?;
let reply = read_frame(&mut output, limits.maximum_frame_bytes)
let reply = read_frame(&mut output)
.await
.map_err(|_| RpcError::Handshake)?;
match reply.message {
Message::Ready {
protocol_version: PROTOCOL_VERSION,
protocol_version,
incarnation,
} if incarnation == handshake.incarnation && reply.payload.is_empty() => Ok(()),
} if protocol_version == handshake.protocol_version
&& incarnation == handshake.incarnation
&& reply.payload.is_empty() =>
{
Ok(())
}
_ => Err(RpcError::Handshake),
}
})
@@ -571,7 +581,6 @@ impl RpcSession {
Arc::clone(&shared),
callbacks.clone(),
handshake.permissions.clone(),
limits,
);
let monitor = Arc::clone(&shared);
let instance_id = handshake.instance_id.clone();
@@ -657,7 +666,6 @@ impl RpcSession {
&self,
method: &str,
context: &CallContext,
payload: &[u8],
) -> Result<tokio::time::Instant, RpcError> {
if context.instance_id != self.handshake.instance_id
|| context.generation != self.handshake.generation
@@ -666,7 +674,6 @@ impl RpcSession {
|| Duration::from_millis(context.timeout_ms) > self.limits.maximum_call_timeout
|| method.is_empty()
|| method.len() > 128
|| payload.len() > self.limits.maximum_frame_bytes
{
return Err(RpcError::Context);
}
@@ -717,7 +724,7 @@ impl RpcSession {
stream: Option<StreamIngress>,
credit: Option<(u32, u32)>,
) -> Result<CompletedCall, RpcError> {
let deadline = self.call_deadline(method, &context, &payload)?;
let deadline = self.call_deadline(method, &context)?;
// FIFO 锁同时拥有 ID 与 data 入队顺序;Call/初始 Credit 入队后必须立即释放,
// 不能把插件回复或重入回调纳入串行区,也不能为等待锁重置调用期限。
let mut next_call = tokio::time::timeout_at(deadline, self.next_call.lock())
@@ -736,6 +743,8 @@ impl RpcSession {
},
payload,
};
// 本地元数据错误不能进入写队列并关闭整个会话。
validate_frame(&frame).map_err(|_| RpcError::Context)?;
let started = self.begin_call(context, deadline, stream)?;
let mut guard = CallGuard {
shared: Arc::clone(&self.shared),
@@ -819,7 +828,7 @@ impl RpcSession {
let slot = Arc::clone(&self.slots)
.try_acquire_owned()
.map_err(|_| RpcError::Capacity)?;
let bytes = self.limits.maximum_frame_bytes.min(256 * 1024) as u32;
let bytes = self.limits.maximum_buffered_body_bytes.min(256 * 1024) as u32;
let frames = 32;
let (stream, chunks, terminal) = StreamIngress::new(bytes, frames);
let completed = self
@@ -51,12 +51,12 @@ async fn install(environment: &Environment, config: Value) -> PluginInstance {
.map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes))))
.collect();
let manifest = json!({
"manifestVersion":3,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0",
"manifestVersion":1,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0",
"engines":{"codex-proxy-rs":">=1.0.0, <2.0.0"},"main":"bin/worker","author":"test","description":"管理测试","license":"MIT",
"runtime":"trustedProcess","resources":{"ui/index.html":"text/html","ui/app.js":"text/javascript","ui/public.svg":"image/svg+xml"},
"contributes":{"management":{"id":"management.example.management","version":1,"stages":["management"],"inputFormats":[],"outputFormats":[]}},
"permissions":["public_endpoints"],
"package":{"protocolVersion":4,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests},
"package":{"protocolVersion":gateway_plugin_sdk::PROTOCOL_VERSION,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests},
});
files.insert("plugin.json".into(), serde_json::to_vec(&manifest).unwrap());
let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap())
@@ -0,0 +1,213 @@
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use async_trait::async_trait;
use gateway_admin::{
model::{
AdminError, MutationContext, Revision,
accounts::AccountRecord,
plugins::{instances::PluginPermissionGrant, management::PluginManagementRequest},
provider_credentials::*,
},
ports::{plugin_accounts::PluginAccountAccess, plugin_management::PluginManagement},
};
use gateway_core::{
account::{AccountWeight, CredentialState, ProviderAccountId, QuotaState},
routing::{ConfigRevision, ProviderKind},
runtime::extensions::ExtensionPreparationPort,
};
use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage};
use serde_json::{Value, json};
#[derive(Default)]
struct Facts(AtomicUsize);
fn account() -> AccountRecord {
let at = "2026-01-01T00:00:00Z".parse().unwrap();
AccountRecord {
id: "acct_facts".into(),
provider_kind: ProviderKind::new("openai").unwrap(),
groups: vec![gateway_admin::model::account_groups::AccountGroupRef {
id: gateway_core::routing::AccountGroupId::new("grp_11111111111111111111111111111111")
.unwrap(),
name: "private group label".into(),
color: gateway_admin::model::account_groups::AccountGroupColor::parse("#112233FF")
.unwrap(),
enabled: true,
}],
name: "private name".into(),
notes: Some("private notes".into()),
email: Some("private@example.invalid".into()),
upstream_user_id: None,
upstream_account_id: None,
plan_type: Some("private plan".into()),
authentication_kind: "oauth".into(),
credential_revision: Revision::new(1).unwrap(),
has_refresh_token: true,
access_token_expires_at: None,
next_refresh_at: None,
enabled: true,
concurrency_limit: None,
weight: AccountWeight::new(1).unwrap(),
model_access: Default::default(),
outbound_proxy: None,
credential_state: CredentialState::Ready,
credential_observed_at: at,
quota: QuotaState::unknown(),
last_error_reason: None,
last_error_message: Some("private diagnostic".into()),
created_at: at,
updated_at: at,
}
}
#[async_trait]
impl PluginAccountAccess for Facts {
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError> {
self.0.fetch_add(1, Ordering::SeqCst);
assert_eq!(query.limit.get(), 1);
assert_eq!(query.provider_kind.unwrap().as_str(), "openai");
assert_eq!(query.cursor.unwrap().as_str(), "acct_before");
Ok(PluginAccountPage {
accounts: vec![account()],
next_cursor: Some(ProviderAccountId::new("acct_facts").unwrap()),
})
}
async fn get_runtime(&self, _: &ProviderAccountId) -> Result<AccountRecord, AdminError> {
panic!("事实回调只使用对应的窄查询")
}
async fn get_credential(
&self,
_: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError> {
panic!("不允许读取凭据")
}
async fn save(
&self,
_: PreparedPluginAccountSave,
_: &MutationContext,
) -> Result<PluginAccountSaveResult, AdminError> {
panic!("不允许修改账号")
}
async fn get_quota(&self, account: &ProviderAccountId) -> Result<ProviderQuota, AdminError> {
self.0.fetch_add(1, Ordering::SeqCst);
assert_eq!(account.as_str(), "acct_facts");
Ok(ProviderQuota {
plan_type: Some("private plan".into()),
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
key: "weekly".into(),
group: "private group".into(),
label: "private label".into(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(604800),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: None,
provider_data: None,
}],
limit_reached: false,
provider_data: None,
})
}
}
#[tokio::test]
async fn management_facts_are_minimal_bounded_and_separately_authorized() {
for permission in [None, Some(Permission::Accounts), Some(Permission::Data)] {
let (cache, store, runtime) = super::setup_with_permissions(
Contributions::from([crate::support::contribution(
Capability::Management,
vec![Stage::Management],
vec![],
vec![],
)]),
Default::default(),
permission.into_iter().collect(),
)
.await;
let facts = Arc::new(Facts::default());
let port: Arc<dyn PluginAccountAccess> = facts.clone();
runtime.bind_account_ports(&port).unwrap();
{
let mut snapshot = store.snapshot.lock().unwrap();
let instance = &mut snapshot.instances[0];
instance.grants = permission
.into_iter()
.map(|permission| PluginPermissionGrant {
permission: permission.as_str().into(),
})
.collect();
instance.configuration = json!({
"management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]},
"data_queries":[
{"method":"host.data.accounts.list","query":{"provider_id":"openai","cursor":"acct_before","limit":1}},
{"method":"host.data.quota.get","query":{"account_id":"acct_facts"}},
{"method":"host.data.accounts.list","query":{"limit":0}},
{"method":"host.data.accounts.list","query":{"limit":65535}},
{"method":"host.data.quota.get","query":{"account_id":"acct_facts","sql":"SELECT secret"}}
]
});
}
let generation =
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
assert!(runtime.middleware_registry().resolve(&generation).is_none());
assert!(runtime.policy_registry().resolve(&generation).is_none());
let view = runtime.views(&generation).await.unwrap().remove(0);
let response = runtime
.handle(
&generation,
&view.target,
PluginManagementRequest {
method: "GET".into(),
path: "facts".into(),
query: String::new(),
content_type: None,
body: vec![],
request_id: "data-fixture".into(),
},
)
.await
.unwrap();
let result: Value = serde_json::from_slice(&response.body).unwrap();
if permission == Some(Permission::Data) {
assert_eq!(facts.0.load(Ordering::SeqCst), 2, "{result}");
assert_eq!(
result[0],
json!({"schema_version":1,"accounts":[{
"account_id":"acct_facts","provider_id":"openai","group_ids":["grp_11111111111111111111111111111111"],
"enabled":true,"updated_at_ms":1767225600000i64
}],"next_cursor":"acct_facts"})
);
assert_eq!(
result[1],
json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null,
"windows":[{"key":"weekly","window_seconds":604800,"used_percent":null,"reset_at_ms":null}]})
);
for invalid in &result.as_array().unwrap()[2..] {
assert_eq!(invalid["error"], "invalid_input");
}
} else {
assert_eq!(facts.0.load(Ordering::SeqCst), 0);
assert!(
result
.as_array()
.unwrap()
.iter()
.all(|result| result["error"] == "permission_denied")
);
}
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
}
@@ -1,7 +1,7 @@
mod authentication;
mod configuration;
mod data;
mod observer;
mod observer_example;
mod policy;
mod prepare;
mod private_state;
@@ -44,13 +44,21 @@ async fn setup_with_restart_circuit(
async fn setup_with_contributions_and_restart_circuit(
contributes: Contributions,
restart_circuit: PluginRestartCircuitConfig,
) -> (tempfile::TempDir, Arc<Store>, PluginRuntime) {
setup_with_permissions(contributes, restart_circuit, vec![]).await
}
async fn setup_with_permissions(
contributes: Contributions,
restart_circuit: PluginRestartCircuitConfig,
permissions: Vec<gateway_plugin_sdk::Permission>,
) -> (tempfile::TempDir, Arc<Store>, PluginRuntime) {
let cache = tempfile::tempdir().unwrap();
let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap())
.inspect(
crate::support::package_with_contributions(
crate::support::worker(),
Vec::new(),
permissions,
contributes,
),
None,
@@ -1,286 +0,0 @@
use std::{
collections::BTreeMap,
num::NonZeroU32,
sync::{Arc, Mutex},
time::Duration,
};
use gateway_admin::{
model::{
Revision,
plugins::instances::{
PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot,
PluginPermissionGrant,
},
},
ports::plugins::PluginPackageInspector,
};
use gateway_core::{
account::ProviderAccountId,
diagnostics::diagnostic_json,
engine::{
ModelRequestId,
observation::{WebSocketResponseAttempt, WebSocketResponseObservation},
},
event::ProtocolWireEvent,
identity::ProviderKind,
operation::OperationKind,
policy::ClientApiKeyId,
routing::{ConfigRevision, PublicModelId},
runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference},
};
use gateway_plugin_runtime::{
PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits,
};
use serde_json::{Value, json};
use crate::support::store::Store;
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<BTreeMap<String, String>>>>);
struct Fields(BTreeMap<String, String>);
impl tracing::field::Visit for Fields {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_owned(), format!("{value:?}"));
}
}
impl tracing::Subscriber for Capture {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
metadata.target() == "gateway_plugin"
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if event.metadata().target() == "gateway_plugin" {
let mut fields = Fields(BTreeMap::new());
event.record(&mut fields);
self.0.lock().unwrap().push(fields.0);
}
}
}
impl Capture {
async fn wait_for(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(5), async {
while self.0.lock().unwrap().len() < count {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("independent observer log callback must complete");
}
fn assert_summary(&self, instance: &str, generation: u64, expected: Value) {
let logs = self.0.lock().unwrap();
let row = logs
.iter()
.rev()
.find(|row| {
row["instance_id"] == instance && row["generation"] == generation.to_string()
})
.expect("observer generation summary");
assert_eq!(
serde_json::from_str::<Value>(&row["fields"]).unwrap(),
diagnostic_json(&expected)
);
assert!(!row["fields"].contains("fixture-private-payload"));
}
}
fn grant(permission: &str) -> PluginPermissionGrant {
PluginPermissionGrant {
permission: permission.to_owned(),
}
}
fn binding(contribution: &str) -> PluginCapabilityBinding {
PluginCapabilityBinding {
contribution: contribution.to_owned(),
stage: "observation".to_owned(),
order: 1,
failure_policy: PluginFailurePolicy::Observe,
client_key_ids: Vec::new(),
account_group_ids: Vec::new(),
provider_ids: vec!["openai".to_owned()],
models: vec!["gpt-observed".to_owned()],
identity_bindings: Vec::new(),
}
}
async fn runtime(cache: &std::path::Path) -> (Arc<Store>, PluginRuntime) {
let inspector = PackageInspector::new(PackageLimits::default(), "3.12.1".parse().unwrap());
let mut artifacts = BTreeMap::new();
let mut instances = Vec::new();
for (variable, plugin, permissions) in [
(
"CPR_PLUGIN_WS_COUNTER_ARCHIVE",
"example-ws-event-counter",
vec![],
),
(
"CPR_PLUGIN_WS_METRICS_ARCHIVE",
"example-ws-payload-metrics",
vec![grant("requests")],
),
] {
let path = std::env::var_os(variable).unwrap_or_else(|| {
panic!("{variable} must point to a packaged independent SDK example")
});
let archive = std::fs::read(path).expect("independent observer archive");
let artifact = inspector.inspect(archive.into(), None).await.unwrap();
assert_eq!(artifact.metadata.plugin_id, plugin);
let contribution = artifact.metadata.contributes["web_socket_observer"]
.id
.clone();
instances.push(PluginInstance {
id: plugin.to_owned(),
name: plugin.to_owned(),
artifact_sha256: artifact.metadata.sha256.clone(),
enabled: true,
trusted_process: true,
configuration: json!({}),
secrets: BTreeMap::new(),
grants: permissions,
bindings: vec![binding(&contribution)],
revision: Revision::new(1).unwrap(),
});
artifacts.insert(artifact.metadata.sha256.clone(), artifact);
}
let store = Arc::new(Store {
artifacts,
snapshot: Mutex::new(PluginInstanceSnapshot {
config_revision: Revision::new(1).unwrap(),
instances,
}),
});
let runtime = PluginRuntime::new(
store.clone(),
store.clone(),
PluginRuntimeConfig {
cache_directory: cache.to_owned(),
host_version: "3.12.1".parse().unwrap(),
package_limits: PackageLimits::default(),
rpc_limits: RpcLimits::default(),
restart_circuit: Default::default(),
},
Arc::new(gateway_host::outbound::HttpClient::new().unwrap()),
Arc::new(gateway_host::process::ProcessSupervisor::new(
std::num::NonZeroUsize::new(8).unwrap(),
)),
);
(store, runtime)
}
fn observation(
generation: u64,
sequence: u64,
model: &str,
body: &Value,
) -> WebSocketResponseObservation {
WebSocketResponseObservation::new(
ModelRequestId::new("req_observer_example").unwrap(),
ConfigRevision::new(generation).unwrap(),
OperationKind::Generate,
WebSocketResponseAttempt::new(
ProviderKind::new("openai").unwrap(),
ProviderAccountId::new("acct_observed").unwrap(),
NonZeroU32::new(1).unwrap(),
),
sequence,
ProtocolWireEvent::json(
"openai",
body["type"].as_str().map(str::to_owned),
body.clone(),
)
.unwrap(),
)
.with_client_scope(
ClientApiKeyId::new("key_observer_example").unwrap(),
Vec::new(),
)
.with_requested_model(PublicModelId::new(model).unwrap())
}
fn dispatch(
runtime: &PluginRuntime,
generation: &ExtensionSetReference,
revision: u64,
sequence: u64,
model: &str,
body: &Value,
) {
runtime
.observer_registry()
.resolve(generation)
.unwrap()
.dispatch_websocket_response(
generation.clone(),
observation(revision, sequence, model, body),
);
}
/// 必须单独按名称显式执行;真实包缺失时报错,不将未运行的依赖场景算作通过。
#[tokio::test]
#[ignore = "requires two packaged standalone observer examples and an isolated log subscriber"]
async fn actual_observer_examples_share_runtime_contract_with_separate_permissions_and_generations()
{
let capture = Capture::default();
tracing::subscriber::set_global_default(capture.clone()).unwrap();
let cache = tempfile::tempdir().unwrap();
let (store, runtime) = runtime(cache.path()).await;
let first = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let body = json!({"type":"response.output_text.delta","delta":"fixture-private-payload 中文","unknown":true});
let bytes = serde_json::to_vec(&body).unwrap().len();
let text_bytes = body["delta"].as_str().unwrap().len();
dispatch(&runtime, &first, 1, 1, "gpt-observed", &body);
capture.wait_for(2).await;
dispatch(&runtime, &first, 1, 2, "other-model", &body);
dispatch(&runtime, &first, 1, 9, "gpt-observed", &body);
capture.wait_for(4).await;
capture.assert_summary(
"example-ws-event-counter",
1,
json!({"events":2,"first_events":1,"maximum_sequence":9}),
);
capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":2,"payload_bytes":bytes*2,"text_delta_bytes":text_bytes*2,"terminal_events":0}));
{
let mut snapshot = store.snapshot.lock().unwrap();
snapshot.config_revision = Revision::new(2).unwrap();
for instance in &mut snapshot.instances {
instance.revision = Revision::new(2).unwrap();
}
}
let second = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap())
.await
.unwrap();
dispatch(&runtime, &second, 2, 1, "gpt-observed", &body);
dispatch(&runtime, &first, 1, 10, "gpt-observed", &body);
capture.wait_for(8).await;
capture.assert_summary(
"example-ws-event-counter",
2,
json!({"events":1,"first_events":1,"maximum_sequence":1}),
);
capture.assert_summary(
"example-ws-event-counter",
1,
json!({"events":3,"first_events":1,"maximum_sequence":10}),
);
capture.assert_summary("example-ws-payload-metrics", 2, json!({"delivered_events":1,"payload_bytes":bytes,"text_delta_bytes":text_bytes,"terminal_events":0}));
capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":3,"payload_bytes":bytes*3,"text_delta_bytes":text_bytes*3,"terminal_events":0}));
assert_eq!(capture.0.lock().unwrap().len(), 8);
runtime.shutdown().await;
drop(first);
drop(second);
super::wait_until_empty(cache.path()).await;
}
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeMap, BTreeSet},
collections::{BTreeMap, BTreeSet, VecDeque},
num::{NonZeroU32, NonZeroUsize},
sync::{
Arc,
@@ -97,6 +97,7 @@ async fn setup(
manifest_permissions: Vec<Permission>,
) -> (tempfile::TempDir, PluginRuntime) {
let contributes = Contributions::from([
crate::support::contribution(Capability::RetryPolicy, vec![Stage::Retry], vec![], vec![]),
crate::support::contribution(
Capability::ModelRouter,
vec![Stage::Routing],
@@ -227,6 +228,124 @@ fn operation() -> Operation {
))
}
#[tokio::test]
async fn retry_policy_uses_ordered_delegation_and_rejects_disallowed_actions() {
use gateway_core::{
engine::policy::{RetryDecision, RetryFacts},
error::ProviderErrorKind,
upstream::UpstreamSendState,
};
for (reply, allowed, expected) in [
(
serde_json::json!({"decision":"delegate"}),
true,
RetryDecision::Stop,
),
(
serde_json::json!({"decision":"retry"}),
true,
RetryDecision::Retry,
),
(
serde_json::json!({"decision":"retry"}),
false,
RetryDecision::Stop,
),
(
serde_json::json!({"decision":"retry","account_id":"acct_forbidden"}),
true,
RetryDecision::Stop,
),
] {
let records = tempfile::tempdir().unwrap();
let marker = records.path().join("retry.jsonl");
let (_cache, runtime) = setup(vec![
InstanceFixture { id: "retry-first", configuration: serde_json::json!({"retry_decision":reply,"retry_marker":marker}), grants: vec![],
bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)] },
InstanceFixture { id: "retry-second", configuration: serde_json::json!({"retry_decision":{"decision":"stop"}}), grants: vec![],
bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)] },
], vec![]).await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry");
let decision = context
.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_secs(3),
retry_allowed: allowed,
})
.await;
assert_eq!(decision, expected);
let facts = marker_lines(&marker, 1).await.remove(0);
assert_eq!(
facts["allowed_actions"],
if allowed {
serde_json::json!(["stop", "retry"])
} else {
serde_json::json!(["stop"])
}
);
assert_eq!(facts["error_kind"], "rate_limited");
assert!(facts.get("account_id").is_none());
runtime.shutdown().await;
}
}
#[tokio::test]
async fn retry_policy_timeout_exhausts_the_chain_budget_and_delegates() {
use gateway_core::{
engine::policy::{RetryDecision, RetryFacts},
error::ProviderErrorKind,
upstream::UpstreamSendState,
};
let records = tempfile::tempdir().unwrap();
let second_marker = records.path().join("second.jsonl");
let (_cache, runtime) = setup(
vec![
InstanceFixture {
id: "retry-delayed",
configuration: serde_json::json!({"retry_delay_ms":500,"retry_decision":{"decision":"stop"}}),
grants: vec![],
bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)],
},
InstanceFixture {
id: "retry-next",
configuration: serde_json::json!({"retry_marker":second_marker,"retry_decision":{"decision":"stop"}}),
grants: vec![],
bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)],
},
],
vec![],
).await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout");
let decision = tokio::time::timeout(
Duration::from_secs(2),
context.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_millis(100),
retry_allowed: true,
}),
)
.await
.expect("the shared deadline bounds the whole retry chain");
assert_eq!(decision, RetryDecision::Delegate);
assert!(
!second_marker.exists(),
"later policies cannot restart an exhausted budget"
);
runtime.shutdown().await;
}
async fn marker_lines(path: &std::path::Path, count: usize) -> Vec<serde_json::Value> {
tokio::time::timeout(Duration::from_secs(3), async {
loop {
@@ -1055,6 +1174,270 @@ async fn instances_without_data_plane_bindings_publish_neither_plan() {
super::wait_until_empty(cache.path()).await;
}
struct TransparencyNext {
expected: MiddlewareRequest,
headers: Vec<MiddlewareHeader>,
frames: VecDeque<MiddlewareFrame>,
}
struct CapabilitiesNext(Arc<AtomicUsize>);
impl MiddlewareNext for CapabilitiesNext {
fn run(
self: Box<Self>,
request: MiddlewareRequest,
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
Box::pin(async move {
self.0.fetch_add(1, Ordering::SeqCst);
let body = serde_json::from_slice(request.body()).unwrap();
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body).unwrap(),
));
let operation = request.apply_capabilities(operation)?;
assert!(
operation
.original_capability_requirements()
.features()
.contains(&gateway_core::operation::Feature::JsonSchema)
);
assert!(
!operation
.capability_requirements()
.features()
.contains(&gateway_core::operation::Feature::JsonSchema)
);
Ok(MiddlewareResponse::new(
"openai".into(),
200,
vec![],
Box::new(TransparencyBody(VecDeque::from([MiddlewareFrame::new(
Bytes::from_static(br#"{"output":[]}"#),
MiddlewareFraming::JsonDocument,
true,
)]))),
))
})
}
}
#[tokio::test]
async fn sdk_capability_declarations_require_v2_authorization_and_request_stage() {
for (version, authorized, attempt) in [
(2, true, false),
(1, true, false),
(2, false, false),
(2, true, true),
] {
let stage = if attempt {
Stage::Attempt
} else {
Stage::Request
};
let (capability, mut contribution) = crate::support::contribution(
Capability::Middleware,
vec![stage],
vec!["openai".into()],
vec!["openai".into()],
);
contribution.version = version;
let permissions = if authorized {
vec![Permission::Requests]
} else {
vec![]
};
let package = crate::support::package_with_contributions(
&std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(),
permissions.clone(),
Contributions::from([(capability, contribution)]),
);
let (_cache, runtime) = setup_package(vec![InstanceFixture {
id: "capability-declaration",
configuration: serde_json::json!({"mode":"declare","middleware_version":version,"attempt":attempt,
"body":{"model":"gpt-test","input":"return JSON matching the original schema"},
"capabilities":{"handled":["json_schema"],"required":[]}}),
grants: permissions.into_iter().map(grant).collect(),
bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, if attempt { "attempt" } else { "request" }, 0, PluginFailurePolicy::Reject)],
}], package).await;
let generation = prepare(&runtime).await;
let plan = runtime.middleware_registry().resolve(&generation).unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let context = if attempt {
attempt_middleware_context(Arc::default())
} else {
middleware_context(ClientTransport::HttpJson)
};
let result = plan.handle(context, MiddlewareRequest::new("openai", vec![], Bytes::from_static(
br#"{"model":"gpt-test","input":"answer","text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}"#,
)), Box::new(CapabilitiesNext(calls.clone()))).await;
if version == 2 && authorized && !attempt {
let mut body = result.unwrap().into_parts().3;
assert!(body.next_frame().await.unwrap().unwrap().terminal());
assert!(body.next_frame().await.unwrap().is_none());
body.close().await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
} else {
assert!(result.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
runtime.shutdown().await;
}
}
impl MiddlewareNext for TransparencyNext {
fn run(
self: Box<Self>,
request: MiddlewareRequest,
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
Box::pin(async move {
assert_eq!(request.protocol(), self.expected.protocol());
assert_eq!(request.body(), self.expected.body());
assert_eq!(request.headers(), self.expected.headers());
Ok(MiddlewareResponse::new(
"openai".into(),
200,
self.headers,
Box::new(TransparencyBody(self.frames)),
))
})
}
}
struct TransparencyBody(VecDeque<MiddlewareFrame>);
impl MiddlewareBody for TransparencyBody {
fn next_frame(&mut self) -> BoxFuture<'_, Result<Option<MiddlewareFrame>, MiddlewareError>> {
Box::pin(async move { Ok(self.0.pop_front()) })
}
fn close(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async {})
}
}
async fn assert_sdk_transparency(mode: &'static str, authorized: bool) {
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let permissions = if authorized {
vec![Permission::Requests]
} else {
vec![]
};
let package = crate::support::package_with_contributions(
&worker,
permissions.clone(),
Contributions::from([crate::support::contribution(
Capability::Middleware,
vec![Stage::Request],
vec!["openai".into()],
vec!["openai".into()],
)]),
);
let (cache, runtime) = setup_package(
vec![InstanceFixture {
id: "transparency",
configuration: serde_json::json!({"mode": mode}),
grants: permissions.into_iter().map(grant).collect(),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
"request",
0,
PluginFailurePolicy::Reject,
)],
}],
package,
)
.await;
let generation = prepare(&runtime).await;
let plan = runtime.middleware_registry().resolve(&generation).unwrap();
let mut headers = middleware_headers();
headers.extend([
MiddlewareHeader::new("X-Repeated", Bytes::from_static(b"first")),
MiddlewareHeader::new("X-Repeated", Bytes::from_static(b"second")),
MiddlewareHeader::new("x-binary", Bytes::from_static(b"\x80\xff")),
]);
let response_headers = vec![
MiddlewareHeader::new("x-repeated", Bytes::from_static(b"first")),
MiddlewareHeader::new("x-repeated", Bytes::from_static(b"second")),
MiddlewareHeader::new("set-cookie", Bytes::from_static(b"test-hidden=kept")),
MiddlewareHeader::new("x-binary", Bytes::from_static(b"\x80\xff")),
];
let json = Bytes::from_static(
br#" { "z": 18446744073709551615, "unknown": { "x": 1e2 }, "a": "\u0061" } "#,
);
let cases = [
(ClientTransport::HttpJson, MiddlewareFraming::JsonDocument, vec![json.clone()]),
(ClientTransport::HttpSse, MiddlewareFraming::SseEvent, vec![
Bytes::from_static(b": keep-alive\r\nevent: response.created\r\ndata: { \"z\": 1, \"a\": 2 }\r\n\r\n"),
Bytes::from_static(b"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{}}\n\n"),
]),
];
for (transport, framing, payloads) in cases {
for request_body in [json.clone(), Bytes::new()] {
let request = MiddlewareRequest::new("openai", headers.clone(), request_body.clone());
let mut expected_headers = headers.clone();
let mut expected_response_headers = response_headers.clone();
if mode == "headers" {
expected_headers.push(MiddlewareHeader::new(
"x-sdk-request",
Bytes::from_static(b"active"),
));
expected_response_headers.push(MiddlewareHeader::new(
"x-sdk-response",
Bytes::from_static(b"active"),
));
}
let next = TransparencyNext {
expected: MiddlewareRequest::new("openai", expected_headers, request_body),
headers: response_headers.clone(),
frames: payloads
.iter()
.enumerate()
.map(|(index, payload)| {
MiddlewareFrame::new(payload.clone(), framing, index + 1 == payloads.len())
})
.collect(),
};
let response = plan
.handle(middleware_context(transport), request, Box::new(next))
.await
.unwrap();
let (protocol, status, actual_headers, mut body, _) = response.into_parts();
assert_eq!(protocol, "openai");
assert_eq!(status, 200);
assert_eq!(actual_headers, expected_response_headers);
for (index, expected) in payloads.iter().enumerate() {
let frame = body.next_frame().await.unwrap().unwrap();
assert!(
!frame.transformed(),
"read-only inspection must retain provenance"
);
assert_eq!(frame.terminal(), index + 1 == payloads.len());
assert_eq!(frame.into_bytes(), *expected);
}
assert!(body.next_frame().await.unwrap().is_none());
body.close().await;
}
}
drop(plan);
drop(generation);
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn sdk_passthrough_preserves_original_bytes_and_hidden_headers() {
assert_sdk_transparency("passthrough", true).await;
assert_sdk_transparency("passthrough", false).await;
}
#[tokio::test]
async fn sdk_read_only_inspection_preserves_original_bytes_and_event_order() {
assert_sdk_transparency("inspect", true).await;
}
#[tokio::test]
async fn sdk_header_mutations_preserve_original_body_and_other_headers() {
assert_sdk_transparency("headers", true).await;
}
#[tokio::test]
async fn a_failed_plugin_rejects_only_its_bound_models_and_preserves_delegate_policy() {
for failure_policy in [PluginFailurePolicy::Reject, PluginFailurePolicy::Delegate] {
@@ -1149,3 +1532,86 @@ async fn a_failed_middleware_preserves_scope_and_the_configured_failure_policy()
super::wait_until_empty(cache.path()).await;
}
}
struct BodyCheckingNext {
calls: Arc<AtomicUsize>,
expected: Bytes,
}
impl MiddlewareNext for BodyCheckingNext {
fn run(
self: Box<Self>,
request: MiddlewareRequest,
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
Box::pin(async move {
assert!(
request.body() == &self.expected,
"request body changed unexpectedly"
);
self.calls.fetch_add(1, Ordering::Relaxed);
Ok(MiddlewareResponse::new(
"openai".to_owned(),
200,
vec![],
Box::new(OneFrameBody {
reads: Arc::default(),
closes: Arc::default(),
frame: Some(MiddlewareFrame::new(
Bytes::from_static(b"{}"),
MiddlewareFraming::JsonDocument,
true,
)),
}),
))
})
}
}
#[tokio::test]
async fn middleware_preserves_large_body_and_following_requests() {
let (cache, runtime) = setup(
vec![InstanceFixture {
id: "large-body",
configuration: serde_json::json!({}),
grants: vec![grant(Permission::Requests)],
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
"request",
0,
PluginFailurePolicy::Reject,
)],
}],
vec![Permission::Requests],
)
.await;
let generation = prepare(&runtime).await;
let plan = runtime.middleware_registry().resolve(&generation).unwrap();
for length in [4 * 1024 * 1024 + 17, 32] {
let payload = Bytes::from(
(0..length)
.map(|index| (index % 251) as u8)
.collect::<Vec<_>>(),
);
let calls = Arc::new(AtomicUsize::new(0));
let response = plan
.handle(
middleware_context(ClientTransport::HttpSse),
MiddlewareRequest::new("openai", vec![], payload.clone()),
Box::new(BodyCheckingNext {
calls: Arc::clone(&calls),
expected: payload,
}),
)
.await
.unwrap();
let (_, status, _, mut body, _) = response.into_parts();
assert_eq!(status, 200);
assert_eq!(calls.load(Ordering::Relaxed), 1);
assert!(body.next_frame().await.unwrap().is_some());
assert!(body.next_frame().await.unwrap().is_none());
body.close().await;
}
drop(plan);
drop(generation);
super::wait_until_empty(cache.path()).await;
}
@@ -27,6 +27,120 @@ fn startup_count(marker: &Path) -> usize {
.unwrap_or_default()
}
#[tokio::test]
async fn model_catalog_is_frozen_without_entering_the_request_chain() {
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::ModelCatalog,
vec![Stage::Registration],
vec![],
vec![],
)]))
.await;
store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({
"model_catalog": {"models": [{"id": "my-model", "provider": "openai", "model": "upstream-model"}]}
});
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
assert_eq!(generation.model_aliases().len(), 1);
assert_eq!(generation.model_aliases()[0].id.as_str(), "my-model");
assert_eq!(
generation.model_aliases()[0].target.as_str(),
"upstream-model"
);
assert!(runtime.policy_registry().resolve(&generation).is_none());
assert!(runtime.middleware_registry().resolve(&generation).is_none());
let reused = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
assert_eq!(reused.id(), generation.id());
assert_eq!(reused.model_aliases(), generation.model_aliases());
{
let mut snapshot = store.snapshot.lock().unwrap();
snapshot.config_revision = Revision::new(2).unwrap();
snapshot.instances[0].revision = Revision::new(2).unwrap();
snapshot.instances[0].configuration = serde_json::json!({"startup":"fail"});
}
assert!(
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap())
.await
.is_err(),
"failed recovery must not publish a catalog with silently removed aliases"
);
assert!(generation.is_ready());
assert_eq!(generation.model_aliases()[0].id.as_str(), "my-model");
{
let mut snapshot = store.snapshot.lock().unwrap();
snapshot.config_revision = Revision::new(2).unwrap();
snapshot.instances[0].enabled = false;
}
let disabled = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap())
.await
.unwrap();
assert!(disabled.model_aliases().is_empty());
assert_eq!(generation.model_aliases().len(), 1);
drop(disabled);
drop(reused);
drop(generation);
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn model_catalog_rejects_invalid_registrations_and_cross_instance_conflicts() {
for models in [
serde_json::json!([{"id":"alias","provider":"custom","model":"target"}]),
serde_json::json!([{"id":"alias","provider":"openai","model":"alias"}]),
serde_json::json!([
{"id":"alias","provider":"openai","model":"target"},
{"id":"alias","provider":"xai","model":"target"}
]),
serde_json::json!([{"id":"alias","provider":"openai","model":"target","capabilities":[]}]),
] {
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::ModelCatalog,
vec![Stage::Registration],
vec![],
vec![],
)]))
.await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration =
serde_json::json!({"model_catalog":{"models":models}});
assert!(
PluginPreparation::prepare(&runtime, snapshot)
.await
.is_err()
);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::ModelCatalog,
vec![Stage::Registration],
vec![],
vec![],
)]))
.await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"model_catalog":{"models":[{"id":"alias","provider":"openai","model":"target"}]}
});
let mut other = snapshot.instances[0].clone();
other.id = "instance-two".into();
snapshot.instances.push(other);
let error = PluginPreparation::prepare(&runtime, snapshot)
.await
.unwrap_err();
assert!(error.to_string().contains("instance-one"));
assert!(error.to_string().contains("instance-two"));
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn runtime_shutdown_reaps_live_generation_and_rejects_late_preparation() {
let (cache, store, runtime) = super::setup().await;
@@ -75,6 +75,8 @@ fn assert_exhaustive_capability(capability: Capability) {
Capability::FrontendAuthentication
| Capability::Scheduler
| Capability::ModelRouter
| Capability::ModelCatalog
| Capability::RetryPolicy
| Capability::Middleware
| Capability::RequestLifecycle
| Capability::WebSocketObserver
@@ -90,15 +92,18 @@ fn assert_exhaustive_permission(permission: Permission) {
| Permission::Requests
| Permission::Models
| Permission::Accounts
| Permission::Data
| Permission::PublicEndpoints => {}
}
}
fn all_capabilities() -> [Capability; 9] {
fn all_capabilities() -> [Capability; 11] {
[
Capability::FrontendAuthentication,
Capability::Scheduler,
Capability::ModelRouter,
Capability::ModelCatalog,
Capability::RetryPolicy,
Capability::Middleware,
Capability::RequestLifecycle,
Capability::WebSocketObserver,
@@ -59,7 +59,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec<u8>) -> std::sync
]);
let resources = BTreeMap::from([(path.to_owned(), content_type.to_owned())]);
let manifest = serde_json::json!({
"manifestVersion": 3,
"manifestVersion": 1,
"name": "icon",
"displayName": "Icon",
"publisher": "test",
@@ -73,7 +73,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec<u8>) -> std::sync
"resources": resources,
"icon": path,
"package": {
"protocolVersion": 4,
"protocolVersion": 1,
"target": {
"os": std::env::consts::OS,
"architecture": std::env::consts::ARCH
@@ -278,7 +278,7 @@ fn icon_content_must_fully_decode_match_its_type_and_fit_dimensions() {
#[test]
fn obsolete_manifest_fields_are_rejected_without_aliases() {
let manifest = serde_json::json!({
"manifestVersion": 3,
"manifestVersion": 1,
"name": "example",
"displayName": "Example",
"publisher": "test",
@@ -303,7 +303,7 @@ fn obsolete_manifest_fields_are_rejected_without_aliases() {
#[test]
fn source_manifest_cannot_be_installed_without_package_metadata() {
let manifest = serde_json::json!({
"manifestVersion": 3,
"manifestVersion": 1,
"name": "example",
"displayName": "Example",
"publisher": "test",
@@ -330,7 +330,7 @@ fn source_manifest_cannot_be_installed_without_package_metadata() {
#[test]
fn duplicate_contribution_keys_are_rejected_before_map_overwrite() {
let manifest = br#"{
"manifestVersion":3,
"manifestVersion":1,
"name":"example",
"displayName":"Example",
"publisher":"test",
@@ -234,3 +234,58 @@ async fn execution_allows_each_declared_callback_permission() {
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn data_callbacks_require_independent_grants_and_management_or_command_stage() {
for permissions in [vec![Permission::Accounts], vec![Permission::Data]] {
let authorized = permissions.contains(&Permission::Data);
let callbacks = Arc::new(Callbacks::default());
let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), permissions).await;
for stage in [
Stage::Registration,
Stage::Configuration,
Stage::Routing,
Stage::Scheduling,
Stage::Request,
Stage::Attempt,
Stage::Retry,
Stage::Observation,
Stage::Authentication,
Stage::PublicManagement,
Stage::Management,
Stage::CommandLine,
] {
for method in ["host.data.accounts.list", "host.data.quota.get"] {
let reply = invoke_callback(&session, stage, method).await;
if authorized && matches!(stage, Stage::Management | Stage::CommandLine) {
assert!(reply.is_ok());
} else {
assert_permission_denied(reply);
}
}
}
assert_eq!(
callbacks.called.load(Ordering::Relaxed),
if authorized { 4 } else { 0 }
);
session.shutdown(Duration::from_secs(1)).await;
}
}
#[tokio::test]
async fn retry_stage_allows_only_logs_and_private_state() {
let callbacks = Arc::new(Callbacks::default());
let (_cache, session) = session(Arc::clone(&callbacks)).await;
for (method, _) in CALLBACK_PERMISSIONS {
assert_permission_denied(invoke_callback(&session, Stage::Retry, method).await);
}
for method in ["host.log", "host.state.get"] {
assert!(
invoke_callback(&session, Stage::Retry, method)
.await
.is_ok()
);
}
assert_eq!(callbacks.called.load(Ordering::Relaxed), 2);
session.shutdown(Duration::from_secs(1)).await;
}
@@ -32,6 +32,28 @@ struct LifecycleCallbacks {
finished: AtomicUsize,
}
struct OversizedReplyCallbacks;
impl CallbackHandler for OversizedReplyCallbacks {
fn call(
&self,
_context: CallContext,
_method: String,
params: serde_json::Value,
_payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
Box::pin(async move {
if params["error"] == true {
return Err(PluginFault::new(ErrorCode::Fault, "x".repeat(64 * 1024)));
}
Ok(RpcReply {
result: json!({"text": "x".repeat(64 * 1024)}),
payload: Vec::new(),
})
})
}
}
struct CallbackDrop(Arc<std::sync::atomic::AtomicBool>);
#[derive(Default)]
@@ -361,6 +383,106 @@ async fn slow_management_call_does_not_block_an_independent_call() {
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn large_binary_payload_round_trips_through_calls_and_callbacks() {
let callbacks = Arc::new(Callbacks::default());
let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await;
let payload = (0..4 * 1024 * 1024 + 17)
.map(|index| (index % 251) as u8)
.collect::<Vec<_>>();
for method in ["echo", "callback"] {
let reply = session
.call(
method,
session.context(Stage::Request, Duration::from_secs(30)),
json!({"large": true}),
payload.clone(),
)
.await
.unwrap();
assert_eq!(reply.result, json!({"large": true}));
assert_eq!(reply.payload, payload);
}
assert_eq!(callbacks.called.load(Ordering::Relaxed), 1);
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn oversized_call_frames_do_not_close_the_session_or_begin_callbacks() {
let callbacks = Arc::new(LifecycleCallbacks::default());
let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await;
let cases = [(json!({"text": "x".repeat(64 * 1024)}), vec![])];
for (params, payload) in cases {
let begun = callbacks.begun.load(Ordering::Relaxed);
let error = session
.call(
"echo",
session.context(Stage::Request, Duration::from_secs(2)),
params.clone(),
payload.clone(),
)
.await
.err()
.expect("oversized call must fail");
assert_eq!(error, RpcError::Context);
assert!(matches!(
session
.call_stream(
"stream",
session.context(Stage::Request, Duration::from_secs(2)),
params,
payload,
)
.await,
Err(RpcError::Context)
));
assert_eq!(callbacks.begun.load(Ordering::Relaxed), begun);
assert!(session.is_ready());
let reply = session
.call(
"echo",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"still": "ready"}),
vec![1, 2, 3],
)
.await
.unwrap();
assert_eq!(reply.result, json!({"still": "ready"}));
assert_eq!(reply.payload, [1, 2, 3]);
}
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn oversized_callback_metadata_only_fails_its_parent_call() {
let (_cache, session) = session(vec![], Arc::new(OversizedReplyCallbacks)).await;
for error in [false, true] {
assert!(matches!(
session
.call(
"callback",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"error": error}),
vec![],
)
.await,
Err(RpcError::Remote(_))
));
assert!(session.is_ready());
let reply = session
.call(
"echo",
session.context(Stage::Request, Duration::from_secs(2)),
json!({"still": "ready"}),
vec![1, 2, 3],
)
.await
.unwrap();
assert_eq!(reply.payload, [1, 2, 3]);
}
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_calls_keep_their_wire_ids_monotonic() {
let callbacks = Arc::new(GatedFirstBegin::default());
@@ -732,7 +732,11 @@ impl SystemOperations for UnusedAdminRuntime {
Err(unused_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(unused_system())
}
@@ -743,6 +747,7 @@ impl SystemOperations for UnusedAdminRuntime {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unused_system())
@@ -5,34 +5,77 @@ use gateway_plugin_sdk::{
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let session = PluginSession::accept(
tokio::io::stdin(),
tokio::io::stdout(),
SessionConfig::default(),
)
.await?;
let mode = session.handshake().configuration["mode"]
.as_str()
.unwrap_or("map")
.to_owned();
let configuration = session.handshake().configuration.clone();
let plugin = MiddlewarePlugin::new(
&Contributions::from([(
Capability::Middleware,
ContributionDeclaration {
id: "test.example.middleware".into(),
version: 1,
stages: vec![Stage::Request],
version: configuration["middleware_version"]
.as_u64()
.unwrap_or(1)
.try_into()?,
stages: vec![if configuration["attempt"] == true {
Stage::Attempt
} else {
Stage::Request
}],
input_formats: vec!["openai".into()],
output_formats: vec!["openai".into()],
},
)]),
|call: MiddlewareCall| async move {
let mut response = call.next.run(call.request).await?;
response.append_header("x-sdk-plugin", b"active".to_vec());
response.body = response.body.map_frames(|mut frame| {
frame.payload.push(b' ');
Ok(vec![frame])
})?;
Ok(response)
move |mut call: MiddlewareCall| {
let mode = mode.clone();
let configuration = configuration.clone();
async move {
if mode == "declare" {
call.request
.replace_body(serde_json::to_vec(&configuration["body"]).unwrap());
call.request.declare_capabilities(
serde_json::from_value(configuration["capabilities"].clone()).unwrap(),
);
}
if mode == "headers" {
call.request
.append_header("x-sdk-request", b"active".to_vec());
}
if mode == "inspect" && !call.request.body.is_empty() {
// 读取和解析只能作用于副本,不能把解析结果写回未改写的正文。
assert!(
serde_json::from_slice::<serde_json::Value>(&call.request.body).is_ok()
);
}
let mut response = call.next.run(call.request).await?;
match mode.as_str() {
"passthrough" | "declare" => {}
"inspect" => {
response.body = response.body.inspect_frames(|frame| {
assert!(!frame.payload.is_empty());
})?;
}
"headers" => response.append_header("x-sdk-response", b"active".to_vec()),
_ => {
response.append_header("x-sdk-plugin", b"active".to_vec());
response.body = response.body.map_frames(|mut frame| {
frame.payload.push(b' ');
Ok(vec![frame])
})?;
}
}
Ok(response)
}
},
)?;
PluginSession::accept(
tokio::io::stdin(),
tokio::io::stdout(),
SessionConfig::default(),
)
.await?
.run(plugin)
.await?;
session.run(plugin).await?;
Ok(())
}
@@ -42,6 +42,8 @@ pub fn contribution_for_id(
Capability::FrontendAuthentication => "frontendAuthentication",
Capability::Scheduler => "scheduler",
Capability::ModelRouter => "modelRouter",
Capability::ModelCatalog => "modelCatalog",
Capability::RetryPolicy => "retryPolicy",
Capability::Middleware => "middleware",
Capability::RequestLifecycle => "requestLifecycle",
Capability::WebSocketObserver => "webSocketObserver",
@@ -297,6 +297,19 @@ impl Peer {
return;
}
match method.as_str() {
"policy.retry_decision" => {
self.append_observation_marker("retry_marker", &params);
if let Some(delay) = self.configuration["retry_delay_ms"].as_u64() {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
let result = self
.configuration
.get("retry_decision")
.cloned()
.unwrap_or_else(|| json!({"decision":"delegate"}));
self.send(Message::Result { id, result }, vec![]).await;
return;
}
"frontend_auth.identifier" => {
self.send(
Message::Result {
@@ -348,6 +361,17 @@ impl Peer {
.await;
return;
}
"model_catalog.register" => {
self.send(
Message::Result {
id,
result: json!({}),
},
serde_json::to_vec(&self.configuration["model_catalog"]).unwrap(),
)
.await;
return;
}
"management.register" => {
self.send(
Message::Result {
@@ -366,6 +390,35 @@ impl Peer {
"management_marker",
&json!({"method":request.method,"path":request.path}),
);
if let Some(queries) = self.configuration["data_queries"].as_array() {
let mut results = vec![];
for query in queries {
let result = self
.callback_payload(
id,
query["method"].as_str().unwrap(),
json!({}),
serde_json::to_vec(&query["query"]).unwrap(),
)
.await;
results.push(match result {
Ok((metadata, payload)) => {
assert_eq!(metadata, json!({}));
serde_json::from_slice::<Value>(&payload).unwrap()
}
Err(error) => json!({"error":error.code}),
});
}
self.send(
Message::Result {
id,
result: json!({"status":200,"content_type":"application/json"}),
},
serde_json::to_vec(&results).unwrap(),
)
.await;
return;
}
if let Err(error) = self
.run_nested_model_fixture(
id,
@@ -679,6 +732,7 @@ impl Peer {
protocol: None,
header_mutations: Vec::new(),
body: MiddlewareRequestBody::Preserve,
capabilities: None,
})
.unwrap(),
vec![],
@@ -1026,10 +1080,10 @@ async fn main() {
tokio::spawn(async move {
let mut output = tokio::io::stdout();
while let Some(frame) = frames.recv().await {
write_frame(&mut output, &frame, 1024 * 1024).await.unwrap();
write_frame(&mut output, &frame).await.unwrap();
}
});
let hello = read_frame(&mut input, 1024 * 1024).await.unwrap();
let hello = read_frame(&mut input).await.unwrap();
let Message::Hello { mut handshake } = hello.message else {
panic!("expected handshake")
};
@@ -1115,7 +1169,7 @@ async fn main() {
let mut tasks = BTreeMap::new();
let mut uncancellable = std::collections::BTreeSet::new();
let mut last_call = 0;
while let Ok(frame) = read_frame(&mut input, 1024 * 1024).await {
while let Ok(frame) = read_frame(&mut input).await {
match frame.message {
Message::Call {
id,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gateway-plugin-sdk"
version = "0.4.0"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
+16 -11
View File
@@ -6,16 +6,16 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声
| 版本 | 当前值 | 用途 |
| --- | --- | --- |
| SDK 包 | `0.4.0` | Rust 开发依赖,独立于网关版本 |
| `manifestVersion` | `3` | 安装清单格式 |
| `package.protocolVersion` | `4` | 宿主与插件的进程通信 |
| SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 |
| `manifestVersion` | `1` | 安装清单格式 |
| `package.protocolVersion` | `1` | 宿主与插件的进程通信 |
| `contributes.*.version` | `1` | 单项能力合同 |
开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md)
打包 → [安装、配置与启用](../../../../docs/plugins.md)。完整可运行示例位于独立仓库
`codex-proxy-plugins` 的 `examples/workbench`。
使用 AI 协作开发时,可调用仓库技能 [`$plugin-dev`](../../../../.agents/skills/plugin-dev/SKILL.md),按任务定位合同、示例和验证入口。
使用 AI 协作开发时,可调用仓库技能 [`$cpr-plugin-dev`](../../../../.agents/skills/cpr-plugin-dev/SKILL.md),按任务定位合同、示例和验证入口。
## 模块与依赖
@@ -25,7 +25,9 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声
| `call::frontend_authentication` | 数据面认证信封、认证器标识与外部 principal 结果 |
| `call::model` | 宿主模型调用的 canonical 事实、原生 wire 与有界事件编解码 |
| `call::middleware` | 洋葱中间件请求/响应 head、single-use next 与惰性正文 frame 合同 |
| `call::policy` | 模型路由、账号调度及终态用量观察合同 |
| `call::policy` | 模型路由、账号调度、受约束重试及终态用量观察合同 |
| `call::catalog` | 固定 Provider 的模型别名注册合同 |
| `call::data` | 最小账号事实与已有额度观测的只读合同 |
| `call::observation` | 实际上游 WebSocket 响应帧的只读观察合同 |
| `call::host` | 账号、私有状态、受管 HTTP、模型、亲和、日志及调用内流的宿主回调合同 |
| `call::management` | 管理 API、页面与资源声明、公开回调,以及 CLI 命令与待保存账号 |
@@ -49,8 +51,9 @@ use gateway_plugin_sdk::client::{read_frame, write_frame};
会话管理握手、有界并发、父调用关联、回调、流信用、取消、排空与关闭。
`ResponseStream::channel` 提供有界流生产入口;`PluginCall` 的宿主客户端只代表当前父调用,
不能用另一调用的 ID 扩大权限。插件程序自行选择运行时和启动入口,不会启动网关。
`SessionConfig` 默认帧上限与宿主默认值一致,为 1 MiB;部署调整宿主帧上限时,插件应同步配置,
当前握手不自动协商该限制。流式分块还必须满足宿主实际授予的信用窗口。
`SessionConfig.maximum_stream_chunk_bytes` 默认 1 MiB,只约束单个业务流分块;
流式响应还需满足宿主实际授予的信用窗口。普通调用与回调的正文不受此预算限制。
业务处理器仍接收完整正文,内存占用随正文增长。
## 通信边界
@@ -76,10 +79,12 @@ sequenceDiagram
### 帧与会话
每帧以两个大端 `u32` 分别声明 JSON 元数据和二进制载荷的长度,随后依次传输两部分。
调用方提供总长度上限;元数据另有 64 KiB 上限。读取在分配内存前校验长度,截断帧和
无效元数据返回 `FrameError`。这些函数不自动重试调用;部分读取或写入中断后,不应
把同一字节位置误当作新帧边界。
协议 v1 的帧头由大端 `u32` 元数据长度和大端 `u64` 正文长度组成,随后依次传输 JSON 元数据和原始二进制正文。
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付。
`read_frame` / `write_frame` 使用当前协议,不接受历史开发协议。帧写入队列之前先用 `validate_frame` 校验元数据,
避免本地输入错误关闭共享传输。协议校验不会自动重试业务调用;部分读写中断后不能把当前位置当作新帧边界。
底层 `read_frame` / `write_frame` 不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后
重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭。
@@ -46,6 +46,8 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能
| --- | --- |
| `middleware` | `PluginBuilder::middleware` / `middleware.handle` |
| `model_router` | `methods::ROUTE_MODEL` |
| `model_catalog` | `PluginBuilder::model_catalog` / `methods::MODEL_CATALOG_REGISTER` |
| `retry_policy` | `methods::RETRY_DECISION` |
| `scheduler` | `methods::SCHEDULE_ACCOUNT` |
| `request_lifecycle`、`usage` | `methods::OBSERVE_REQUEST`,同一实例的终态订阅合并调用 |
| `web_socket_observer` | `methods::OBSERVE_WEBSOCKET`,线方法为 `websocket.response_event` |
@@ -53,7 +55,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能
| `management` | `PluginBuilder::management`;公开回调另用 `methods::MANAGEMENT_CALLBACK` |
| `command_line` | `PluginBuilder::command_line` |
## 五个访问域
## 访问域
安装时接受清单声明的访问域,不存在另一套逐方法、逐用途或资源 ID 白名单。宿主仍会检查父调用、阶段、
期限、实例代次、资源归属,以及 Key、账号和 Provider 自身的业务规则。
@@ -63,6 +65,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能
| `network` | 通过 `host.http.*` 使用宿主受管出站网络;仍受统一代理、超时、大小和流控规则约束 |
| `models` | 列出非秘密 Key、按所选 Key 查询模型,以及通过 `host.model.*` 调用模型;调用可能产生消耗 |
| `accounts` | 查询账号、读取原始凭据及创建或替换账号;写入仍经过 revision CAS、审计和发布事务 |
| `data` | 在管理/命令阶段只读全部账号的最小基础信息及已有额度观测,不包含凭据、写入或预测 |
| `requests` | 参与请求/响应处理、路由、调度、观察及亲和查询 |
| `public_endpoints` | 提供无需登录即可访问的已声明静态资源或一次性票据回调 |
@@ -73,6 +76,37 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能
所有回调都通过当前 `TypedCall.host` 发起,并受父调用截止时间和取消信号约束。
### 基础事实
纯展示页面声明 `management` 和 `data` 即可,不需要请求处理 binding 或 `accounts` 权限。
`data` 是独立的管理员授权域,只允许 `management`、`command_line` 阶段使用;请求链、注册及公开回调均拒绝。
它可读取全部账号的下列最小事实,不继承或授予客户端 Key 的模型执行权。
| SDK 方法 | 回调 | 查询与结果 |
| --- | --- | --- |
| `call.host.account_facts(query)` | `host.data.accounts.list` | `AccountFactsQuery`:可选 `provider_id`、`cursor`,必填 `limit`(1~200);按账号 ID 升序,`next_cursor=null` 表示本页已结束 |
| `call.host.quota_facts(query)` | `host.data.quota.get` | `QuotaFactsQuery { account_id }`:读取 Provider 现有观测,不访问上游刷新 |
类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;结果固定 `schema_version=1`。
账号仅返回 `account_id`、`provider_id`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带姓名、邮箱、令牌或代理信息。
额度仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。
时间均为 UTC Unix 毫秒,比例为百分数;未知值保留 `null`,不能解释为 0。`observed_at_ms=null` 表示没有可用观测时间,
不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性。
```rust,ignore
use gateway_plugin_sdk::call::data::{AccountFactsQuery, QuotaFactsQuery};
let page = call.host.account_facts(AccountFactsQuery {
provider_id: Some("openai".into()), cursor: None, limit: 100,
}).await?;
for account in page.accounts {
let quota = call.host.quota_facts(QuotaFactsQuery { account_id: account.account_id }).await?;
// 插件自行解释样本的新鲜度并计算展示或预测结果。
}
```
已有 `usage` 观察提供请求最终用量、时间和结算事实;该投递有界、不是历史补偿接口。
本接口不提供 SQL、池汇总、健康分、额度预测或历史使用记录查询;插件结果保存在自身 `host.state.*` 中。
### 账号与凭据
`accounts` 域开放 `host.auth.list/get_runtime/get/save`:
@@ -127,9 +161,80 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能
`MiddlewareRequestBody::Preserve`,只有 `replace_body(Vec::new())` 才表示清空。Header 修改采用增量
remove/append,保留合法多值。`MiddlewareBody::map_frames` 复用会话的流控和唯一终态,不建立插件全局流表。
**不改写就 1:1 保留**:同一网关处理边界上,默认 `next.run(request)` 保留原始正文、未修改的 header 和响应流字节及顺序。
只解析 `request.body` 不会触发替换;SDK 比较原投影,宿主持有的隐藏 header 不会被脱敏视图覆盖。
只观察响应可使用 `response.body.inspect_frames(|frame| { /* 读取,不修改 */ })?`,保留来源信封、背压和取消语义。
这一保证比较插件前后的同一边界,Provider 自身的协议适配、两次调用的随机值和网络分段不在比较范围内。
需要声明功能转换时,在清单选择 `middleware.version=2`,普通 v1 插件仍可原样运行。
`request.declare_capabilities(CapabilityDeclaration { handled, required })` 随单次 `next` 发出,必须同时显式替换正文且具有 `requests` 权限。
当前仅允许 OpenAI 生成请求的 request 阶段,HTTP 与 WebSocket 共用规则;不扩展端点允许的协议或流模式。
`handled` 只允许列举改写前确实存在、改写后已从实际请求移除的 `tools`、`vision`、`reasoning`、`json_schema`,
插件须负责对应的提示词转换及普通响应、错误、流式响应还原。`required` 为额外上游需求,不能覆盖正文推导出的要求。
重复项、同一项同时出现在两组、空声明、v1 使用声明、attempt 阶段使用声明均会拒绝。
原始语义中未由插件承担的功能继续约束路由;实际正文重新加入某功能时,该需求仍生效。
原生续接不能由声明豁免,attempt 阶段的既有冻结与保护字段检查继续生效。
```rust,ignore
use gateway_plugin_sdk::call::middleware::{CapabilityDeclaration, RequestFeature};
// 将 JSON Schema 转为提示词前保留原 schema,响应返回时按它验证并还原。
request.replace_body(convert_schema_to_prompt(&request.body)?);
request.declare_capabilities(CapabilityDeclaration {
handled: vec![RequestFeature::JsonSchema], required: vec![],
});
let response = next.run(request).await?;
restore_and_validate_response(response).await
```
仅实现中间件时也可使用轻量的 `MiddlewarePlugin`;需要与管理或其他方法组合时使用
`PluginBuilder`,两者复用相同的 `MiddlewareCall` 和 `MiddlewareResponse`。
### 模型目录
`model_catalog` v1 在 registration 阶段一次提交 `ModelCatalogRegistration { models: Vec<ModelAlias> }`,不需要请求级 binding。
每条 `ModelAlias { id, provider, model }` 定义公开 ID、内置 Provider(`openai` 或 `xai`)及直接上游目标。
上游目标须已存在于该 Provider 的当前目录;同名原生模型、静态映射、其他插件条目、别名链和循环均拒绝发布。
每实例最多 256 条,整个二进制 JSON 最多 64 KiB;不允许覆盖原生元数据或自行宣称目标不支持的功能。
```rust,ignore
use gateway_plugin_sdk::call::catalog::{ModelAlias, ModelCatalogRegistration};
let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))?
.model_catalog(ModelCatalogRegistration { models: vec![ModelAlias {
id: "team-default".into(), provider: "openai".into(), model: "gpt-target".into(),
}] })?
.build()?;
```
有效目录、路由和目标能力随同一个不可变快照发布,`/v1/models`、详情、原生目录和 `host.models.list` 共用该事实及现有 Key/账号模型范围。
原生别名继承目标的完整可公开对象;更新失败保留旧快照,禁用后新请求不再使用该别名,在途请求持有旧代次。
v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同。
### 重试决策
`retry_policy` v1 固定绑定 `retry` 阶段,故障策略只能为 `delegate`。使用 `methods::RETRY_DECISION` 处理 `RetryDecisionRequest`。
输入包含失败分类、必要状态码、发送状态、attempt 序号、Provider/上游模型、剩余路由次数和期限,以及 `allowed_actions`。
不发送账号凭据、请求正文或上游错误转储。路由次数不等同于 Provider 自有传输恢复预算。
返回 `RetryDecision::Delegate`、`Stop` 或 `Retry`。`Retry` 必须出现在宿主允许动作中,并且仅继续宿主已经选定的恢复路径;
不能指定账号、延迟、目标或提高预算。响应已交付、发送不明确、外部副作用及续接绑定等既有安全门不可绕过。
Core 处理建立响应流前与流中的失败,并在策略返回后再次检查期限、取消和副作用。
```rust,ignore
use gateway_plugin_sdk::{client::TypedReply, call::policy::RetryDecision};
let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))?
.on(methods::RETRY_DECISION, |call| async move {
let decision = if call.request.upstream_status == Some(429) {
RetryDecision::Stop
} else { RetryDecision::Delegate };
Ok(TypedReply::new(decision))
})?
.build()?;
```
按 binding 顺序、插件 ID、实例 ID 确定委托链,首个合法非委托结果生效。整条链最多等待 2 秒,并受请求剩余期限约束。
退出、超时、未知字段及不允许的动作委托后续处理,最终回到宿主决定;故障不改写原始上游错误。
本阶段宿主只开放日志与插件私有状态回调,不允许额外网络、模型调用或账号访问。middleware 的 `next` 仍最多调用一次。
### 路由、调度与观察
`policy.route_model` 和 `policy.schedule_account` 只提出决定,宿主随后复核 Key 规则、模型能力、账号资格和租约。
@@ -40,12 +40,13 @@
}
```
默认扩展项 ID 为 `<publisher>.<name>.<capability-kebab>`,版本固定为 `1`。除 `middleware` 外,阶段由
默认扩展项 ID 为 `<publisher>.<name>.<capability-kebab>`,版本默认为 `1`;使用能力需求声明的 `middleware` 选择 `2`。除 `middleware` 外,阶段由
capability 固定并由工具生成:
| 阶段 | 能力 |
| --- | --- |
| `authentication` / `routing` / `scheduling` | `frontend_authentication` / `model_router` / `scheduler` |
| `registration` / `retry` | `model_catalog` / `retry_policy` |
| `observation` | `request_lifecycle`、`web_socket_observer`、`usage` |
| `management` / `command_line` | `management` / `command_line` |
@@ -54,19 +55,20 @@ capability 固定并由工具生成:
## 权限
权限只有五个稳定访问域:
权限使用以下稳定访问域:
| 标识 | 含义 |
| --- | --- |
| `network` | 使用宿主受管网络 |
| `models` | 查询非秘密 Key 与模型并调用模型,可能产生消耗 |
| `accounts` | 查询、读取原始凭据和修改账号 |
| `data` | 仅在管理与命令阶段只读全部账号的基础信息和已有额度观测 |
| `requests` | 查看和处理请求、响应、路由、调度与观察事实 |
| `public_endpoints` | 提供无需登录即可访问的资源或回调 |
安装时统一接受清单声明的域,不再填写逐方法、用途、Key、账号或 Provider 白名单。日志与清单声明的
本插件私有状态是基础设施,无需单独 permission。权限并不替代方法阶段、父调用、Key 规则、账号 revision、
资源归属和流生命周期校验,具体接口见[能力与回调](capabilities.md#五个访问域)。
资源归属和流生命周期校验,具体接口见[能力与回调](capabilities.md#访问域)。
## 插件图标

Some files were not shown because too many files have changed in this diff Show More