Compare commits

...
25 Commits
Author SHA1 Message Date
ZyyandCodex 0534dd8f26 chore(release): v3.16.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 22:02:00 +08:00
ZyyandCodex 525f3d51c1 refactor(store): consolidate unreleased scheduling migration
Fold smart scheduling settings into migration 0019 while preserving all released migrations through 0018. Update the checksum manifest and upgrade test.

Development databases that applied the previous 0019 or 0020 cannot upgrade in place because the migration history differs.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 21:59:05 +08:00
CharmskyandCodex 33cc427b86 feat(scheduling): add configurable smart scheduling preferences (#295)
Make smart scheduling coefficients configurable and expose optional
higher-weight switchback while preserving the original defaults.

Reuse current quota reset signals, process-local queues, and frozen request
settings while preserving continuation ownership and queue fairness.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 21:51:28 +08:00
ZyyandCodex d1bf90d5fd fix(frontend): support HTTP plugin pages and consolidate shared helpers
Reuse secure UUID generation for plugin frame channels and sessions on HTTP.
Consolidate utility modules and shorten the plugin views store name.
Declare route roles and default entries in metadata instead of guard mappings.
Keep source integration build output under node_modules/.vite.

Validated frontend lint, production and source builds, browser plugin flows,
and 20 route guard scenarios with simulated sessions and plugin resources.

Fixes #288

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:33:17 +08:00
eb23a0fb3b fix(plugins): align management request timeouts (#294)
Use a shared 180-second timeout for management route HTTP calls and the page bridge while keeping the backend RPC limit at 120 seconds. Fix the existing Vue attribute-order lint warning.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:11:01 +08:00
niceSpriteandCodex ca90f2b586 fix(openai): recover oversized Codex requests before WebSocket send (#287)
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:03:40 +08:00
ZyyandCodex cfcf7c486a fix(plugins): detect latest releases and streamline upgrades (#293)
Refresh explicit GitHub release checks while preserving concurrent fetch sharing and fixed-release download caching.

Compare verified package versions and offer a direct upgrade with pinned digests, revision checks, permission disclosure, and configuration fallback.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:00:23 +08:00
b981e42342 feat(plugins): provision owned resources and reconcile account changes (#292)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 18:46:12 +08:00
ZyyandCodex 02177af921 fix(accounts): refresh OAuth tokens while scheduling is disabled (#291)
Separate scheduling availability from OAuth candidate selection and credential
state writes across OpenAI, xAI, and the shared account store. Cover disabled
refresh, terminal failures, and in-flight scheduling changes with regression tests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 18:32:48 +08:00
ZyyandCodex 2d20f03bcb refactor(admin): simplify diagnostics and refresh shared UI integration
Adopt UI v0.3.0 and its shared Vite adapter, preserve request-volume ordering in diagnostics, stabilize sidebar animation, and track workbench v0.1.15.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 17:14:54 +08:00
ZyyandCodex c12c600b42 fix(models): scope catalog visibility to discovering accounts (#286)
Preserve OpenAI catalog account sources in runtime snapshots and apply
frozen account scope and model policies when publishing model metadata.
Keep discovery catalogs separate from inference authorization and cover
mixed policies, aliases, group changes, and catalog generation updates.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 15:51:13 +08:00
ZyyandCodex 1c6b5a8f10 fix(accounts): restore OAuth reauthorization flow
Generate authorization links when reopening account authorization and regenerate them in a single action. Remove the added authorization page shortcut and unused reset event wiring.

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

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

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

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

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

Remove the ignored external observer example test.

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:53:10 +08:00
346 changed files with 11790 additions and 2216 deletions
+58
View File
@@ -0,0 +1,58 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 仓库开发与问题审查入口。用户要求“开发/实现这个功能”“直接改”“修复一下”“看下报错”“审查这个问题/代码”“自审当前改动”“调整页面”“更新文档”,或继续修改本仓库的 Rust、Vue、协议、存储、部署和 CI 时主动使用,无需点名技能。负责定位、方案、自审与验证;提交或审查 PR 另配合 cpr-github-pr。Use for implementation, debugging, code review and documentation in this repository, not generic advice for unrelated projects.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
## 按任务读取
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 开发独立网关插件 | [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [cpr-github-pr](../cpr-github-pr/SKILL.md) |
| 整理或提交 Issue | [cpr-github-issue](../cpr-github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [cpr-release](../cpr-release/SKILL.md) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
## 后端任务
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定或评估方案。结合上表读取所需文档,先找可复用的同类实现,再按授权修改或审查所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 复核相对目标基线的完整差异及上下游:开发时清理本次引入的职责越界、重复逻辑、冗余状态和无依据的分支;仅审查时报告问题与依据。页面与后端分别按上述任务入口执行,不等待 PR 审查代做开发自审。
6. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
7. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。用户要求提交或更新 PR 时,进入 `cpr-github-pr` 流程整理最终证据与正文。
发布任务按 `cpr-release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
policy:
allow_implicit_invocation: true
@@ -1,6 +1,6 @@
---
name: github-issue
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
name: cpr-github-issue
description: 整理、创建和补充 GitHub Issue。用户说“提/开/提交一个 Issue”“报个 bug 到仓库”“反馈给上游”“把问题或功能建议整理成 Issue”“补充这个 Issue”时主动使用;已有 Issue 反馈上下文中的“提交吧”“补上复现”也适用。处理缺陷、功能与协作规则反馈,区分草拟和发布。Use for drafting, filing and updating issues; standalone “审查问题/看下报错/修一下” belongs to cpr-dev-guide, not automatic Issue submission.
---
# 整理与提交 Issue
@@ -16,6 +16,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
@@ -54,6 +55,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-github-issue"
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
policy:
allow_implicit_invocation: true
@@ -1,9 +1,9 @@
---
name: github-pr
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
name: cpr-github-pr
description: 准备、提交、更新与审查 GitHub PR。用户说“提交 PR/提个 PR/发起拉取请求”“把改动放到这个 PR”“改下 PR 描述”“看下/review/审查这个 PR”“处理审查意见”“这个 PR 能合并吗”,或提供 /pull/ 链接、PR 编号并要求处理时主动使用;已有 PR 上下文中的“提交吧”“继续改”也适用。包含问题核实、自审、页面截图、AI 模型披露与验证。Use for pull request preparation, creation, updates and review; plain git commits or issue debugging without PR context do not create a PR.
---
# 创建与审查 PR
# 提交、更新与审查 PR
## 先确定模式和仓库
@@ -11,6 +11,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
@@ -27,6 +28,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
## 创建和审查前的核对
@@ -46,7 +48,13 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
## 创建 PR
## 页面证据与 AI 披露
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
## 创建或更新 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
@@ -55,19 +63,19 @@ PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
@@ -95,11 +103,12 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-github-pr"
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
policy:
allow_implicit_invocation: true
@@ -1,12 +1,13 @@
---
name: plugin-dev
description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。
name: cpr-plugin-dev
description: 开发、排查和打包 Codex Proxy RS 网关插件。用户说“写/做一个网关插件”“给插件加能力或页面”“改 plugin.json”“接入公开 SDK”“插件不生效/接入报错”“打包插件”时主动使用,覆盖能力选择、SDK 处理器、管理页面和安装包验证。Use for gateway plugin authoring and debugging; host Runtime/installer changes use cpr-dev-guide, not this workflow, and OpenAI .codex-plugin or Vue plugins are outside its scope.
---
# Codex Proxy 插件开发
# cpr-plugin-dev
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
## 先建立正确的模型
@@ -25,7 +26,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
@@ -35,6 +36,9 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
| --- | --- | --- |
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 |
| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 |
| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next |
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
@@ -50,6 +54,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
@@ -59,13 +64,14 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
### 只有需要页面时才创建前端
- 沿用已有插件页面与 [管理端主题](../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../CONTRIBUTING.md#界面验证) 提供实际截图
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-plugin-dev"
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
policy:
allow_implicit_invocation: true
@@ -8,8 +8,8 @@
| --- | --- |
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground |
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
@@ -72,13 +72,23 @@ cpr-plugin package \
```
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
目标平台指运行宿主的服务器/容器,不是访问管理端的浏览器所在电脑;平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
## 发布与安装来源
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
## 验证与交付
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
name: cpr-release
description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个版本/正式发版”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“发布 alpha/beta/rc/exp/实验版”“打发行 tag”“重试失败发版”时主动使用;已有发版上下文中的“发布吧”“继续发布”也适用。Use for release planning, notes, publishing and recovery; deployment/restarts and ordinary Git tag cleanup are not release requests.
---
# 发版
@@ -19,6 +19,7 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
需要修复本仓库代码、工作流或文档时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备或更新相关 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md),不以发版步骤替代开发自审。
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
@@ -45,9 +46,9 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-release"
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
policy:
allow_implicit_invocation: true
-48
View File
@@ -1,48 +0,0 @@
---
name: dev-guide
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
## 按任务读取
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -1,4 +0,0 @@
interface:
display_name: "dev-guide"
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
@@ -1,4 +0,0 @@
interface:
display_name: "github-issue"
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
@@ -1,4 +0,0 @@
interface:
display_name: "github-pr"
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
@@ -1,4 +0,0 @@
interface:
display_name: "Codex Proxy 插件开发"
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
@@ -1,4 +0,0 @@
interface:
display_name: "release"
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
+20
View File
@@ -0,0 +1,20 @@
## 问题与改动
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
## 自审与验证
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
## 页面截图
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
## AI 使用
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
+65
View File
@@ -26,6 +26,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source: ${{ steps.filter.outputs.source }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -45,6 +46,13 @@ jobs:
frontend:
- 'frontend/**'
- '.github/workflows/**'
source:
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
container:
- 'backend/**'
- 'frontend/**'
@@ -72,3 +80,60 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source-development:
needs: changes
if: needs.changes.outputs.source == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout with development modules
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
if [ "$locked_revision" != "$ui_revision" ]; then
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
exit 1
fi
done
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
pnpm --dir frontend install --frozen-lockfile
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
- name: Check UI source integration
run: |
pnpm --dir frontend build:source
pnpm --dir modules/plugins/examples/workbench/frontend build:source
- name: Check local SDK integration
env:
RUST_MIN_STACK: 16777216
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
run: |
sdk_check_dir="$RUNNER_TEMP/plugin-source"
mkdir -p "$sdk_check_dir/backend"
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
- name: Ensure source checks preserve lockfiles
run: |
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
-1
View File
@@ -6,7 +6,6 @@
# Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/frontend/.pnpm-store/
/frontend/.pnpm-cache/
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
[submodule "modules/ui"]
path = modules/ui
url = https://github.com/zyycn/codex-proxy-ui.git
[submodule "modules/plugins"]
path = modules/plugins
url = https://github.com/zyycn/codex-proxy-plugins.git
+17 -3
View File
@@ -1,6 +1,17 @@
# 项目协作约定
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
按用户意图和当前会话主动加载并执行仓库技能,不要求用户写出 `$技能名`:
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
| 提交/提个 PR、准备或更新 PR、提交到已有 PR、看下 PR、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入。
任务进入新阶段时补载对应技能,不能只读取文件而跳过其中的问题核实、自审、验证与交付步骤。协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)。
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
@@ -19,10 +30,13 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
## Code Review Rules
@@ -36,8 +50,8 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
### 代码与界面一致性
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
### 审查输出
+30 -14
View File
@@ -31,8 +31,8 @@
## PR 流程
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
@@ -41,17 +41,25 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
| [plugin-dev](.agents/skills/plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$plugin-dev 开发一个添加响应头的插件` |
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
| [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) | 项目开发与文档导航 | `$cpr-dev-guide 修复当前问题` |
| [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$cpr-plugin-dev 开发一个添加响应头的插件` |
| [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md) | 准备、创建、更新或审查 PR | `$cpr-github-pr 创建当前分支的 PR` / `$cpr-github-pr 审查 PR #编号` |
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
### AI 使用披露
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
### 规则文件保护
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
@@ -99,7 +107,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
@@ -110,13 +118,21 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
开发前查找同类页面及同级 `codex-proxy-ui/src/components` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
@@ -132,15 +148,15 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。普通开发与生产构建都使用 UI 编译产物,源码联调通过 `@codex-proxy/ui/vite` 显式开启。
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级标签时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级依赖时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
```bash
pnpm --dir frontend install --frozen-lockfile
```
需要修改组件库时,按 [UI 源码联调指南](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/guide/development.md) 建立临时本地链接;提交前恢复发布依赖并核对锁文件,不能将开发机目录写入发行依赖。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
需要联合修改组件库或插件时,使用 [源码联调](docs/development.md) 中的子模块与开发入口;联调只临时替换解析路径,不改写正式依赖或锁文件。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
前端从仓库根目录执行:
+1 -1
View File
@@ -114,7 +114,7 @@ curl http://127.0.0.1:8080/v1/models \
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
## 社区
+1 -1
View File
@@ -1830,7 +1830,7 @@ dependencies = [
[[package]]
name = "gateway-plugin-sdk"
version = "0.4.0"
version = "0.1.0"
dependencies = [
"semver",
"serde",
+17
View File
@@ -187,6 +187,14 @@ async fn launch(
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
@@ -231,6 +239,14 @@ async fn launch(
plugin_runtime,
plugin_runtime.bind_client_key_ports(&plugin_keys)
);
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
@@ -322,6 +338,7 @@ async fn launch(
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
+1 -1
View File
@@ -1,7 +1,7 @@
# Codex Proxy Plugin CLI
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
## 安装
+1 -1
View File
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
fn source_manifest() -> &'static [u8] {
br#"{
"manifestVersion":3,
"manifestVersion":1,
"name":"request-workbench",
"displayName":"Request Workbench",
"publisher":"codex-proxy",
+9
View File
@@ -539,6 +539,15 @@ pub fn initialize_plugin_client_keys(
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
fn backup_worker_contribution(
task: backup::task::BackupTask,
@@ -12,6 +12,7 @@ pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider_credentials;
@@ -1154,7 +1154,6 @@ pub struct DiagnosticsItem {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -0,0 +1,42 @@
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
use super::Revision;
#[derive(Debug, Clone)]
pub struct PluginResourceOwner {
pub instance_id: String,
pub artifact_sha256: String,
pub revision: Revision,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedResource {
pub id: String,
pub name: String,
pub enabled: bool,
}
pub struct ResourceMutation<T> {
/// 没有事实变化时不递增配置 revision,也不触发发布。
pub revision: Option<Revision>,
pub value: T,
}
pub struct GroupMembersChange {
pub resource_key: String,
pub add: Vec<String>,
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMembersChanged {
pub added: u64,
pub removed: u64,
}
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
pub struct ManagedKeyConfig {
pub name: String,
pub limits: gateway_core::policy::RateLimits,
pub budget: gateway_core::engine::budget::ClientBudgetLimits,
}
@@ -38,6 +38,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -73,6 +74,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -1,6 +1,40 @@
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemUpdateChannel {
Stable,
Alpha,
Beta,
Rc,
#[serde(rename = "exp")]
Experimental,
}
impl SystemUpdateChannel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
/// Host 返回的本次检查通道及当前构建允许选择的范围。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemUpdatePolicy {
pub channel: SystemUpdateChannel,
pub available_channels: Vec<SystemUpdateChannel>,
}
/// 当前运行版本及更新检查摘要。
///
@@ -23,6 +57,7 @@ pub struct SystemVersion {
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateDetail {
pub policy: SystemUpdatePolicy,
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
@@ -6,6 +6,7 @@ pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_management;
pub mod plugin_release;
pub mod plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider;
@@ -26,6 +26,14 @@ pub trait PluginAccountAccess: Send + Sync {
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError>;
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
async fn get_quota(
&self,
_account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
@@ -0,0 +1,63 @@
//! 插件自有资源的管理入口与原子持久化端口。
use super::store::AdminStoreResult;
use crate::model::{
AdminError, MutationContext,
account_groups::{CreateAccountGroup, NewAccountGroup},
client_keys::NewClientKey,
plugin_resources::{
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
PluginResourceOwner, ResourceMutation,
},
};
use async_trait::async_trait;
#[async_trait]
pub trait PluginResourceAccess: Send + Sync {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: CreateAccountGroup,
context: &MutationContext,
) -> Result<ManagedResource, AdminError>;
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: ManagedKeyConfig,
context: &MutationContext,
) -> Result<ManagedResource, AdminError>;
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> Result<GroupMembersChanged, AdminError>;
}
#[async_trait]
pub trait PluginResourceStore: Send + Sync {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: NewAccountGroup,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: NewClientKey,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<GroupMembersChanged>>;
}
@@ -515,9 +515,15 @@ pub struct AdminStorePorts {
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
}
impl AdminStorePorts {
#[must_use]
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
self.plugin_resources.clone()
}
#[must_use]
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
Arc::clone(&self.plugins)
@@ -542,6 +548,7 @@ impl AdminStorePorts {
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
) -> Self {
Self {
accounts,
@@ -552,6 +559,7 @@ impl AdminStorePorts {
backup,
plugins,
plugin_state,
plugin_resources,
}
}
@@ -7,8 +7,8 @@ use futures::Stream;
use crate::model::Revision;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
@@ -89,6 +89,7 @@ pub trait SystemOperations: Send + Sync {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError>;
fn update_events(&self) -> SystemUpdateEventStream;
@@ -96,6 +97,7 @@ pub trait SystemOperations: Send + Sync {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
@@ -124,9 +124,7 @@ impl ClientKeyService for DefaultClientKeyService {
let plaintext = if let Some(key) = command.custom_key {
key.expose_for_auth().to_owned()
} else {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
generate_key()
};
let (config_revision, record) = self
.store
@@ -257,3 +255,10 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
Err(AdminError::invalid("Client API Key 游标不合法"))
}
}
// 原生与插件创建共用相同的密钥生成规则。
pub(super) fn generate_key() -> String {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -12,6 +12,7 @@ pub mod key_usage;
pub mod observability;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub(crate) mod plugin_update;
pub mod plugins;
pub mod proxies;
@@ -342,21 +342,13 @@ impl ObservabilityService for DefaultObservabilityService {
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
let mut items = items
let items = items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
DiagnosticsItem {
key: item.key,
name: item.name,
@@ -372,19 +364,12 @@ impl ObservabilityService for DefaultObservabilityService {
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
@@ -724,25 +709,6 @@ fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
costs
.iter()
@@ -95,6 +95,20 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
})
}
async fn get_quota(
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
let account = self.get_runtime(account_id).await?;
self.provider(&account.provider_kind)?
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: false,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "plugin quota facts"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
@@ -0,0 +1,108 @@
use crate::{
model::{
AdminError, MutationContext,
account_groups::{CreateAccountGroup, NewAccountGroup},
client_keys::NewClientKey,
plugin_resources::{
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
PluginResourceOwner, ResourceMutation,
},
},
ports::plugin_resources::{PluginResourceAccess, PluginResourceStore},
};
use async_trait::async_trait;
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId, runtime::SnapshotControl};
use std::sync::Arc;
use uuid::Uuid;
pub(crate) struct DefaultPluginResourceAccess {
pub store: Arc<dyn PluginResourceStore>,
pub snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultPluginResourceAccess {
async fn publish<T>(
&self,
result: crate::ports::store::AdminStoreResult<ResourceMutation<T>>,
) -> Result<T, AdminError> {
let result = result.map_err(|error| super::map_store_error(error, "plugin resource"))?;
if let Some(revision) = result.revision {
super::publish_committed(self.snapshot.as_ref(), revision).await?;
}
Ok(result.value)
}
}
#[async_trait]
impl PluginResourceAccess for DefaultPluginResourceAccess {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: CreateAccountGroup,
context: &MutationContext,
) -> Result<ManagedResource, AdminError> {
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
self.store
.ensure_group(
owner,
resource_key,
NewAccountGroup {
id,
name: command.name,
description: command.description,
color: command.color,
disable_fast: command.disable_fast,
},
context,
)
.await,
)
.await
}
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: ManagedKeyConfig,
context: &MutationContext,
) -> Result<ManagedResource, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Key ID 失败"))?;
self.publish(
self.store
.ensure_key(
owner,
resource_key,
groups,
NewClientKey {
id,
name: command.name,
label: None,
group_ids: Vec::new(),
limits: command.limits,
budget: command.budget,
request_profile_overrides: Default::default(),
plaintext: super::client_keys::generate_key(),
},
context,
)
.await,
)
.await
}
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> Result<GroupMembersChanged, AdminError> {
self.publish(self.store.change_members(owner, command, context).await)
.await
}
}
@@ -377,7 +377,11 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
.filter(|(capability, _)| {
!matches!(
capability.as_str(),
"frontend_authentication" | "management" | "command_line"
"frontend_authentication"
| "management"
| "command_line"
| "model_catalog"
| "maintenance"
)
})
.flat_map(|(_, contribution)| {
@@ -390,6 +394,8 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
order: 0,
failure_policy: if stage == "observation" {
PluginFailurePolicy::Observe
} else if stage == "retry" {
PluginFailurePolicy::Delegate
} else {
PluginFailurePolicy::Reject
},
@@ -7,7 +7,10 @@ use async_trait::async_trait;
use crate::{
model::{
AdminError, AdminErrorKind,
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
SystemVersion,
},
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
@@ -19,11 +22,16 @@ use crate::{
#[async_trait]
pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
@@ -55,9 +63,13 @@ impl SystemService for DefaultSystemService {
self.operations.version().await.map_err(map_system_error)
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh)
.update_detail(refresh, channel)
.await
.map_err(map_system_error)
}
@@ -69,12 +81,13 @@ impl SystemService for DefaultSystemService {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError> {
let target_version = target_version
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version, Arc::clone(&self.preflight))
.perform_update(target_version, channel, Arc::clone(&self.preflight))
.await
.map_err(map_system_error)
}
@@ -1105,6 +1105,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -38,6 +38,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -258,6 +258,7 @@ impl AdminHarness {
self.backup,
self.plugin_store,
Arc::new(plugins::TestPluginPorts),
Arc::new(plugins::TestPluginPorts),
),
gateway_admin::AdminRuntimePorts {
plugin_preparation: Arc::new(plugins::TestPluginPorts),
@@ -1035,7 +1036,11 @@ impl SystemOperations for UnavailableSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
@@ -1046,6 +1051,7 @@ impl SystemOperations for UnavailableSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
@@ -502,7 +502,31 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let now = Utc::now();
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
let success = diagnostic("popular", 100);
let mut failure = diagnostic("single_failure", 1);
failure.success_count = 0;
failure.failure_count = 1;
store.replace_diagnostics(vec![success, failure]);
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("diagnostics");
assert_eq!(result.items[0].key, "popular");
assert_eq!(result.items[1].key, "single_failure");
assert_eq!(result.items[0].error_count, 0);
assert_eq!(result.items[1].error_count, 1);
assert_eq!(result.items[1].error_rate, 1.0);
}
#[tokio::test]
@@ -821,6 +845,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -225,7 +225,11 @@ impl SystemOperations for PreflightingSystem {
unreachable!()
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!()
}
@@ -236,6 +240,7 @@ impl SystemOperations for PreflightingSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
let revision = preflight.validate(self.candidate.clone()).await?;
@@ -291,7 +296,7 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.expect("compatible update");
}
@@ -309,7 +314,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
assert!(
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
@@ -325,7 +330,7 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
assert!(
services
.system()
.perform_update(Some("1.2.0".into()))
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
@@ -401,3 +401,46 @@ impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts {
Err(AdminError::invalid("unused plugin fixture"))
}
}
#[async_trait::async_trait]
impl gateway_admin::ports::plugin_resources::PluginResourceStore for TestPluginPorts {
async fn ensure_group(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: String,
_: gateway_admin::model::account_groups::NewAccountGroup,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::ManagedResource,
>,
> {
unreachable!("resource port is not used by this fixture")
}
async fn ensure_key(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: String,
_: Vec<String>,
_: gateway_admin::model::client_keys::NewClientKey,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::ManagedResource,
>,
> {
unreachable!("resource port is not used by this fixture")
}
async fn change_members(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: gateway_admin::model::plugin_resources::GroupMembersChange,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::GroupMembersChanged,
>,
> {
unreachable!("resource port is not used by this fixture")
}
}
@@ -225,6 +225,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -13,6 +13,7 @@ use gateway_admin::{
#[derive(Default)]
struct RecordingSystemOperations {
target: Mutex<Option<Option<String>>>,
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
}
#[async_trait]
@@ -24,6 +25,7 @@ impl SystemOperations for RecordingSystemOperations {
build_time: "unknown".to_owned(),
deployment_mode: "source".to_owned(),
update_channel: "stable".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
update_cached: false,
@@ -31,8 +33,16 @@ impl SystemOperations for RecordingSystemOperations {
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Ok(SystemUpdateDetail {
policy: gateway_admin::model::system::SystemUpdatePolicy {
channel: gateway_admin::model::system::SystemUpdateChannel::Stable,
available_channels: vec![gateway_admin::model::system::SystemUpdateChannel::Stable],
},
current_version: "1.0.0".to_owned(),
latest_version: "1.0.0".to_owned(),
has_update: false,
@@ -54,9 +64,11 @@ impl SystemOperations for RecordingSystemOperations {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
*self.target.lock().expect("target") = Some(target_version.clone());
*self.channel.lock().expect("channel") = channel;
Ok(SystemOperationAccepted::Update {
operation_id: "operation-update".to_owned(),
deployment_mode: "source".to_owned(),
@@ -111,9 +123,29 @@ async fn system_update_should_normalize_blank_target_to_latest() {
.await;
services
.system()
.perform_update(Some(" ".to_owned()))
.perform_update(Some(" ".to_owned()), None)
.await
.expect("perform update");
assert_eq!(*operations.target.lock().expect("target"), Some(None));
}
#[tokio::test]
async fn system_update_should_forward_the_confirmed_channel() {
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
let services = super::AdminHarness::new()
.system(operations.clone())
.build()
.await;
let channel = gateway_admin::model::system::SystemUpdateChannel::Beta;
services
.system()
.perform_update(Some(" 1.2.0-beta.1 ".into()), Some(channel))
.await
.expect("accepted");
assert_eq!(
*operations.target.lock().expect("target"),
Some(Some("1.2.0-beta.1".into()))
);
assert_eq!(*operations.channel.lock().expect("channel"), Some(channel));
}
@@ -1294,7 +1294,6 @@ pub(crate) fn diagnostics_view(
non_completion_rate: item.non_completion_rate,
retry_count: item.retry_count,
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -758,7 +758,6 @@ pub struct DiagnosticItemView {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -37,6 +37,7 @@ pub type ProviderRequestProfileUpdates =
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig,
pub provider_request_profiles: ProviderRequestProfiles,
/// 固定兼容字段;值始终从 provider_request_profiles 派生。
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
@@ -53,6 +54,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -95,6 +97,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -227,6 +230,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -274,6 +278,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
+16 -4
View File
@@ -20,8 +20,8 @@ use axum::{
use futures::{Stream, StreamExt};
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
SystemVersion,
SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
};
use serde::{Deserialize, Serialize};
@@ -35,9 +35,15 @@ use super::{
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateDetailQuery {
refresh: Option<bool>,
channel: Option<SystemUpdateChannel>,
}
impl UpdateDetailQuery {
#[must_use]
pub const fn channel(&self) -> Option<SystemUpdateChannel> {
self.channel
}
/// 是否强制从发布源刷新。
#[must_use]
pub fn refresh(&self) -> bool {
@@ -50,6 +56,7 @@ impl UpdateDetailQuery {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRequest {
target_version: String,
channel: Option<SystemUpdateChannel>,
}
impl UpdateRequest {
@@ -95,6 +102,7 @@ impl From<SystemVersion> for SystemVersionView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SystemUpdateDetailView {
policy: SystemUpdatePolicy,
current_version: String,
latest_version: String,
has_update: bool,
@@ -115,6 +123,7 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
Self {
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
build_type_label: build_type_label(&detail.build_type),
policy: detail.policy,
current_version: detail.current_version,
latest_version: detail.latest_version,
has_update: detail.has_update,
@@ -263,7 +272,7 @@ where
let detail = state
.admin_services()
.system()
.update_detail(query.refresh())
.update_detail(query.refresh(), query.channel())
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
@@ -299,10 +308,13 @@ async fn perform_update<S>(
where
S: SessionState + Send + Sync,
{
let (target, channel) = payload.map_or((None, None), |AdminJson(value)| {
(Some(value.target_version), value.channel)
});
let result = state
.admin_services()
.system()
.perform_update(payload.map(|AdminJson(value)| value.into_target_version()))
.perform_update(target, channel)
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Update {
@@ -54,11 +54,11 @@ where
input,
Box::new(move |prepared, request| {
Box::pin(async move {
let (protocol, headers, body) = request_parts(request)?;
let (protocol, headers, body) = request_parts(request.clone())?;
if protocol != "openai" {
return Err(MiddlewareError::Rejected);
}
let operation = decode(body, &headers)?;
let operation = request.apply_capabilities(decode(body, &headers)?)?;
let started = match service
.start_prepared_provider_endpoint(
prepared, operation, client_ip, user_agent, endpoint,
@@ -164,7 +164,7 @@ pub(crate) async fn execute_prepared_responses(
input,
Box::new(move |prepared, request| {
Box::pin(async move {
let (protocol, request_headers, request_body) = request_parts(request)?;
let (protocol, request_headers, request_body) = request_parts(request.clone())?;
if protocol != OPENAI_PROTOCOL {
return Err(MiddlewareError::Rejected);
}
@@ -174,7 +174,8 @@ pub(crate) async fn execute_prepared_responses(
maximum_body_bytes,
)
.map_err(|_| MiddlewareError::Rejected)?
.with_client_context(client_ip, user_agent);
.with_client_context(client_ip, user_agent)
.with_middleware_capabilities(&request)?;
if decoded.metadata().stream() != stream {
return Err(MiddlewareError::Rejected);
}
@@ -272,6 +272,14 @@ pub struct DecodedResponsesRequest {
}
impl DecodedResponsesRequest {
pub(crate) fn with_middleware_capabilities(
mut self,
request: &gateway_core::engine::middleware::MiddlewareRequest,
) -> Result<Self, gateway_core::engine::middleware::MiddlewareError> {
self.operation = request.apply_capabilities(self.operation)?;
Ok(self)
}
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态。
#[must_use]
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
@@ -278,7 +278,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
input,
Box::new(move |prepared, request| {
Box::pin(async move {
let (protocol, headers, body) = request_parts(request)?;
let (protocol, headers, body) = request_parts(request.clone())?;
if protocol != "openai" {
return Err(MiddlewareError::Rejected);
}
@@ -291,7 +291,9 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
let decoded = decode_response_create_with_context(payload, &request_headers)
.map_err(|_| MiddlewareError::Rejected)?
.with_client_context(client_ip, user_agent_for_terminal);
let decoded = replay_for_terminal.prepare(decoded);
let decoded = replay_for_terminal
.prepare(decoded)
.with_middleware_capabilities(&request)?;
let started = service_for_terminal
.start_prepared_response(
prepared,
@@ -169,6 +169,7 @@ impl AdminTestFixture {
gateway_admin::ports::backup::BackupStorePorts::disabled(),
plugin_ports.clone(),
plugin_ports.clone(),
plugin_ports.clone(),
);
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
@@ -556,6 +557,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
@@ -1456,7 +1458,11 @@ impl SystemOperations for UnusedSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
}
@@ -1467,6 +1473,7 @@ impl SystemOperations for UnusedSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
@@ -1513,6 +1520,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -595,6 +595,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert!(value["data"]["items"][0].get("impactScore").is_none());
assert_eq!(
(
&value["data"]["items"][0]["key"],
@@ -1008,3 +1008,46 @@ async fn version_plan_requires_admin_and_an_explicit_target() {
assert_eq!(response.headers()["cache-control"], "no-store");
}
}
#[async_trait::async_trait]
impl gateway_admin::ports::plugin_resources::PluginResourceStore for TestPluginPorts {
async fn ensure_group(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: String,
_: gateway_admin::model::account_groups::NewAccountGroup,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::ManagedResource,
>,
> {
unreachable!("resource port is not used by this fixture")
}
async fn ensure_key(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: String,
_: Vec<String>,
_: gateway_admin::model::client_keys::NewClientKey,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::ManagedResource,
>,
> {
unreachable!("resource port is not used by this fixture")
}
async fn change_members(
&self,
_: &gateway_admin::model::plugin_resources::PluginResourceOwner,
_: gateway_admin::model::plugin_resources::GroupMembersChange,
_: &gateway_admin::model::MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::plugin_resources::ResourceMutation<
gateway_admin::model::plugin_resources::GroupMembersChanged,
>,
> {
unreachable!("resource port is not used by this fixture")
}
}
@@ -52,6 +52,7 @@ fn update_body() -> Value {
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
@@ -70,6 +71,76 @@ fn update_body() -> Value {
})
}
#[tokio::test]
async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_intact() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
let custom = json!({"loadWeight": 2.0, "quotaWeight": 0.0, "healthWeight": 1.0, "latencyWeight": 0.5, "resetWeight": 1.2, "queueWeight": 2.3, "preferHigherWeight": true});
body["smartScheduling"] = custom.clone();
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body.clone()),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = response_json(response).await;
assert_eq!(response["data"]["smartScheduling"], custom);
assert_eq!(
response["data"]["smartSchedulingDefaults"],
json!(gateway_core::account::SmartSchedulingConfig::default())
);
let mut invalid_values = vec![
json!(null),
json!({}),
json!({"loadWeight":0,"quotaWeight":0,"healthWeight":0,"latencyWeight":0,"resetWeight":0,"queueWeight":0,"preferHigherWeight":true}),
json!({"loadWeight":0.01,"quotaWeight":1,"healthWeight":1,"latencyWeight":1,"resetWeight":0,"queueWeight":0,"preferHigherWeight":false}),
];
for field in ["resetWeight", "queueWeight"] {
for value in [json!(-0.1), json!(10.1), json!(0.01), json!(null)] {
let mut invalid = custom.clone();
invalid[field] = value;
invalid_values.push(invalid);
}
let mut missing = custom.clone();
missing.as_object_mut().unwrap().remove(field);
invalid_values.push(missing);
}
for invalid in invalid_values {
body["smartScheduling"] = invalid;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body.clone()),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
body.as_object_mut().unwrap().remove("smartScheduling");
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(
response_json(response).await["data"]["smartScheduling"],
custom
);
}
#[test]
fn settings_request_should_reject_unknown_rotation_strategy() {
let mut body = update_body();
@@ -149,6 +220,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
@@ -193,6 +265,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
"smartSchedulingDefaults": gateway_core::account::SmartSchedulingConfig::default(),
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
@@ -257,6 +331,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
@@ -290,6 +365,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
expected_fields.insert("smartSchedulingDefaults".to_owned());
assert_eq!(response_fields, expected_fields);
}
@@ -125,8 +125,29 @@ impl SystemOperations for ProgressSystem {
Err(unavailable_system())
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
async fn update_detail(
&self,
_: bool,
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
use gateway_admin::model::system::{SystemUpdateChannel, SystemUpdatePolicy};
Ok(SystemUpdateDetail {
policy: SystemUpdatePolicy {
channel: channel.unwrap_or(SystemUpdateChannel::Stable),
available_channels: vec![SystemUpdateChannel::Stable, SystemUpdateChannel::Beta],
},
current_version: "0.1.0".into(),
latest_version: "0.2.0".into(),
has_update: true,
deployment_mode: "docker".into(),
build_type: "release".into(),
release_url: None,
notes: None,
cached: false,
update_supported: true,
unsupported_reason: None,
warning: None,
})
}
fn update_events(&self) -> SystemUpdateEventStream {
@@ -147,6 +168,7 @@ impl SystemOperations for ProgressSystem {
async fn perform_update(
&self,
target: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Update {
@@ -186,3 +208,39 @@ impl SystemOperations for ProgressSystem {
Err(unavailable_system())
}
}
#[tokio::test]
async fn update_detail_should_validate_and_forward_the_temporary_channel() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
fixture.auth.insert_session("valid-session");
let router = app(fixture.state());
for (authenticated, query, expected) in [
(false, "channel=beta", StatusCode::UNAUTHORIZED),
(true, "channel=nightly", StatusCode::BAD_REQUEST),
(true, "channel=beta&extra=true", StatusCode::BAD_REQUEST),
(true, "channel=beta", StatusCode::OK),
(true, "", StatusCode::OK),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/system/update/detail?{query}"))
.header("x-request-id", "req_channel_check");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = router
.clone()
.oneshot(request.body(Body::empty()).expect("request"))
.await
.expect("response");
assert_eq!(response.status(), expected);
if expected == StatusCode::OK {
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
.expect("json");
assert_eq!(
body["data"]["policy"]["channel"],
if query.is_empty() { "stable" } else { "beta" }
);
}
}
}
@@ -522,6 +522,24 @@ async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_s
assert!(error["headers"].get("retry-after").is_none());
}
#[tokio::test]
async fn initial_continuation_recovery_delivers_the_official_client_replay_signal() {
let provider = ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Previous response was not found. Retrying the full request.",
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
));
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
assert_eq!(error["type"], "error");
assert_eq!(error["status"], 400);
assert_eq!(error["error"]["code"], "previous_response_not_found");
assert_eq!(error["error"]["type"], "invalid_request_error");
}
#[tokio::test]
async fn locally_exhausted_account_pool_sends_one_official_usage_limit_error() {
let provider = ProviderError::new(
+7 -1
View File
@@ -93,6 +93,7 @@ impl SystemOperations for VersionSystem {
build_time: "internal-build-time".to_owned(),
deployment_mode: "binary".to_owned(),
update_channel: "release".to_owned(),
latest_version: "3.8.0".to_owned(),
has_update: true,
update_cached: true,
@@ -100,7 +101,11 @@ impl SystemOperations for VersionSystem {
})
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
async fn update_detail(
&self,
_: bool,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!("client route must not request update details")
}
@@ -111,6 +116,7 @@ impl SystemOperations for VersionSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!("client route must not perform updates")
@@ -13,10 +13,12 @@ mod proxy;
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
pub mod scope;
mod selection;
mod smart_scheduling;
mod store;
pub use error::CredentialError;
pub use model::*;
pub(crate) use selection::smart_score;
pub use selection::*;
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
pub use smart_scheduling::{SmartSchedulingConfig, SmartSchedulingConfigError};
pub use store::ProviderAccountStore;
@@ -1053,6 +1053,7 @@ pub struct LoadedCredential {
/// Provider 已计算好时间边界的有界 OAuth refresh 候选查询。
///
/// Store 只负责按持久事实筛选和稳定排序,不拥有提前量或恢复窗口语义。
/// 调度启停不影响凭据续期;停用账号仍按 Provider 的凭据状态与时间边界刷新。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderRefreshQuery {
provider: ProviderKind,
@@ -1117,7 +1118,6 @@ impl ProviderRefreshQuery {
#[must_use]
pub fn contains(&self, account: &ProviderAccount) -> bool {
account.provider() == &self.provider
&& account.enabled()
&& account.has_refresh_token()
&& matches!(
account.credential_state(),
@@ -6,10 +6,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use crate::concurrency::ConcurrencyQueuePolicy;
use crate::concurrency::{CapacityWait, ConcurrencyQueuePolicy, QueueRejection};
use crate::identity::ProviderKind;
use super::{AccountConcurrency, AccountStatus, ProviderAccount, ProviderAccountId};
use super::{
AccountConcurrency, AccountStatus, ProviderAccount, ProviderAccountId, SmartSchedulingConfig,
};
/// `runtime_settings.rotation_strategy` 的稳定值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -47,6 +49,7 @@ impl RotationStrategy {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountSelectionPolicy {
strategy: RotationStrategy,
smart_scheduling: SmartSchedulingConfig,
max_concurrent_per_account: AccountConcurrency,
request_interval: Duration,
queue_policy: ConcurrencyQueuePolicy,
@@ -61,6 +64,7 @@ impl AccountSelectionPolicy {
) -> Self {
Self {
strategy,
smart_scheduling: SmartSchedulingConfig::default(),
max_concurrent_per_account: max_concurrent_per_account.into(),
request_interval,
queue_policy: ConcurrencyQueuePolicy {
@@ -86,6 +90,17 @@ impl AccountSelectionPolicy {
self.strategy
}
#[must_use]
pub const fn with_smart_scheduling(mut self, config: SmartSchedulingConfig) -> Self {
self.smart_scheduling = config;
self
}
#[must_use]
pub const fn smart_scheduling(self) -> SmartSchedulingConfig {
self.smart_scheduling
}
#[must_use]
pub const fn max_concurrent_per_account(self) -> AccountConcurrency {
self.max_concurrent_per_account
@@ -510,12 +525,7 @@ impl AccountSelector {
if self.scheduling_blocker(candidate, context).is_some() {
return None;
}
let highest_weight = candidates
.iter()
.filter(|candidate| self.scheduling_blocker(candidate, context).is_none())
.map(|candidate| candidate.account.weight())
.max()?;
let (preferred, _) = self.preferred_decision(candidates, context, highest_weight);
let (preferred, _) = self.preferred_decision(candidates, context);
let preferred = if preferred == PreferredAccountSelection::Hit
&& context.preferred_account.as_ref() != Some(account_id)
{
@@ -585,13 +595,22 @@ impl AccountSelector {
.iter()
.map(|candidate| candidate.account.weight())
.max()?;
let (preferred, preferred_candidate) =
self.preferred_decision(candidates, context, highest_weight);
let (mut preferred, preferred_candidate) = self.preferred_decision(candidates, context);
if let Some(candidate) = preferred_candidate {
return Some(AccountSelection {
candidate,
preferred: PreferredAccountSelection::Hit,
});
// 权重回切只裁决内置策略的软亲和,插件显式选号按实际选择记录结果。
let prefer_higher_weight = context.policy.strategy() == RotationStrategy::Smart
&& context.policy.smart_scheduling().prefer_higher_weight();
if (!context.preferred_account_overrides_weight || prefer_higher_weight)
&& candidate.account.weight() < highest_weight
{
preferred =
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight);
} else {
return Some(AccountSelection {
candidate,
preferred: PreferredAccountSelection::Hit,
});
}
}
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
@@ -629,6 +648,8 @@ impl AccountSelector {
&eligible,
context.policy.max_concurrent_per_account(),
context.round_robin_cursor,
context.policy.smart_scheduling(),
context.now,
)?,
RotationStrategy::Sticky => {
eligible.sort_by_key(|candidate| {
@@ -650,7 +671,6 @@ impl AccountSelector {
&self,
candidates: &'a [AccountCandidate],
context: &AccountSelectionContext,
highest_weight: super::AccountWeight,
) -> (PreferredAccountSelection, Option<&'a AccountCandidate>) {
let Some(preferred) = context.preferred_account.as_ref() else {
return (PreferredAccountSelection::NotRequested, None);
@@ -663,14 +683,6 @@ impl AccountSelector {
};
match self.scheduling_blocker(candidate, context) {
Some(blocker) => (PreferredAccountSelection::Blocked(blocker), None),
None if !context.preferred_account_overrides_weight
&& candidate.account.weight() < highest_weight =>
{
(
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight),
None,
)
}
None => (PreferredAccountSelection::Hit, Some(candidate)),
}
}
@@ -707,6 +719,41 @@ impl AccountSelector {
.collect()
}
/// Provider 先投影续写范围与资格,再将等待候选交给共用评分规则选择队列。
pub async fn wait_for_capacity(
&self,
waiting: &mut CapacityWait<'_, ProviderAccountId>,
keys: &[ProviderAccountId],
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
) -> Result<(), QueueRejection> {
let config = context.policy.smart_scheduling();
let queue_weight = config.weights()[5];
if context.policy.strategy() != RotationStrategy::Smart || queue_weight == 0.0 {
return waiting.wait(keys).await;
}
// 账号信号在队列锁外评分,锁内只读取实时队长并查表,避免扫描候选阻塞其他等待者。
let scores = candidates
.iter()
.map(|candidate| {
(
candidate.account.id(),
smart_score(
candidate,
context.policy.max_concurrent_per_account(),
config,
context.now,
),
)
})
.collect::<HashMap<_, _>>();
waiting
.wait_with_priority(keys, |key, count| {
scores[key] + queue_weight / (1.0 + count as f64)
})
.await
}
pub(crate) fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
@@ -755,14 +802,10 @@ impl AccountSelector {
}
}
const SMART_LOAD_WEIGHT: f64 = 1.0;
const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
// 容忍 5 个百分点的单项负载/失败率差异,避免微小信号波动独占新会话。
pub(crate) const SMART_SCORE_TOLERANCE: f64 = 0.05;
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化。
const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
// 距重置一小时时得分减半;未知和已过期时间不提供重置奖励。
const SMART_RESET_HALF_SCORE_SECONDS: f64 = 3_600.0;
fn capacity_utilization(
candidate: &AccountCandidate,
@@ -781,16 +824,23 @@ fn select_smart_candidate<'a>(
candidates: &[&'a AccountCandidate],
default_concurrency: AccountConcurrency,
cursor: u64,
config: SmartSchedulingConfig,
now: SystemTime,
) -> Option<&'a AccountCandidate> {
let mut ranked = candidates
.iter()
.map(|candidate| (*candidate, smart_score(candidate, default_concurrency)))
.map(|candidate| {
(
*candidate,
smart_score(candidate, default_concurrency, config, now),
)
})
.collect::<Vec<_>>();
let best_score = ranked
.iter()
.map(|(_, score)| *score)
.max_by(f64::total_cmp)?;
ranked.retain(|(_, score)| best_score - score <= SMART_SCORE_TOLERANCE);
ranked.retain(|(_, score)| best_score - score <= config.score_tolerance());
// 轮换顺序保持稳定,避免分数轻微交错与 cursor 同步后仍反复命中同一账号。
ranked.sort_unstable_by(|(left, _), (right, _)| left.account.id().cmp(right.account.id()));
let index = (cursor % ranked.len() as u64) as usize;
@@ -800,6 +850,8 @@ fn select_smart_candidate<'a>(
pub(crate) fn smart_score(
candidate: &AccountCandidate,
default_concurrency: AccountConcurrency,
config: SmartSchedulingConfig,
now: SystemTime,
) -> f64 {
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
let quota = candidate
@@ -822,8 +874,26 @@ pub(crate) fn smart_score(
SMART_LATENCY_HALF_SCORE_MS / (SMART_LATENCY_HALF_SCORE_MS + latency as f64)
});
SMART_LOAD_WEIGHT * load
+ SMART_QUOTA_WEIGHT * quota
+ SMART_FAILURE_WEIGHT * failure
+ SMART_LATENCY_WEIGHT * latency
let reset = candidate
.signals
.quota_reset_at
.and_then(|reset| reset.duration_since(now).ok())
.filter(|remaining| !remaining.is_zero())
.map_or(0.0, |remaining| {
SMART_RESET_HALF_SCORE_SECONDS
/ (SMART_RESET_HALF_SCORE_SECONDS + remaining.as_secs_f64())
});
let [
load_weight,
quota_weight,
health_weight,
latency_weight,
reset_weight,
_,
] = config.weights();
load_weight * load
+ quota_weight * quota
+ health_weight * failure
+ latency_weight * latency
+ reset_weight * reset
}
@@ -0,0 +1,138 @@
//! 智能调度参数;固定十分位保证快照可精确比较,浮点数只用于 wire 与评分。
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "SmartSchedulingValues", into = "SmartSchedulingValues")]
pub struct SmartSchedulingConfig {
weights: [u8; 6],
prefer_higher_weight: bool,
}
impl Default for SmartSchedulingConfig {
fn default() -> Self {
Self {
weights: [10, 8, 10, 5, 0, 0],
prefer_higher_weight: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SmartSchedulingConfigError {
#[error("{0} must be between 0 and 10 with at most one decimal place")]
InvalidWeight(&'static str),
#[error("at least one smart scheduling weight must be positive")]
EmptyWeights,
}
impl SmartSchedulingConfig {
/// 顺序为负载、剩余额度、健康、首输出延迟、额度重置和排队压力。
pub fn new(
weights: [f64; 6],
prefer_higher_weight: bool,
) -> Result<Self, SmartSchedulingConfigError> {
let mut tenths = [0; 6];
for (index, field) in [
"loadWeight",
"quotaWeight",
"healthWeight",
"latencyWeight",
"resetWeight",
"queueWeight",
]
.into_iter()
.enumerate()
{
let value = weights[index];
if !value.is_finite()
|| !(0.0..=10.0).contains(&value)
|| (value * 10.0).round() / 10.0 != value
{
return Err(SmartSchedulingConfigError::InvalidWeight(field));
}
tenths[index] = (value * 10.0).round() as u8;
}
if tenths == [0; 6] {
return Err(SmartSchedulingConfigError::EmptyWeights);
}
Ok(Self {
weights: tenths,
prefer_higher_weight,
})
}
#[must_use]
pub fn weights(self) -> [f64; 6] {
self.weights.map(|weight| f64::from(weight) / 10.0)
}
#[must_use]
pub const fn prefer_higher_weight(self) -> bool {
self.prefer_higher_weight
}
pub(crate) fn score_tolerance(self) -> f64 {
// 与系数同步缩放,避免仅放大相同比例的系数就改变近似最优候选集合。
// 排队系数只参与入队选择,不能放大立即选号的候选容差。
0.05 * f64::from(
self.weights[..5]
.iter()
.copied()
.map(u16::from)
.sum::<u16>(),
) / 33.0
}
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SmartSchedulingValues {
load_weight: f64,
quota_weight: f64,
health_weight: f64,
latency_weight: f64,
reset_weight: f64,
queue_weight: f64,
prefer_higher_weight: bool,
}
impl TryFrom<SmartSchedulingValues> for SmartSchedulingConfig {
type Error = SmartSchedulingConfigError;
fn try_from(value: SmartSchedulingValues) -> Result<Self, Self::Error> {
Self::new(
[
value.load_weight,
value.quota_weight,
value.health_weight,
value.latency_weight,
value.reset_weight,
value.queue_weight,
],
value.prefer_higher_weight,
)
}
}
impl From<SmartSchedulingConfig> for SmartSchedulingValues {
fn from(value: SmartSchedulingConfig) -> Self {
let [
load_weight,
quota_weight,
health_weight,
latency_weight,
reset_weight,
queue_weight,
] = value.weights();
Self {
load_weight,
quota_weight,
health_weight,
latency_weight,
reset_weight,
queue_weight,
prefer_higher_weight: value.prefer_higher_weight,
}
}
}
+30 -6
View File
@@ -80,6 +80,18 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
keys: &[K],
max_waiting: u32,
deadline: Instant,
) -> Result<WaitTicket<K>, QueueRejection> {
self.enqueue_with_priority(keys, max_waiting, deadline, |_, waiting| -(waiting as f64))
}
/// 在同一锁内读取队长并入队,分数越大越优先,同分沿用候选顺序。
/// 回调只做内存评分,不得阻塞或再次访问本队列。
pub fn enqueue_with_priority(
&self,
keys: &[K],
max_waiting: u32,
deadline: Instant,
mut priority: impl FnMut(&K, usize) -> f64,
) -> Result<WaitTicket<K>, QueueRejection> {
if Instant::now() >= deadline {
return Err(QueueRejection::Timeout);
@@ -95,9 +107,9 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
.iter()
.filter_map(|key| {
let len = state.queues.get(key).map_or(0, VecDeque::len);
(len < max_waiting as usize).then_some((key, len))
(len < max_waiting as usize).then(|| (key, priority(key, len)))
})
.min_by_key(|(_, len)| *len)
.min_by(|(_, left), (_, right)| right.total_cmp(left))
.map(|(key, _)| key.clone())
.ok_or(QueueRejection::Full)?;
let waker = Arc::new(AtomicWaker::new());
@@ -237,6 +249,16 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
}
pub async fn wait(&mut self, keys: &[K]) -> Result<(), QueueRejection> {
self.wait_with_priority(keys, |_, waiting| -(waiting as f64))
.await
}
/// 只在首次入队或原账号不再合格时重新选队列,已取得的位置不因评分变化而移动。
pub async fn wait_with_priority(
&mut self,
keys: &[K],
priority: impl FnMut(&K, usize) -> f64,
) -> Result<(), QueueRejection> {
self.started_at.get_or_insert_with(Instant::now);
let deadline = self
.budget
@@ -249,10 +271,12 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
self.ticket = None;
}
if self.ticket.is_none() {
self.ticket = Some(
self.queue
.enqueue(keys, self.policy.max_waiting, deadline)?,
);
self.ticket = Some(self.queue.enqueue_with_priority(
keys,
self.policy.max_waiting,
deadline,
priority,
)?);
}
if let Some(ticket) = &self.ticket {
ticket.retry().await?;
@@ -5,7 +5,7 @@ use serde_json::json;
use super::TraceContext;
use crate::account::{
AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, RotationStrategy,
SMART_SCORE_TOLERANCE, smart_score,
smart_score,
};
// 单个 trace 事件最多 4 KiB;优先保留实际选中的账号。
@@ -50,7 +50,7 @@ impl TraceContext {
"failureRateBasisPoints": signals.failure_rate_basis_points,
"firstOutputLatencyMs": signals.first_output_latency_ms,
"smartScore": smart.then(|| smart_score(
candidate, context.policy.max_concurrent_per_account()
candidate, context.policy.max_concurrent_per_account(), context.policy.smart_scheduling(), context.now
)),
})
})
@@ -63,7 +63,7 @@ impl TraceContext {
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
"roundRobinCursor": context.round_robin_cursor,
"smartScoreTolerance": smart.then_some(SMART_SCORE_TOLERANCE),
"smartScoreTolerance": smart.then(|| context.policy.smart_scheduling().score_tolerance()),
"candidateCount": candidates.len(),
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
"candidates": observations,
@@ -1062,25 +1062,45 @@ where
Ok(stream) => stream,
Err(error) => {
record_trace_error(&attempt_trace, &error);
if self.prepare_unavailable_native_continuation_replay(&error) {
return Ok(Some(PullOutcome::AttemptDiscarded));
}
if matches!(
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && error.send_state() == UpstreamSendState::NotSent
let continuation_retry =
self.prepare_unavailable_native_continuation_replay(&error);
let candidate_retry = !continuation_retry
&& matches!(
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
)
&& error.send_state() == UpstreamSendState::NotSent
&& self.current_send_state() == UpstreamSendState::NotSent
&& matches!(
self.continuation_attempt,
ContinuationAttempt::None | ContinuationAttempt::ReplayAny
)
&& self.advance_provider_candidate()
{
&& self.advance_provider_candidate();
let retryable = self
.apply_retry_policy(super::policy::RetryFacts {
attempt_index: next_attempt,
provider: candidate.provider().clone(),
model: candidate
.upstream_model()
.map(|model| model.as_str().to_owned()),
error_kind: error.kind(),
upstream_status: error.upstream_status(),
send_state: error.send_state(),
remaining_routing_attempts: self
.plan
.max_attempts()
.get()
.saturating_sub(self.routing_attempts),
remaining_deadline: Duration::ZERO,
retry_allowed: continuation_retry || candidate_retry,
})
.await?;
if retryable {
return Ok(Some(PullOutcome::AttemptDiscarded));
}
if matches!(
@@ -1511,6 +1531,35 @@ where
|| account_rotation_retry
|| transport_recovery.is_some();
let retryable = match self
.apply_retry_policy(super::policy::RetryFacts {
attempt_index: current.index,
provider: current.metadata.provider().clone(),
model: current
.metadata
.upstream_model()
.map(|model| model.as_str().to_owned()),
error_kind: error.kind(),
upstream_status: error.upstream_status(),
send_state: attempt_send_state,
remaining_routing_attempts: self
.plan
.max_attempts()
.get()
.saturating_sub(self.routing_attempts),
remaining_deadline: Duration::ZERO,
retry_allowed: retryable,
})
.await
{
Ok(retryable) => retryable,
Err(error) => {
self.current = Some(current);
self.finish_interruption(&error).await?;
return Err(error);
}
};
self.trace.attempt(current.index.get()).record("retry.decided", json!({
"retryable": retryable, "continuationRetry": continuation_retry,
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
@@ -1608,6 +1657,41 @@ where
Err(provider_engine_error(error))
}
async fn apply_retry_policy(
&mut self,
mut facts: super::policy::RetryFacts,
) -> Result<bool, EngineError> {
let Some(policy) = self.request_policy.clone() else {
return Ok(facts.retry_allowed);
};
facts.remaining_deadline = self
.deadline
.duration_since(SystemTime::now())
.unwrap_or_default();
facts.retry_allowed &= !facts.remaining_deadline.is_zero()
&& !self.cancellation.is_cancelled()
&& !self.execution_effect_observed();
let allowed = facts.retry_allowed;
let decision = {
let cancellation = self.cancellation.clone();
let cancelled = cancellation.cancelled().fuse();
let decision = policy.retry_decision(facts).fuse();
let mut deadline_timer = &mut self.deadline_timer;
pin_mut!(cancelled, decision);
select_biased! {
() = cancelled => Err(EngineError::Cancelled),
() = deadline_timer => Err(EngineError::Deadline),
decision = decision => Ok(decision),
}
}?;
// 策略只收窄宿主已允许的恢复,返回后仍复核期限和外部副作用。
Ok(allowed
&& decision != super::policy::RetryDecision::Stop
&& !self.execution_effect_observed()
&& !self.cancellation.is_cancelled()
&& SystemTime::now() < self.deadline)
}
fn observe_atomic_terminal_events(&mut self, events: &mut [ProviderEvent]) {
for event in events {
if let Some(observation) = event.take_observation() {
@@ -2118,9 +2118,10 @@ impl ExecutionService for DefaultExecutionService {
.await?
else {
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
if !scope.allows_provider_model(
if !client.snapshot.catalog_model_allowed_for_scope(
kind,
&client.snapshot.mapped_model(profile.model().as_str()),
profile.model(),
scope,
) {
continue;
}
@@ -4,7 +4,7 @@
//! Runtime/SDK 负责;身份、路由、账号租约、发送事实、重试、计量与结算仍由 Core
//! 持有,不能通过本端口交给插件解释。
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::num::NonZeroU32;
use std::sync::{Arc, RwLock, Weak};
@@ -23,7 +23,9 @@ use crate::error::{GatewayError, ProviderError};
use crate::event::ProviderEvent;
use crate::identity::ProviderKind;
use crate::lifecycle::CancellationToken;
use crate::operation::OperationKind;
use crate::operation::{
CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload,
};
use crate::policy::ClientApiKeyId;
use crate::runtime::extensions::{ExtensionSetId, ExtensionSetReference};
@@ -95,13 +97,27 @@ pub struct MiddlewareRequest {
protocol: String,
headers: Vec<MiddlewareHeader>,
body: Bytes,
original_body: Bytes,
original_protocol: String,
capabilities: Option<MiddlewareCapabilityDeclaration>,
}
/// 只声明具体功能的责任归属,实际正文中的需求始终继续生效。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MiddlewareCapabilityDeclaration {
pub handled: BTreeSet<Feature>,
pub required: BTreeSet<Feature>,
}
impl MiddlewareRequest {
#[must_use]
pub fn new(protocol: impl Into<String>, headers: Vec<MiddlewareHeader>, body: Bytes) -> Self {
let protocol = protocol.into();
Self {
protocol: protocol.into(),
original_protocol: protocol.clone(),
original_body: body.clone(),
capabilities: None,
protocol,
headers,
body,
}
@@ -126,6 +142,78 @@ impl MiddlewareRequest {
pub fn into_parts(self) -> (String, Vec<MiddlewareHeader>, Bytes) {
(self.protocol, self.headers, self.body)
}
/// 保留链入口的语义来源;新增声明必须对应当前这一层实际消除的功能字段。
pub fn replace_parts(
mut self,
protocol: String,
headers: Vec<MiddlewareHeader>,
body: Bytes,
declaration: Option<MiddlewareCapabilityDeclaration>,
) -> Result<Self, MiddlewareError> {
if let Some(declaration) = declaration {
if self.protocol != "openai"
|| protocol != "openai"
|| self.original_protocol != "openai"
|| declaration.handled.contains(&Feature::NativeContinuation)
|| !declaration.handled.is_disjoint(&declaration.required)
|| (declaration.handled.is_empty() && declaration.required.is_empty())
{
return Err(MiddlewareError::InvalidState);
}
let before = generate_requirements(&self.body)?;
let after = generate_requirements(&body)?;
if !declaration.handled.is_subset(before.features())
|| !declaration.handled.is_disjoint(after.features())
{
return Err(MiddlewareError::InvalidState);
}
let accumulated = self.capabilities.get_or_insert_default();
accumulated.handled.extend(declaration.handled);
accumulated.required.extend(declaration.required);
}
self.protocol = protocol;
self.headers = headers;
self.body = body;
Ok(self)
}
/// API 在最终解码后应用;没有声明的透传路径不解析或重新编码正文。
pub fn apply_capabilities(&self, operation: Operation) -> Result<Operation, MiddlewareError> {
let Some(declaration) = &self.capabilities else {
return Ok(operation);
};
if operation.kind() != OperationKind::Generate || operation.protocol() != "openai" {
return Err(MiddlewareError::InvalidState);
}
let original = generate_requirements(&self.original_body)?;
let mut effective = CapabilityRequirements::new(OperationKind::Generate);
for feature in original
.features()
.difference(&declaration.handled)
.chain(declaration.required.iter())
{
effective = effective.require(*feature);
}
operation
.with_middleware_requirements(original, effective)
.map_err(|_| MiddlewareError::InvalidState)
}
#[must_use]
pub fn has_capability_declaration(&self) -> bool {
self.capabilities.is_some()
}
}
fn generate_requirements(body: &[u8]) -> Result<CapabilityRequirements, MiddlewareError> {
let body = serde_json::from_slice(body).map_err(|_| MiddlewareError::InvalidState)?;
let payload =
ProtocolPayload::json_object("openai", body).map_err(|_| MiddlewareError::InvalidState)?;
Ok(
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
.capability_requirements(),
)
}
impl fmt::Debug for MiddlewareRequest {
@@ -235,6 +235,37 @@ pub enum AccountScheduleDecision {
Reject,
}
/// 重试策略只能收窄宿主决定;不能自行创造恢复路径。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryDecision {
Delegate,
Stop,
Retry,
}
/// 协调器计算的安全失败事实;无凭据、原始错误或请求正文。
#[derive(Debug, Clone)]
pub struct RetryFacts {
pub attempt_index: NonZeroU32,
pub provider: ProviderKind,
pub model: Option<String>,
pub error_kind: crate::error::ProviderErrorKind,
pub upstream_status: Option<u16>,
pub send_state: super::UpstreamSendState,
pub remaining_routing_attempts: u32,
pub remaining_deadline: std::time::Duration,
pub retry_allowed: bool,
}
#[derive(Debug, Clone)]
pub struct RetryInput {
pub request_id: super::ModelRequestId,
pub client_key_id: ClientApiKeyId,
pub account_group_ids: Arc<[AccountGroupId]>,
pub extension_scope: ExtensionCallScope,
pub facts: RetryFacts,
}
/// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("request policy call failed")]
@@ -253,6 +284,13 @@ pub enum AccountPolicyError {
/// 一个发布代次的不可变请求策略计划。
pub trait RequestPolicyPlan: Send + Sync + fmt::Debug {
fn retry_decision(
&self,
_input: RetryInput,
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
Box::pin(async { Ok(RetryDecision::Delegate) })
}
fn route_model(
&self,
input: ModelRouteInput,
@@ -288,6 +326,25 @@ impl fmt::Debug for RequestPolicyContext {
}
impl RequestPolicyContext {
pub async fn retry_decision(&self, facts: RetryFacts) -> RetryDecision {
self.plan
.retry_decision(RetryInput {
request_id: self.request_id.clone(),
client_key_id: self.client_key_id.clone(),
account_group_ids: self.account_group_ids.clone(),
extension_scope: self.extension_scope.clone(),
facts,
})
.await
.unwrap_or_else(|_| {
tracing::warn!(
request_id = self.request_id.as_str(),
"重试策略调用失败,使用宿主决定"
);
RetryDecision::Delegate
})
}
#[must_use]
pub fn new(
plan: Arc<dyn RequestPolicyPlan>,
@@ -479,6 +479,9 @@ impl MiddlewareNext for ProviderMiddlewareNext {
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
Box::pin(async move {
let fallback_protocol = self.operation.protocol().to_owned();
if request.has_capability_declaration() {
return Err(MiddlewareError::InvalidState);
}
let (protocol, headers, body) = request.into_parts();
validate_attempt_headers(&headers)?;
let operation = self
+4 -1
View File
@@ -336,7 +336,10 @@ impl CoreControlPlaneStartup {
match self.publisher.refresh().await {
Ok(_) => {}
// 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求。
Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {}
Err(
routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable
| routing::snapshot::RuntimeSnapshotCompileError::InvalidExtensionModels,
) => {}
Err(_) => return Err(CoreError::SnapshotUnavailable),
}
Ok(CoreControlPlaneBundle {
@@ -374,6 +374,7 @@ struct GeneratePayload {
protocol_payload: ProtocolPayload,
provider_session_state: Option<ProviderSessionState>,
source_requirements: Option<CapabilityRequirements>,
middleware_requirements: Option<(CapabilityRequirements, CapabilityRequirements)>,
}
impl GenerateRequest {
@@ -388,6 +389,7 @@ impl GenerateRequest {
protocol_payload,
provider_session_state: None,
source_requirements: None,
middleware_requirements: None,
}),
}
}
@@ -487,6 +489,11 @@ impl GenerateRequest {
}
let mut requirements = CapabilityRequirements::new(OperationKind::Generate)
.with_requested_output_tokens(self.max_output_tokens());
if let Some((_, inherited)) = &self.payload.middleware_requirements {
for feature in inherited.features() {
requirements = requirements.require(*feature);
}
}
if self
.body()
.get("tools")
@@ -837,6 +844,31 @@ pub enum Operation {
}
impl Operation {
pub(crate) fn with_middleware_requirements(
self,
original: CapabilityRequirements,
inherited: CapabilityRequirements,
) -> Result<Self, OperationError> {
let Self::Generate(mut request) = self else {
return Err(OperationError::EmptyField {
field: "middleware capabilities",
});
};
Arc::make_mut(&mut request.payload).middleware_requirements = Some((original, inherited));
Ok(Self::Generate(request))
}
/// 请求转换前的需求仅供诊断,选路始终使用有效上游需求。
#[must_use]
pub fn original_capability_requirements(&self) -> CapabilityRequirements {
if let Self::Generate(request) = self
&& let Some((original, _)) = &request.payload.middleware_requirements
{
return original.clone();
}
self.capability_requirements()
}
/// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。
///
/// # Errors
@@ -1,9 +1,10 @@
//! 快照编译与客户端读取需要的 Provider 目录合同,不暴露执行或账号选择能力。
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use futures::future::BoxFuture;
use crate::account::ProviderAccountId;
use crate::identity::ProviderKind;
use crate::operation::RawJsonPayload;
@@ -11,6 +12,15 @@ use super::{
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
};
/// 扩展集合持有的直接模型别名;执行与元数据仍归属内置 Provider。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContributedModelAlias {
pub owner: String,
pub id: PublicModelId,
pub provider: ProviderKind,
pub target: UpstreamModelId,
}
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
@@ -54,6 +64,7 @@ pub struct ProviderModelCapabilities {
upstream_model: UpstreamModelId,
capabilities: ModelCapabilities,
presentation: Option<ModelPresentation>,
catalog_accounts: Option<BTreeSet<ProviderAccountId>>,
}
impl ProviderModelCapabilities {
@@ -63,6 +74,7 @@ impl ProviderModelCapabilities {
upstream_model,
capabilities,
presentation: None,
catalog_accounts: None,
}
}
@@ -72,6 +84,19 @@ impl ProviderModelCapabilities {
self
}
/// 声明发现此模型的账号;仅用于目录可见性,不作为推理能力白名单。
#[must_use]
pub fn with_catalog_accounts(mut self, accounts: BTreeSet<ProviderAccountId>) -> Self {
self.catalog_accounts = Some(accounts);
self
}
/// 未提供账号来源的 Provider 沿用 Provider 级目录;空集合表示无可展示来源。
#[must_use]
pub const fn catalog_accounts(&self) -> Option<&BTreeSet<ProviderAccountId>> {
self.catalog_accounts.as_ref()
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
@@ -9,9 +9,9 @@ pub use crate::account::scope::{
};
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor,
PublicModelDescriptor,
ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent,
ProviderModelDescriptor, PublicModelDescriptor,
};
pub use snapshot::RuntimeSnapshot;
@@ -27,6 +27,8 @@ use super::{
const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
type ModelCatalogAccounts = BTreeMap<ProviderKind, BTreeMap<String, BTreeSet<ProviderAccountId>>>;
/// Store 在一个一致性读取中提供的调度设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSettingsFacts {
@@ -40,6 +42,7 @@ pub struct SnapshotSettingsFacts {
concurrency_wait_timeout_seconds: u32,
responses_max_decompressed_body_bytes: u64,
request_interval_ms: u64,
smart_scheduling: crate::account::SmartSchedulingConfig,
rotation_strategy: String,
model_mappings: BTreeMap<String, String>,
min_codex_desktop_version: Option<String>,
@@ -47,6 +50,15 @@ pub struct SnapshotSettingsFacts {
}
impl SnapshotSettingsFacts {
#[must_use]
pub const fn with_smart_scheduling(
mut self,
config: crate::account::SmartSchedulingConfig,
) -> Self {
self.smart_scheduling = config;
self
}
#[must_use]
pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self {
self.pricing = Arc::new(pricing);
@@ -112,6 +124,7 @@ impl SnapshotSettingsFacts {
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
request_interval_ms,
smart_scheduling: crate::account::SmartSchedulingConfig::default(),
rotation_strategy: rotation_strategy.into(),
model_mappings,
min_codex_desktop_version,
@@ -303,6 +316,8 @@ pub enum RuntimeSnapshotCompileError {
RevisionChanged,
#[error("runtime snapshot contains invalid frozen data")]
InvalidData,
#[error("extension model aliases conflict with the provider catalog or model mappings")]
InvalidExtensionModels,
#[error("provider model catalog changed while the snapshot was compiling")]
CatalogChanged,
}
@@ -420,13 +435,15 @@ impl RuntimeSnapshotCompiler {
{
return Err(RuntimeSnapshotCompileError::RevisionChanged);
}
return Ok(snapshot
let snapshot = snapshot
.with_provider_catalog_generations(if cached.is_some() {
BTreeMap::new()
} else {
observed_generations
})
.with_extensions(extensions));
.with_extensions(extensions);
snapshot.validate_extension_models()?;
return Ok(snapshot);
}
}
Err(RuntimeSnapshotCompileError::CatalogChanged)
@@ -441,9 +458,13 @@ async fn compile_runtime_snapshot(
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。
let mut provider_models = Vec::new();
let mut catalog_accounts = BTreeMap::new();
let mut exhaustive_provider_catalogs = BTreeSet::new();
for provider in &provider_kinds {
if let Some(previous) = previous {
if let Some(accounts) = previous.model_catalog_accounts.get(provider) {
catalog_accounts.insert(provider.clone(), accounts.clone());
}
if previous.exhaustive_provider_catalogs.contains(provider) {
exhaustive_provider_catalogs.insert(provider.clone());
}
@@ -470,6 +491,12 @@ async fn compile_runtime_snapshot(
exhaustive_provider_catalogs.insert(provider.clone());
}
provider_models.extend(models.into_iter().map(|model| {
if let Some(accounts) = model.catalog_accounts() {
catalog_accounts
.entry(provider.clone())
.or_default()
.insert(model.upstream_model().as_str().to_owned(), accounts.clone());
}
let compiled = ProviderModel::new(
provider.clone(),
model.upstream_model().clone(),
@@ -576,6 +603,7 @@ async fn compile_runtime_snapshot(
AccountConcurrency::new(facts.settings.max_concurrent_per_account),
Duration::from_millis(facts.settings.request_interval_ms),
)
.with_smart_scheduling(facts.settings.smart_scheduling)
.with_queue(ConcurrencyQueuePolicy {
max_waiting: facts.settings.max_waiting_per_account,
timeout: queue_timeout,
@@ -661,6 +689,7 @@ async fn compile_runtime_snapshot(
.with_model_mappings(model_mappings)
.with_account_directory(account_directory)
.with_exhaustive_provider_catalogs(exhaustive_provider_catalogs)
.with_model_catalog_accounts(catalog_accounts)
.with_min_codex_client_versions(min_client_versions)
})
}
@@ -682,6 +711,7 @@ pub struct RuntimeSnapshot {
model_mappings: Arc<BTreeMap<String, String>>,
provider_catalog_generations: Arc<BTreeMap<ProviderKind, ProviderCatalogGeneration>>,
exhaustive_provider_catalogs: Arc<BTreeSet<ProviderKind>>,
model_catalog_accounts: Arc<ModelCatalogAccounts>,
account_directory: Arc<RuntimeAccountDirectory>,
client_policies: Arc<BTreeMap<ClientApiKeyId, ClientPolicy>>,
min_codex_client_versions: CodexClientMinVersions,
@@ -701,6 +731,47 @@ impl RuntimeSnapshot {
pub const fn extensions(&self) -> Option<&crate::runtime::extensions::ExtensionSetReference> {
self.extensions.as_ref()
}
fn model_aliases(&self) -> &[super::ContributedModelAlias] {
self.extensions
.as_ref()
.map_or(&[], |set| set.model_aliases())
}
fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> {
self.model_aliases()
.iter()
.find(|alias| alias.id.as_str() == model)
}
fn validate_extension_models(&self) -> Result<(), RuntimeSnapshotCompileError> {
let mut ids = BTreeSet::new();
for alias in self.model_aliases() {
let valid = ids.insert(&alias.id)
&& self.providers.contains(&alias.provider)
&& !self.model_mappings.contains_key(alias.id.as_str())
&& !self.model_mappings.contains_key(alias.target.as_str())
&& !self
.model_mappings
.values()
.any(|target| target == alias.id.as_str())
&& self.model_alias(alias.target.as_str()).is_none()
&& !self.provider_models.values().any(|models| {
models
.keys()
.any(|model| model.as_str() == alias.id.as_str())
})
&& self
.provider_models
.get(&alias.provider)
.is_some_and(|models| models.contains_key(&alias.target));
if !valid {
tracing::warn!(owner = %alias.owner, model = %alias.id, "插件模型别名与宿主目录或映射冲突,拒绝发布候选快照");
return Err(RuntimeSnapshotCompileError::InvalidExtensionModels);
}
}
Ok(())
}
#[must_use]
pub fn with_pricing(mut self, pricing: Arc<crate::metering::PricingOverrides>) -> Self {
self.pricing = pricing;
@@ -823,6 +894,7 @@ impl RuntimeSnapshot {
model_mappings: Arc::new(BTreeMap::new()),
provider_catalog_generations: Arc::new(BTreeMap::new()),
exhaustive_provider_catalogs: Arc::new(exhaustive_provider_catalogs),
model_catalog_accounts: Arc::default(),
account_directory: Arc::new(RuntimeAccountDirectory::default()),
client_policies: Arc::new(client_policy_map),
min_codex_client_versions: CodexClientMinVersions::default(),
@@ -853,6 +925,11 @@ impl RuntimeSnapshot {
self
}
fn with_model_catalog_accounts(mut self, accounts: ModelCatalogAccounts) -> Self {
self.model_catalog_accounts = Arc::new(accounts);
self
}
#[must_use]
pub fn all_account_scope(&self) -> Arc<FrozenAccountScope> {
Arc::new(FrozenAccountScope::new(
@@ -901,6 +978,12 @@ impl RuntimeSnapshot {
.keys()
.filter_map(|model| PublicModelId::new(model.clone()).ok()),
);
models.extend(
self.model_aliases()
.iter()
.filter(|alias| &alias.provider == provider)
.map(|alias| alias.id.clone()),
);
models.into_iter().collect()
}
@@ -930,6 +1013,15 @@ impl RuntimeSnapshot {
profiles.insert(public_model, presentation.clone());
}
}
for alias in self
.model_aliases()
.iter()
.filter(|alias| &alias.provider == provider)
{
if let Some(presentation) = presentations.get(&alias.target) {
profiles.insert(alias.id.clone(), presentation.clone());
}
}
profiles
.into_iter()
.map(|(model, presentation)| super::PublicModelProfile::new(model, presentation))
@@ -955,9 +1047,7 @@ impl RuntimeSnapshot {
.flat_map(|provider| {
self.public_models_for_provider(provider)
.into_iter()
.filter(|model| {
scope.allows_provider_model(provider, &self.mapped_model(model.as_str()))
})
.filter(|model| self.catalog_model_allowed_for_scope(provider, model, scope))
})
.collect::<BTreeSet<_>>()
.into_iter()
@@ -973,9 +1063,7 @@ impl RuntimeSnapshot {
let mut profiles = BTreeMap::new();
for provider in scope.provider_kinds() {
for profile in self.public_model_profiles_for_provider(provider) {
if !scope
.allows_provider_model(provider, &self.mapped_model(profile.model().as_str()))
{
if !self.catalog_model_allowed_for_scope(provider, profile.model(), scope) {
continue;
}
profiles
@@ -999,6 +1087,12 @@ impl RuntimeSnapshot {
if !self.providers.contains(provider) {
return false;
}
if self
.model_alias(public_model.as_str())
.is_some_and(|alias| &alias.provider != provider)
{
return false;
}
if !self.exhaustive_provider_catalogs.contains(provider) {
return true;
}
@@ -1016,12 +1110,35 @@ impl RuntimeSnapshot {
) -> bool {
scope.provider_kinds().iter().any(|provider| {
self.contains_public_model_for_provider(public_model, provider)
&& scope.allows_provider_model(provider, &self.mapped_model(public_model.as_str()))
&& self.catalog_model_allowed_for_scope(provider, public_model, scope)
})
}
pub(crate) fn catalog_model_allowed_for_scope(
&self,
provider: &ProviderKind,
public_model: &PublicModelId,
scope: &FrozenAccountScope,
) -> bool {
let upstream_model = self.mapped_model(public_model.as_str());
match self
.model_catalog_accounts
.get(provider)
.and_then(|models| models.get(&upstream_model))
{
Some(accounts) => accounts.iter().any(|account| {
scope.account_provider(account) == Some(provider)
&& scope.allows_model(account, &upstream_model)
}),
None => scope.allows_provider_model(provider, &upstream_model),
}
}
#[must_use]
pub fn mapped_model(&self, requested: &str) -> String {
if let Some(alias) = self.model_alias(requested) {
return alias.target.as_str().to_owned();
}
let original = requested;
let mut current = original.to_owned();
let mut seen = BTreeSet::new();
@@ -1122,6 +1239,12 @@ impl RuntimeSnapshot {
if !self.providers.contains(provider) {
continue;
}
if self
.model_alias(public_model.as_str())
.is_some_and(|alias| &alias.provider != provider)
{
continue;
}
if context
.required_provider
.as_ref()
@@ -1132,7 +1255,9 @@ impl RuntimeSnapshot {
}
let requested_model = public_model.as_str();
let mapped_model = self.mapped_model(requested_model);
let upstream_model = if self.model_mappings.contains_key(requested_model) {
let upstream_model = if self.model_mappings.contains_key(requested_model)
|| self.model_alias(requested_model).is_some()
{
UpstreamModelId::new(mapped_model)
} else {
UpstreamModelId::from_client_wire(mapped_model)
@@ -1,10 +1,10 @@
//! 可发布能力集合的中立身份与保活合同;不解释包格式、进程或管理配置。
//! 可发布能力集合的中立身份、静态目录与保活合同;不解释包格式、进程或管理配置。
use std::{fmt, sync::Arc};
use futures::future::BoxFuture;
use crate::routing::ConfigRevision;
use crate::routing::{ConfigRevision, ContributedModelAlias};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ExtensionSetId(String);
@@ -32,6 +32,11 @@ impl ExtensionSetId {
pub trait ExtensionSetLease: Send + Sync {
fn is_ready(&self) -> bool;
/// 与集合一起冻结的目录事实,读取不调用插件进程。
fn model_aliases(&self) -> &[ContributedModelAlias] {
&[]
}
/// 局部故障已有请求级处理计划时,集合仍可提供服务。
fn can_serve(&self) -> bool {
self.is_ready()
@@ -61,6 +66,11 @@ impl ExtensionSetReference {
pub fn can_serve(&self) -> bool {
self.lease.can_serve()
}
#[must_use]
pub fn model_aliases(&self) -> &[ContributedModelAlias] {
self.lease.model_aliases()
}
}
impl fmt::Debug for ExtensionSetReference {
@@ -69,6 +69,7 @@ pub struct RuntimeSnapshotUnavailable;
#[derive(Clone, Default)]
pub struct RuntimeSnapshotHandle {
current: Arc<RwLock<Option<Arc<RuntimeSnapshot>>>>,
publications: Arc<SyncMutex<Vec<futures::channel::mpsc::Sender<()>>>>,
}
impl RuntimeSnapshotHandle {
@@ -76,15 +77,38 @@ impl RuntimeSnapshotHandle {
pub fn new(initial: RuntimeSnapshot) -> Self {
Self {
current: Arc::new(RwLock::new(Some(Arc::new(initial)))),
publications: Default::default(),
}
}
pub fn publish(&self, snapshot: RuntimeSnapshot) {
*write_unpoisoned(&self.current) = Some(Arc::new(snapshot));
self.notify_publication();
}
pub fn suspend(&self) {
*write_unpoisoned(&self.current) = None;
self.notify_publication();
}
/// 合并发布与暂停通知;订阅者收到后读取当前快照,不依赖逐条投递。
pub fn subscribe_publications(&self) -> futures::channel::mpsc::Receiver<()> {
let (sender, receiver) = futures::channel::mpsc::channel(0);
self.publications
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(sender);
receiver
}
fn notify_publication(&self) {
self.publications
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain_mut(|sender| match sender.try_send(()) {
Ok(()) => true,
Err(error) => error.is_full(),
});
}
#[must_use]
@@ -3,6 +3,7 @@ mod model_access;
mod proxy;
mod selection;
mod selection_diagnostics;
mod smart_scheduling;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -0,0 +1,349 @@
use gateway_core::account::{
AccountSelector, PreferredAccountSelection, RotationStrategy, SmartSchedulingConfig,
};
use serde_json::json;
use std::time::{Duration, Instant, SystemTime};
use futures::FutureExt;
use gateway_core::concurrency::{
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
};
use super::{candidate_with_concurrency, context, weighted_candidate};
#[test]
fn smart_config_rejects_invalid_weights_and_incomplete_wire_values() {
for value in [-0.1, 10.1, 0.01, f64::NAN, f64::INFINITY] {
for dimension in 0..6 {
let mut weights = [1.0; 6];
weights[dimension] = value;
assert!(SmartSchedulingConfig::new(weights, false).is_err());
}
}
assert!(SmartSchedulingConfig::new([0.0; 6], true).is_err());
for value in [json!({}), json!(null), json!({"loadWeight": 1.0})] {
assert!(serde_json::from_value::<SmartSchedulingConfig>(value).is_err());
}
let config = SmartSchedulingConfig::new([0.0, 0.1, 9.9, 10.0, 1.2, 2.3], true).unwrap();
let encoded = serde_json::to_value(config).unwrap();
assert_eq!(
serde_json::from_value::<SmartSchedulingConfig>(encoded).unwrap(),
config
);
}
#[test]
fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
let mut context = context(RotationStrategy::Smart);
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 1.0, 0.0], false).unwrap(),
);
let mut candidates = [
candidate_with_concurrency("acct_unknown", 0, 2),
candidate_with_concurrency("acct_past", 0, 2),
candidate_with_concurrency("acct_now", 0, 2),
candidate_with_concurrency("acct_near", 0, 2),
candidate_with_concurrency("acct_far", 0, 2),
];
candidates[1].signals.quota_reset_at = Some(context.now - Duration::from_secs(1));
candidates[2].signals.quota_reset_at = Some(context.now);
candidates[3].signals.quota_reset_at = Some(context.now + Duration::from_secs(3600));
candidates[4].signals.quota_reset_at = Some(context.now + Duration::from_secs(86_400));
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_near"
);
let trace = gateway_core::diagnostics::TraceContext::new("req_reset_scores");
trace.account_selection(&candidates, &context, None);
let snapshot = trace.snapshot().unwrap();
let scores = snapshot["events"][0]["data"]["candidates"]
.as_array()
.unwrap();
for (candidate, expected) in scores.iter().zip([0.0, 0.0, 0.0, 0.5, 0.04]) {
assert_eq!(candidate["smartScore"].as_f64().unwrap(), expected);
}
candidates[3].signals.in_flight = 2;
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_far"
);
candidates[3].signals.in_flight = 0;
context.now += Duration::from_secs(3600);
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_far"
);
}
#[test]
fn queue_weight_balances_live_pressure_and_health_only_when_enabled_for_smart() {
for (strategy, queue_weight, expects_shortest) in [
(RotationStrategy::Smart, 0.0, true),
(RotationStrategy::Smart, 0.2, false),
(RotationStrategy::Smart, 2.0, true),
(RotationStrategy::RoundRobin, 0.2, true),
(RotationStrategy::QuotaResetPriority, 0.2, true),
(RotationStrategy::Sticky, 0.2, true),
] {
let mut candidates = [
candidate_with_concurrency("acct_healthy", 2, 2),
candidate_with_concurrency("acct_shortest", 2, 2),
];
candidates[1].signals.failure_rate_basis_points = Some(6000);
let mut context = context(strategy);
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([0.0, 0.0, 1.0, 0.0, 0.0, queue_weight], false).unwrap(),
);
let queue = ConcurrencyWaitQueue::default();
let head = queue
.enqueue(
&[candidates[0].account.id().clone()],
2,
Instant::now() + Duration::from_secs(2),
)
.unwrap();
let budget = ConcurrencyWaitBudget::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 2,
timeout: Duration::from_secs(2),
};
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
);
let keys = AccountSelector.wait_candidates(&candidates, &context);
assert!(
AccountSelector
.wait_for_capacity(&mut waiting, &keys, &candidates, &context)
.now_or_never()
.is_none()
);
assert_eq!(
queue.has_waiters(candidates[1].account.id()),
expects_shortest,
"{strategy:?}, {queue_weight}"
);
// 新请求不能绕过已经选定的队列,取消请求同步归还位置。
let newcomer = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
);
assert!(!newcomer.can_try(candidates[usize::from(expects_shortest)].account.id()));
drop(waiting);
assert!(!queue.has_waiters(candidates[1].account.id()));
drop(head);
assert!(!queue.has_waiters(candidates[0].account.id()));
}
}
#[test]
fn queue_weight_does_not_widen_immediate_selection_tolerance() {
let mut candidates = [
candidate_with_concurrency("acct_a", 0, 2),
candidate_with_concurrency("acct_b", 0, 2),
];
candidates[0].signals.quota_remaining_rank = Some(10000);
candidates[1].signals.quota_remaining_rank = Some(9000);
let mut context = context(RotationStrategy::Smart);
context.round_robin_cursor = 1;
for queue_weight in [0.0, 10.0] {
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([0.0, 1.0, 0.0, 0.0, 0.0, queue_weight], false).unwrap(),
);
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_a"
);
}
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 0.0, 1.0], false).unwrap(),
);
// 仅开启排队系数时,立即可用的同权重账号按既有游标轮换。
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_b"
);
}
#[test]
fn each_smart_weight_controls_its_signal_without_relaxing_capacity() {
let mut candidates = [
candidate_with_concurrency("acct_idle", 0, 10),
candidate_with_concurrency("acct_healthy", 5, 10),
];
candidates[0].signals.quota_remaining_rank = Some(1_000);
candidates[1].signals.quota_remaining_rank = Some(9_000);
candidates[0].signals.failure_rate_basis_points = Some(5_000);
candidates[1].signals.failure_rate_basis_points = Some(0);
candidates[0].signals.first_output_latency_ms = Some(30_000);
candidates[1].signals.first_output_latency_ms = Some(1_000);
for dimension in 0..4 {
let mut weights = [0.0; 6];
weights[dimension] = 1.0;
let mut context = context(RotationStrategy::Smart);
context.policy = context
.policy
.with_smart_scheduling(SmartSchedulingConfig::new(weights, false).unwrap());
let selected = AccountSelector.select(&candidates, &context).unwrap();
assert_eq!(
selected.candidate().account.id().as_str(),
if dimension == 0 {
"acct_idle"
} else {
"acct_healthy"
}
);
let mut saturated = candidates.clone();
saturated[1].signals.in_flight = 10;
assert_eq!(
AccountSelector
.select(&saturated, &context)
.unwrap()
.candidate()
.account
.id()
.as_str(),
"acct_idle"
);
}
}
#[test]
fn proportional_coefficients_preserve_near_best_rotation_and_diagnostics() {
let mut candidates = [
candidate_with_concurrency("acct_a", 0, 100),
candidate_with_concurrency("acct_b", 0, 100),
candidate_with_concurrency("acct_c", 0, 100),
];
// 默认额度权重 0.8 下,9375 正好位于容差边界,9374 刚好超出。
for (candidate, quota) in candidates.iter_mut().zip([10_000, 9_375, 9_374]) {
candidate.signals.quota_remaining_rank = Some(quota);
}
for scale in [1.0, 2.0, 5.0] {
let mut context = context(RotationStrategy::Smart);
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([scale, 0.8 * scale, scale, 0.5 * scale, 0.0, 0.0], false)
.unwrap(),
);
let mut ids = Vec::new();
for cursor in 0..4 {
candidates.reverse();
context.round_robin_cursor = cursor;
ids.push(
AccountSelector
.select(&candidates, &context)
.unwrap()
.candidate()
.account
.id()
.as_str()
.to_owned(),
);
}
assert_eq!(ids, ["acct_a", "acct_b", "acct_a", "acct_b"]);
let trace = gateway_core::diagnostics::TraceContext::new("req_scaled_weights");
trace.account_selection(&candidates, &context, None);
let snapshot = trace.snapshot().unwrap();
let tolerance = snapshot["events"][0]["data"]["smartScoreTolerance"]
.as_f64()
.unwrap();
assert!((tolerance - 0.05 * scale).abs() < 1e-12);
}
}
#[test]
fn switchback_is_opt_in_and_only_affects_smart_soft_affinity() {
let mut candidates = [
weighted_candidate("acct_affinity", 10, 0),
weighted_candidate("acct_primary", 100, 0),
];
for strategy in [
RotationStrategy::Smart,
RotationStrategy::Sticky,
RotationStrategy::RoundRobin,
RotationStrategy::QuotaResetPriority,
] {
let mut context = context(strategy);
context.preferred_account = Some(candidates[0].account.id().clone());
context.preferred_account_overrides_weight = true;
for enabled in [false, true] {
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], enabled).unwrap(),
);
let selected = AccountSelector.select(&candidates, &context).unwrap();
let should_switch = enabled && strategy == RotationStrategy::Smart;
assert_eq!(
selected.candidate().account.id(),
candidates[usize::from(should_switch)].account.id()
);
}
}
let mut context = context(RotationStrategy::Smart);
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], true).unwrap(),
);
context.preferred_account_overrides_weight = true;
context.preferred_account = Some(candidates[0].account.id().clone());
candidates[1].signals.in_flight = 3;
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.preferred(),
PreferredAccountSelection::Hit
);
candidates[1].signals.in_flight = 0;
context
.excluded_accounts
.insert(candidates[1].account.id().clone());
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.preferred(),
PreferredAccountSelection::Hit
);
context.excluded_accounts.clear();
candidates[0] = weighted_candidate("acct_affinity", 100, 2);
assert_eq!(
AccountSelector
.select(&candidates, &context)
.unwrap()
.preferred(),
PreferredAccountSelection::Hit
);
}
@@ -159,3 +159,75 @@ fn creating_a_waiter_does_not_start_the_budget_before_queueing() {
waiting.wait(&["account"]).await.unwrap();
});
}
#[test]
fn custom_priority_reads_live_counts_and_preserves_limits_and_fifo() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let head = queue.enqueue(&["a"], 2, deadline).unwrap();
let mut observed = Vec::new();
let tail = queue
.enqueue_with_priority(&["a", "b"], 2, deadline, |key, count| {
observed.push((*key, count));
if *key == "a" { 10.0 } else { 0.0 }
})
.unwrap();
assert_eq!(observed, [("a", 1), ("b", 0)]);
assert_eq!(*tail.key(), "a");
assert!(tail.turn().now_or_never().is_none());
// 满队列不参与评分,不能因偏好而突破上限。
let fallback = queue
.enqueue_with_priority(&["a", "b"], 2, deadline, |key, count| {
assert_eq!((*key, count), ("b", 0));
0.0
})
.unwrap();
assert_eq!(*fallback.key(), "b");
drop(head);
tail.turn().await.unwrap();
drop((tail, fallback));
let tie = queue
.enqueue_with_priority(&["b", "a"], 2, deadline, |_, _| 1.0)
.unwrap();
assert_eq!(*tie.key(), "b");
});
}
#[test]
fn changed_scores_keep_existing_position_until_account_becomes_ineligible() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let budget = ConcurrencyWaitBudget::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 1,
timeout: Duration::from_secs(2),
};
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
);
waiting
.wait_with_priority(&["a", "b"], |key, _| if *key == "a" { 1.0 } else { 0.0 })
.await
.unwrap();
waiting
.wait_with_priority(&["a", "b"], |_, _| {
panic!("existing ticket must not be rescored")
})
.await
.unwrap();
assert!(queue.has_waiters(&"a"));
assert!(!queue.has_waiters(&"b"));
waiting
.wait_with_priority(&["b"], |_, _| 1.0)
.await
.unwrap();
assert!(!queue.has_waiters(&"a"));
assert!(queue.has_waiters(&"b"));
drop(waiting);
assert!(!queue.has_waiters(&"b"));
});
}
@@ -723,6 +723,162 @@ struct FixedRoutingPolicy {
inputs: Mutex<Vec<ModelRouteInput>>,
}
#[derive(Debug)]
struct RetryTestPolicy {
decision: gateway_core::engine::policy::RetryDecision,
facts: Arc<Mutex<Vec<gateway_core::engine::policy::RetryFacts>>>,
}
impl RequestPolicyPlan for RetryTestPolicy {
fn route_model(
&self,
_: ModelRouteInput,
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
Box::pin(async { Ok(ModelRouteDecision::Unhandled) })
}
fn schedule_account(
&self,
_: AccountScheduleInput,
) -> BoxFuture<'static, Result<AccountScheduleDecision, RequestPolicyFault>> {
Box::pin(async { Ok(AccountScheduleDecision::Delegate) })
}
fn retry_decision(
&self,
input: gateway_core::engine::policy::RetryInput,
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
{
self.facts.lock().unwrap().push(input.facts);
let decision = self.decision;
Box::pin(async move { Ok(decision) })
}
}
struct RetryTestProvider {
error: ProviderError,
calls: AtomicUsize,
}
#[async_trait]
impl Provider for RetryTestProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(vec![])
}
async fn execute(
self: Arc<Self>,
request: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
if self.calls.fetch_add(1, Ordering::SeqCst) > 0 {
return Err(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
));
}
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().cloned().unwrap(),
ProviderAccountId::new("acct_openai").unwrap(),
UpstreamTransport::new("http").unwrap(),
);
Ok(ProviderStream::new(
metadata,
futures::stream::iter([Err(self.error.clone())]),
(),
))
}
}
#[test]
fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
use gateway_core::engine::policy::RetryDecision;
for (decision, send_state, replay_safe, expected_allowed, expected_calls) in [
(
RetryDecision::Stop,
UpstreamSendState::NotSent,
true,
true,
1,
),
(
RetryDecision::Retry,
UpstreamSendState::NotSent,
true,
true,
2,
),
(
RetryDecision::Retry,
UpstreamSendState::Ambiguous,
true,
false,
1,
),
(
RetryDecision::Retry,
UpstreamSendState::Sent,
false,
false,
1,
),
] {
block_on(async {
let error = ProviderError::new(ProviderErrorKind::Transport, send_state);
let error = if replay_safe {
error.with_replay_safe()
} else {
error
};
let provider = Arc::new(RetryTestProvider {
error,
calls: AtomicUsize::new(0),
});
let facts = Arc::new(Mutex::new(vec![]));
let generation = super::extensions::reference("retry-policy");
let policies = RequestPolicyExtensionIndex::default();
let _owner = policies
.register(
generation.id().clone(),
Arc::new(RetryTestPolicy {
decision,
facts: facts.clone(),
}),
)
.unwrap();
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(request_policy_snapshot(
generation,
&[ProviderKind::new("openai").unwrap()],
)),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
Arc::new(Admissions::default()),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
)
.with_request_policies(policies);
let mut started = service
.start(request(&service, ClientTransport::HttpJson))
.await
.unwrap();
assert!(started.session.collect_uncommitted().await.is_err());
assert_eq!(provider.calls.load(Ordering::SeqCst), expected_calls);
let observed = facts.lock().unwrap();
assert_eq!(observed[0].retry_allowed, expected_allowed);
assert_eq!(observed[0].send_state, send_state);
assert_eq!(observed[0].remaining_routing_attempts, 31);
});
}
}
impl RequestPolicyPlan for FixedRoutingPolicy {
fn route_model(
&self,
@@ -2957,11 +3113,34 @@ impl Provider for NativeCatalogProvider {
#[test]
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
#[derive(Debug)]
struct Aliases(Vec<gateway_core::routing::ContributedModelAlias>);
impl gateway_core::runtime::extensions::ExtensionSetLease for Aliases {
fn is_ready(&self) -> bool {
true
}
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
&self.0
}
}
for fail in [false, true] {
let snapshot = start_snapshot().with_model_mappings(BTreeMap::from([
("alias".to_owned(), "gpt-start".to_owned()),
("missing-alias".to_owned(), "unavailable-model".to_owned()),
]));
let snapshot = start_snapshot()
.with_model_mappings(BTreeMap::from([
("alias".to_owned(), "gpt-start".to_owned()),
("missing-alias".to_owned(), "unavailable-model".to_owned()),
]))
.with_extensions(Some(ExtensionSetReference::new(
gateway_core::runtime::extensions::ExtensionSetId::new("catalog-aliases".into())
.unwrap(),
Arc::new(Aliases(vec![
gateway_core::routing::ContributedModelAlias {
owner: "catalog-plugin".into(),
id: PublicModelId::new("plugin-alias").unwrap(),
provider: ProviderKind::new("openai").unwrap(),
target: UpstreamModelId::new("gpt-start").unwrap(),
},
])),
)));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
@@ -2993,9 +3172,13 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
.collect::<Vec<_>>();
assert_eq!(
pairs.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
["gpt-start", "alias"]
["gpt-start", "alias", "plugin-alias"]
);
assert_eq!(pairs[0].1, pairs[1].1, "Core preserves alias payload bytes");
assert_eq!(
pairs[0].1, pairs[2].1,
"插件别名使用相同原生对象,不伪造字段"
);
}
}
@@ -169,6 +169,138 @@ fn operation() -> Operation {
))
}
fn body_operation(body: &Value) -> Operation {
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(),
))
}
#[test]
fn capability_declaration_preserves_original_semantics_and_recomputes_upstream_needs() {
use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature};
let original = json!({"model":"gpt-test","input":"answer", "tools":[{"type":"function"}],
"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}});
let mut converted = original.clone();
converted.as_object_mut().unwrap().remove("text");
converted["input"] = json!(
"Return an object matching the supplied schema; the middleware validates the response."
);
let request = MiddlewareRequest::new(
"openai",
vec![],
serde_json::to_vec(&original).unwrap().into(),
)
.replace_parts(
"openai".into(),
vec![],
serde_json::to_vec(&converted).unwrap().into(),
Some(MiddlewareCapabilityDeclaration {
handled: [Feature::JsonSchema].into(),
required: [Feature::Reasoning].into(),
}),
)
.unwrap();
let operation = request
.apply_capabilities(body_operation(&converted))
.unwrap();
assert_eq!(
operation.original_capability_requirements(),
body_operation(&original).capability_requirements()
);
assert_eq!(
operation.capability_requirements().features(),
&[Feature::Tools, Feature::Reasoning].into()
);
assert_eq!(
request.body().as_ref(),
serde_json::to_vec(&converted).unwrap()
);
// 后续普通正文修改也不能删掉未承担的原始语义,或藏起实际新增的需求。
converted.as_object_mut().unwrap().remove("tools");
converted["input"] = json!([{"type":"input_image","image_url":"synthetic"}]);
let request = request
.replace_parts(
"openai".into(),
vec![],
serde_json::to_vec(&converted).unwrap().into(),
None,
)
.unwrap();
let operation = request
.apply_capabilities(body_operation(&converted))
.unwrap();
assert_eq!(
operation.capability_requirements().features(),
&[Feature::Tools, Feature::Reasoning, Feature::Vision].into()
);
let changed = operation
.replace_middleware_wire(
"openai",
serde_json::to_vec(&json!({"input":"answer","text":{"format":{"type":"json_schema"}}}))
.unwrap()
.into(),
)
.unwrap();
assert!(
changed
.capability_requirements()
.features()
.contains(&Feature::JsonSchema)
);
}
#[test]
fn capability_declaration_cannot_erase_remaining_or_invented_features_or_native_continuation() {
use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature};
let source = json!({"tools":[{"type":"function"}],"previous_response_id":"resp-original"});
let request = MiddlewareRequest::new(
"openai",
vec![],
serde_json::to_vec(&source).unwrap().into(),
);
for (handled, body) in [
(Feature::Tools, source),
(Feature::JsonSchema, json!({"input":"removed"})),
(Feature::NativeContinuation, json!({"input":"removed"})),
] {
assert!(
request
.clone()
.replace_parts(
"openai".into(),
vec![],
serde_json::to_vec(&body).unwrap().into(),
Some(MiddlewareCapabilityDeclaration {
handled: [handled].into(),
required: Default::default()
})
)
.is_err()
);
}
let converted = json!({"input":"tools converted"});
let request = request
.replace_parts(
"openai".into(),
vec![],
serde_json::to_vec(&converted).unwrap().into(),
Some(MiddlewareCapabilityDeclaration {
handled: [Feature::Tools].into(),
required: Default::default(),
}),
)
.unwrap();
assert!(
request
.apply_capabilities(body_operation(&converted))
.unwrap()
.capability_requirements()
.features()
.contains(&Feature::NativeContinuation)
);
}
#[test]
fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
futures::executor::block_on(async {
@@ -169,6 +169,52 @@ fn delegated_scheduler_keeps_builtin_affinity() {
});
}
#[test]
fn smart_switchback_does_not_override_explicit_plugin_choices_or_their_telemetry() {
block_on(async {
let mut candidates = [candidate("acct_a"), candidate("acct_b")];
candidates[0].account = candidates[0]
.account
.clone()
.with_scheduling(None, AccountWeight::new(10).unwrap());
let mut selection_context = context();
selection_context.policy = AccountSelectionPolicy::new(
RotationStrategy::Smart,
NonZeroU32::new(2).unwrap(),
Duration::ZERO,
)
.with_smart_scheduling(
gateway_core::account::SmartSchedulingConfig::new([1.0, 0.8, 1.0, 0.5, 0.0, 0.0], true)
.unwrap(),
);
for chosen in [Some("acct_a"), Some("acct_b"), None] {
let selected = policy(chosen)
.select_account(
NonZeroU32::new(1).unwrap(),
&ProviderKind::new("openai").unwrap(),
None,
&candidates,
&selection_context,
)
.await
.unwrap()
.unwrap();
assert_eq!(
selected.candidate().account.id().as_str(),
chosen.unwrap_or("acct_b")
);
let expected = match chosen {
Some("acct_a") => PreferredAccountSelection::Hit,
Some(_) => PreferredAccountSelection::OverriddenByPolicy,
None => PreferredAccountSelection::Blocked(
gateway_core::account::AccountSchedulingBlocker::LowerWeight,
),
};
assert_eq!(selected.preferred(), expected);
}
});
}
#[test]
fn scheduler_cannot_select_outside_the_hard_bound_candidate_pool() {
block_on(async {
@@ -13,9 +13,9 @@ use gateway_core::routing::snapshot::{
SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ModelPresentation, ProviderCatalogGeneration,
ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind, ProviderModelCapabilities,
PublicModelId, UpstreamModelId,
ConfigRevision, ContributedModelAlias, ModelCapabilities, ModelPresentation,
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind,
ProviderModelCapabilities, PublicModelId, UpstreamModelId,
};
use gateway_core::runtime::extensions::{
ExtensionPreparationError, ExtensionPreparationPort, ExtensionSetId, ExtensionSetLease,
@@ -93,6 +93,134 @@ struct CountingExtensionPreparation {
drops: Arc<AtomicUsize>,
}
struct AliasLease(Vec<ContributedModelAlias>);
impl ExtensionSetLease for AliasLease {
fn is_ready(&self) -> bool {
true
}
fn model_aliases(&self) -> &[ContributedModelAlias] {
&self.0
}
}
struct AliasPreparation(ExtensionSetReference);
impl ExtensionPreparationPort for AliasPreparation {
fn prepare(
&self,
_: ConfigRevision,
) -> BoxFuture<'_, Result<ExtensionSetReference, ExtensionPreparationError>> {
Box::pin(async move { Ok(self.0.clone()) })
}
}
fn alias(id: &str, target: &str) -> ContributedModelAlias {
ContributedModelAlias {
owner: "test-plugin".into(),
id: PublicModelId::new(id).unwrap(),
provider: ProviderKind::new("alpha").unwrap(),
target: UpstreamModelId::new(target).unwrap(),
}
}
fn compile_aliases(
aliases: Vec<ContributedModelAlias>,
) -> Result<gateway_core::routing::RuntimeSnapshot, RuntimeSnapshotCompileError> {
let facts = SnapshotFacts::new(
revision(1),
revision(1),
SnapshotSettingsFacts::new(
3,
50,
"smart",
BTreeMap::from([("configured".into(), "upstream-model".into())]),
None,
None,
),
vec![],
vec![],
vec![SnapshotProviderAccountFacts::new(
gateway_core::account::ProviderAccountId::new("acct_alias").unwrap(),
"alpha",
)],
vec![],
);
block_on(
RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts))),
Arc::new(PublishingCatalog {
generation: AtomicU64::new(1),
queries: AtomicUsize::new(1),
}),
)
.with_extensions(Arc::new(AliasPreparation(ExtensionSetReference::new(
ExtensionSetId::new("alias-generation".into()).unwrap(),
Arc::new(AliasLease(aliases)),
))))
.compile(),
)
}
#[test]
fn contributed_alias_uses_the_same_target_for_listing_profiles_and_routing() {
let snapshot = compile_aliases(vec![alias("plugin-model", "upstream-model")]).unwrap();
let model = PublicModelId::new("plugin-model").unwrap();
let provider = ProviderKind::new("alpha").unwrap();
assert!(
snapshot
.public_models_for_scope(&snapshot.all_account_scope())
.contains(&model)
);
assert!(snapshot.contains_public_model_for_scope(&model, &snapshot.all_account_scope()));
let profiles = snapshot.public_model_profiles_for_provider(&provider);
let alias_profile = profiles
.iter()
.find(|profile| profile.model() == &model)
.unwrap();
let native_profile = profiles
.iter()
.find(|profile| profile.model().as_str() == "upstream-model")
.unwrap();
assert_eq!(alias_profile.presentation(), native_profile.presentation());
let plan = snapshot
.plan(
&model,
&super::operation(),
snapshot.all_account_scope(),
&Default::default(),
)
.unwrap();
assert_eq!(plan.candidates()[0].provider(), &provider);
assert_eq!(
plan.candidates()[0].upstream_model().unwrap().as_str(),
"upstream-model"
);
}
#[test]
fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_publication() {
for aliases in [
vec![alias("upstream-model", "upstream-model")],
vec![alias("configured", "upstream-model")],
vec![alias("plugin-model", "configured")],
vec![alias("plugin-model", "missing")],
vec![
alias("plugin-model", "other"),
alias("other", "plugin-model"),
],
vec![
alias("plugin-model", "upstream-model"),
alias("plugin-model", "upstream-model"),
],
] {
assert_eq!(
compile_aliases(aliases).unwrap_err(),
RuntimeSnapshotCompileError::InvalidExtensionModels
);
}
}
impl ExtensionPreparationPort for CountingExtensionPreparation {
fn prepare(
&self,
@@ -234,6 +362,49 @@ fn compiler_accepts_unlimited_default_account_concurrency() {
);
}
#[test]
fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
use gateway_core::account::SmartSchedulingConfig;
let configs = [
SmartSchedulingConfig::default(),
SmartSchedulingConfig::new([0.0, 2.0, 1.0, 0.5, 1.2, 2.3], true).unwrap(),
];
let mut plans = Vec::new();
for (index, config) in configs.into_iter().enumerate() {
let facts = SnapshotFacts::new(
revision(index as u64 + 1),
revision(index as u64 + 1),
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_smart_scheduling(config),
vec![],
vec![],
vec![SnapshotProviderAccountFacts::new(
gateway_core::account::ProviderAccountId::new("acct_config").unwrap(),
"alpha",
)],
vec![],
);
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts))),
Arc::new(TestCatalog::Unavailable),
);
let snapshot = block_on(compiler.compile()).unwrap();
plans.push(
snapshot
.plan(
&PublicModelId::new("any-model").unwrap(),
&super::operation(),
snapshot.all_account_scope(),
&Default::default(),
)
.unwrap(),
);
}
for (plan, config) in plans.iter().zip(configs) {
assert_eq!(plan.account_selection_policy().smart_scheduling(), config);
}
}
#[test]
fn withdrawn_provider_keeps_accounts_without_becoming_a_route_or_expanding_group_scope() {
use gateway_core::{
@@ -168,19 +168,30 @@ fn committed_account_change_should_publish_without_waiting_for_provider_catalog(
block_on(async {
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(9, true))));
let catalog = Arc::new(TestCatalog::default());
catalog
.models
.lock()
.expect("models lock")
.push(ProviderModelCapabilities::new(
catalog.models.lock().expect("models lock").push(
ProviderModelCapabilities::new(
UpstreamModelId::new("listed-model").expect("model"),
ModelCapabilities::new(Default::default(), None),
));
)
.with_catalog_accounts(std::collections::BTreeSet::from([
ProviderAccountId::new("acct_audit_removed").expect("source account"),
])),
);
let compiler = Arc::new(catalog_compiler(store.clone(), catalog.clone()));
let initial = compiler.compile().await.expect("initial snapshot");
let provider = ProviderKind::new("audit").expect("provider");
let initial_models = initial.public_models_for_provider(&provider);
let handle = RuntimeSnapshotHandle::new(initial);
let initial_scope = initial
.client_policies()
.next()
.unwrap()
.account_scope()
.clone();
assert_eq!(
initial.public_models_for_scope(&initial_scope),
initial_models
);
let handle = RuntimeSnapshotHandle::new(initial.clone());
let publisher = RuntimeSnapshotPublisher::new(
compiler,
handle.clone(),
@@ -200,6 +211,16 @@ fn committed_account_change_should_publish_without_waiting_for_provider_catalog(
initial_models
);
assert_eq!(catalog.queries.load(Ordering::SeqCst), 1);
let committed_scope = committed.client_policies().next().unwrap().account_scope();
assert!(
committed
.public_models_for_scope(committed_scope)
.is_empty()
);
assert_eq!(
initial.public_models_for_scope(&initial_scope),
initial_models
);
assert_ne!(
committed.provider_catalog_generations(),
&catalog.catalog_generations(),
@@ -26,6 +26,7 @@ use super::{
pub(super) struct CachedRelease {
result: Result<ResolvedRelease, AdminError>,
until: Instant,
fetched_at: Instant,
}
#[derive(Clone)]
@@ -57,7 +58,9 @@ impl HttpPluginDistribution {
mut query: GithubReleaseQuery,
credentials: &[SourceCredential],
egress: Option<&PluginDistributionEgress>,
refresh: bool,
) -> Result<ResolvedRelease, AdminError> {
let requested_at = Instant::now();
validate_repository(&query.repository)?;
query.repository.make_ascii_lowercase();
let url = self.release_url(&query)?;
@@ -91,7 +94,11 @@ impl HttpPluginDistribution {
tokio::time::timeout(Duration::from_secs(60), async {
// 同一来源共用锁和结果;取消请求释放锁,下一位查询者可重新发起。
let mut cached = slot.lock().await;
if let Some(entry) = cached.as_ref().filter(|entry| entry.until > Instant::now()) {
// 显式查询刷新成功结果;等待同一次请求的调用者仍共享结果,失败保留短缓存限流。
if let Some(entry) = cached.as_ref().filter(|entry| {
entry.until > Instant::now()
&& (!refresh || entry.fetched_at >= requested_at || entry.result.is_err())
}) {
return entry.result.clone();
}
let _permit = self
@@ -105,9 +112,11 @@ impl HttpPluginDistribution {
} else {
Duration::from_secs(30)
};
let fetched_at = Instant::now();
*cached = Some(CachedRelease {
result: result.clone(),
until: Instant::now() + ttl,
until: fetched_at + ttl,
fetched_at,
});
result
})
@@ -213,7 +222,7 @@ impl HttpPluginDistribution {
credentials: &[SourceCredential],
egress: Option<&PluginDistributionEgress>,
) -> Result<(Url, Option<String>, PluginSource, bool), AdminError> {
let release = self.release(query, credentials, egress).await?;
let release = self.release(query, credentials, egress, false).await?;
let selected = release
.assets
.iter()
@@ -153,7 +153,7 @@ impl PluginDistribution for HttpPluginDistribution {
egress: Option<PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError> {
Ok(self
.release(query, &credentials, egress.as_ref())
.release(query, &credentials, egress.as_ref(), true)
.await?
.view)
}
@@ -0,0 +1,103 @@
//! 安装文件指纹用于区分本进程安装的候选与外部重新部署,历史版本号不代表磁盘事实。
use std::fs;
use std::io::{self, Read};
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use super::swap::backup_path_for;
use super::{OperationError, SystemUpdateConfig, conflict, internal};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReleaseFiles {
binary: String,
web: String,
official_plugins: String,
}
impl ReleaseFiles {
pub(crate) fn installed(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
Self::read(
&config.executable_path()?,
config.web_dist_dir()?,
&config.official_plugins_dir()?,
)
}
pub(crate) fn backup(config: &SystemUpdateConfig) -> Result<Self, OperationError> {
Self::read(
&backup_path_for(&config.executable_path()?),
&backup_path_for(config.web_dist_dir()?),
&backup_path_for(&config.official_plugins_dir()?),
)
}
fn read(binary: &Path, web: &Path, plugins: &Path) -> Result<Self, OperationError> {
if !web.join("index.html").is_file()
|| !plugins.join(super::OFFICIAL_PLUGIN_MANIFEST).is_file()
{
return Err(conflict("安装文件不完整,请核对部署后重试"));
}
let mut binary_hash = Sha256::new();
hash_file(binary, &mut binary_hash).map_err(read_error)?;
Ok(Self {
binary: hex::encode(binary_hash.finalize()),
web: hash_directory(web).map_err(read_error)?,
official_plugins: hash_directory(plugins).map_err(read_error)?,
})
}
}
fn read_error(error: io::Error) -> OperationError {
tracing::warn!(error = %error, "核对系统安装文件失败");
internal("无法核对安装文件,请检查文件完整性与读取权限")
}
fn hash_directory(path: &Path) -> io::Result<String> {
let mut hash = Sha256::new();
hash_tree(path, &mut hash)?;
Ok(hex::encode(hash.finalize()))
}
fn hash_tree(path: &Path, hash: &mut Sha256) -> io::Result<()> {
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let name = entry.file_name();
let name = name.as_encoded_bytes();
hash.update((name.len() as u64).to_le_bytes());
hash.update(name);
if entry.file_type()?.is_dir() {
hash.update(b"directory");
hash_tree(&entry.path(), hash)?;
hash.update(b"end-directory");
} else {
hash.update(b"file");
hash_file(&entry.path(), hash)?;
}
}
Ok(())
}
fn hash_file(path: &Path, hash: &mut Sha256) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"发行文件必须为普通文件",
));
}
hash.update(metadata.len().to_le_bytes());
let mut file = fs::File::open(path)?;
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
return Ok(());
}
hash.update(&buffer[..count]);
}
}
@@ -2,6 +2,7 @@
mod archive;
mod download;
mod installation;
mod process;
mod release;
mod state;
@@ -17,8 +18,9 @@ use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateEventLevel, SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemOperationKind, SystemUpdateChannel as UpdateChannel,
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy,
SystemUpdateStatus, SystemVersion,
};
use gateway_admin::ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateCandidate,
@@ -32,14 +34,15 @@ use crate::config::ConfigError;
use self::archive::extract_release;
use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum};
use self::installation::ReleaseFiles;
use self::process::{environment_value, spawn_replacement};
use self::release::{
ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest,
select_archive, validate_update_target, version_channel,
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
validate_update_target, version_channel,
};
use self::state::{
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
recover_interrupted, set_running,
reconcile_installation, recover_interrupted, set_running,
};
use self::swap::{replace_release_files, rollback_official_plugins_dir, rollback_release};
@@ -233,23 +236,28 @@ pub struct ProcessSystemOperations {
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<()>>,
release_cache: Arc<ReleaseCache>,
running_files: Arc<Result<ReleaseFiles, OperationError>>,
}
impl ProcessSystemOperations {
#[must_use]
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
// 组合根在接收请求前创建服务,记录本次启动的发行文件,后续替换不能改写这份事实。
let running_files = Arc::new(ReleaseFiles::installed(&config));
Self {
cancellation,
events: Arc::new(UpdateEvents::default()),
config: Arc::new(config),
operation_lock: Arc::new(AsyncMutex::new(())),
release_cache: Arc::new(ReleaseCache::default()),
running_files,
}
}
fn start_update(
&self,
target_version: Option<String>,
channel: Option<UpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, OperationError> {
let operation_lock = Arc::clone(&self.operation_lock)
@@ -264,13 +272,14 @@ impl ProcessSystemOperations {
return Err(conflict(reason));
}
let target = confirmed_target(target_version)?;
if let Err(error) = validate_update_target(&self.config.version, &target) {
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let selected = self.resolve_channel(channel)?;
if let Err(error) = validate_update_target(&self.config.version, &target, selected) {
self.events
.error_terminal(None, Some("preflight"), error.to_string());
return Err(error);
}
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let status = read_status(&self.config.update_state_file, &self.config.version)?;
let status = self.reconcile_installation()?;
if status.need_restart {
return Err(conflict("更新已完成,请先重启服务"));
}
@@ -295,14 +304,14 @@ impl ProcessSystemOperations {
let result = tokio::select! {
biased;
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
result = service.perform_update_inner(&target, &operation_id, preflight.as_ref()) => result,
result = service.perform_update_inner(&target, selected, &operation_id, preflight.as_ref()) => result,
};
if let Err(error) = operation.complete(&result) {
tracing::warn!(error = %error, "系统更新终态落盘失败");
return;
}
match result {
Ok(()) => service.events.success_terminal(
Ok(_) => service.events.success_terminal(
Some(&operation_id),
Some("done"),
"更新文件已替换,等待服务重启生效",
@@ -320,9 +329,10 @@ impl ProcessSystemOperations {
async fn perform_update_inner(
&self,
target: &str,
channel: UpdateChannel,
operation_id: &str,
preflight: &dyn SystemUpdatePreflight,
) -> Result<(), OperationError> {
) -> Result<ReleaseFiles, OperationError> {
let repository = self
.config
.update_repository
@@ -337,10 +347,11 @@ impl ProcessSystemOperations {
&self.config.github_api_base,
repository,
&self.config.version,
channel,
)
.await?
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
let detail = detail_from_release(&self.config, &release);
let detail = detail_from_release(&self.config, &release, channel);
if detail.latest_version != target {
return Err(conflict("远端最新版本已变化,请重新确认"));
}
@@ -362,7 +373,7 @@ impl ProcessSystemOperations {
version: &str,
operation_id: &str,
preflight: &dyn SystemUpdatePreflight,
) -> Result<(), OperationError> {
) -> Result<ReleaseFiles, OperationError> {
self.events.info(
Some(operation_id),
Some("asset"),
@@ -461,24 +472,46 @@ impl ProcessSystemOperations {
if let Err(error) = preflight.confirm_revision(plugin_revision).await {
return Err(applied.rollback(error));
}
let files = ReleaseFiles::installed(&self.config)?;
applied.commit();
self.events
.success(Some(operation_id), Some("replace"), "应用文件替换完成");
Ok(())
Ok(files)
}
fn resolve_channel(
&self,
requested: Option<UpdateChannel>,
) -> Result<UpdateChannel, OperationError> {
let channel = requested.unwrap_or_else(|| {
version_channel(&self.config.version).unwrap_or(UpdateChannel::Stable)
});
if !update_policy(&self.config, channel)
.available_channels
.contains(&channel)
{
return Err(conflict("当前发行版本不支持此更新通道"));
}
Ok(channel)
}
fn reconcile_installation(&self) -> Result<SystemUpdateStatus, OperationError> {
let running_files = self.running_files.as_ref().as_ref().map_err(Clone::clone)?;
reconcile_installation(&self.config, running_files)
}
}
#[async_trait]
impl SystemOperations for ProcessSystemOperations {
async fn version(&self) -> Result<SystemVersion, OperationError> {
let detail = self.update_detail(false).await?;
let detail = self.update_detail(false, None).await?;
Ok(SystemVersion {
version: self.config.version.clone(),
git_sha: self.config.git_sha.clone(),
build_time: self.config.build_time.clone(),
deployment_mode: self.config.deployment_mode.clone(),
update_channel: version_channel(&self.config.version)
.map(|channel| channel.label().to_owned())
.map(|channel| channel.as_str().to_owned())
.unwrap_or_else(|| "unknown".to_owned()),
latest_version: detail.latest_version,
has_update: detail.has_update,
@@ -487,11 +520,21 @@ impl SystemOperations for ProcessSystemOperations {
})
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, OperationError> {
match self.release_cache.detail(&self.config, refresh).await {
async fn update_detail(
&self,
refresh: bool,
channel: Option<UpdateChannel>,
) -> Result<SystemUpdateDetail, OperationError> {
let channel = self.resolve_channel(channel)?;
match self
.release_cache
.detail(&self.config, refresh, channel)
.await
{
Ok(detail) => Ok(detail),
Err(error) => Ok(base_update_detail(
&self.config,
channel,
self.config.update_support_error(),
Some(error.to_string()),
)),
@@ -520,9 +563,10 @@ impl SystemOperations for ProcessSystemOperations {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<UpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, OperationError> {
self.start_update(target_version, preflight)
self.start_update(target_version, channel, preflight)
}
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
@@ -531,8 +575,15 @@ impl SystemOperations for ProcessSystemOperations {
&self.config.update_state_file,
&self.config.update_lock_file,
)?;
if self.config.update_support_error().is_none() {
match OperationFileLock::acquire(&self.config.update_lock_file) {
Ok(_lock) => return self.reconcile_installation(),
Err(error) if error.kind() == SystemOperationErrorKind::Conflict => {}
Err(error) => return Err(error),
}
}
}
read_status(&self.config.update_state_file, &self.config.version)
read_status(&self.config.update_state_file, false)
}
async fn rollback(
@@ -548,7 +599,8 @@ impl SystemOperations for ProcessSystemOperations {
}
let operation_id = operation_id("rollback");
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
let target_version = read_status(&self.config.update_state_file, &self.config.version)?
let target_version = self
.reconcile_installation()?
.previous_version
.ok_or_else(|| conflict("没有可用于回滚的上一版本"))?;
let release_manifest =
@@ -592,6 +644,7 @@ impl SystemOperations for ProcessSystemOperations {
&operation_id,
SystemOperationKind::Rollback,
None,
None,
result.as_ref().err().map(ToString::to_string),
)?;
match &result {
@@ -700,12 +753,26 @@ impl Drop for AppliedReleaseGuard<'_> {
}
}
fn update_policy(config: &SystemUpdateConfig, channel: UpdateChannel) -> SystemUpdatePolicy {
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
SystemUpdatePolicy {
channel,
available_channels: if version_channel(&config.version) == Some(Experimental) {
vec![Experimental]
} else {
vec![Stable, Rc, Beta, Alpha]
},
}
}
fn base_update_detail(
config: &SystemUpdateConfig,
channel: UpdateChannel,
unsupported_reason: Option<String>,
warning: Option<String>,
) -> SystemUpdateDetail {
SystemUpdateDetail {
policy: update_policy(config, channel),
current_version: config.version.clone(),
latest_version: config.version.clone(),
has_update: false,

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