mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 00:06:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589e1bc999 | ||
|
|
2956f07723 | ||
|
|
bb76399e59 | ||
|
|
66a3474baf | ||
|
|
5e7196cfbf | ||
|
|
a340bec472 | ||
|
|
5782c71f73 | ||
|
|
999fcc1770 | ||
|
|
b920dcec83 | ||
|
|
21c0711a66 | ||
|
|
f770ba127d | ||
|
|
66719f22e2 | ||
|
|
00a1956df7 |
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库开发与问题审查入口。用户要求“开发/实现这个功能”“直接改”“修复一下”“看下报错”“审查这个问题/代码”“自审当前改动”“调整页面”“更新文档”,或继续修改本仓库的 Rust、Vue、协议、存储、部署和 CI 时主动使用,无需点名技能。负责定位、方案、自审与验证;提交或审查 PR 另配合 cpr-github-pr。Use for implementation, debugging, code review and documentation in this repository, not generic advice for unrelated projects.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
|
||||
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 开发独立网关插件 | [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [cpr-github-issue](../cpr-github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [cpr-release](../cpr-release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
|
||||
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
|
||||
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
|
||||
|
||||
## 后端任务
|
||||
|
||||
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
|
||||
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定或评估方案。结合上表读取所需文档,先找可复用的同类实现,再按授权修改或审查所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 复核相对目标基线的完整差异及上下游:开发时清理本次引入的职责越界、重复逻辑、冗余状态和无依据的分支;仅审查时报告问题与依据。页面与后端分别按上述任务入口执行,不等待 PR 审查代做开发自审。
|
||||
6. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
7. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。用户要求提交或更新 PR 时,进入 `cpr-github-pr` 流程整理最终证据与正文。
|
||||
|
||||
发布任务按 `cpr-release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-dev-guide"
|
||||
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
|
||||
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: github-issue
|
||||
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
|
||||
name: cpr-github-issue
|
||||
description: 整理、创建和补充 GitHub Issue。用户说“提/开/提交一个 Issue”“报个 bug 到仓库”“反馈给上游”“把问题或功能建议整理成 Issue”“补充这个 Issue”时主动使用;已有 Issue 反馈上下文中的“提交吧”“补上复现”也适用。处理缺陷、功能与协作规则反馈,区分草拟和发布。Use for drafting, filing and updating issues; standalone “审查问题/看下报错/修一下” belongs to cpr-dev-guide, not automatic Issue submission.
|
||||
---
|
||||
|
||||
# 整理与提交 Issue
|
||||
@@ -16,6 +16,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
|
||||
@@ -54,6 +55,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
|
||||
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
|
||||
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
|
||||
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-issue"
|
||||
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
|
||||
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: github-pr
|
||||
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
|
||||
name: cpr-github-pr
|
||||
description: 准备、提交、更新与审查 GitHub PR。用户说“提交 PR/提个 PR/发起拉取请求”“把改动放到这个 PR”“改下 PR 描述”“看下/review/审查这个 PR”“处理审查意见”“这个 PR 能合并吗”,或提供 /pull/ 链接、PR 编号并要求处理时主动使用;已有 PR 上下文中的“提交吧”“继续改”也适用。包含问题核实、自审、页面截图、AI 模型披露与验证。Use for pull request preparation, creation, updates and review; plain git commits or issue debugging without PR context do not create a PR.
|
||||
---
|
||||
|
||||
# 创建与审查 PR
|
||||
# 提交、更新与审查 PR
|
||||
|
||||
## 先确定模式和仓库
|
||||
|
||||
@@ -11,6 +11,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
|
||||
| --- | --- |
|
||||
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
|
||||
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
|
||||
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
|
||||
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
|
||||
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
|
||||
|
||||
@@ -27,6 +28,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
|
||||
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
|
||||
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
|
||||
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
|
||||
|
||||
## 创建和审查前的核对
|
||||
|
||||
@@ -46,7 +48,13 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
|
||||
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
|
||||
|
||||
## 创建 PR
|
||||
## 页面证据与 AI 披露
|
||||
|
||||
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
|
||||
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
|
||||
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
|
||||
|
||||
## 创建或更新 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
@@ -55,19 +63,19 @@ PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
|
||||
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
@@ -95,11 +103,12 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-pr"
|
||||
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: plugin-dev
|
||||
description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查和打包 Codex Proxy RS 网关插件。用户说“写/做一个网关插件”“给插件加能力或页面”“改 plugin.json”“接入公开 SDK”“插件不生效/接入报错”“打包插件”时主动使用,覆盖能力选择、SDK 处理器、管理页面和安装包验证。Use for gateway plugin authoring and debugging; host Runtime/installer changes use cpr-dev-guide, not this workflow, and OpenAI .codex-plugin or Vue plugins are outside its scope.
|
||||
---
|
||||
|
||||
# Codex Proxy 插件开发
|
||||
# cpr-plugin-dev
|
||||
|
||||
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
|
||||
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
|
||||
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
|
||||
|
||||
## 先建立正确的模型
|
||||
|
||||
@@ -25,7 +26,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
|
||||
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
|
||||
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
|
||||
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
|
||||
|
||||
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
|
||||
|
||||
@@ -35,6 +36,9 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
| --- | --- | --- |
|
||||
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
|
||||
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
|
||||
| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 |
|
||||
| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 |
|
||||
| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next |
|
||||
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
|
||||
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
|
||||
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
|
||||
@@ -50,6 +54,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
|
||||
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
|
||||
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
|
||||
@@ -59,13 +64,14 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
|
||||
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
|
||||
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
|
||||
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
|
||||
- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
|
||||
### 只有需要页面时才创建前端
|
||||
|
||||
- 沿用已有插件页面与 [管理端主题](../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../CONTRIBUTING.md#界面验证) 提供实际截图
|
||||
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
|
||||
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
|
||||
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-plugin-dev"
|
||||
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
+13
-3
@@ -8,8 +8,8 @@
|
||||
| --- | --- |
|
||||
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
|
||||
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
|
||||
| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground |
|
||||
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
@@ -72,13 +72,23 @@ cpr-plugin package \
|
||||
```
|
||||
|
||||
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
|
||||
目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
目标平台指运行宿主的服务器/容器,不是访问管理端的浏览器所在电脑;平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
|
||||
|
||||
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
|
||||
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
|
||||
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 发布与安装来源
|
||||
|
||||
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
|
||||
|
||||
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
|
||||
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
|
||||
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
|
||||
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
|
||||
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
name: cpr-release
|
||||
description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个版本/正式发版”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“发布 alpha/beta/rc/exp/实验版”“打发行 tag”“重试失败发版”时主动使用;已有发版上下文中的“发布吧”“继续发布”也适用。Use for release planning, notes, publishing and recovery; deployment/restarts and ordinary Git tag cleanup are not release requests.
|
||||
---
|
||||
|
||||
# 发版
|
||||
@@ -19,6 +19,7 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
|
||||
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
需要修复本仓库代码、工作流或文档时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备或更新相关 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md),不以发版步骤替代开发自审。
|
||||
|
||||
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
|
||||
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
|
||||
@@ -45,9 +46,9 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
|
||||
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
|
||||
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
|
||||
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
|
||||
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-release"
|
||||
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
|
||||
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: dev-guide
|
||||
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
|
||||
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "dev-guide"
|
||||
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
|
||||
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-issue"
|
||||
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
|
||||
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-pr"
|
||||
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
|
||||
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Codex Proxy 插件开发"
|
||||
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
|
||||
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "release"
|
||||
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
|
||||
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
|
||||
@@ -0,0 +1,20 @@
|
||||
## 问题与改动
|
||||
|
||||
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
|
||||
|
||||
## 自审与验证
|
||||
|
||||
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
|
||||
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
|
||||
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
|
||||
|
||||
## 页面截图
|
||||
|
||||
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
|
||||
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
|
||||
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
|
||||
|
||||
## AI 使用
|
||||
|
||||
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
|
||||
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source: ${{ steps.filter.outputs.source }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -45,6 +46,13 @@ jobs:
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/**'
|
||||
source:
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
@@ -72,3 +80,60 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
|
||||
- name: Check UI source integration
|
||||
run: |
|
||||
pnpm --dir frontend build:source
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
- name: Check local SDK integration
|
||||
env:
|
||||
RUST_MIN_STACK: 16777216
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
|
||||
run: |
|
||||
sdk_check_dir="$RUNNER_TEMP/plugin-source"
|
||||
mkdir -p "$sdk_check_dir/backend"
|
||||
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
|
||||
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
|
||||
- name: Ensure source checks preserve lockfiles
|
||||
run: |
|
||||
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
|
||||
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "modules/ui"]
|
||||
path = modules/ui
|
||||
url = https://github.com/zyycn/codex-proxy-ui.git
|
||||
[submodule "modules/plugins"]
|
||||
path = modules/plugins
|
||||
url = https://github.com/zyycn/codex-proxy-plugins.git
|
||||
@@ -1,6 +1,17 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
|
||||
按用户意图和当前会话主动加载并执行仓库技能,不要求用户写出 `$技能名`:
|
||||
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
|
||||
| 提交/提个 PR、准备或更新 PR、提交到已有 PR、看下 PR、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
|
||||
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入。
|
||||
任务进入新阶段时补载对应技能,不能只读取文件而跳过其中的问题核实、自审、验证与交付步骤。协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
|
||||
@@ -19,10 +30,13 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
|
||||
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
@@ -36,8 +50,8 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
|
||||
+30
-14
@@ -31,8 +31,8 @@
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
|
||||
@@ -41,17 +41,25 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
|
||||
| [plugin-dev](.agents/skills/plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$plugin-dev 开发一个添加响应头的插件` |
|
||||
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
|
||||
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
|
||||
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
|
||||
| [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) | 项目开发与文档导航 | `$cpr-dev-guide 修复当前问题` |
|
||||
| [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$cpr-plugin-dev 开发一个添加响应头的插件` |
|
||||
| [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md) | 准备、创建、更新或审查 PR | `$cpr-github-pr 创建当前分支的 PR` / `$cpr-github-pr 审查 PR #编号` |
|
||||
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
|
||||
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
|
||||
### AI 使用披露
|
||||
|
||||
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
|
||||
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
|
||||
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
|
||||
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
@@ -99,7 +107,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
@@ -110,13 +118,21 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
|
||||
|
||||
开发前查找同类页面及同级 `codex-proxy-ui/src/components` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
|
||||
|
||||
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
|
||||
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
|
||||
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
|
||||
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
|
||||
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
|
||||
@@ -132,15 +148,15 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
|
||||
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `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 提交,不依赖浮动分支。
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
|
||||
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
|
||||
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1830,7 +1830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.4.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。
|
||||
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
|
||||
|
||||
fn source_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"manifestVersion":3,
|
||||
"manifestVersion":1,
|
||||
"name":"request-workbench",
|
||||
"displayName":"Request Workbench",
|
||||
"publisher":"codex-proxy",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t) else {
|
||||
let Some(version) = eligible_release_version(&release, ¤t, 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(¤t, &target) {
|
||||
if !update_target_allowed(¤t, &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", ¶ms);
|
||||
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,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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user