Compare commits

..
34 Commits
Author SHA1 Message Date
ZyyandCodex c622f08574 chore: bump version to 3.44.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:17:06 +08:00
ZyyandCodex 5741683ea1 chore(deps): upgrade UI to v0.9.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:14:33 +08:00
Zyy 16b3671733 fix(accounts): show proxy failures in connection tests 2026-11-09 12:05:11 +08:00
dependabot[bot] a708301cb4 chore(deps): bump the minor-and-patch group in /frontend with 6 updates (#648)
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.67.0` | `1.70.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.53.1` | `1.53.2` |
| [vue](https://github.com/vuejs/core) | `3.5.44` | `3.5.45` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.4` | `4.3.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.4` | `4.3.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.5` | `8.3.6` |


Updates `@lucide/vue` from 1.67.0 to 1.70.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.70.0/packages/vue)

Updates `es-toolkit` from 1.53.1 to 1.53.2
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.53.1...v1.53.2)

Updates `vue` from 3.5.44 to 3.5.45
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.44...v3.5.45)

Updates `@tailwindcss/vite` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/tailwindcss)

Updates `vite` from 8.3.5 to 8.3.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:22:27 +08:00
dependabot[bot] 65fd34991b chore(deps): bump node from bc0a2af to add5c17 in /deploy (#650)
Bumps node from `bc0a2af` to `add5c17`.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.13.0-bookworm-slim
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:59 +08:00
dependabot[bot] 03186b5411 chore(deps): bump debian from 99c0b25 to 734a72b in /deploy (#649)
Bumps debian from `99c0b25` to `734a72b`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:47 +08:00
dependabot[bot] 47ce36f4bf chore(deps): bump yaml-rust2 from 0.11.0 to 0.13.0 in /backend (#647)
Bumps [yaml-rust2](https://github.com/Ethiraric/yaml-rust2) from 0.11.0 to 0.13.0.
- [Release notes](https://github.com/Ethiraric/yaml-rust2/releases)
- [Changelog](https://github.com/Ethiraric/yaml-rust2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Ethiraric/yaml-rust2/compare/v0.11.0...v0.13.0)

---
updated-dependencies:
- dependency-name: yaml-rust2
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:33 +08:00
dependabot[bot] 462cef5dd5 chore(deps): bump the minor-and-patch group in /backend with 2 updates (#646)
Bumps the minor-and-patch group in /backend with 2 updates: [zip](https://github.com/zip-rs/zip2) and [async-compression](https://github.com/Nullus157/async-compression).


Updates `zip` from 9.2.2 to 9.4.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.2.2...v9.4.0)

Updates `async-compression` from 0.4.51 to 0.4.52
- [Release notes](https://github.com/Nullus157/async-compression/releases)
- [Commits](https://github.com/Nullus157/async-compression/compare/async-compression-v0.4.51...async-compression-v0.4.52)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: async-compression
  dependency-version: 0.4.52
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:16 +08:00
dependabot[bot] 5647dc8dcb chore(deps): bump the minor-and-patch group with 5 updates (#645)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [taiki-e/install-action](https://github.com/taiki-e/install-action) | `2.89.1` | `2.90.2` |
| [docker/login-action](https://github.com/docker/login-action) | `4.7.0` | `4.8.0` |
| [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) | `4.1.2` | `4.1.3` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.3` | `3.0.4` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.1.0` | `6.1.1` |


Updates `taiki-e/install-action` from 2.89.1 to 2.90.2
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/682ce6c3c696a4cd5168a05f98e51aa02b877941...3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2)

Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `sigstore/cosign-installer` from 4.1.2 to 4.1.3
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/6f9f17788090df1f26f669e9d70d6ae9567deba6...756fe2d284c7292c4e9b62c8a28f1e5629c59532)

Updates `softprops/action-gh-release` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/efb35369e0ad2afab669f228072c1b0d510eae64...8a0cd651e5b2365bd640854a3fa5dfc70e4875f3)

Updates `pnpm/action-setup` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/ea17c68df8912ef543352723c149a84f56e3d413...73e46992e05b19abd31f2f3c20e0e7370d762115)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.90.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:20:09 +08:00
ZyyandCodex 0d44492309 fix(openai): scope active quota window reassignment to image endpoints
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 11:16:15 +08:00
ZyyandZyy 31f471b051 feat(plugin): support HTTP requests by proxy ID (#644)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-09 03:29:52 +08:00
github-actions[bot] e7ef31d882 chore(release): update release channels to 3.43.1 2026-11-08 10:55:22 +00:00
ZyyandCodex 197e9e517a chore: bump version to 3.43.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 18:12:22 +08:00
Zyy e770c94a9e fix(openai): preserve Codex quota after image generation 2026-11-08 18:04:45 +08:00
ce620f1b53 feat(release): add Intel macOS downloads (#641)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 17:30:22 +08:00
ZyyandCodex 290aecb7a4 fix(admin): keep page scrolling on the compositor
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 14:05:10 +08:00
ZyyandCodex 1a2a4a56ae fix(openai): update account identity on OAuth reauthorization
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:55:30 +08:00
github-actions[bot] 6dd0da1fbd chore(release): update release channels to 3.43.0 2026-11-07 19:38:40 +00:00
ZyyandCodex 402bc37fbb chore: bump version to 3.43.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:25:42 +08:00
ZyyandCodex 8b9aacd8af chore(release): distinguish version bumps from channel updates
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:24:05 +08:00
ZyyandCodex d9f9eca7e8 feat(admin): adopt UI v0.9.0 scene presets and shared sidebar
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:15:33 +08:00
ff1ea92155 fix(store): release account concurrency leases on shutdown (#638)
Supervise scheduling lease release tasks and drain them within a bounded shutdown budget. Preserve other instances and the existing TTL fallback.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 01:01:11 +08:00
github-actions[bot] f772bda57b chore(release): publish v3.42.1 2026-11-07 07:14:34 +00:00
ZyyandCodex e0a3dc20dd chore(release): prepare v3.42.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 15:04:23 +08:00
39c8a6352d feat(plugins): confirm external links from management pages (#635)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 15:01:44 +08:00
Ke WangandCodex db779a4a21 fix(openai): hide untyped models from agent picker (#632)
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:43:05 +08:00
ZyyandCodex 750dd32200 fix(scheduling): log queue fallback binding migrations
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:28:56 +08:00
ZyyandCodex 6230d95729 fix(admin): adopt UI v0.8.1 overlay fixes
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:23:49 +08:00
ZyyandCodex 11b64b3026 fix(update): publish verified channels on main
Read update candidates from the published channel table and advance it only after the release assets are public. Keep the tagged preparation commit separate from the publication commit.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 13:47:32 +08:00
ca5901ba56 fix(admin): preserve proxy capacity in OAuth pending state (#631)
* fix(admin): preserve proxy capacity in OAuth pending state

Persist the saved proxy's WebSocket capacity in the shared pending
authorization codec and restore it before the Store checks the snapshot.
Keep genuine proxy changes and disabled proxies rejected.

Co-authored-by: Codex <noreply@openai.com>

* docs(deploy): clarify OAuth proxy upgrade guidance

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 13:24:15 +08:00
ZyyandCodex 81de0f5331 build(release): keep the plugin manifest inside distribution packages
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 03:00:01 +08:00
ZyyandCodex 69c1ee15bb docs(release): clarify same-version retries and simplify release notes
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:56:49 +08:00
ZyyandCodex ba87cfde6a chore(ci): remove the release script test suite
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:41:37 +08:00
ZyyandCodex d0a588fbc3 test(plugin): signal crashes after host registration
Drive each worker exit from the prepared generation so scheduling delays cannot turn a post-registration crash into a registration failure.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:28:00 +08:00
168 changed files with 3404 additions and 1989 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 备版只更新源码的构建版本;通道快照是版本 Release 的 `version.yaml` 附件,验收时核对固定预发行页 `update-index` 指向的公开快照
- 同版本失败先区分[续跑原任务与修复源码后重发](references/operations.md#恢复与验收):前者沿用原提交;后者仅在版本尚未公开时清理失败的版本提交,再生成唯一的版本提交。已发布的 tag 和产物保持不变
- 备版只更新构建版本;发布后回读 `main:release/version.yaml` 的 `published`,核对已公开的通道候选
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
@@ -27,7 +27,7 @@
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
发布任务应将该基线的最高公开实验版写入版本 Release 通道快照的 `published[大版本].exp[基线]`,不推进普通通道。
发布任务应将该基线的最高公开实验版写入`main:release/version.yaml` 的 `published[大版本].exp[基线]`,不推进普通通道。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
@@ -20,7 +20,7 @@
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore: bump version to <version>`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
@@ -38,15 +38,29 @@ release/publish "$release_version"
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
先核对本地与远端的分支头、版本提交、tag,以及对应的工作流、GitHub Release 和版本镜像。超时或任务失败不等于版本未公开;即使 Release 还是草稿,版本镜像也可能已经推送
### 续跑原任务
源码无需修改时保留版本提交和 tag,核对两者指向后从断点继续:
- 尚未推送:补齐缺失的步骤,按入口原子推送分支和 tag
- 已推送但未触发:使用入口提示的 dispatch 命令
- 已有 run:跟踪对应提交的任务,失败时重跑失败任务。构建产物保留 7 天,过期后从原 tag 重跑完整工作流
### 修复源码后同版本重发
1. 确认旧工作流已停止、该版本没有公开 Release 或版本镜像,且待清理提交不在其他已发布 tag 的历史中。已有公开产物时保留原提交和 tag,代码修复另发新版本;发布状态不明时先查清
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过已核实的失败版本提交。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
3. 只清理本次失败的 tag 和草稿。涉及远端改写时,先核对授权,以事先记录的分支头和 tag 对象 SHA 作显式租约,原子更新分支并删除未发行 tag。租约不匹配就停止,不合并旧线
4. 修复验证通过后执行 `release/publish --check <version>`,再按[执行发版](#执行发版)重发。核对上一发行至新 tag 的历史中只有一个本版本的发行提交,且与工作流构建的提交一致
### 验收
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 回读固定预发行页 `update-index`(不是 GitHub Latest),按正文 JSON 的 `current` 下载对应 `v<current>` 的 `version.yaml`。核对快照版本及 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选或丢失其他发行线
- 只有指针写入失败时,在既有授权内执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务与发布共用锁,只核对已公开版本的附件和待发布快照摘要,不重新打包、改版本 tag 或写 main。完成后回读指针与附件;待发布记录不能当作成功。索引附件最多 128 KiB
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): update release channels to <version>` 提交;重复执行或较旧版本未推进任何通道时无提交
- 通道表写入失败时,确认该版本已公开、工作流能写入 `main`,再按已有授权执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务重新校验公开附件、读取并合并当前清单,不重新打包或改 tag
- v3.42.0 在线升级:首个包含新更新器的 3.x 正式版公开并通过校验后,从该次通道提交取出 `release/version.yaml`,将顶层 `version` 设为该正式版,作为同名附件上传到该 Release。回读附件,确认 3.x 稳定通道指向该版本且安装包可用,再手动将已有 `update-index` 正文改为 `{"current":"<该正式版>"}`。此后保留该页与附件,无需再编辑
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -5,7 +5,7 @@
## 基线与版本
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分源码构建版本和 `update-index` 指向的发行附件中的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分顶层构建版本与 `main` 上的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
@@ -14,7 +14,9 @@
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
- 常规版本按「新增功能」「优化改进」「问题修复」「升级须知」归类,省略空章节;单一改动可直接写一句。安装命令和文档入口由工作流附加
- 每条用一句短语讲清一个用户可感知的结果,不加句号,例如「系统更新:确认当前平台有安装包后才提示升级」
- 同一变化只写一次,保留会影响使用或升级的默认值和限制,不展开实现过程、逐条翻译提交或写未经验证的承诺
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
@@ -26,6 +28,8 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore: bump version to <version>`;查历史时也匹配 `chore(release): prepare v<version>` 和 `chore(release): v<version>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
-1
View File
@@ -34,7 +34,6 @@ module ReleasePackage
default_image = "${CPR_IMAGE:-#{image}:latest}"
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
FileUtils.cp(MANIFEST, release_dir)
platforms.each do |platform|
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
+30 -95
View File
@@ -1,11 +1,10 @@
require 'base64'
require 'digest'
require 'uri'
require_relative 'release-metadata'
# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要
# 各发行线共用 main 通道表;只合并本次已核实的版本
module PublishedChannels
POINTER = 'update-index'
PATH = 'release/version.yaml'
LIMIT = 128 * 1024
def self.advance(document, version)
@@ -46,40 +45,15 @@ module PublishedChannels
end
def initialize(repository)
@repository = repository
@base = "repos/#{repository}"
release = api("#{@base}/releases/tags/#{POINTER}", optional: true)
if release
raise 'Invalid update-index release' unless release.fetch('tag_name') == POINTER &&
release.fetch('prerelease') && !release.fetch('draft')
@id = release.fetch('id')
@state = JSON.parse(release.fetch('body'))
@state.fetch('current')
else
@state = { 'current' => nil }
end
end
def save(current:, pending: nil)
state = { 'current' => current }
state['pending'] = pending if pending
body = { body: JSON.generate(state), make_latest: 'false' }
if @id
api("#{@base}/releases/#{@id}", method: 'PATCH', data: body)
else
release = api("#{@base}/releases", method: 'POST', data: body.merge(
tag_name: POINTER, name: '更新通道索引', target_commitish: 'main', prerelease: true
))
@id = release.fetch('id')
end
@state = state
end
def release(version)
def find_release(version)
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
end
def verify_assets(version, release, index:)
def verify_assets(version)
release = find_release(version)
tag = "v#{version}"
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
@@ -87,73 +61,34 @@ module PublishedChannels
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
raise "Missing platforms for #{tag}" if platforms.empty?
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
expected.concat(%w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json])
expected << 'version.yaml' if index
assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.to_h { |a| [a.fetch('name'), a] }
missing = expected - assets.keys
expected.concat(%w[checksums.txt compose.yaml config.example.yaml])
uploaded = release.fetch('assets').filter_map do |asset|
asset.fetch('name') if asset.fetch('state') == 'uploaded' && asset.fetch('size').positive?
end
missing = expected - uploaded
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
assets
end
def download(version)
content, error, status = Open3.capture3('gh', 'release', 'download', "v#{version}", '--repo', @repository,
'--pattern', 'version.yaml', '--output', '-')
raise "Cannot download v#{version} snapshot: #{error.strip}" unless status.success?
content
end
def read_snapshot(version, asset)
raise 'Invalid version.yaml size' unless (1..LIMIT).cover?(asset.fetch('size'))
content = download(version)
digest = Digest::SHA256.hexdigest(content)
raise "Snapshot size or digest differs for v#{version}" unless content.bytesize == asset.fetch('size') && asset.fetch('digest') == "sha256:#{digest}"
doc = YAML.safe_load(content)
raise 'Invalid channel snapshot' unless doc.fetch('version') == version && doc.fetch('published').is_a?(Hash)
[doc, digest]
end
def previous
if (current = @state.fetch('current'))
assets = verify_assets(current, release(current), index: true)
return read_snapshot(current, assets.fetch('version.yaml')).first
end
# 首次接入时从实际公开的稳定版引导;没有历史 Release 的仓库从空表开始
doc = { 'published' => {} }
latest = api("#{@base}/releases/latest", optional: true)
if latest
version = ReleaseMetadata.version(latest.fetch('tag_name'))
verify_assets(version, latest, index: false)
PublishedChannels.advance(doc, version)
end
doc
end
# 调用方持有同一仓库的发布锁;快照必须在取锁后生成,不能在并行构建中预先合并
def prepare(version, directory)
if (pending = @state.dig('pending', 'version')) && (old = release(pending)) && !old.fetch('draft')
finish(pending)
end
existing = release(version)
def check_unpublished(version)
existing = find_release(version)
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
doc = previous
doc['version'] = version
PublishedChannels.advance(doc, version)
content = YAML.dump(doc)
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
digest = Digest::SHA256.hexdigest(content)
File.write(File.join(directory, 'version.yaml'), content)
File.open(File.join(directory, 'checksums.txt'), 'a') { |file| file.puts "#{digest} version.yaml" }
save(current: @state.fetch('current'), pending: { 'version' => version, 'sha256' => digest })
end
def finish(version)
assets = verify_assets(version, release(version), index: true)
_, digest = read_snapshot(version, assets.fetch('version.yaml'))
return if @state.fetch('current') == version
unless @state['pending'] == { 'version' => version, 'sha256' => digest }
raise "v#{version} is not the reserved snapshot; refusing to overwrite another publication"
end
save(current: version)
def publish(version)
verify_assets(version)
# 读取 main 的当前文件,实验分支和较晚结束的旧构建都不能覆盖其他通道或构建版本
endpoint = "#{@base}/contents/#{PATH}"
file = api("#{endpoint}?ref=main")
document = YAML.safe_load(Base64.decode64(file.fetch('content')))
return unless PublishedChannels.advance(document, version)
content = YAML.dump(document).delete_prefix("---\n")
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
# 按读取的文件 SHA 写入;并发备版改过同一文件时交给恢复任务重新合并
api(endpoint, method: 'PUT', data: {
branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content),
message: "chore(release): update release channels to #{version}"
})
end
end
end
@@ -163,8 +98,8 @@ if $PROGRAM_NAME == __FILE__
version = ReleaseMetadata.version(raw || '')
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
case mode
when 'prepare' then publisher.prepare(version, 'dist/release')
when 'finish' then publisher.finish(version)
else abort 'usage: published-channels.rb prepare|finish VERSION'
when 'check' then publisher.check_unpublished(version)
when 'publish' then publisher.publish(version)
else abort 'usage: published-channels.rb check|publish VERSION'
end
end
@@ -1,115 +0,0 @@
require 'tmpdir'
require_relative '../published-channels'
# 测试只替换 GitHub 读写边界,执行真实的发布状态转换
class PublishedChannelsTest
def assert_equal(expected, actual)
raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual
end
def assert_rejected
yield
rescue RuntimeError
return
else
raise 'expected publication to be rejected'
end
def initialize
@pointer = nil
@releases = {}
@files = {}
@calls = []
@platforms = [{ 'os' => 'linux', 'arch' => 'amd64', 'archive' => 'tar.gz' }]
end
def api(endpoint, method: 'GET', data: nil, optional: false)
path = endpoint.delete_prefix('repos/test/repo/')
@calls << [method, path]
case [method, path]
when ['GET', 'releases/tags/update-index'] then @pointer
when ['POST', 'releases']
raise 'duplicate pointer' if @pointer
@pointer = data.transform_keys(&:to_s).merge('id' => 12, 'draft' => false)
when ['PATCH', 'releases/12']
@pointer.merge!(data.transform_keys(&:to_s))
when ['GET', 'releases/latest'] then @releases['3.41.2']
else
return @releases[path.delete_prefix('releases/tags/v')] if method == 'GET' && path.start_with?('releases/tags/v')
return { 'content' => Base64.encode64(YAML.dump({ 'platforms' => @platforms })) } if path.start_with?('contents/release/platforms.yaml?ref=v')
raise "unexpected #{method} #{path}"
end
end
def publish_assets(version, content = nil)
names = %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json]
names << ReleaseMetadata.archive_name(version, @platforms.first)
@files[version] = content if content
assets = names.map { |name| { 'name' => name, 'size' => 100, 'state' => 'uploaded' } }
assets << { 'name' => 'version.yaml', 'size' => content.bytesize, 'digest' => "sha256:#{Digest::SHA256.hexdigest(content)}", 'state' => 'uploaded' } if content
@releases[version] = { 'tag_name' => "v#{version}", 'draft' => false, 'prerelease' => ReleaseMetadata.prerelease?(version), 'assets' => assets }
end
def publisher
github = self
files = @files
Class.new(PublishedChannels::Publisher) do
define_method(:api) { |endpoint, **args| github.api(endpoint, **args) }
define_method(:download) { |version| files.fetch(version) }
end.new('test/repo')
end
def prepare(version)
Dir.mktmpdir do |dir|
File.write(File.join(dir, 'checksums.txt'), '')
publisher.prepare(version, dir)
content = File.read(File.join(dir, 'version.yaml'))
assert_equal "#{Digest::SHA256.hexdigest(content)} version.yaml\n", File.read(File.join(dir, 'checksums.txt'))
content
end
end
def current = JSON.parse(@pointer.fetch('body')).fetch('current')
def test_interrupted_publication_recovers_without_losing_another_channel
publish_assets('3.41.2')
stable = prepare('3.42.0')
assert_equal nil, current
assert_rejected { publisher.finish('3.42.0') }
publish_assets('3.42.0', stable)
# 模拟公开后更新指针中断,下一次发布在同一把锁内补写,再合并自己的通道
beta = prepare('3.43.0-beta.1')
assert_equal '3.42.0', current
assert_equal({ 'stable' => '3.42.0', 'rc' => '3.42.0', 'beta' => '3.43.0-beta.1', 'alpha' => '3.43.0-beta.1' },
YAML.safe_load(beta).dig('published', '3'))
publish_assets('3.43.0-beta.1', beta)
publisher.finish('3.43.0-beta.1')
publisher.finish('3.43.0-beta.1')
assert_rejected { publisher.finish('3.42.0') }
assert_equal '3.43.0-beta.1', current
experiment = YAML.safe_load(prepare('3.44.0-exp.1')).dig('published', '3')
assert_equal({ '3.44.0' => '3.44.0-exp.1' }, experiment.fetch('exp'))
assert_equal '3.43.0-beta.1', experiment.fetch('beta')
assert_equal false, @calls.include?(['GET', 'releases'])
end
def test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
publish_assets('3.42.0', prepare('3.42.0'))
publisher.finish('3.42.0')
beta = prepare('3.43.0-beta.1')
publish_assets('3.43.0-beta.1', beta)
release = @releases.fetch('3.43.0-beta.1')
release['draft'] = true
assert_rejected { publisher.finish('3.43.0-beta.1') }
release['draft'] = false
missing = release.fetch('assets').shift
assert_rejected { publisher.finish('3.43.0-beta.1') }
release.fetch('assets') << missing
publish_assets('3.43.0-beta.1', beta.sub('3.42.0', '3.10.0'))
assert_rejected { publisher.finish('3.43.0-beta.1') }
assert_equal '3.42.0', current
end
end
PublishedChannelsTest.new.test_interrupted_publication_recovers_without_losing_another_channel
PublishedChannelsTest.new.test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
puts 'Published channel recovery and integrity checks passed'
+1 -2
View File
@@ -125,7 +125,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -258,5 +258,4 @@ jobs:
for script in .github/**/*.sh; do bash -n "$script"; done
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby .github/scripts/tests/published-channels-test.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
+13 -25
View File
@@ -115,7 +115,7 @@ jobs:
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -340,7 +340,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -434,10 +434,10 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -508,24 +508,12 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
## 📥 安装
## Linux 一键安装(Docker Compose)
**Docker 部署:**
```bash
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
curl -fsSLO https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.release-info.outputs.tag }}/deploy/install.sh && CPR_RELEASE_TAG=${{ needs.release-info.outputs.tag }} bash install.sh
```
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
@@ -538,11 +526,11 @@ jobs:
File.write(output, "#{notes}\n\n#{installation}\n")
RUBY
- name: Prepare versioned channel snapshot under the publication lock
- name: Check that the release is unpublished
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb prepare "$VERSION"
run: ruby .github/scripts/published-channels.rb check "$VERSION"
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
@@ -551,7 +539,7 @@ jobs:
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
@@ -573,11 +561,11 @@ jobs:
RELEASE_ID: ${{ steps.release.outputs.id }}
run: ruby .github/scripts/publish-release.rb
- name: Advance update-index to the verified snapshot
- name: Publish verified channels to main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb finish "$VERSION"
run: ruby .github/scripts/published-channels.rb publish "$VERSION"
recover-index:
if: inputs.index_only
@@ -589,8 +577,8 @@ jobs:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify public assets and finish pending publication
- name: Verify public assets and publish channels to main
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: ruby .github/scripts/published-channels.rb finish "$TAG"
run: ruby .github/scripts/published-channels.rb publish "$TAG"
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -53,7 +53,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
package_json_file: frontend/package.json
+2 -1
View File
@@ -40,7 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
+32 -12
View File
@@ -147,9 +147,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.51"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
@@ -942,9 +942,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.46"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"compression-core",
"flate2",
@@ -966,7 +966,7 @@ dependencies = [
"pathdiff",
"serde_core",
"winnow 1.0.3",
"yaml-rust2",
"yaml-rust2 0.11.1",
]
[[package]]
@@ -1834,7 +1834,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"wiremock",
"yaml-rust2",
"yaml-rust2 0.13.0",
]
[[package]]
@@ -2065,6 +2065,15 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "headers"
version = "0.4.2"
@@ -4245,7 +4254,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"hashlink 0.11.1",
"indexmap",
"log",
"memchr",
@@ -5519,13 +5528,24 @@ dependencies = [
[[package]]
name = "yaml-rust2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e"
checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
"hashlink 0.11.1",
]
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink 0.12.2",
]
[[package]]
@@ -5647,9 +5667,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.2.2"
version = "9.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
checksum = "3cec733c9820347a592f6375c3c0089b479ba0a0b1cbf3dd1958b02959b7c033"
dependencies = [
"crc32fast",
"flate2",
@@ -7,6 +7,7 @@ use gateway_admin::{
ports::{
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
proxy::PluginProxyAccess,
},
};
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
@@ -35,6 +36,7 @@ pub(super) struct ManagementPorts {
pub(super) accounts: Arc<dyn PluginAccountAccess>,
keys: Arc<dyn PluginClientKeyAccess>,
resources: Arc<dyn PluginResourceAccess>,
proxies: Arc<dyn PluginProxyAccess>,
}
impl ManagementPorts {
@@ -46,6 +48,7 @@ impl ManagementPorts {
) -> Self {
let ports = store.admin_ports();
Self {
proxies: gateway_admin::initialize_plugin_proxies(ports.proxies()),
accounts: gateway_admin::initialize_plugin_accounts(
providers.clone(),
ports.accounts(),
@@ -66,6 +69,7 @@ impl ManagementPorts {
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
runtime.bind_account_ports(&self.accounts)?;
runtime.bind_proxy_ports(&self.proxies)?;
runtime.bind_client_key_ports(&self.keys)?;
runtime.bind_resource_ports(&self.resources)
}
@@ -264,6 +264,14 @@ pub fn initialize_plugin_accounts(
))
}
/// 为 Runtime 创建代理目录与当前出口解析的窄端口
#[must_use]
pub fn initialize_plugin_proxies(
store: Arc<dyn ports::proxy::ProxyStore>,
) -> Arc<dyn ports::proxy::PluginProxyAccess> {
Arc::new(use_case::proxies::DefaultPluginProxyAccess { store })
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
+2 -2
View File
@@ -42,8 +42,8 @@ pub use use_case::{
pub use bootstrap::{
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
initialize_plugin_resources, initialize_settings, initialize_usage_data,
initialize_with_plugin_accounts,
initialize_plugin_proxies, initialize_plugin_resources, initialize_settings,
initialize_usage_data, initialize_with_plugin_accounts,
};
use ports::{
@@ -293,6 +293,7 @@ pub enum AccountConnectionTestEvent {
Failed {
source: AccountProbeErrorSource,
gateway_error_code: GatewayErrorKind,
proxy_hint: Option<gateway_core::error::ProxyConnectionHint>,
send_state: Option<UpstreamSendState>,
message: String,
provider_error_code: Option<String>,
@@ -304,6 +304,12 @@ impl PendingAuthorizationMutation {
"outbound_proxy_url".to_owned(),
Value::String(proxy.expose_url().to_owned()),
);
if proxy.max_websocket_connections() != 0 {
document.insert(
"outbound_proxy_max_websocket_connections".to_owned(),
Value::from(proxy.max_websocket_connections()),
);
}
}
if let Some(id) = &self.outbound_proxy_id {
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
@@ -344,6 +350,11 @@ impl PendingAuthorizationMutation {
.map(gateway_core::account::OutboundProxy::parse)
.transpose()
.map_err(|_| invalid())?;
let proxy = match (proxy, document.outbound_proxy_max_websocket_connections) {
(Some(proxy), Some(limit)) => Some(proxy.with_max_websocket_connections(limit)),
(None, Some(_)) => return Err(invalid()),
(proxy, None) => proxy,
};
Ok(Self::new(
provider_kind,
target,
@@ -367,6 +378,8 @@ struct StoredAuthorizationMutationV1 {
#[serde(default)]
outbound_proxy_url: Option<String>,
#[serde(default)]
outbound_proxy_max_websocket_connections: Option<u32>,
#[serde(default)]
outbound_proxy_id: Option<String>,
}
@@ -5,13 +5,20 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
AdminError, MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
/// 插件受管网络读取代理的窄端口,连接配置仅供宿主发送请求
#[async_trait]
pub trait PluginProxyAccess: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError>;
}
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
@@ -1214,6 +1214,7 @@ impl AccountsService for DefaultAccountsService {
vec![AccountConnectionTestEvent::Failed {
source: error.source(),
gateway_error_code: error.kind(),
proxy_hint: error.proxy_hint(),
send_state: error.send_state(),
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "linux",
runtime_architecture: "aarch64",
},
Platform {
release_os: "darwin",
release_architecture: "amd64",
target: "x86_64-apple-darwin",
runtime_os: "macos",
runtime_architecture: "x86_64",
},
Platform {
release_os: "darwin",
release_architecture: "arm64",
@@ -23,11 +23,51 @@ use crate::{
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
pub(crate) struct DefaultPluginProxyAccess {
pub(crate) store: Arc<dyn ProxyStore>,
}
#[async_trait]
impl PluginProxyAccess for DefaultPluginProxyAccess {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
list_proxies(self.store.as_ref(), query).await
}
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError> {
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
if !record.enabled {
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
}
Ok(record.proxy)
}
}
async fn list_proxies(
store: &dyn ProxyStore,
query: ProxyListQuery,
) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) {
return Err(AdminError::invalid("代理查询参数不合法"));
}
store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn create_batch(
@@ -268,14 +308,7 @@ impl ProxiesService for DefaultProxiesService {
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
list_proxies(self.store.as_ref(), query).await
}
async fn create(
@@ -267,7 +267,8 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
gateway_core::account::OutboundProxy::parse(
"http://user:secret@proxy.example:8080",
)
.unwrap(),
.unwrap()
.with_max_websocket_connections(16),
))
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
assert_eq!(
@@ -293,6 +294,27 @@ fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_do
let restored =
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
let mut legacy_proxy = legacy.clone();
legacy_proxy["outbound_proxy_url"] = json!("socks5h://proxy.example.invalid:1080");
legacy_proxy["outbound_proxy_id"] = json!("proxy_saved");
let restored = PendingAuthorizationMutation::from_storage_v1(legacy_proxy.clone())
.expect("legacy proxy without a capacity field remains readable");
assert_eq!(
restored
.outbound_proxy()
.unwrap()
.max_websocket_connections(),
0
);
assert_eq!(Value::Object(restored.to_storage_v1()), legacy_proxy);
for invalid in [json!(-1), json!(4294967296_u64), json!("16")] {
let mut document = legacy_proxy.clone();
document["outbound_proxy_max_websocket_connections"] = invalid;
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
}
let mut missing_proxy = legacy.clone();
missing_proxy["outbound_proxy_max_websocket_connections"] = json!(16);
assert!(PendingAuthorizationMutation::from_storage_v1(missing_proxy).is_err());
for (pointer, invalid) in [
("/provider_kind", json!("")),
("/target/account_id", json!("")),
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
}
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
let (release_os, release_architecture, target) = release_platform();
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
let artifacts = [
("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "arm64", "aarch64-unknown-linux-gnu"),
("darwin", "amd64", "x86_64-apple-darwin"),
("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "arm64", "aarch64-pc-windows-msvc"),
]
.map(|(release_os, release_architecture, target)| {
let os = if release_os == "darwin" {
"macos"
} else {
release_os
};
let (architecture, _) = target.split_once('-').unwrap();
serde_json::json!({
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": os,
"architecture": architecture,
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
"sha256": format!("{marker:01x}").repeat(64),
})
});
serde_json::json!({
"id": id,
"version": "1.0.0",
"publisher": "test",
"stability": "experimental",
"artifacts": [{
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": std::env::consts::OS,
"architecture": std::env::consts::ARCH,
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
"sha256": format!("{marker:01x}").repeat(64),
}]
"artifacts": artifacts,
})
}
fn release_platform() -> (&'static str, &'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
platform => panic!("unsupported test platform: {platform:?}"),
}
}
@@ -56,8 +56,13 @@ impl ProxyStore for TestProxies {
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Ok(ProxyPage {
items: self.records.clone(),
total: self.records.len() as u64,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn list_accounts(
&self,
@@ -114,6 +119,79 @@ impl ProxyStore for TestProxies {
}
}
#[tokio::test]
async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_or_probe() {
use gateway_admin::model::{AdminErrorKind, PageSize};
use std::sync::Arc;
let now = chrono::Utc::now();
let records: Vec<_> = [true, false]
.into_iter()
.map(|enabled| ProxyRecord {
id: format!("proxy_{enabled}"),
name: "Login".into(),
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
enabled,
auto_location: false,
location: None,
detected_location: None,
account_count: 0,
last_test_at: Some(now),
last_test: Some(ProxyTestResult {
location: Default::default(),
success: false,
latency_ms: 1,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "fixture failure".into(),
}),
created_at: now,
updated_at: now,
})
.collect();
let access = gateway_admin::initialize_plugin_proxies(Arc::new(TestProxies {
records: records.clone(),
..Default::default()
}));
assert_eq!(
access.resolve("proxy_true").await.unwrap(),
records[0].proxy
);
assert_eq!(
access.resolve("proxy_false").await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
assert!(access.resolve("missing").await.is_err());
for id in ["", "bad\n", &"x".repeat(129)] {
assert_eq!(
access.resolve(id).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
let query = || ProxyListQuery {
page: 1,
page_size: PageSize::new(10).unwrap(),
search: String::new(),
};
let page = access.list(query()).await.unwrap();
assert_eq!(page.items.len(), 2);
assert!(!page.items[1].enabled);
for invalid in [
ProxyListQuery { page: 0, ..query() },
ProxyListQuery {
search: "bad\n".into(),
..query()
},
] {
assert_eq!(
access.list(invalid).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
@@ -1129,6 +1129,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
proxy_hint,
send_state,
message,
provider_error_code,
@@ -1140,6 +1141,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"proxyHint": proxy_hint.map(gateway_core::error::ProxyConnectionHint::as_str),
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
@@ -944,6 +944,7 @@ mod actions {
DomainConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
proxy_hint: None,
send_state: Some(UpstreamSendState::Sent),
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
@@ -1005,6 +1006,7 @@ mod actions {
"type": "error",
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"proxyHint": null,
"sendState": "sent",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
@@ -1463,7 +1463,18 @@ impl DefaultExecutionService {
}
_ => (AccountProbeErrorSource::Gateway, None, None),
};
let mut failure = AccountProbeError::new(
gateway_error_from_engine(error),
source,
send_state,
upstream_response,
);
if let EngineError::Provider(provider_error) = error {
if source == AccountProbeErrorSource::Provider {
failure.proxy_hint = provider_error
.diagnostic()
.and_then(|diagnostic| diagnostic.proxy_hint());
}
let latency = started_at.elapsed().unwrap_or_default();
let latency_ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
tracing::warn!(
@@ -1499,12 +1510,7 @@ impl DefaultExecutionService {
);
}
}
AccountProbeError::new(
gateway_error_from_engine(error),
source,
send_state,
upstream_response,
)
failure
}
}
@@ -5,7 +5,9 @@ use std::fmt;
use bytes::Bytes;
use futures::future::BoxFuture;
use crate::error::{ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind};
use crate::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProxyConnectionHint,
};
use crate::identity::ProviderKind;
use crate::routing::UpstreamModelId;
use crate::{
@@ -126,6 +128,7 @@ impl AccountProbeErrorSource {
#[derive(Debug)]
pub struct AccountProbeError {
gateway: GatewayError,
pub(super) proxy_hint: Option<ProxyConnectionHint>,
source: AccountProbeErrorSource,
send_state: Option<UpstreamSendState>,
upstream_response: Option<Box<AccountProbeUpstreamResponse>>,
@@ -141,6 +144,7 @@ impl AccountProbeError {
) -> Self {
Self {
gateway,
proxy_hint: None,
source,
send_state,
upstream_response: upstream_response.map(Box::new),
@@ -162,6 +166,11 @@ impl AccountProbeError {
self.send_state
}
#[must_use]
pub const fn proxy_hint(&self) -> Option<ProxyConnectionHint> {
self.proxy_hint
}
#[must_use]
pub fn client_message(&self) -> &str {
self.gateway.client_message()
@@ -220,12 +220,34 @@ impl fmt::Debug for ClientVisibleUpstreamError {
}
}
/// 本次建连的代理诊断,仅用于管理端展示,不参与错误处置
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyConnectionHint {
InvalidConfiguration,
Unreachable,
TunnelFailed,
CheckProxy,
}
impl ProxyConnectionHint {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidConfiguration => "invalid_configuration",
Self::Unreachable => "unreachable",
Self::TunnelFailed => "tunnel_failed",
Self::CheckProxy => "check_proxy",
}
}
}
/// Provider 已分类为可安全持久化和展示的诊断摘要
///
/// Adapter 只能放入不含凭据、cookie、原始请求正文和 opaque response ID 的有界摘要;
/// 原始上游错误返回由 [`RawUpstreamError`] 单独承载
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderDiagnostic {
proxy_hint: Option<ProxyConnectionHint>,
message: String,
stage: Option<&'static str>,
code: Option<&'static str>,
@@ -236,11 +258,23 @@ impl ProviderDiagnostic {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
proxy_hint: None,
stage: None,
code: None,
}
}
#[must_use]
pub const fn with_proxy_hint(mut self, hint: ProxyConnectionHint) -> Self {
self.proxy_hint = Some(hint);
self
}
#[must_use]
pub const fn proxy_hint(&self) -> Option<ProxyConnectionHint> {
self.proxy_hint
}
/// 附着本地定义的阶段与原因码;独立于发送状态和重试策略
#[must_use]
pub const fn with_classification(mut self, stage: &'static str, code: &'static str) -> Self {
@@ -3232,6 +3232,10 @@ fn provider_local_probe_failure_should_remain_distinct_from_upstream() {
assert_eq!(error.source(), AccountProbeErrorSource::Provider);
assert_eq!(error.send_state(), Some(UpstreamSendState::NotSent));
assert!(error.upstream_response().is_none());
assert_eq!(
error.proxy_hint(),
Some(gateway_core::error::ProxyConnectionHint::InvalidConfiguration)
);
}
#[test]
@@ -3673,10 +3677,17 @@ impl Provider for LocalFailingProvider {
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
))
Err(
ProviderError::new(ProviderErrorKind::Unsupported, UpstreamSendState::NotSent)
.with_diagnostic(
gateway_core::error::ProviderDiagnostic::new(
"Unsupported account proxy configuration",
)
.with_proxy_hint(
gateway_core::error::ProxyConnectionHint::InvalidConfiguration,
),
),
)
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ tokio-tungstenite = { version = "0.29.0", default-features = false, features = [
tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
yaml-rust2 = "0.11.0"
yaml-rust2 = "0.13.0"
[dev-dependencies]
filetime = "0.2.27"
@@ -7,7 +7,6 @@ use chrono::{DateTime, Utc};
use gateway_admin::model::system::SystemUpdateChannel as Channel;
use reqwest::{Client, StatusCode, Url};
use semver::Version;
use serde::Deserialize;
use tokio::sync::{Mutex, OnceCell};
use tokio::time::Instant;
use yaml_rust2::{
@@ -15,14 +14,13 @@ use yaml_rust2::{
parser::{Event, Parser},
};
use super::release::{GitHubRelease, channel_accepts, channel_for_version, download_client};
use super::release::{GitHubRelease, channel_accepts, channel_for_version};
use super::{OperationError, SystemUpdateConfig, invalid, upstream};
#[derive(Default)]
pub(super) struct Metadata {
client: OnceCell<Client>,
snapshot: Mutex<Option<(Version, Arc<Yaml>)>>,
asset_until: Mutex<Option<Cooldown>>,
raw_until: Mutex<Option<Cooldown>>,
api_until: Mutex<Option<Cooldown>>,
}
@@ -43,42 +41,18 @@ impl Metadata {
&self,
config: &SystemUpdateConfig,
) -> Result<Arc<Yaml>, OperationError> {
#[derive(Deserialize)]
struct Pointer {
current: Option<String>,
}
let pointer = self
.by_tag(config, "update-index")
.await?
.filter(|r| r.tag_name == "update-index" && !r.draft && r.prerelease)
.ok_or_else(|| upstream("更新仓库缺少公开的 update-index 指针"))?;
let pointer: Pointer = serde_json::from_str(pointer.body.as_deref().unwrap_or_default())
.map_err(|_| upstream("发行索引指针格式无效"))?;
let version = pointer
.current
.ok_or_else(|| upstream("尚未发布通道索引"))?;
let version = Version::parse(&version).map_err(|_| upstream("发行索引指针版本无效"))?;
let mut snapshot = self.snapshot.lock().await;
if let Some((previous, yaml)) = snapshot.as_ref()
&& previous == &version
{
return Ok(Arc::clone(yaml));
}
// 正文只接受版本号,下载地址始终由已配置的仓库和固定附件名构造
let repo = repository(config)?;
let mut url =
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
if url.host_str() == Some("api.github.com") {
url = Url::parse("https://github.com").expect("固定的 GitHub 发行源");
url = Url::parse("https://raw.githubusercontent.com").expect("固定的 GitHub raw 源");
}
url.set_path(&format!(
"/{}/releases/download/v{version}/version.yaml",
repository(config)?
));
let client = download_client(&config.github_api_base, Duration::from_secs(10))?;
// 配置校验只额外接受回环源,测试时索引和 Release 均留在本机
url.set_path(&format!("/{repo}/refs/heads/main/release/version.yaml"));
let bytes = self
.read(client.get(url), &self.asset_until, 128 * 1024)
.read(url.as_str(), &self.raw_until, 128 * 1024, false)
.await?
.ok_or_else(|| upstream("发行附件缺少 version.yaml"))?;
.ok_or_else(|| upstream("更新仓库缺少发布索引"))?;
let source = std::str::from_utf8(&bytes).map_err(|_| upstream("发布索引格式无效"))?;
let bad = || upstream("发布索引格式无效");
// 远程 YAML 不使用别名,限制嵌套后再构造节点,避免小响应放大内存
@@ -102,12 +76,7 @@ impl Metadata {
if docs.len() != 1 || docs[0]["published"].as_hash().is_none() {
return Err(upstream("发布索引缺少有效的 published 通道表"));
}
let yaml = Arc::new(docs.remove(0));
if yaml["version"].as_str() != Some(version.to_string().as_str()) {
return Err(upstream("发行索引附件与指针版本不符"));
}
*snapshot = Some((version, Arc::clone(&yaml)));
Ok(yaml)
Ok(Arc::new(docs.remove(0)))
}
pub(super) async fn release(
@@ -115,17 +84,29 @@ impl Metadata {
config: &SystemUpdateConfig,
version: &Version,
) -> Result<Option<Arc<GitHubRelease>>, OperationError> {
let stable = version.pre.is_empty();
let mut url =
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
let prerelease = !version.pre.is_empty();
let version = version.to_string();
let tag = format!("v{version}");
let Some(release) = self.by_tag(config, &tag).await? else {
url.path_segments_mut()
.map_err(|()| invalid("发行查询地址无效"))?
.pop_if_empty()
.extend(repository(config)?.split('/'))
.extend(["releases", "tags", &tag]);
let Some(bytes) = self
.read(url.as_str(), &self.api_until, 4 * 1024 * 1024, true)
.await?
else {
// 当前运行版本可以没有公开说明;索引承诺的升级目标缺失属于短期查询失败
if config.version.trim_start_matches('v') == version {
return Ok(None);
}
return Err(upstream("发布索引指向的 Release 不存在"));
};
if release.tag_name != tag || release.draft || release.prerelease == stable {
let release: GitHubRelease = serde_json::from_slice(&bytes)
.map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error))?;
if release.tag_name != tag || release.draft || release.prerelease != prerelease {
return Err(upstream("GitHub 发行身份与发布索引不符"));
}
if config.version.trim_start_matches('v') != version {
@@ -134,18 +115,20 @@ impl Metadata {
Ok(Some(Arc::new(release)))
}
async fn by_tag(
async fn read(
&self,
config: &SystemUpdateConfig,
tag: &str,
) -> Result<Option<GitHubRelease>, OperationError> {
let mut url =
Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?;
url.path_segments_mut()
.map_err(|()| invalid("发行查询地址无效"))?
.pop_if_empty()
.extend(repository(config)?.split('/'))
.extend(["releases", "tags", tag]);
url: &str,
cooldown: &Mutex<Option<Cooldown>>,
limit: usize,
api: bool,
) -> Result<Option<Vec<u8>>, OperationError> {
if let Some(active) = cooldown
.lock()
.await
.filter(|active| active.until > Instant::now())
{
return Err(active.warning());
}
let client = self
.client
.get_or_try_init(|| async {
@@ -159,40 +142,15 @@ impl Metadata {
})
.await
.map_err(|e| super::error::http("无法初始化发行查询连接", e))?;
let bytes = self
.read(
client
.get(url)
.header("Accept", "application/vnd.github+json"),
&self.api_until,
4 * 1024 * 1024,
)
.await?;
bytes
.map(|bytes| {
serde_json::from_slice(&bytes)
.map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error))
})
.transpose()
}
async fn read(
&self,
request: reqwest::RequestBuilder,
cooldown: &Mutex<Option<Cooldown>>,
limit: usize,
) -> Result<Option<Vec<u8>>, OperationError> {
if let Some(active) = cooldown
.lock()
.await
.filter(|active| active.until > Instant::now())
{
return Err(active.warning());
let request = client.get(url);
let mut response = if api {
request.header("Accept", "application/vnd.github+json")
} else {
request
}
let mut response = request
.send()
.await
.map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?;
.send()
.await
.map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(None);
@@ -49,7 +49,7 @@ async fn temporary_channel_should_filter_future_releases_without_changing_runnin
assert_eq!(version.latest_version, "1.1.0");
assert!(!fixture.state().exists(), "检查通道不能写入实例偏好");
let requests = server.received_requests().await.expect("requests");
assert_eq!(requests.len(), 6);
assert_eq!(requests.len(), 5);
}
#[tokio::test]
@@ -52,7 +52,6 @@ async fn discovery_request_budget_is_constant_under_load() {
.collect::<serde_json::Map<_, _>>()
.into();
}
mount_pointer(&server).await;
Mock::given(path(INDEX_PATH))
.respond_with(
ResponseTemplate::new(200)
@@ -81,7 +80,7 @@ async fn discovery_request_budget_is_constant_under_load() {
.unwrap();
assert_eq!(summary.latest_version, target);
assert!(summary.notes.is_none());
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
let mut callers = tokio::task::JoinSet::new();
for i in 0..512 {
@@ -103,9 +102,9 @@ async fn discovery_request_budget_is_constant_under_load() {
for _ in 0..64 {
assert!(service.version().await.unwrap().has_update);
}
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
eprintln!(
"history={history} concurrent_detail=512 local_probes=65 pointer=1 snapshot=1 release=1 lists=0 elapsed={:?}",
"history={history} concurrent_detail=512 local_probes=65 index=1 release=1 lists=0 elapsed={:?}",
started.elapsed()
);
}
@@ -117,7 +116,6 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() {
let fixture = Fixture::new();
let service = Arc::new(fixture.service(&server));
let (started, mut observed) = tokio::sync::mpsc::unbounded_channel();
mount_pointer(&server).await;
Mock::given(path(INDEX_PATH))
.respond_with(move |_: &wiremock::Request| {
started.send(()).unwrap();
@@ -161,7 +159,7 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() {
while let Some(summary) = callers.join_next().await {
assert!(summary.unwrap().has_update);
}
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
@@ -215,7 +213,7 @@ async fn api_cooldown_is_shared_and_summary_does_not_advertise_an_unverified_rel
.unwrap()
.has_update
);
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
}
@@ -224,15 +222,14 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or
for body in [
"version: '1.9.9'".to_owned(),
"version: '9.0.0'\npublished: {'2': {stable: '2.0.0'}}".to_owned(),
format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '2.0.0'}}}}"),
format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '1.2.0-rc.1'}}}}"),
format!("version: '{BUILD_VERSION}'\npublished: {{'1': {{stable: '2.0.0'}}}}"),
format!("version: '{BUILD_VERSION}'\npublished: {{'1': {{stable: '1.2.0-rc.1'}}}}"),
"bad: &loop [1]\npublished: *loop".to_owned(),
"x".repeat(128 * 1024 + 1),
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
mount_pointer(&server).await;
Mock::given(path(INDEX_PATH))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.expect(1)
@@ -246,7 +243,7 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or
assert!(detail.warning.is_some());
assert!(!detail.has_update);
}
assert_eq!(server.received_requests().await.unwrap().len(), 2);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
@@ -283,33 +280,18 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta
let fixture = Fixture::new();
let service = fixture.service(&server);
let requests = AtomicUsize::new(0);
Mock::given(path(POINTER_PATH))
Mock::given(path(INDEX_PATH))
.respond_with(move |_: &wiremock::Request| {
pointer_response(if requests.fetch_add(1, Ordering::SeqCst) == 0 {
SNAPSHOT
let target = if requests.fetch_add(1, Ordering::SeqCst) == 0 {
"1.1.0"
} else {
"4.0.0"
})
"1.2.0"
};
ResponseTemplate::new(200).set_body_json(fixture_index(target))
})
.expect(2)
.mount(&server)
.await;
for (path_name, latest, target) in [
(INDEX_PATH, SNAPSHOT, "1.1.0"),
(
"/owner/repository/releases/download/v4.0.0/version.yaml",
"4.0.0",
"1.2.0",
),
] {
let mut index = fixture_index(target);
index["version"] = latest.into();
Mock::given(path(path_name))
.respond_with(ResponseTemplate::new(200).set_body_json(index))
.expect(1)
.mount(&server)
.await;
}
Mock::given(path("/repos/owner/repository/releases/tags/v1.1.0"))
.respond_with(release_response("1.1.0", available_assets("1.1.0")))
.expect(1)
@@ -323,7 +305,7 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta
let status = complete_update(&service, &detail.latest_version).await;
assert_eq!(status.operation.status, SystemOperationStatus::Failed);
assert!(status.operation.error.unwrap().contains("重新检查并确认"));
assert_eq!(server.received_requests().await.unwrap().len(), 5);
assert_eq!(server.received_requests().await.unwrap().len(), 3);
}
#[tokio::test]
@@ -352,7 +334,7 @@ async fn channels_pointing_to_one_release_share_its_detail() {
.has_update
);
}
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
@@ -360,10 +342,9 @@ async fn transient_release_absence_expires_and_refresh_interval_is_bounded() {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
mount_pointer(&server).await;
Mock::given(path(INDEX_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture_index(TARGET_VERSION)))
.expect(1)
.expect(2)
.mount(&server)
.await;
let calls = AtomicUsize::new(0);
@@ -463,7 +444,7 @@ async fn metadata_never_follows_redirects_or_accepts_oversized_release() {
} else {
"大小限制"
}));
assert_eq!(server.received_requests().await.unwrap().len(), 3);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
}
@@ -473,7 +454,6 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads(
let fixture = Fixture::new();
let service = Arc::new(fixture.service(&server));
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel();
mount_pointer(&server).await;
Mock::given(path(INDEX_PATH))
.respond_with(move |_: &wiremock::Request| {
sent.send(()).unwrap();
@@ -497,7 +477,7 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads(
assert!(detail.warning.is_some());
assert!(!detail.has_update);
assert_eq!(service.version().await.unwrap().version, "1.0.0");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
@@ -525,27 +505,3 @@ async fn summary_and_local_probe_require_downloadable_target() {
assert!(!service.version().await.unwrap().has_update);
}
}
#[tokio::test]
async fn unpublished_or_untrusted_pointer_cannot_redirect_discovery() {
for (draft, current) in [
(true, Some("3.42.0")),
(false, None),
(false, Some("https://example.com/v1")),
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
let service = fixture.service(&server);
Mock::given(path(POINTER_PATH)).respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"tag_name":"update-index","prerelease":true,"draft":draft,
"body":serde_json::json!({"current":current,"pending":{"version":"3.42.0"}}).to_string()})
)).expect(1).mount(&server).await;
let detail = service
.update_detail(SystemUpdateCheck::Summary, None)
.await
.unwrap();
assert!(!detail.has_update);
assert!(detail.warning.is_some());
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
}
@@ -40,7 +40,6 @@ async fn archive_is_the_last_remote_request_before_activation() {
assert_eq!(
paths,
[
POINTER_PATH,
INDEX_PATH,
&format!("/repos/owner/repository/releases/tags/v{TARGET_VERSION}"),
"/checksums",
@@ -515,23 +515,8 @@ async fn mount_release_json(
}
}
const POINTER_PATH: &str = "/repos/owner/repository/releases/tags/update-index";
const SNAPSHOT: &str = "4.0.0-beta.1";
const INDEX_PATH: &str = "/owner/repository/releases/download/v4.0.0-beta.1/version.yaml";
fn pointer_response(version: &str) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name":"update-index", "draft":false, "prerelease":true,
"body":serde_json::json!({"current":version}).to_string()
}))
}
async fn mount_pointer(server: &MockServer) {
Mock::given(path(POINTER_PATH))
.respond_with(pointer_response(SNAPSHOT))
.mount(server)
.await;
}
const BUILD_VERSION: &str = "4.0.0-beta.1";
const INDEX_PATH: &str = "/owner/repository/refs/heads/main/release/version.yaml";
fn available_assets(version: &str) -> Vec<serde_json::Value> {
[archive_name(version), "checksums.txt".to_owned()].into_iter()
@@ -541,12 +526,10 @@ fn available_assets(version: &str) -> Vec<serde_json::Value> {
fn fixture_index(version: &str) -> serde_json::Value {
let major = version.split('.').next().unwrap();
let base = version.split('-').next().unwrap();
serde_json::json!({"version":SNAPSHOT, "published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}})
serde_json::json!({"version":BUILD_VERSION, "published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}})
}
async fn mount_index(server: &MockServer, mut index: serde_json::Value) {
mount_pointer(server).await;
index["version"] = SNAPSHOT.into();
async fn mount_index(server: &MockServer, index: serde_json::Value) {
Mock::given(method("GET"))
.and(path(INDEX_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(index))
@@ -71,6 +71,7 @@ impl CallbackHandler for PluginCallbacks {
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let proxies = self.proxies.clone();
let resources = self.resources.clone();
let data = self.data.clone();
let keys = self.keys.clone();
@@ -233,6 +234,12 @@ impl CallbackHandler for PluginCallbacks {
maximum_payload,
request_limits: &request_limits,
};
if method == host::PROXIES_LIST {
return proxies.list(params, &payload).await;
}
if method == host::PROXY_HTTP {
return http.proxy(&proxies, params, payload).await;
}
if method == host::ACCOUNT_HTTP {
return http.account(&accounts, params, payload).await;
}
@@ -55,6 +55,30 @@ impl HttpCallbacks<'_> {
.await
}
pub(super) async fn proxy(
&self,
proxies: &super::PluginProxyPortSlot,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
let request: wire::ProxyHttpRequest = decode(params)?;
let maximum_body_bytes = self.body_limit(&payload)?;
let proxy = proxies.resolve(&request.proxy_id).await?;
self.send(
HttpRequest {
method: request.request.method,
url: request.request.url,
headers: request.request.headers,
body: payload,
maximum_body_bytes,
},
true,
None,
Some(&proxy),
)
.await
}
pub(super) async fn send(
&self,
request: HttpRequest,
@@ -20,6 +20,7 @@ mod model;
pub(crate) mod outbound;
mod ports;
pub(crate) mod private_state;
mod proxies;
mod replay;
pub(crate) mod request_limits;
mod resources;
@@ -40,6 +41,7 @@ pub(crate) use middleware::{
MiddlewareInvocation,
};
pub(crate) use model::PluginModelPortSlot;
pub(crate) use proxies::PluginProxyPortSlot;
pub(crate) use resources::PluginResourcePorts;
pub(crate) use scope::{CallbackScope, InvocationContext};
@@ -11,7 +11,7 @@ use gateway_host::outbound::{HttpClient, NetworkPolicy};
use super::{
CallResources, CallbackScope, MiddlewareCallback, PluginAccountPortSlot,
PluginAffinityPortSlot, PluginClientIdentityPortSlot, PluginClientKeyPortSlot,
PluginModelPortSlot, PluginResourcePorts,
PluginModelPortSlot, PluginProxyPortSlot, PluginResourcePorts,
};
use super::{
accounts, affinity, client, data, keys, log, model, private_state, request_limits, resources,
@@ -23,6 +23,7 @@ pub(crate) struct PluginCallbackPorts {
pub(crate) services: Arc<services::ServicePorts>,
pub(crate) http: Arc<HttpClient>,
pub(crate) accounts: Arc<PluginAccountPortSlot>,
pub(crate) proxies: Arc<PluginProxyPortSlot>,
pub(crate) keys: Arc<PluginClientKeyPortSlot>,
pub(crate) clients: Arc<PluginClientIdentityPortSlot>,
pub(crate) models: Arc<PluginModelPortSlot>,
@@ -36,6 +37,7 @@ pub(crate) struct PluginCallbacks {
pub(super) services: Arc<services::ServicePorts>,
pub(super) service_actor: gateway_admin::model::usage_data::UsageActor,
pub(super) accounts: Arc<accounts::PluginAccounts>,
pub(super) proxies: Arc<PluginProxyPortSlot>,
pub(super) resources: Arc<resources::PluginResources>,
pub(super) data: Arc<data::PluginData>,
pub(super) keys: Arc<keys::PluginClientKeys>,
@@ -79,6 +81,7 @@ impl PluginCallbacks {
ports.keys.clone(),
)),
accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)),
proxies: ports.proxies,
keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)),
affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)),
log: Arc::new(
@@ -0,0 +1,80 @@
//! 代理目录的安全投影与受管 HTTP 出口解析,配置事实由 Admin 持有
use std::sync::{Arc, OnceLock, Weak};
use gateway_admin::{
model::{AdminError, PageSize, proxies::ProxyListQuery},
ports::proxy::PluginProxyAccess,
};
use gateway_core::account::OutboundProxy;
use gateway_plugin_sdk::{PluginFault, call::host as wire};
use super::{
admin::{encode, map_admin_error},
invalid,
};
use crate::RpcReply;
pub(crate) struct PluginProxyPortSlot {
access: OnceLock<Weak<dyn PluginProxyAccess>>,
}
impl PluginProxyPortSlot {
pub(crate) const fn new() -> Self {
Self {
access: OnceLock::new(),
}
}
pub(crate) fn bind(&self, access: &Arc<dyn PluginProxyAccess>) -> Result<(), AdminError> {
self.access
.set(Arc::downgrade(access))
.map_err(|_| AdminError::conflict("插件代理端口已经绑定"))
}
fn access(&self) -> Result<Arc<dyn PluginProxyAccess>, PluginFault> {
self.access
.get()
.and_then(Weak::upgrade)
.ok_or_else(|| map_admin_error(AdminError::unavailable("插件代理服务暂不可用")))
}
pub(super) async fn resolve(&self, id: &str) -> Result<OutboundProxy, PluginFault> {
self.access()?.resolve(id).await.map_err(map_admin_error)
}
pub(super) async fn list(
&self,
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: wire::ProxyListRequest =
serde_json::from_slice(payload).map_err(|_| invalid())?;
let page = self
.access()?
.list(ProxyListQuery {
page: request.page,
page_size: PageSize::new(request.page_size).map_err(|_| invalid())?,
search: request.search,
})
.await
.map_err(map_admin_error)?;
encode(&wire::ProxyListResult {
items: page
.items
.into_iter()
.map(|proxy| wire::ProxySummary {
id: proxy.id,
name: proxy.name,
enabled: proxy.enabled,
})
.collect(),
total: page.total,
page: page.page,
page_size: page.page_size,
})
}
}
@@ -195,6 +195,7 @@ impl PluginRuntime {
usage: self.usage_ports.clone(),
http: self.http.clone(),
accounts: self.account_ports.clone(),
proxies: self.proxy_ports.clone(),
keys: self.client_key_ports.clone(),
clients: self.client_identity_port.clone(),
models: self.model_ports.clone(),
@@ -9,7 +9,7 @@ use crate::{
PackageLimits, RpcLimits, RpcSession,
callback::{
PluginAccountPortSlot, PluginAffinityPortSlot, PluginClientIdentityPortSlot,
PluginClientKeyPortSlot, PluginModelPortSlot,
PluginClientKeyPortSlot, PluginModelPortSlot, PluginProxyPortSlot,
},
};
use gateway_admin::{
@@ -69,6 +69,7 @@ pub struct PluginRuntime {
pub(super) configuration_schemas: Arc<super::configuration::ConfigurationSchemas>,
pub(super) log_slots: Arc<Semaphore>,
pub(super) account_ports: Arc<PluginAccountPortSlot>,
pub(super) proxy_ports: Arc<PluginProxyPortSlot>,
pub(super) client_key_ports: Arc<PluginClientKeyPortSlot>,
pub(super) resource_ports: Arc<crate::callback::PluginResourcePorts>,
pub(super) client_identity_port: Arc<PluginClientIdentityPortSlot>,
@@ -115,6 +116,7 @@ impl PluginRuntime {
request_limits: Arc::default(),
service_ports: Arc::new(crate::callback::services::ServicePorts::default()),
account_ports: Arc::new(PluginAccountPortSlot::new()),
proxy_ports: Arc::new(PluginProxyPortSlot::new()),
client_key_ports: Arc::new(PluginClientKeyPortSlot::new()),
resource_ports: Arc::new(crate::callback::PluginResourcePorts::new()),
client_identity_port: Arc::new(PluginClientIdentityPortSlot::new()),
@@ -172,6 +174,14 @@ impl PluginRuntime {
self.account_ports.bind(access)
}
/// 代理读取由 Admin 提供并保活,Runtime 不依赖账号绑定
pub fn bind_proxy_ports(
&self,
access: &Arc<dyn gateway_admin::ports::proxy::PluginProxyAccess>,
) -> Result<(), AdminError> {
self.proxy_ports.bind(access)
}
/// Key 目录与预算端口由 Admin 组合并保活,完整字段由对应公开合同返回
pub fn bind_client_key_ports(
&self,
@@ -363,7 +363,11 @@ async fn quota_refresh_is_available_without_permission_declarations() {
}
}
async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Value) -> Value {
async fn management_queries(
accounts: Option<Arc<dyn PluginAccountAccess>>,
proxies: Option<Arc<dyn gateway_admin::ports::proxy::PluginProxyAccess>>,
queries: Value,
) -> Value {
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::Management,
@@ -372,7 +376,12 @@ async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Val
vec![],
)]))
.await;
runtime.bind_account_ports(&accounts).unwrap();
if let Some(accounts) = &accounts {
runtime.bind_account_ports(accounts).unwrap();
}
if let Some(proxies) = &proxies {
runtime.bind_proxy_ports(proxies).unwrap();
}
store.snapshot.lock().unwrap().instances[0].configuration = json!({
"management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]},
"data_queries":queries,
@@ -416,95 +425,260 @@ async fn request_head(stream: &mut tokio::net::TcpStream) -> String {
String::from_utf8(bytes).unwrap()
}
struct ProxyFacts {
resolutions: std::sync::Mutex<
std::collections::VecDeque<Result<gateway_core::account::OutboundProxy, AdminError>>,
>,
page: Option<gateway_admin::model::proxies::ProxyPage>,
}
#[async_trait]
impl gateway_admin::ports::proxy::PluginProxyAccess for ProxyFacts {
async fn resolve(&self, id: &str) -> Result<gateway_core::account::OutboundProxy, AdminError> {
assert_eq!(id, "proxy_login");
self.resolutions
.lock()
.unwrap()
.pop_front()
.expect("每次发送读取当前配置")
}
async fn list(
&self,
query: gateway_admin::model::proxies::ProxyListQuery,
) -> Result<gateway_admin::model::proxies::ProxyPage, AdminError> {
assert_eq!(query.page, 2);
assert_eq!(query.page_size.get(), 1);
assert_eq!(query.search, "Login");
Ok(self.page.clone().unwrap())
}
}
#[tokio::test]
async fn account_http_uses_the_selected_proxy_and_rejects_unavailable_account_versions() {
async fn selected_http_uses_account_or_proxy_without_fallback_or_redirects() {
use gateway_core::account::OutboundProxy;
use tokio::io::AsyncWriteExt as _;
for scenario in [
"plain",
"explicit-auth",
"rotated",
"disabled",
"proxy-failed",
"missing",
for (selector, scenarios) in [
(
"account",
&[
"plain",
"explicit-auth",
"rotated",
"disabled",
"proxy-failed",
"missing",
][..],
),
(
"proxy",
&[
"plain",
"explicit-auth",
"disabled",
"proxy-failed",
"missing",
"redirect",
][..],
),
] {
let target = wiremock::MockServer::start().await;
for &scenario in scenarios {
let target = wiremock::MockServer::start().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = OutboundProxy::parse(&format!("http://{}", listener.local_addr().unwrap()))
.unwrap();
let successful = matches!(scenario, "plain" | "explicit-auth" | "redirect");
let target_url = target.uri();
let proxy_task = if successful {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = request_head(&mut socket).await;
let response = if scenario == "redirect" {
format!(
"HTTP/1.1 302 Found\r\nLocation: {target_url}/redirect\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
} else {
"HTTP/1.1 201 Created\r\nSet-Cookie: step=one\r\nSet-Cookie: session=two\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
};
socket.write_all(response.as_bytes()).await.unwrap();
request
}))
} else {
drop(listener);
None
};
let headers = if scenario == "explicit-auth" {
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
} else {
vec![]
};
let request =
json!({"method":"GET", "url":format!("{}/login", target.uri()), "headers":headers});
let results = if selector == "account" {
let mut selected = account();
selected.outbound_proxy = Some(proxy);
selected.enabled = scenario != "disabled";
if scenario == "rotated" {
selected.credential_revision = Revision::new(2).unwrap();
}
let accounts = Arc::new(Facts {
runtime_account: Some(selected),
..Default::default()
});
management_queries(Some(accounts), None, json!([{
"method":"host.http.account", "params":{
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
"credential_revision":1, "request":request,
},
}])).await
} else {
let resolution = match scenario {
"missing" => Err(AdminError::not_found("代理不存在")),
"disabled" => Err(AdminError::invalid("代理已禁用")),
_ => Ok(proxy),
};
let proxies = Arc::new(ProxyFacts {
resolutions: std::sync::Mutex::new([resolution].into()),
page: None,
});
management_queries(None, Some(proxies), json!([{
"method":"host.http.proxy", "params":{"proxy_id":"proxy_login", "request":request},
}])).await
};
if let Some(task) = proxy_task {
assert_eq!(
results[0]["status"],
if scenario == "redirect" { 302 } else { 201 }
);
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap()
.to_ascii_lowercase();
assert!(
request.starts_with("get http://127.0.0.1:")
&& request.contains("/login http/1.1")
);
assert_eq!(
request
.lines()
.any(|line| line.starts_with("authorization:")),
scenario == "explicit-auth"
);
if scenario == "explicit-auth" {
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
}
if scenario != "redirect" {
assert_eq!(
results[0]["headers"]
.as_array()
.unwrap()
.iter()
.filter(|header| header[0] == "set-cookie")
.count(),
2
);
}
} else {
assert_eq!(
results[0]["error"],
match scenario {
"missing" => "rejected",
"rotated" => "conflict",
"disabled" if selector == "account" => "permission_denied",
"disabled" => "invalid_input",
_ => "upstream",
},
"{selector}/{scenario}: {results}"
);
}
assert!(
target.received_requests().await.unwrap().is_empty(),
"{selector}/{scenario} must not connect directly"
);
}
}
}
#[tokio::test]
async fn proxy_http_resolves_each_send_and_directory_only_exposes_selection_facts() {
use gateway_admin::model::proxies::{ProxyPage, ProxyRecord};
use gateway_core::account::OutboundProxy;
use tokio::io::AsyncWriteExt as _;
let target = wiremock::MockServer::start().await;
let mut resolutions = std::collections::VecDeque::new();
let mut tasks = Vec::new();
for status in [201, 202] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
resolutions.push_back(Ok(OutboundProxy::parse(&format!(
"http://{}",
listener.local_addr().unwrap()
))
.unwrap();
let successful = matches!(scenario, "plain" | "explicit-auth");
let proxy_task = if successful {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = request_head(&mut socket).await;
assert!(request.starts_with("GET http://127.0.0.1:"));
socket
.write_all(
b"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
.unwrap()));
tasks.push(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
request_head(&mut socket).await;
socket
.write_all(
format!(
"HTTP/1.1 {status} OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.await
.unwrap();
request
}))
} else {
drop(listener);
None
};
let mut selected = account();
selected.outbound_proxy = Some(proxy);
selected.enabled = scenario != "disabled";
if scenario == "rotated" {
selected.credential_revision = Revision::new(2).unwrap();
}
let accounts: Arc<dyn PluginAccountAccess> = Arc::new(Facts {
runtime_account: Some(selected),
..Default::default()
});
let headers = if scenario == "explicit-auth" {
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
} else {
vec![]
};
let results = management_queries(accounts, json!([{
"method":gateway_plugin_sdk::call::host::ACCOUNT_HTTP,
"params":{
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
"credential_revision":1,
"request":{"method":"GET","url":format!("{}/facts", target.uri()),"headers":headers},
},
}])).await;
if let Some(task) = proxy_task {
assert_eq!(results[0]["status"], 201, "{scenario}: {results}");
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
.as_bytes(),
)
.await
.unwrap()
.unwrap()
.to_ascii_lowercase();
assert!(
request.starts_with("get http://127.0.0.1:") && request.contains("/facts http/1.1"),
"{request}"
);
assert_eq!(
request.contains("authorization:"),
scenario == "explicit-auth"
);
if scenario == "explicit-auth" {
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
}
} else {
let expected = match scenario {
"rotated" => "conflict",
"disabled" => "permission_denied",
"missing" => "rejected",
_ => "upstream",
};
assert_eq!(results[0]["error"], expected, "{scenario}: {results}");
}
assert!(
target.received_requests().await.unwrap().is_empty(),
"{scenario} must not connect directly"
);
.unwrap();
}));
}
let now = chrono::Utc::now();
let proxies = Arc::new(ProxyFacts {
resolutions: std::sync::Mutex::new(resolutions),
page: Some(ProxyPage {
total: 2,
page: 2,
page_size: 1,
items: vec![ProxyRecord {
id: "proxy_login".into(),
name: "Login".into(),
enabled: false,
proxy: OutboundProxy::parse("http://user:secret@private.example:8080").unwrap(),
revision: Revision::new(2).unwrap(),
auto_location: false,
detected_location: None,
location: None,
account_count: 0,
last_test_at: None,
last_test: None,
created_at: now,
updated_at: now,
}],
}),
});
let request = json!({"method":"host.http.proxy", "params":{
"proxy_id":"proxy_login", "request":{"method":"GET", "url":target.uri(), "headers":[]},
}});
let results = management_queries(
None,
Some(proxies.clone()),
json!([
request, request,
{"method":"host.proxies.list", "query":{"page":2,"page_size":1,"search":"Login"}},
]),
)
.await;
assert_eq!(results[0]["status"], 201);
assert_eq!(results[1]["status"], 202);
assert_eq!(
results[2],
json!({"items":[{"id":"proxy_login","name":"Login","enabled":false}],"total":2,"page":2,"page_size":1})
);
assert!(proxies.resolutions.lock().unwrap().is_empty());
assert!(target.received_requests().await.unwrap().is_empty());
for task in tasks {
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap();
}
}
@@ -129,18 +129,15 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() {
#[tokio::test]
async fn diagnostics_report_a_published_process_fault_without_plugin_details() {
let (cache, store, runtime) = super::setup().await;
let controls = tempfile::tempdir().unwrap();
let exit = controls.path().join("exit");
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_ms":20});
snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_signals":[exit]});
let active = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("candidate starts before the controlled exit");
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while active.is_ready() {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("worker exit observed");
std::fs::write(exit, b"exit").unwrap();
wait_until_unready(&active).await;
let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics(
&runtime,
@@ -171,17 +168,20 @@ async fn diagnostics_report_a_published_process_fault_without_plugin_details() {
#[tokio::test]
async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plugin_status() {
let (cache, store, runtime) = super::setup().await;
let controls = tempfile::tempdir().unwrap();
let exit = controls.path().join("exit");
let snapshot = {
let mut snapshot = store.snapshot.lock().unwrap();
let mut failed = snapshot.instances[0].clone();
failed.id = "crashing-plugin".into();
failed.configuration = serde_json::json!({"exit_after_ready_ms":100});
failed.configuration = serde_json::json!({"exit_after_ready_signals":[exit]});
snapshot.instances.push(failed);
snapshot.clone()
};
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
std::fs::write(exit, b"exit").unwrap();
wait_until_unready(&generation).await;
assert!(generation.can_serve());
assert!(!generation.is_ready());
@@ -94,29 +94,32 @@ async fn registration_with_a_changed_contribution_id_is_rejected() {
async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() {
let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig {
maximum_failures: NonZeroUsize::new(3).unwrap(),
stability_window: Duration::from_secs(1),
// 本用例只验证连续崩溃的预算,不让宿主调度延迟触发稳定运行后的重置
stability_window: Duration::MAX,
};
let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await;
let controls = tempfile::tempdir().unwrap();
let marker = controls.path().join("starts.jsonl");
let exits = [1, 2, 3].map(|start| controls.path().join(format!("exit-{start}")));
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"startup_marker": marker,
"exit_after_ready_ms": 20,
"exit_after_ready_signals": exits,
});
let mut active = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("first incarnation");
for expected_start in 2..=3 {
for (index, exit) in exits.iter().enumerate() {
assert_eq!(startup_count(&marker), index + 1);
// Ready 早于注册完成;只有宿主拿到这一代后,才让对应的进程退出
std::fs::write(exit, b"exit").unwrap();
wait_until_unready(&active).await;
let replacement = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("failure budget still permits a restart");
drop(active);
active = replacement;
assert_eq!(startup_count(&marker), expected_start);
if index + 1 < exits.len() {
active = PluginPreparation::prepare(&runtime, snapshot.clone())
.await
.expect("failure budget still permits a restart");
}
}
wait_until_unready(&active).await;
assert!(
PluginPreparation::prepare(&runtime, snapshot.clone())
.await
@@ -434,7 +437,6 @@ async fn abandoned_candidate_keeps_shared_process_failure_in_restart_budget() {
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"startup_marker":marker,
"exit_after_ready_delays_ms":[null,null],
"exit_after_ready_signals":[old_exit,null],
});
let mut other = snapshot.instances[0].clone();
@@ -1546,11 +1546,6 @@ async fn main() {
resumptions: Mutex::new(BTreeMap::new()),
streams: Mutex::new(BTreeMap::new()),
});
let exit_after_ready = peer.configuration["exit_after_ready_delays_ms"]
.as_array()
.and_then(|delays| delays.get(startup))
.and_then(Value::as_u64)
.or_else(|| peer.configuration["exit_after_ready_ms"].as_u64());
let exit_after_ready_signal = peer.configuration["exit_after_ready_signals"]
.as_array()
.and_then(|signals| signals.get(startup))
@@ -1571,12 +1566,7 @@ async fn main() {
vec![],
)
.await;
if let Some(delay) = exit_after_ready {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(delay)).await;
std::process::exit(7);
});
} else if let Some(signal) = exit_after_ready_signal {
if let Some(signal) = exit_after_ready_signal {
tokio::spawn(async move {
while !std::path::Path::new(&signal).exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
@@ -263,12 +263,23 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约,代理不可用时直接失败,不回退直连。
这项能力与上游适配器的账号推理 HTTP 分开,适用于插件自行构造的账号网络请求
`HostClient::proxy_http(ProxyHttpRequest { proxy_id, request }, body)` 经 `host.http.proxy` 使用指定代理的当前配置,
返回同一响应头与 `HostHttpBody`,不要求账号存在或绑定代理,也不校验账号凭据版本。
代理不存在、禁用或连接失败时返回错误,不回退直连;未测试或历史测试失败不阻止实际请求。
每次发送重新读取代理,已发出的请求沿该次解析的出口完成,不锁定整个插件业务流程的代理配置。
目标请求的 headers 与正文由插件提供,宿主不注入账号认证;代理连接所需认证由宿主处理
`HostClient::list_proxies(ProxyListRequest { page, page_size, search })` 经 `host.proxies.list` 查询代理目录。
`page` 从 1 开始,`page_size` 为 1–200,`search` 可省略,默认空字符串,最多 256 字节且不能包含控制字符。
返回 `{ items: [{ id, name, enabled }], total, page, page_size }`,包含禁用代理,不返回连接地址、凭据或账号信息。
查询和结果使用二进制 JSON 载荷,控制参数及结果均为 `{}`;目录展示不代替发送时的状态校验
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
受管 HTTP(包括 `host.http.account` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
受管 HTTP(包括 `host.http.account`、`host.http.proxy` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
attempt、outbound、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。
超限返回带 HTTP 413 的 `invalid_input` 和 `not_sent`,不向目标发送请求。
@@ -692,7 +703,12 @@ keep、replace 或 delete;全部完成后宿主才原子提交新配置与状
宿主注入只读的 `window.codexProxyPlugin`。`request({ method, path, query?, contentType?, body? })` 只能访问当前版本
已注册的管理路由,返回 `{ status, contentType, body: ArrayBuffer }`;`callbackTicket({ path, ttlSeconds })` 为已注册
回调签发 1–600 秒的一次性地址;`resourceUrl(path)` 只解析同一制品已注册的非 HTML 资源。`theme` 为当前
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。页面不能指定其他实例、制品、版本、URL 或请求 header
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。管理请求不能指定其他实例、制品、版本、URL 或请求 header
需要跳转到外部页面时,在插件页面的用户点击处理器中调用 `openExternal(url): Promise<void>`。
地址必须是绝对 HTTPS URL,不能包含用户名或密码。宿主展示目标主机名和非默认端口,管理员可打开新标签页、复制链接或取消。
Promise 成功只表示宿主已接受确认请求,不代表链接已打开;无用户操作、地址无效或已有链接待确认时,请求会被拒绝。
切换页面会关闭确认框。兼容较早的 v2 宿主时,先检查 `typeof window.codexProxyPlugin.openExternal === 'function'`
普通模型请求使用标准 `Response` 接口:
@@ -87,6 +87,39 @@ pub struct HttpRequest {
}
pub const ACCOUNT_HTTP: &str = "host.http.account";
pub const PROXY_HTTP: &str = "host.http.proxy";
pub const PROXIES_LIST: &str = "host.proxies.list";
/// 按当前代理配置发送请求,不依赖账号或注入账号认证
#[derive(Clone, Serialize, Deserialize)]
pub struct ProxyHttpRequest {
pub proxy_id: String,
pub request: HttpRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyListRequest {
pub page: u32,
pub page_size: u16,
#[serde(default)]
pub search: String,
}
/// 插件可选择的代理摘要,不携带连接地址或凭据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxySummary {
pub id: String,
pub name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyListResult {
pub items: Vec<ProxySummary>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
/// 显式选择账号代理和凭据版本;认证头仍由插件提供,不取得推理租约
#[derive(Clone, Serialize, Deserialize)]
@@ -29,6 +29,29 @@ pub struct HostHttpBody {
}
impl HostClient {
/// 分页查询代理摘要,不读取连接地址或凭据
///
/// # Errors
/// 查询参数无效或宿主读取失败时返回错误
pub async fn list_proxies(
&self,
query: wire::ProxyListRequest,
) -> Result<wire::ProxyListResult, PluginFault> {
super::payload_call(self, wire::PROXIES_LIST, query).await
}
/// 使用指定代理的当前配置发送请求;代理不存在、禁用或连接失败时不回退直连
///
/// # Errors
/// 代理不可用、期限或网络请求失败时返回错误
pub async fn proxy_http(
&self,
request: wire::ProxyHttpRequest,
body: Vec<u8>,
) -> Result<HostHttpResponse, PluginFault> {
self.open_http(wire::PROXY_HTTP, request, body, false).await
}
/// 使用受管 HTTP 发送请求;账号上游使用 [`Self::upstream_http`]
///
/// # Errors
@@ -5,7 +5,7 @@ use gateway_plugin_sdk::client::{CallFuture, CallReply, PluginCall, PluginHandle
use gateway_plugin_sdk::{ErrorCode, Frame, Message};
use gateway_plugin_sdk::{
call::{
host::{AccountHttpRequest, HttpRequest},
host::{AccountHttpRequest, HttpRequest, ProxyHttpRequest, ProxyListRequest},
upstream_adapter::{UpstreamHttpRequest, UpstreamWebSocketRequest, WebSocketMessageKind},
},
client::UpstreamWebSocketUpgrade,
@@ -17,6 +17,21 @@ struct NetworkHandler;
impl PluginHandler for NetworkHandler {
fn call(&self, call: PluginCall) -> CallFuture<'_> {
Box::pin(async move {
if call.method == "proxies" {
let page = call
.host
.list_proxies(ProxyListRequest {
page: 2,
page_size: 10,
search: "login".into(),
})
.await?;
assert_eq!(page.page, 2);
assert_eq!(page.total, 11);
assert_eq!(page.items[0].id, "proxy_one");
assert!(!page.items[0].enabled);
return Ok(CallReply::unary(json!({}), vec![]));
}
if call.method.starts_with("websocket") {
let upgrade = call
.host
@@ -106,6 +121,20 @@ impl PluginHandler for NetworkHandler {
call.payload,
)
.await?
} else if call.method == "proxy" {
call.host
.proxy_http(
ProxyHttpRequest {
proxy_id: "proxy_one".into(),
request: HttpRequest {
method: "POST".into(),
url: "https://example.test/login".into(),
headers: vec![],
},
},
call.payload,
)
.await?
} else {
call.host
.upstream_http(
@@ -172,7 +201,7 @@ async fn reply_callback(
#[tokio::test]
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
for method in ["http", "account", "upstream"] {
for method in ["http", "account", "proxy", "upstream"] {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(
&mut host,
@@ -189,6 +218,8 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
};
let open_method = if method == "account" {
gateway_plugin_sdk::call::host::ACCOUNT_HTTP.to_owned()
} else if method == "proxy" {
gateway_plugin_sdk::call::host::PROXY_HTTP.to_owned()
} else {
format!("{prefix}.do_stream")
};
@@ -201,9 +232,13 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
.await;
assert_eq!(frame.payload, b"request");
if method == "account" {
assert!(matches!(frame.message, Message::Callback { params, .. }
assert!(matches!(frame.message, Message::Callback { ref params, .. }
if params["account_id"] == "acct_one" && params["credential_revision"] == 7));
}
if method == "proxy" {
assert!(matches!(frame.message, Message::Callback { ref params, .. }
if params["proxy_id"] == "proxy_one" && params["request"]["url"] == "https://example.test/login"));
}
let frame = reply_callback(
&mut host,
&format!("{prefix}.stream_read"),
@@ -228,6 +263,33 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
}
}
#[tokio::test]
async fn proxy_directory_uses_typed_binary_payloads() {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(&mut host, 1, "proxies", json!({}), vec![]).await;
let frame = reply_callback(
&mut host,
"host.proxies.list",
json!({}),
serde_json::to_vec(&json!({
"items":[{"id":"proxy_one","name":"login","enabled":false}],
"total":11,"page":2,"page_size":10,
}))
.unwrap(),
)
.await;
assert!(matches!(frame.message, Message::Callback { params, .. } if params == json!({})));
assert_eq!(
serde_json::from_slice::<Value>(&frame.payload).unwrap(),
json!({"page":2,"page_size":10,"search":"login"})
);
assert!(matches!(
receive(&mut host).await.message,
Message::Result { id: 1, .. }
));
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn bounded_collection_closes_the_host_stream_before_reporting_overflow() {
let (mut host, task) = start_session(NetworkHandler).await;
@@ -361,6 +361,20 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
})
}
/// Images 端点有时仅用 active-limit 标明通用窗口属于哪个图片计量项
pub fn parse_image_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRateLimits> {
let mut parsed = parse_rate_limit_headers(headers)?;
if let Some(active) = &parsed.active_limit
&& active != "codex"
&& !parsed.limits.contains_key(active)
&& let Some(mut details) = parsed.limits.remove("codex")
{
details.limit_id.clone_from(active);
parsed.limits.insert(active.clone(), details);
}
Some(parsed)
}
/// 判断响应头是否属于可被动同步的限流领域
pub fn is_rate_limit_header_name(name: &str) -> bool {
let normalized = name.to_ascii_lowercase();
@@ -2,8 +2,9 @@
use gateway_protocol::openai::events::{
RateLimitKeySource, RateLimitWindow, TokenUsage, billable_usage_is_complete, extract_sse_usage,
extract_usage, is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers,
parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body,
extract_usage, is_codex_quota_header_name, is_rate_limit_header_name,
parse_image_rate_limit_headers, parse_rate_limit_headers, parse_rate_limits_event,
rate_limits_to_header_pairs, retry_after_seconds_from_body,
};
use serde_json::json;
@@ -540,6 +541,7 @@ fn parse_rate_limit_headers_should_extract_primary_and_secondary_windows() {
#[test]
fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata() {
let headers = vec![
("x-codex-primary-used-percent".to_owned(), "95".to_owned()),
(
"x-codex-other-primary-used-percent".to_owned(),
"63.5".to_owned(),
@@ -556,6 +558,13 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
];
let parsed = parse_rate_limit_headers(&headers).expect("rate-limit headers");
assert_eq!(
parsed.limits["codex"]
.primary
.expect("core window")
.used_percent,
95.0
);
let details = parsed.limits.get("codex_other").expect("dynamic limit");
let credits = parsed.credits.expect("credits");
@@ -579,6 +588,40 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
);
}
#[test]
fn image_headers_assign_generic_windows_to_the_active_limit_only_without_its_own_headers() {
let headers = [
("x-codex-active-limit", "imagegen_premium"),
("x-codex-primary-used-percent", "0"),
("x-codex-primary-window-minutes", "1440"),
("x-codex-primary-reset-at", "1900000000"),
("x-codex-secondary-used-percent", "0"),
("x-codex-secondary-window-minutes", "0"),
("x-codex-secondary-reset-at", ""),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
let parsed = parse_image_rate_limit_headers(&headers).expect("image rate-limit headers");
let image = parsed.limits.get("imagegen_premium").expect("image limit");
assert_eq!(image.limit_id, "imagegen_premium");
assert_eq!(image.key_source, RateLimitKeySource::LimitId);
assert_eq!(
image.primary,
Some(RateLimitWindow {
used_percent: 0.0,
window_minutes: Some(1440),
reset_at: Some(1_900_000_000),
})
);
assert!(!parsed.limits.contains_key("codex"));
let mut headers = rate_limits_to_header_pairs(&parsed);
headers.push(("x-codex-primary-used-percent".to_owned(), "89".to_owned()));
let separate =
parse_image_rate_limit_headers(&headers).expect("separate image and core limits");
assert_eq!(separate.limits["imagegen_premium"], *image);
assert_eq!(separate.limits["codex"].primary.unwrap().used_percent, 89.0);
}
#[test]
fn parse_rate_limit_headers_should_preserve_a_code_review_window() {
let headers = vec![
@@ -275,6 +275,7 @@ async fn connect(
REDIS_NAMESPACE,
)?));
let lease_cleanup = provider_leases.cleanup_worker();
let provider_ports = ProviderStorePorts::new(
account_store,
provider_leases,
@@ -311,6 +312,7 @@ async fn connect(
client_key_usage_writer,
admission_release_writer,
budget_writer,
lease_cleanup,
)?,
None,
),
@@ -138,10 +138,13 @@ async fn commit_authorization_in_transaction(
context,
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize,
)?;
let result =
repository::rotate_provider_account_admin_in_transaction(transaction, prepared)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
let result = repository::rotate_provider_account_admin_in_transaction(
transaction,
prepared,
true,
)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
admin_adapter::rotation_result(result, account_id.clone())?
}
_ => return Err(invalid_receipt()),
@@ -520,7 +520,8 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
postgres_unavailable("begin provider account admin rotation", source)
})?;
let account_id = command.credential.account_id.clone();
let result = rotate_provider_account_admin_in_transaction(&mut transaction, command).await;
let result =
rotate_provider_account_admin_in_transaction(&mut transaction, command, false).await;
let rotation =
finish_admin_transaction(transaction, result, "provider account admin rotation")
.await?;
@@ -849,6 +850,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
profile: &UpdateProviderAccount,
replacement_identity: Option<&ProviderAccountIdentity>,
update: &ProviderCredentialUpdate,
preserve_name: bool,
) -> StoreResult<Revision> {
let replace_identity = replacement_identity.is_some();
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
@@ -857,11 +859,15 @@ pub(crate) async fn rotate_provider_account_in_transaction(
// 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = case when $14 then name else $4 end,
set name = case when $14 or $16 then name else $4 end,
email = case when $14 then email else $5 end,
plan_type = case when $14 then plan_type else $6 end,
provider_quota_json = case when $14 then provider_quota_json else null end,
quota_observed_at = case when $14 then quota_observed_at else null end,
quota_access_state = case when $11 then 'unknown' else quota_access_state end,
quota_evidence = case when $11 then null else quota_evidence end,
quota_access_observed_at = case when $11 then null else quota_access_observed_at end,
quota_reset_at = case when $11 then null else quota_reset_at end,
provider_credentials_json = $7,
credential_revision = credential_revision + 1,
has_refresh_token = $8,
@@ -900,6 +906,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
.bind(upstream_account_id)
.bind(update.preserve_profile)
.bind(update.preserve_credential_state)
.bind(preserve_name)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
@@ -1166,6 +1173,7 @@ pub(super) async fn import_provider_accounts_in_transaction(
pub(super) async fn rotate_provider_account_admin_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
command: RotateProviderAccount,
preserve_name: bool,
) -> StoreResult<ProviderAccountAdminRotation> {
command.scope.validate()?;
require_nonempty(ENTITY, "account_id", &command.profile.id)?;
@@ -1194,6 +1202,7 @@ pub(super) async fn rotate_provider_account_admin_in_transaction(
&command.profile,
command.replacement_identity.as_ref(),
&command.credential,
preserve_name,
)
.await?;
// 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新
@@ -0,0 +1,103 @@
//! 监督本进程账号租约的释放任务,关闭时取消持有请求并有界等待 Redis 清理
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::future::BoxFuture;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
use tokio::sync::{RwLock, RwLockReadGuard, oneshot};
use tokio::task::JoinSet;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone, Default)]
pub(crate) struct SchedulingLeaseCleanup(Arc<State>);
#[derive(Default)]
struct State {
stopping: CancellationToken,
acquisitions: RwLock<()>,
tasks: Mutex<JoinSet<()>>,
}
pub(crate) struct SchedulingLeaseGuard {
_release: oneshot::Sender<()>,
// 活跃请求可能比组合根活得久,不能因端口析构提前中止其续期与租约
_cleanup: SchedulingLeaseCleanup,
}
impl SchedulingLeaseCleanup {
// 关闭必须等已发出的申请完成登记;不能漏掉 Redis 已创建、尚未返回的租约
pub(crate) async fn acquire(&self) -> Option<RwLockReadGuard<'_, ()>> {
let permit = self.0.acquisitions.read().await;
(!self.0.stopping.is_cancelled()).then_some(permit)
}
pub(crate) fn track(
&self,
cancellation: CancellationToken,
release: impl Future<Output = crate::StoreResult<bool>> + Send + 'static,
) -> SchedulingLeaseGuard {
let (guard, dropped) = oneshot::channel::<()>();
let stopping = self.0.stopping.clone();
let mut tasks = self
.0
.tasks
.lock()
.unwrap_or_else(|error| error.into_inner());
while let Some(result) = tasks.try_join_next() {
report_join(result);
}
tasks.spawn(async move {
tokio::select! {
biased;
() = stopping.cancelled() => cancellation.cancel(),
_ = dropped => {}
}
if let Err(error) = release.await {
tracing::warn!(%error, "账号租约释放失败,依赖 TTL 回收");
}
});
SchedulingLeaseGuard {
_release: guard,
_cleanup: self.clone(),
}
}
async fn shutdown(&self) {
self.0.stopping.cancel();
let drain = async {
let _closed = self.0.acquisitions.write().await;
let mut tasks = std::mem::take(
&mut *self
.0
.tasks
.lock()
.unwrap_or_else(|error| error.into_inner()),
);
while let Some(result) = tasks.join_next().await {
report_join(result);
}
};
if tokio::time::timeout(SHUTDOWN_TIMEOUT, drain).await.is_err() {
tracing::warn!("账号租约关闭清理超时,剩余租约依赖 TTL 回收");
}
}
}
impl DaemonTask for SchedulingLeaseCleanup {
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
context.cancellation().cancelled().await;
self.shutdown().await;
Ok(())
})
}
}
fn report_join(result: Result<(), tokio::task::JoinError>) {
if let Err(error) = result {
tracing::warn!(%error, "账号租约释放任务中断,依赖 TTL 回收");
}
}
@@ -23,6 +23,7 @@ use crate::{Revision, StoreError, StoreResult, redis_unavailable, require_nonemp
use super::{MAX_REDIS_EXACT_INTEGER, namespace, resource_fingerprint};
mod cleanup;
mod details;
const SIGNAL_TTL_MILLIS: u64 = 24 * 60 * 60 * 1_000;
@@ -255,6 +256,17 @@ impl CredentialLeaseGuard {
deadline: gateway_core::lifecycle::Deadline,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> StoreResult<impl gateway_core::provider_ports::ProviderLeaseGuard> {
Ok(RenewingSchedulingLease {
_renewal: self.renewal(deadline, cancellation)?,
_guard: self,
})
}
fn renewal(
&self,
deadline: gateway_core::lifecycle::Deadline,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> StoreResult<crate::lease_renewal::LeaseRenewal> {
let repository = self.repository.clone();
let lease_request = self.request.clone();
let grant = self
@@ -265,7 +277,7 @@ impl CredentialLeaseGuard {
entity: "credential lease",
message: "lease has already been released".to_owned(),
})?;
let renewal = crate::lease_renewal::LeaseRenewal::spawn(
Ok(crate::lease_renewal::LeaseRenewal::spawn(
deadline,
Some((
cancellation,
@@ -283,11 +295,7 @@ impl CredentialLeaseGuard {
.map(|grant| grant.is_some())
})
},
);
Ok(RenewingSchedulingLease {
_renewal: renewal,
_guard: self,
})
))
}
#[must_use]
@@ -591,6 +599,7 @@ pub(crate) struct RedisProviderLeaseCoordinator {
repository: RedisCredentialLeaseRepository,
process_id: String,
sequence: AtomicU64,
cleanup: cleanup::SchedulingLeaseCleanup,
}
impl RedisProviderLeaseCoordinator {
@@ -600,9 +609,14 @@ impl RedisProviderLeaseCoordinator {
repository,
process_id: format!("gateway_{}", Uuid::now_v7().simple()),
sequence: AtomicU64::new(0),
cleanup: cleanup::SchedulingLeaseCleanup::default(),
}
}
pub(crate) fn cleanup_worker(&self) -> impl gateway_core::task::DaemonTask + use<> {
self.cleanup.clone()
}
fn owner_id(&self, operation: &str) -> String {
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed);
format!("{}:{operation}:{sequence}", self.process_id)
@@ -662,6 +676,12 @@ impl RedisProviderLeaseCoordinator {
&self,
request: &ProviderSchedulingLeaseRequest,
) -> Result<ProviderLeaseAcquisition, ProviderStoreError> {
let _acquisition = self.cleanup.acquire().await.ok_or_else(|| {
ProviderStoreError::new(
ProviderStoreErrorKind::Unavailable,
"account lease coordinator is shutting down",
)
})?;
let ttl = request.deadline().bounded(REQUEST_LEASE_TTL);
if ttl.is_zero() {
return Err(ProviderStoreError::new(
@@ -685,13 +705,18 @@ impl RedisProviderLeaseCoordinator {
.map_err(|source| crate::provider_unavailable("acquire scheduling lease", source))?;
Ok(match acquisition {
CredentialBoundedLeaseAcquisition::Acquired(guard) => {
ProviderLeaseAcquisition::Acquired(Box::new(
guard
.maintain(request.deadline(), request.cancellation())
.map_err(|source| {
crate::provider_unavailable("maintain scheduling lease", source)
})?,
))
let renewal = guard
.renewal(request.deadline(), request.cancellation())
.map_err(|source| {
crate::provider_unavailable("maintain scheduling lease", source)
})?;
ProviderLeaseAcquisition::Acquired(Box::new(self.cleanup.track(
request.cancellation(),
async move {
drop(renewal);
guard.release().await
},
)))
}
CredentialBoundedLeaseAcquisition::Busy { retry_after } => {
ProviderLeaseAcquisition::Busy { retry_after }
@@ -105,6 +105,7 @@ pub(crate) fn store_worker_contributions(
client_key_usage_writer: postgres::PgClientApiKeyUsageWriter,
admission_release_writer: redis::ClientAdmissionReleaseWriter,
budget_writer: postgres::ClientBudgetWriter,
lease_cleanup: impl DaemonTask + 'static,
) -> StoreResult<Vec<WorkerContribution>> {
let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres")
.map_err(worker_definition_error)?;
@@ -117,10 +118,22 @@ pub(crate) fn store_worker_contributions(
.map_err(worker_definition_error)?;
let budget_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "postgres_client_budget")
.map_err(worker_definition_error)?;
let lease_cleanup_id = WorkerId::try_new(WorkerKind::OpsFlush, "redis_account_leases")
.map_err(worker_definition_error)?;
let ops_flush_restart =
DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(worker_definition_error)?;
Ok(vec![
WorkerContribution::Registration(
WorkerRegistration::try_new(
lease_cleanup_id,
WorkerRunnable::Daemon {
restart: ops_flush_restart,
task: Box::new(lease_cleanup),
},
)
.map_err(worker_definition_error)?,
),
WorkerContribution::Registration(
WorkerRegistration::try_new(
budget_flush_id,
+161 -1
View File
@@ -1,4 +1,4 @@
//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界
//! 验证 Store 认证、凭据编码、只读写入边界与账号租约关闭清理
use gateway_core::{
account::{
@@ -13,6 +13,166 @@ use sqlx::postgres::PgPoolOptions;
use crate::support::config_connection;
#[tokio::test]
async fn shutdown_releases_owned_account_pools_and_preserves_other_instances() {
use gateway_core::lifecycle::{CancellationToken, Deadline};
use gateway_core::provider_ports::{
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseRequest,
ProviderSchedulingLeaseRequest,
};
use gateway_core::task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable};
use std::{num::NonZeroU32, time::Duration};
let (Some(database), Some(redis)) = (
crate::support::test_env("CPR_TEST_DATABASE_URL"),
crate::support::test_env("CPR_TEST_REDIS_URL"),
) else {
return;
};
let mut config: StoreConfig = serde_json::from_value(json!({
"database": config_connection(url::Url::parse(&database).unwrap()),
"redis": config_connection(url::Url::parse(&redis).unwrap()),
}))
.unwrap();
let directory = tempfile::tempdir().unwrap();
config.resolve_and_validate(directory.path()).unwrap();
// 只使用真实 Bundle 的租约端口与关闭任务,不需要创建业务账号或启动其他 Worker
let mut first = gateway_store::initialize_read_only(config.clone())
.await
.unwrap();
let mut second = gateway_store::initialize_read_only(config).await.unwrap();
let first_port = first.provider_ports().leases();
let second_port = second.provider_ports().leases();
let account =
ProviderAccountId::new(format!("acct_shutdown_{}", uuid::Uuid::new_v4().simple())).unwrap();
let mut first_requests = Vec::new();
let mut first_guards = Vec::new();
let mut second_requests = Vec::new();
let mut second_guards = Vec::new();
for pool in [
ProviderConcurrencyPool::Shared,
ProviderConcurrencyPool::Reserved,
] {
let request = ProviderSchedulingLeaseRequest::new(
ProviderKind::new("openai").unwrap(),
account.clone(),
CredentialRevision::new(1).unwrap(),
NonZeroU32::new(2).unwrap(),
Deadline::default(),
)
.with_concurrency_pool(pool);
let ProviderLeaseAcquisition::Acquired(first_guard) = first_port
.try_acquire(ProviderLeaseRequest::Scheduling(request.clone()))
.await
.unwrap()
else {
panic!("first instance must acquire a slot")
};
let other = request.clone().with_cancellation(CancellationToken::new());
let ProviderLeaseAcquisition::Acquired(second_guard) = second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap()
else {
panic!("second instance must acquire a slot")
};
assert!(matches!(
second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap(),
ProviderLeaseAcquisition::Busy { .. }
));
first_requests.push(request);
second_requests.push(other);
first_guards.push(first_guard);
second_guards.push(second_guard);
}
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
2
);
for (index, store) in [&mut first, &mut second].into_iter().enumerate() {
let cleanup = store
.take_worker_contributions()
.into_iter()
.find_map(|contribution| {
let WorkerContribution::Registration(registration) = contribution else {
return None;
};
if registration.id.owner() != "redis_account_leases" {
return None;
}
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("cleanup is a daemon")
};
Some(task)
})
.expect("account lease shutdown worker must be registered");
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
if index == 0 {
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
1
);
for (first_request, other) in first_requests.iter().zip(&second_requests) {
assert!(first_request.cancellation().is_cancelled());
assert!(!other.cancellation().is_cancelled());
assert!(
first_port
.try_acquire(ProviderLeaseRequest::Scheduling(first_request.clone()))
.await
.is_err()
);
let replacement = second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap();
assert!(matches!(
&replacement,
ProviderLeaseAcquisition::Acquired(_)
));
assert!(matches!(
second_port
.try_acquire(ProviderLeaseRequest::Scheduling(other.clone()))
.await
.unwrap(),
ProviderLeaseAcquisition::Busy { .. }
));
drop(replacement);
}
}
}
assert!(
second_requests
.iter()
.all(|request| request.cancellation().is_cancelled())
);
assert_eq!(
second_port
.account_in_flight(std::slice::from_ref(&account))
.await
.unwrap()[&account],
0
);
drop((first_guards, second_guards));
}
#[tokio::test]
async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() {
let (Some(database), Some(redis)) = (
@@ -39,6 +39,130 @@ async fn authorization_persists_an_unnamed_account_with_its_email() {
database.close().await;
}
#[tokio::test]
async fn authorization_preserves_saved_proxy_capacity_across_pending_storage() {
use gateway_core::account::OutboundProxy;
let Some(database) = TestDatabase::create("authorization_proxy_snapshot").await else {
return;
};
let proxy_url = "socks5h://proxy.example.invalid:1080";
let proxy_id = "proxy_synthetic";
sqlx::query(
"insert into outbound_proxies (id, name, proxy_url, enabled, max_websocket_connections) \
values ($1, 'Synthetic proxy', $2, true, 16)",
)
.bind(proxy_id)
.bind(proxy_url)
.execute(&database.pool)
.await
.unwrap();
let (mut authorization, context) = command(credential("acct_proxy_test", "synthetic-user"));
let pending = authorization
.pending
.with_outbound_proxy(Some(
OutboundProxy::parse(proxy_url)
.unwrap()
.with_max_websocket_connections(16),
))
.with_outbound_proxy_id(Some(proxy_id.into()));
authorization.pending = PendingAuthorizationMutation::from_storage_v1(
serde_json::Value::Object(pending.to_storage_v1()),
)
.unwrap();
if let AuthorizationCredentialCommit::Create(credential) = &mut authorization.credential {
credential.outbound_proxy = authorization.pending.outbound_proxy().cloned();
}
let store = admin_account_store(&database.pool);
let committed = store
.commit_authorization(authorization, &context)
.await
.expect("unchanged saved proxy must not cause an authorization conflict");
let saved_proxy: Option<String> =
sqlx::query_scalar("select outbound_proxy_id from provider_accounts where id = $1")
.bind(committed.result.account_id.as_str())
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(saved_proxy.as_deref(), Some(proxy_id));
database.close().await;
}
#[tokio::test]
async fn authorization_rejects_a_saved_proxy_that_changed_after_start() {
use gateway_admin::ports::store::AdminStoreErrorKind;
use gateway_core::account::OutboundProxy;
let Some(database) = TestDatabase::create("authorization_proxy_changed").await else {
return;
};
let proxy_url = "socks5h://proxy.example.invalid:1080";
let proxy_id = "proxy_synthetic";
sqlx::query(
"insert into outbound_proxies (id, name, proxy_url, enabled, max_websocket_connections) \
values ($1, 'Synthetic proxy', $2, true, 16)",
)
.bind(proxy_id)
.bind(proxy_url)
.execute(&database.pool)
.await
.unwrap();
let store = admin_account_store(&database.pool);
for (current_url, limit, enabled, expected) in [
(
"socks5h://other.example.invalid:1080",
16_i64,
true,
AdminStoreErrorKind::StaleRevision,
),
(proxy_url, 17, true, AdminStoreErrorKind::StaleRevision),
(proxy_url, 16, false, AdminStoreErrorKind::Conflict),
] {
sqlx::query(
"update outbound_proxies set proxy_url=$2, max_websocket_connections=$3, enabled=$4 where id=$1",
)
.bind(proxy_id)
.bind(current_url)
.bind(limit)
.bind(enabled)
.execute(&database.pool)
.await
.unwrap();
let (mut authorization, context) = command(credential("acct_proxy_test", "synthetic-user"));
let pending = authorization
.pending
.with_outbound_proxy(Some(
OutboundProxy::parse(proxy_url)
.unwrap()
.with_max_websocket_connections(16),
))
.with_outbound_proxy_id(Some(proxy_id.into()));
authorization.pending = PendingAuthorizationMutation::from_storage_v1(
serde_json::Value::Object(pending.to_storage_v1()),
)
.unwrap();
let receipt_key = authorization.key.clone();
let error = store
.commit_authorization(authorization, &context)
.await
.expect_err("changed or disabled proxy must fail closed");
assert_eq!(error.kind(), expected);
assert!(
store
.authorization_receipt(&receipt_key)
.await
.unwrap()
.is_none()
);
}
let accounts: i64 = sqlx::query_scalar("select count(*) from provider_accounts")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(accounts, 0);
database.close().await;
}
#[tokio::test]
async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() {
let Some(database) = TestDatabase::create("authorization_receipt_race").await else {
@@ -186,11 +310,14 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
account_id: id.clone(),
provider_kind: ProviderKind::new("openai").unwrap(),
expected_credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
replacement_identity: None,
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
name: "remote profile".into(),
email: None,
plan_type: None,
preserve_profile: true,
email: Some("new@example.com".into()),
plan_type: Some("plus".into()),
preserve_profile: false,
preserve_credential_state: false,
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"access_token":"rotated-test-only"})
@@ -223,15 +350,17 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
.commit_authorization(command.clone(), &context)
.await
.unwrap();
let account = store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account;
assert_eq!(account.name, "renamed team");
assert_eq!(account.email.as_deref(), Some("new@example.com"));
assert_eq!(
store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account
.name,
"renamed team"
account.upstream_account_id.as_deref(),
Some("new-workspace")
);
store
.update_account(
@@ -3007,7 +3007,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
}
#[tokio::test]
async fn verified_credential_rotation_preserves_quota_exhaustion() {
async fn quota_exhaustion_survives_rotation_only_for_the_same_upstream_identity() {
let Some(database) = TestDatabase::create("provider_account_rotation_quota").await else {
return;
};
@@ -3043,7 +3043,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
scope: scope.clone(),
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: None,
credential: credential_update("acct_rotation_quota", 1, "reauthorized-secret"),
@@ -3064,6 +3064,28 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
.expect("load exhausted account after credential rotation");
assert_eq!(quota_access_state, "exhausted".to_owned());
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
credential: credential_update("acct_rotation_quota", 2, "new-account-secret"),
audit: audit("audit_quota_rebind", "reauthorize", "acct_rotation_quota"),
})
.await
.unwrap();
let account = repository
.get_account(&ProviderAccountId::new("acct_rotation_quota").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(account.upstream_account_id(), Some("new-workspace"));
assert_eq!(account.quota(), QuotaState::unknown());
database.close().await;
}
@@ -0,0 +1,132 @@
//! 验证账号租约关闭与申请交错、异步释放完成和清理超时
#[path = "../../../src/redis/credential_leases/cleanup.rs"]
mod implementation;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
use implementation::SchedulingLeaseCleanup;
use std::time::Duration;
use tokio::sync::oneshot;
#[tokio::test]
async fn live_guard_keeps_its_lease_task_alive_after_the_port_is_dropped() {
let cleanup = SchedulingLeaseCleanup::default();
let (released, mut completed) = oneshot::channel();
let guard = cleanup.track(CancellationToken::new(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(cleanup);
tokio::task::yield_now().await;
assert!(matches!(
completed.try_recv(),
Err(oneshot::error::TryRecvError::Empty)
));
drop(guard);
}
#[tokio::test]
async fn shutdown_waits_for_pending_acquisitions_and_releases_without_waiting_for_guards() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let stopping = CancellationToken::new();
let request = CancellationToken::new();
let (released, completed) = oneshot::channel();
let worker = {
let cleanup = cleanup.clone();
let stopping = stopping.clone();
tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await })
};
stopping.cancel();
tokio::task::yield_now().await;
assert!(!worker.is_finished());
// Redis 申请在停机开始后才返回,登记仍必须被本次清理包含
let guard = cleanup.track(request.clone(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(acquisition);
tokio::time::timeout(Duration::from_secs(1), worker)
.await
.unwrap()
.unwrap()
.unwrap();
completed.await.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
#[tokio::test]
async fn shutdown_joins_a_release_already_started_by_guard_drop() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let request = CancellationToken::new();
let (started, entered) = oneshot::channel();
let (finish, finished) = oneshot::channel();
let guard = cleanup.track(request.clone(), async move {
started.send(()).unwrap();
finished.await.unwrap();
Ok(true)
});
drop(acquisition);
drop(guard);
entered.await.unwrap();
assert!(!request.is_cancelled());
let stopping = CancellationToken::new();
stopping.cancel();
let worker = tokio::spawn(async move { cleanup.run(WorkerDaemonContext::new(stopping)).await });
tokio::task::yield_now().await;
assert!(!worker.is_finished());
finish.send(()).unwrap();
worker.await.unwrap().unwrap();
}
#[tokio::test(start_paused = true)]
async fn unavailable_release_does_not_hold_shutdown_past_its_budget() {
let cleanup = SchedulingLeaseCleanup::default();
let request = CancellationToken::new();
let guard = cleanup.track(request.clone(), std::future::pending());
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
#[tokio::test(start_paused = true)]
async fn acquisition_returning_after_shutdown_timeout_is_still_released() {
let cleanup = SchedulingLeaseCleanup::default();
let acquisition = cleanup.acquire().await.unwrap();
let stopping = CancellationToken::new();
stopping.cancel();
tokio::time::timeout(
Duration::from_secs(3),
cleanup.run(WorkerDaemonContext::new(stopping)),
)
.await
.unwrap()
.unwrap();
let request = CancellationToken::new();
let (released, completed) = oneshot::channel();
let guard = cleanup.track(request.clone(), async move {
released.send(()).unwrap();
Ok(true)
});
drop(acquisition);
tokio::time::timeout(Duration::from_secs(1), completed)
.await
.unwrap()
.unwrap();
assert!(request.is_cancelled());
assert!(cleanup.acquire().await.is_none());
drop(guard);
}
@@ -1,5 +1,6 @@
//! 验证账号调度租约的容量、续租、失效与释放
mod cleanup;
mod details;
use std::collections::BTreeMap;
@@ -661,7 +661,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.prepare_oauth_rotation(current, secret, None, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -31,9 +31,8 @@ use super::token_client::{
OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher,
};
use super::types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, ResponsesTransport,
parse_access_token_expiration, parse_chatgpt_jwt_claims,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData, CodexOAuthMetadata,
CodexOAuthSecret, ResponsesTransport, parse_access_token_expiration, parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
@@ -272,26 +271,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
pub secret: CodexOAuthSecret,
pub verified_account: CodexAccountProfile,
pub next_refresh_at: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for RotateManagedCodexCredential {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RotateManagedCodexCredential")
.field("current", &self.current)
.field("secret", &"<redacted>")
.field("verified_account", &self.verified_account)
.field("next_refresh_at", &self.next_refresh_at)
.finish()
}
}
/// Provider 验证后的 rotation;App 只做 Core -> Store command 的机械映射
pub struct PreparedCodexCredentialRotation {
pub profile: ProviderAccountUpdate,
@@ -725,21 +704,12 @@ impl CodexCredentialAdmin {
})
}
pub fn prepare_rotation(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, false)
}
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料
///
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
/// token 重新执行身份验证
pub(crate) fn prepare_refreshed_oauth_rotation(
/// 重新授权提供本次登录的身份;普通令牌续期只轮换凭据
pub(crate) fn prepare_oauth_rotation(
&self,
current: LoadedCredential,
secret: CodexOAuthSecret,
metadata: Option<CodexOAuthMetadata>,
access_token_expires_at: Option<DateTime<Utc>>,
next_refresh_at: Option<DateTime<Utc>>,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
@@ -753,6 +723,32 @@ impl CodexCredentialAdmin {
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let mut profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let reauthorization = metadata.is_some();
let mut replacement_identity = None;
if let Some(metadata) = metadata {
let user_id = metadata
.chatgpt_user_id
.filter(|id| !id.trim().is_empty())
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if current.account.upstream_user_id() != Some(user_id.as_str())
|| current.account.upstream_account_id() != metadata.chatgpt_account_id.as_deref()
{
replacement_identity = Some(ProviderAccountIdentity::new(
user_id,
metadata.chatgpt_account_id,
));
oauth.cookies.clear();
oauth.principal = None;
}
profile.email = metadata.email;
profile.plan_type = metadata.chatgpt_plan_type;
}
oauth.access_token = secret.access_token.expose_secret().to_owned();
oauth.refresh_token = secret
.refresh_token
@@ -764,12 +760,6 @@ impl CodexCredentialAdmin {
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let credential = CredentialCasUpdate::new(
current.account.id().clone(),
current.account.revision(),
@@ -779,76 +769,14 @@ impl CodexCredentialAdmin {
optional_time(access_token_expires_at),
optional_time(next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
.preserving_profile();
Ok(PreparedCodexCredentialRotation {
profile,
credential,
replacement_identity: None,
refresh_guards: None,
})
}
fn prepare_oauth_rotation(
&self,
input: RotateManagedCodexCredential,
replace_identity: bool,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
let access_token_expires_at = input.verified_account.access_token_expires_at;
let mut data = CodexCredentialCodec::decode_complete(&input.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if input.current.account.provider().as_str() != PROVIDER_NAME
|| input.current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
let replacement_identity = replace_identity.then(|| {
ProviderAccountIdentity::new(
input.verified_account.chatgpt_user_id.clone(),
Some(input.verified_account.chatgpt_account_id.clone()),
)
});
if replace_identity {
oauth.principal = Some(CodexCredentialPrincipal {
oauth_subject: input.verified_account.oauth_subject.clone(),
poid: input.verified_account.poid.clone(),
});
}
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
oauth.refresh_token = input
.secret
.refresh_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
oauth.id_token = input
.secret
.id_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: input.current.account.id().clone(),
name: input.current.account.name().to_owned(),
email: input.verified_account.email,
plan_type: input.verified_account.plan_type,
};
let credential = CredentialCasUpdate::new(
input.current.account.id().clone(),
input.current.account.revision(),
profile.clone(),
credential,
input.secret.refresh_token.is_some(),
optional_time(access_token_expires_at),
optional_time(input.next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
Ok(PreparedCodexCredentialRotation {
profile,
credential,
credential: if reauthorization {
credential
} else {
credential.preserving_profile()
},
replacement_identity,
refresh_guards: None,
})
@@ -1002,9 +930,10 @@ impl CodexCredentialAdminService {
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before
let next_refresh_at = None;
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
let mut prepared = CodexCredentialAdmin.prepare_oauth_rotation(
current,
secret,
None,
access_token_expires_at,
next_refresh_at,
)?;
@@ -1095,20 +1024,7 @@ impl CodexCredentialAdminService {
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata = parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
}
id_metadata.with_access_token_fallback(&secret.access_token)
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
@@ -480,12 +480,11 @@ impl CodexCredentialCatalogService {
content: if model.document().protocol() == "codex" {
ProviderModelContent::Native(model.document().clone())
} else {
let model_id = model.request_model().as_str();
ProviderModelContent::Adapted(
ModelPresentation::new(
Some(model.request_model().as_str().to_owned()),
None,
)
.with_agent_tools(true, false),
ModelPresentation::new(Some(model_id.to_owned()), None)
.with_agent_tools(true, false)
.with_hidden(is_specialized_api_model(model_id)),
)
},
})
@@ -1052,6 +1051,54 @@ impl CodexCredentialCatalogService {
}
}
fn is_specialized_api_model(model: &str) -> bool {
// 普通目录不提供 Codex 可见性;仅按已知的专用模型 ID 隐藏,保留未知 ID 和直接调用
// 依据 https://developers.openai.com/api/docs/models 与 Codex 的语音通话模型
matches!(
model,
"dall-e-2"
| "dall-e-3"
| "chatgpt-image-latest"
| "gpt-image-1"
| "gpt-image-1-mini"
| "gpt-image-1.5"
| "gpt-image-1.5-2025-12-16"
| "gpt-image-2"
| "gpt-image-2-2026-04-21"
| "gpt-image-2.5-flare"
| "gpt-image-2.5-flare-2026-09-08"
| "gpt-image-2.5-sunburst"
| "gpt-image-2.5-sunburst-2026-09-08"
| "gpt-image-2.6-flare"
| "gpt-image-2.6-sunburst"
| "gpt-live-1"
| "gpt-live-1-codex"
| "gpt-live-transcribe"
| "gpt-realtime"
| "gpt-realtime-2025-08-28"
| "gpt-realtime-1.5"
| "gpt-realtime-2"
| "gpt-realtime-2.1"
| "gpt-realtime-2.1-mini"
| "gpt-realtime-mini"
| "gpt-realtime-mini-2025-10-06"
| "gpt-realtime-mini-2025-12-15"
| "gpt-realtime-translate"
| "gpt-realtime-whisper"
| "gpt-4o-realtime-preview"
| "gpt-4o-realtime-preview-2024-10-01"
| "gpt-4o-realtime-preview-2024-12-17"
| "gpt-4o-realtime-preview-2025-06-03"
| "gpt-4o-mini-realtime-preview"
| "gpt-4o-mini-realtime-preview-2024-12-17"
| "tts-1"
| "tts-1-hd"
| "gpt-4o-mini-tts"
| "gpt-4o-mini-tts-2025-03-20"
| "gpt-4o-mini-tts-2025-12-15"
)
}
fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
models
.iter()
@@ -36,7 +36,7 @@ pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
PreparedCodexAccountImport, PreparedCodexCredentialRotation,
PreparedCodexCredentialRotationGuard, RotateManagedCodexCredential,
PreparedCodexCredentialRotationGuard,
};
pub use catalog::{
CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService,
@@ -72,6 +72,7 @@ pub use selector::{
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub(crate) use types::oauth_identity_key;
pub use types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
@@ -516,16 +516,10 @@ impl CodexOAuthAdminService {
} else {
None
};
let fallback_refresh_token = current
.as_ref()
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token) = self
.exchange_pending(
&pending,
callback_url,
fallback_refresh_token,
current
.as_ref()
.map_or(pending.mutation.outbound_proxy(), |current| {
@@ -537,14 +531,16 @@ impl CodexOAuthAdminService {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
.map_err(|_| CodexOAuthAdminError::TokenRejected)?
.with_access_token_fallback(&secret.access_token);
secret.id_token = Some(id_token);
let credential = if let Some(current) = current {
CompletedCodexOAuthCredential::Reauthorize(Box::new(
self.credentials
.prepare_refreshed_oauth_rotation(
.prepare_oauth_rotation(
current,
secret,
Some(metadata),
access_token_expires_at,
None,
)
@@ -680,7 +676,6 @@ impl CodexOAuthAdminService {
&self,
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
@@ -701,10 +696,7 @@ impl CodexOAuthAdminService {
)
.await
.map_err(CodexOAuthAdminError::Exchange)?;
let mut secret = tokens.secret;
if secret.refresh_token.is_none() {
secret.refresh_token = fallback_refresh_token;
}
let secret = tokens.secret;
record_oauth_recovery(
CodexOAuthRecoveryOperation::AuthorizationCode,
pending
@@ -720,17 +712,6 @@ impl CodexOAuthAdminService {
}
}
fn current_oauth_refresh_token(
current: &LoadedCredential,
) -> Result<Option<SecretString>, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
Ok(runtime
.authentication
.oauth()
.and_then(|oauth| oauth.refresh_token.clone()))
}
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
@@ -110,7 +110,7 @@ pub struct CodexCredentialProfileService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
avatar_sources: Mutex<HashMap<ProviderAccountId, CachedProfileAvatarSource>>,
avatar_sources: Mutex<HashMap<String, CachedProfileAvatarSource>>,
}
impl CodexCredentialProfileService {
@@ -183,23 +183,36 @@ impl CodexCredentialProfileService {
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
self.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await
}
async fn fetch_profile_statistics(
&self,
authorization: &SecretString,
upstream_account_id: Option<&str>,
account: &gateway_core::account::ProviderAccount,
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
let statistics = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.for_account(&account)
.for_account(account)
.map_err(map_client_error)?
.fetch_profile_statistics(CodexRequestContext::auxiliary(
authorization.expose_secret(),
upstream_account_id.as_deref(),
upstream_account_id,
&request_id,
None,
))
.await
.map_err(map_client_error)?;
self.remember_avatar_source(account_id, statistics.image_url.as_deref());
self.remember_avatar_source(
super::oauth_identity_key(account),
statistics.image_url.as_deref(),
);
Ok(statistics)
}
@@ -253,16 +266,16 @@ impl CodexCredentialProfileService {
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileAvatar, CodexProfileAvatarError> {
let source = match self.cached_avatar_source(account_id) {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let source = match self.cached_avatar_source(&super::oauth_identity_key(&account)) {
Some(source) => source,
None => self
.profile_statistics(account_id)
.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await?
.image_url
.ok_or(CodexProfileAvatarError::Missing)?,
};
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let http = if account.outbound_proxy().is_some() {
crate::transport::client::build_account_http_client(
account.id().as_str(),
@@ -288,17 +301,17 @@ impl CodexCredentialProfileService {
.map_err(map_avatar_fetch_error)
}
fn cached_avatar_source(&self, account_id: &ProviderAccountId) -> Option<String> {
fn cached_avatar_source(&self, identity: &str) -> Option<String> {
let now = Instant::now();
let mut sources = self
.avatar_sources
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sources.retain(|_, source| source.expires_at > now);
sources.get(account_id).map(|source| source.source.clone())
sources.get(identity).map(|source| source.source.clone())
}
fn remember_avatar_source(&self, account_id: &ProviderAccountId, source: Option<&str>) {
fn remember_avatar_source(&self, identity: String, source: Option<&str>) {
let now = Instant::now();
let mut sources = self
.avatar_sources
@@ -308,7 +321,7 @@ impl CodexCredentialProfileService {
match source {
Some(source) => {
sources.insert(
account_id.clone(),
identity,
CachedProfileAvatarSource {
source: source.to_owned(),
expires_at: now + PROFILE_AVATAR_SOURCE_TTL,
@@ -316,7 +329,7 @@ impl CodexCredentialProfileService {
);
}
None => {
sources.remove(account_id);
sources.remove(&identity);
}
}
}
@@ -480,17 +480,18 @@ impl CodexCredentialSelector {
&& request
.session_affinity_observation
.is_some_and(CodexSessionAffinity::follow_only);
let select = |queue_policy| {
let select = |queue_policy, queue_rejection| {
self.select_inner_loop(
request,
cyber_policy_session_key,
model,
follow_only,
queue_policy,
queue_rejection,
)
};
let selection = async {
let rejection = match select(policy.queue_policy()).await {
let rejection = match select(policy.queue_policy(), None).await {
Err(CredentialSelectionError::QueueRejected(reason))
if !follow_only
&& request.attempt.required_account().is_none()
@@ -512,7 +513,7 @@ impl CodexCredentialSelector {
"account.queue.reselect",
serde_json::json!({"reason": rejection.to_string()}),
);
select(queue)
select(queue, Some(rejection))
},
|err| {
matches!(
@@ -555,6 +556,7 @@ impl CodexCredentialSelector {
model: CodexSelectionModel<'_>,
follow_only: bool,
mut queue_policy: ConcurrencyQueuePolicy,
queue_rejection: Option<QueueRejection>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let adapter_model = match model {
CodexSelectionModel::Requested(model) if request.managed_upstream => Some(
@@ -1351,7 +1353,30 @@ impl CodexCredentialSelector {
tracing::warn!(%error, "OpenAI cache preference update unavailable");
}
Err(error) => return Err(error),
Ok(true) => {}
Ok(true) => {
if let Some(reason) = queue_rejection
&& let Some(previous) = binding.as_ref().filter(|binding| {
binding.account_id() != binding_account
})
{
tracing::info!(
request_id = %request.attempt.request_id(),
attempt_index = request.attempt.attempt_index().get(),
reason = %reason,
from_account_id = %previous.account_id(),
to_account_id = %binding_account,
"OpenAI 账号排队失败后已迁移绑定"
);
request.attempt.trace().record_evidence(
"account.binding.migrated",
serde_json::json!({
"reason": reason.to_string(),
"fromAccountId": previous.account_id().as_str(),
"toAccountId": binding_account.as_str(),
}),
);
}
}
}
}
let affinity_telemetry = affinity.telemetry(account.id(), cache_preference);
@@ -4,10 +4,26 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use url::Url;
/// 复用上游会话与账号资料时绑定本地记录和真实 OAuth 身份,正常续期不改变归属
pub(crate) fn oauth_identity_key(account: &gateway_core::account::ProviderAccount) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for part in [
account.id().as_str(),
account.upstream_user_id().unwrap_or_default(),
account.upstream_account_id().unwrap_or_default(),
] {
hash.update((part.len() as u64).to_le_bytes());
hash.update(part.as_bytes());
}
hex::encode(hash.finalize())
}
/// OAuth AT/RT/ID Token;`Debug` 永不输出明文
#[derive(Clone)]
pub struct CodexOAuthSecret {
@@ -53,6 +69,18 @@ pub(crate) struct CodexOAuthMetadata {
pub(crate) chatgpt_account_id: Option<String>,
}
impl CodexOAuthMetadata {
pub(crate) fn with_access_token_fallback(self, access_token: &SecretString) -> Self {
let fallback = parse_chatgpt_jwt_claims(access_token.expose_secret()).unwrap_or_default();
Self {
email: self.email.or(fallback.email),
chatgpt_plan_type: self.chatgpt_plan_type.or(fallback.chatgpt_plan_type),
chatgpt_user_id: self.chatgpt_user_id.or(fallback.chatgpt_user_id),
chatgpt_account_id: self.chatgpt_account_id.or(fallback.chatgpt_account_id),
}
}
}
impl fmt::Debug for CodexOAuthMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -351,7 +351,7 @@ impl CodexProvider {
installation_id,
client: client
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_middleware_headers(middleware_headers)
.with_outbound_middleware(outbound_middleware)
@@ -429,6 +429,8 @@ pub(super) struct OpenAiSessionState {
pub(super) account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
#[serde(default)]
pub(super) turn_state: Option<String>,
@@ -450,6 +452,7 @@ pub(super) enum OpenAiContinuationScope {
pub(super) struct OpenAiSessionCapture {
pub(super) account_id: String,
pub(super) credential_revision: Option<u64>,
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
pub(super) turn_state: Option<String>,
pub(super) client_turn_id: Option<String>,
@@ -498,6 +501,7 @@ fn encode_openai_session_capture(
encode_openai_session_state(OpenAiSessionState {
account_id: capture.account_id.clone(),
credential_revision: capture.credential_revision,
oauth_identity: capture.oauth_identity.clone(),
conversation_id: capture.conversation_id.clone(),
turn_state: capture.turn_state.clone(),
client_turn_id: capture.client_turn_id.clone(),
@@ -584,9 +588,12 @@ pub(super) async fn create_response_attempt(
pub(super) fn map_handshake_attempt_error(
error: CodexHandshakeAttemptError,
account: &ProviderAccount,
) -> MappedProviderFailure {
match error {
CodexHandshakeAttemptError::Client(error) => map_handshake_error(error),
CodexHandshakeAttemptError::Client(error) => {
map_client_error(error, UpstreamSendState::Ambiguous, Some(account))
}
CodexHandshakeAttemptError::Cancelled => MappedProviderFailure::plain(provider_error(
ProviderErrorKind::Cancelled,
UpstreamSendState::Ambiguous,
@@ -637,6 +644,7 @@ pub(super) async fn create_json_attempt(
pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
Box::pin(async_stream::try_stream! {
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
let image_endpoint = matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH);
let failure_context = OpenAiFailureContext {
client: &request.client,
selector: &request.selector,
@@ -644,6 +652,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
response_origin: &request.response_origin,
cyber_policy_scope: None,
allows_account_state_mutation,
image_endpoint,
upstream_model: None,
};
let active_account = request.lease.account().clone();
@@ -677,7 +686,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
error,
);
}
let response = match response.map_err(map_handshake_attempt_error) {
let response = match response.map_err(|error| map_handshake_attempt_error(error, &active_account)) {
Ok(response) => response,
Err(mut failure) => {
if let Some(observation) = failure.observation.take() {
@@ -709,6 +718,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
&request.quota,
&active_account,
&response.rate_limit_headers,
image_endpoint,
)
.await;
if !response.set_cookie_headers.is_empty()
@@ -732,7 +742,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
let response_meta =
ResponseMeta::for_provider_endpoint(request.context.request_id().as_str());
yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone()));
if matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH)
if image_endpoint
&& let Some((usage, cost)) = image_response_metering(&request.body, &response.body, request.context.pricing())
{
yield ProviderEvent::canonical(GatewayEvent::Usage(usage));
@@ -808,6 +818,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
let allows_account_state_mutation = lease.allows_account_state_mutation();
let failure_context = OpenAiFailureContext {
client: &client,
image_endpoint: false,
selector: &selector,
quota: &quota,
response_origin: &response_origin,
@@ -866,7 +877,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
error,
);
}
let response = response.map_err(map_handshake_attempt_error);
let response = response.map_err(|error| map_handshake_attempt_error(error, &active_account));
let response = match response {
Ok(response) => response,
Err(mut failure) => {
@@ -1164,7 +1175,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
if let Some(affinity) = &routing_affinity {
for fact in events.iter().flat_map(ProviderEvent::canonical_facts) {
if let GatewayEvent::Started(meta) = fact {
guardian_routing.remember(context.client_api_key_ref(), active_account.id(), meta.response_id(), affinity);
guardian_routing.remember(context.client_api_key_ref(), &active_account, meta.response_id(), affinity);
}
}
}
@@ -2,7 +2,7 @@
use super::*;
use crate::transport::protocol::responses::is_replaceable_response_prelude;
use gateway_core::error::TransientRetryPolicy;
use gateway_core::error::{ProxyConnectionHint, TransientRetryPolicy};
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集
///
@@ -161,6 +161,7 @@ pub(super) struct OpenAiFailureContext<'a> {
pub(super) response_origin: &'a Url,
pub(super) cyber_policy_scope: Option<&'a CodexCyberPolicyScope>,
pub(super) allows_account_state_mutation: bool,
pub(super) image_endpoint: bool,
pub(super) upstream_model: Option<&'a UpstreamModelId>,
}
@@ -310,7 +311,13 @@ pub(super) async fn apply_failure(
if !context.allows_account_state_mutation {
return;
}
synchronize_passive_quota_headers(context.quota, account, &failure.rate_limit_headers).await;
synchronize_passive_quota_headers(
context.quota,
account,
&failure.rate_limit_headers,
context.image_endpoint,
)
.await;
if failure.model_unsupported
&& let Some(model) = context.upstream_model
&& let Err(error) = context
@@ -430,10 +437,6 @@ pub(super) fn schedule_authoritative_quota_refresh_after_failure(
}));
}
pub(super) fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
map_client_error(error, UpstreamSendState::Ambiguous, true)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WebSocketFailurePolicy {
Budgeted,
@@ -711,7 +714,7 @@ fn continuation_replay_error_detail() -> ClientVisibleUpstreamError {
pub(super) fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
let allows_pre_delivery_retry = stream_transport_allows_pre_delivery_retry(&error);
let websocket_failure = error.transport() == Some(CodexBackendTransport::WebSocket);
let mut failure = map_client_error(error, UpstreamSendState::Sent, false);
let mut failure = map_client_error(error, UpstreamSendState::Sent, None);
failure.websocket_transport_retryable = allows_pre_delivery_retry && websocket_failure;
if allows_pre_delivery_retry && !websocket_failure {
failure.error = failure.error.with_pre_delivery_retry();
@@ -761,7 +764,7 @@ pub(super) fn map_canonical_error(
pub(super) fn map_client_error(
error: CodexClientError,
uncertain_state: UpstreamSendState,
observe_transport: bool,
handshake_account: Option<&ProviderAccount>,
) -> MappedProviderFailure {
if let CodexClientError::Outbound(error) = error {
return MappedProviderFailure::plain(*error);
@@ -773,7 +776,10 @@ pub(super) fn map_client_error(
.with_classification("admission", "local_connection_capacity"),
)
} else {
client_diagnostic(&error)
client_diagnostic(
&error,
handshake_account.is_some_and(|account| account.outbound_proxy().is_some()),
)
};
let raw_upstream_error = match &error {
CodexClientError::WebSocket(error) => websocket_raw_error(error),
@@ -808,9 +814,7 @@ pub(super) fn map_client_error(
}),
_ => None,
};
let observation = observe_transport
.then(|| codex_error_observation(&error))
.flatten();
let observation = handshake_account.and_then(|_| codex_error_observation(&error));
if let Some(failure) = error.upstream_failure() {
return map_upstream_failure(failure, observation, ReplayBoundary::BeforeSemanticOutput);
}
@@ -1007,7 +1011,10 @@ pub(super) fn map_client_error(
}
/// 在消费 transport 错误前提取可持久化事实,不能使用可能携带 URL/凭据的 Display
pub(super) fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiagnostic> {
pub(super) fn client_diagnostic(
error: &CodexClientError,
uses_account_proxy: bool,
) -> Option<ProviderDiagnostic> {
let (stage, code, message) = match error {
CodexClientError::ConnectionBudgetExhausted => (
"connect",
@@ -1040,13 +1047,16 @@ pub(super) fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiag
} else {
("exchange", "http_transport_failed")
};
return Some(
ProviderDiagnostic::new(format!(
"OpenAI HTTP failure: stage={stage}, cause={code}"
))
.with_classification(stage, code)
.with_io_cause(error),
);
let diagnostic = ProviderDiagnostic::new(format!(
"OpenAI HTTP failure: stage={stage}, cause={code}"
))
.with_classification(stage, code)
.with_io_cause(error);
return Some(if uses_account_proxy && error.is_connect() {
diagnostic.with_proxy_hint(ProxyConnectionHint::CheckProxy)
} else {
diagnostic
});
}
CodexClientError::StreamIdleTimeout { timeout } => (
"receive",
@@ -1114,10 +1124,11 @@ pub(super) fn profile_diagnostic(
fn custom_ca_diagnostic(error: &crate::transport::tls::CustomCaError) -> ProviderDiagnostic {
use crate::transport::tls::CustomCaError;
let (code, message) = match error {
CustomCaError::ProxyConfiguration => (
"account_proxy_invalid",
"OpenAI account proxy configuration is invalid".to_owned(),
),
CustomCaError::ProxyConfiguration => {
return ProviderDiagnostic::new("OpenAI account proxy configuration is invalid")
.with_classification("prepare", "account_proxy_invalid")
.with_proxy_hint(ProxyConnectionHint::InvalidConfiguration);
}
CustomCaError::ReadCaFile { source_env, .. } => (
"custom_ca_read_failed",
format!("OpenAI CA certificate file selected by {source_env} could not be read"),
@@ -1155,7 +1166,7 @@ fn custom_ca_diagnostic(error: &crate::transport::tls::CustomCaError) -> Provide
fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnostic {
let (stage, code) = match error.diagnostic_cause() {
CodexWebSocketExchangeError::Connect(_) => (
CodexWebSocketExchangeError::Connect { .. } => (
"connect",
error
.transport_failure_reason()
@@ -1191,7 +1202,18 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
.unwrap_or("websocket_transport_failed"),
),
};
websocket_diagnostic_message(error).with_classification(stage, code)
let diagnostic = websocket_diagnostic_message(error).with_classification(stage, code);
match error.classified() {
CodexWebSocketExchangeError::Connect {
proxy_hint: Some(hint),
..
}
| CodexWebSocketExchangeError::ConnectTimeout {
proxy_hint: Some(hint),
..
} => diagnostic.with_proxy_hint(*hint),
_ => diagnostic,
}
}
fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> ProviderDiagnostic {
@@ -1212,7 +1234,7 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
if let Some(reason) = error.transport_failure_reason() {
if matches!(
error.diagnostic_cause(),
CodexWebSocketExchangeError::Connect(_)
CodexWebSocketExchangeError::Connect { .. }
) {
return ProviderDiagnostic::new(format!(
"OpenAI WebSocket connection failed before payload send ({reason})"
@@ -1235,10 +1257,10 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
| CodexWebSocketExchangeError::PostSendAmbiguous { .. } => {
"OpenAI WebSocket transport failed after payload send; result is ambiguous".to_owned()
}
CodexWebSocketExchangeError::Connect(_) => {
CodexWebSocketExchangeError::Connect { .. } => {
"OpenAI WebSocket connection failed before payload send".to_owned()
}
CodexWebSocketExchangeError::ConnectTimeout { timeout } => {
CodexWebSocketExchangeError::ConnectTimeout { timeout, .. } => {
format!("OpenAI WebSocket connection timed out after {timeout:?}")
}
CodexWebSocketExchangeError::OriginCircuitOpen => {
@@ -1615,7 +1637,7 @@ pub(super) fn account_failure(
pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> UpstreamSendState {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::Connect { .. }
| CodexWebSocketExchangeError::ConnectTimeout { .. }
| CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy
@@ -1659,7 +1681,7 @@ pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> Provi
CodexWebSocketExchangeError::ConnectionLimitReached(_) => ProviderErrorKind::RateLimited,
CodexWebSocketExchangeError::Transport(_)
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::Connect { .. }
| CodexWebSocketExchangeError::StreamEndedBeforeTerminal { .. }
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
ProviderErrorKind::Transport
@@ -6,7 +6,7 @@ use std::{
time::{Duration, Instant},
};
use gateway_core::{account::ProviderAccountId, policy::ClientApiKeyId};
use gateway_core::{account::ProviderAccount, policy::ClientApiKeyId};
use sha2::{Digest, Sha256};
use crate::transport::routing_affinity::ChatGptRoutingAffinity;
@@ -29,7 +29,7 @@ impl GuardianRouting {
pub(super) fn get(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> Option<ChatGptRoutingAffinity> {
let key = parent_key(client_key, account, response_id);
@@ -48,7 +48,7 @@ impl GuardianRouting {
pub(super) fn remember(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
affinity: &ChatGptRoutingAffinity,
) {
@@ -83,11 +83,12 @@ impl GuardianRouting {
fn parent_key(
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> [u8; 32] {
let mut hash = Sha256::new();
for part in [client_key.as_str(), account.as_str(), response_id] {
let identity = crate::credential::oauth_identity_key(account);
for part in [client_key.as_str(), &identity, response_id] {
hash.update(part.len().to_le_bytes());
hash.update(part.as_bytes());
}
@@ -367,7 +367,7 @@ impl CodexProvider {
client: self
.client_for_request(&context)?
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_middleware_headers(middleware_headers)
.with_outbound_middleware(outbound_middleware),
@@ -418,6 +418,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream {
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
let failure_context = OpenAiFailureContext {
client: &client,
image_endpoint: false,
selector: &request.selector,
quota: &request.quota,
response_origin: &request.response_origin,
@@ -478,7 +479,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream {
error,
);
}
let response = match attempt.map_err(map_handshake_attempt_error) {
let response = match attempt.map_err(|error| map_handshake_attempt_error(error, &active_account)) {
Ok(response) => response,
Err(mut failure) => {
if let Some(observation) = failure.observation.take() {
@@ -655,7 +656,7 @@ fn map_live_http_error(error: CodexClientError) -> LiveGatewayError {
fn local_live_error(error: CodexClientError, message: &'static str) -> LiveGatewayError {
// 本地传输异常可能包含 URL 或代理上下文;客户端提示与现有安全诊断各自保留
if let Some(diagnostic) = super::failure::client_diagnostic(&error) {
if let Some(diagnostic) = super::failure::client_diagnostic(&error, false) {
tracing::warn!(
stage = diagnostic.stage(),
code = diagnostic.code(),
@@ -888,15 +888,24 @@ impl CodexProvider {
}
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let oauth_identity = matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
)
.then(|| crate::credential::oauth_identity_key(lease.account()));
let native_owner = context.continuation().and_then(ContinuationBinding::pinned);
let state_owner_cross_account = native_owner
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|| previous_session.as_ref().is_some_and(|state| {
state.account_id != lease.account_id().as_str()
|| state
.oauth_identity
.as_ref()
.is_some_and(|identity| Some(identity) != oauth_identity.as_ref())
});
let has_explicit_state_owner = context.account_state_owner().is_some()
|| previous_session.is_some()
|| native_owner.is_some();
@@ -970,11 +979,7 @@ impl CodexProvider {
.flatten()
.and_then(|response_id| {
self.guardian_routing
.get(
context.client_api_key_ref(),
lease.account_id(),
response_id,
)
.get(context.client_api_key_ref(), lease.account(), response_id)
.map(|affinity| (response_id.to_owned(), affinity))
});
let client = self.client_for_request(&context)?;
@@ -1133,6 +1138,7 @@ impl CodexProvider {
let session_capture =
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
account_id: lease.account_id().as_str().to_owned(),
oauth_identity,
credential_revision: matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::ApiKey(_)
@@ -1164,7 +1170,7 @@ impl CodexProvider {
installation_id,
client: client
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_routing_affinity(guardian_parent.map(|(_, affinity)| affinity))
.with_connection_budget(context.connection_budget().clone())
@@ -553,11 +553,16 @@ pub(super) async fn synchronize_passive_quota_headers(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
image_endpoint: bool,
) {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return;
let parsed = if image_endpoint {
gateway_protocol::openai::events::parse_image_rate_limit_headers(headers)
} else {
parse_rate_limit_headers(headers)
};
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
if let Some(rate_limits) = parsed {
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
}
}
pub(super) const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
@@ -885,6 +885,10 @@ impl CodexBackendClient {
client
.egress_key
.push_str(&format!(":revision:{}", account.revision().get()));
} else {
client
.egress_key
.push_str(&crate::credential::oauth_identity_key(account));
}
client.websocket_origin_key = format!(
"{}:{}",
@@ -440,7 +440,7 @@ fn start_pooled_websocket_connect(
fn opening_failure_reason(error: &CodexWebSocketExchangeError) -> &'static str {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_) => "request_build_failure",
CodexWebSocketExchangeError::Connect(error)
CodexWebSocketExchangeError::Connect { source: error, .. }
| CodexWebSocketExchangeError::Transport(error) => transport_metric_reason(error),
CodexWebSocketExchangeError::ConnectTimeout { .. } => "connect_timeout",
CodexWebSocketExchangeError::Upstream(_) => "upgrade_rejected",
@@ -2,6 +2,7 @@
use std::{fmt, time::Duration};
use gateway_core::error::ProxyConnectionHint;
use gateway_protocol::openai::sse::SseError;
use thiserror::Error;
use uuid::Uuid;
@@ -27,11 +28,16 @@ pub enum CodexWebSocketExchangeError {
#[error("websocket transport error: {0}")]
Transport(#[from] tungstenite::Error),
/// DNS、TCP、TLS 或 opening handshake 在发送 payload 前失败
#[error("websocket connect failed before payload send: {0}")]
Connect(#[source] tungstenite::Error),
#[error("websocket connect failed before payload send: {source}")]
Connect {
#[source]
source: tungstenite::Error,
proxy_hint: Option<ProxyConnectionHint>,
},
/// DNS、TCP、TLS 或 WebSocket upgrade 未在限定时间内完成
#[error("websocket connect timed out after {timeout:?}")]
ConnectTimeout {
proxy_hint: Option<ProxyConnectionHint>,
/// 建连超时时长
timeout: Duration,
},
@@ -257,7 +263,7 @@ impl CodexWebSocketExchangeError {
/// 与已有指标粗分类分开:缺少关闭握手本身不能证明收到 TCP RST
pub(crate) fn transport_failure_reason(&self) -> Option<&'static str> {
match self.diagnostic_cause() {
Self::Transport(error) | Self::Connect(error) => Some(match error {
Self::Transport(error) | Self::Connect { source: error, .. } => Some(match error {
tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
) => "reset_without_closing_handshake",
@@ -3,6 +3,7 @@
use std::time::Duration;
use bytes::Bytes;
use gateway_core::error::ProxyConnectionHint;
use gateway_protocol::openai::events;
use tokio::{io::BufWriter, time::timeout};
use tokio_tungstenite::{
@@ -205,10 +206,9 @@ async fn connect_websocket(
) -> Result<(RawWsStream, WsResponse<Option<Vec<u8>>>), CodexWebSocketExchangeError> {
let request = websocket_handshake_request(connection)?;
let connector = tls::maybe_build_rustls_client_config_with_custom_ca()
.map_err(|error| {
CodexWebSocketExchangeError::Connect(tungstenite::Error::Io(std::io::Error::other(
error,
)))
.map_err(|error| CodexWebSocketExchangeError::Connect {
source: tungstenite::Error::Io(std::io::Error::other(error)),
proxy_hint: None,
})?
.map(Connector::Rustls);
let remaining = connection
@@ -216,15 +216,16 @@ async fn connect_websocket(
.as_ref()
.map(|budget| budget.begin())
.transpose()
.map_err(|error| {
CodexWebSocketExchangeError::Connect(tungstenite::Error::Io(std::io::Error::other(
error,
)))
.map_err(|error| CodexWebSocketExchangeError::Connect {
source: tungstenite::Error::Io(std::io::Error::other(error)),
proxy_hint: None,
})?
.flatten();
let connect_timeout = remaining.map_or(WEBSOCKET_CONNECT_TIMEOUT, |remaining| {
remaining.min(WEBSOCKET_CONNECT_TIMEOUT)
});
// 超时会取消拨号 future,当前阶段留给外层生成准确的代理诊断
let mut proxy_hint = None;
let result = timeout(connect_timeout, async {
let _permit = if fast_path {
crate::transport::connection::try_acquire()
@@ -248,7 +249,7 @@ async fn connect_websocket(
.await?;
return Ok((Box::new(websocket) as RawWsStream, response));
}
let stream = dial_account(connection).await?;
let stream = dial_account(connection, &mut proxy_hint).await?;
match stream {
MaybeTlsStream::Plain(tcp) => {
let (websocket, response) = client_async_tls_with_config(
@@ -275,18 +276,23 @@ async fn connect_websocket(
.await
.map_err(|_| CodexWebSocketExchangeError::ConnectTimeout {
timeout: connect_timeout,
proxy_hint,
})?;
match result {
Ok((websocket, response)) => Ok((websocket, response)),
Err(tungstenite::Error::Http(response)) => {
Err(websocket_opening_error(response.as_ref(), connection))
}
Err(error) => Err(CodexWebSocketExchangeError::Connect(error)),
Err(error) => Err(CodexWebSocketExchangeError::Connect {
source: error,
proxy_hint,
}),
}
}
async fn dial_account(
connection: &CodexWebSocketConnection,
proxy_hint: &mut Option<ProxyConnectionHint>,
) -> Result<MaybeTlsStream<tokio::net::TcpStream>, tungstenite::Error> {
use tokio::net::lookup_host;
use tungstenite::proxy::ProxyConfig;
@@ -302,6 +308,7 @@ async fn dial_account(
let Some(proxy) = connection.outbound_proxy.as_ref() else {
return Ok(MaybeTlsStream::Plain(connect_tcp(host, port).await?));
};
*proxy_hint = Some(ProxyConnectionHint::InvalidConfiguration);
let mut proxy_url = url::Url::parse(proxy.expose_url()).map_err(|_| invalid())?;
let tls_proxy = proxy_url.scheme() == "https";
let proxy_port = proxy_url.port_or_known_default().ok_or_else(invalid)?;
@@ -316,10 +323,13 @@ async fn dial_account(
Some(url::Host::Ipv6(ip)) => ip.to_string(),
_ => config.host.clone(),
};
*proxy_hint = Some(ProxyConnectionHint::Unreachable);
let tcp = connect_tcp(&proxy_host, config.port).await?;
let stream = if tls_proxy {
*proxy_hint = None;
let tls = tls::account_proxy_tls_config().map_err(|_| invalid())?;
let name = rustls_pki_types::ServerName::try_from(proxy_host).map_err(|_| invalid())?;
*proxy_hint = Some(ProxyConnectionHint::Unreachable);
MaybeTlsStream::Rustls(
tokio_rustls::TlsConnector::from(tls)
.connect(name, tcp)
@@ -329,6 +339,7 @@ async fn dial_account(
MaybeTlsStream::Plain(tcp)
};
// 固定的 tungstenite 分支对两种 SOCKS 协议均把域名交给代理解析
*proxy_hint = None;
let target = if proxy_url.scheme() == "socks5" {
lookup_host((dns_host.as_str(), port))
.await?
@@ -344,9 +355,12 @@ async fn dial_account(
// 依赖分两次写入 SOCKS 方法协商,再显式 flush;部分代理会提前拒绝半包
// 仅在代理握手期间合并写入,不缓冲读取,也不改变后续 TLS/WS 的发送方式
// 这只是兼容措施,不能保证网络层不再拆分 TCP 数据
tokio_tungstenite::proxy::connect_via_proxy(BufWriter::new(stream), &config, &target, port)
.await
.map(BufWriter::into_inner)
*proxy_hint = Some(ProxyConnectionHint::TunnelFailed);
let stream =
tokio_tungstenite::proxy::connect_via_proxy(BufWriter::new(stream), &config, &target, port)
.await?;
*proxy_hint = Some(ProxyConnectionHint::CheckProxy);
Ok(stream.into_inner())
}
async fn connect_tcp(host: &str, port: u16) -> Result<tokio::net::TcpStream, tungstenite::Error> {
@@ -1197,7 +1197,7 @@ async fn api_key_client_catalog_cache_is_invalidated_by_credential_revision() {
}
#[tokio::test]
async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabilities() {
async fn api_key_catalog_hides_only_known_specialized_models_in_codex_picker() {
use provider_openai::credential::ResponsesTransport;
use provider_openai::transport::CodexCatalogCapabilityEvidence;
@@ -1206,11 +1206,18 @@ async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabili
store
.seed_api_key("acct_api", upstream.uri(), ResponsesTransport::Http)
.await;
let ids = [
"gpt-5.4",
"ft:gpt-4.1-mini:example:custom:abc",
"vendor/model",
let picker_visibility = [
("gpt-5.4", true),
("ft:gpt-4.1-mini:example:custom:abc", true),
("vendor/model", true),
("gpt-image-2-custom", true),
("gpt-image-2", false),
("gpt-image-2-2026-04-21", false),
("gpt-live-1", false),
("gpt-realtime-2", false),
("tts-1", false),
];
let ids = picker_visibility.map(|(id, _)| id);
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@@ -1240,21 +1247,19 @@ async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabili
.client_model_catalog(&CLIENT_KEY, &client_scope(&[account]), "1.0.0")
.await
.expect("client catalog");
assert_eq!(
models
.iter()
.map(|model| model.model.as_str())
.collect::<std::collections::BTreeSet<_>>(),
ids.into_iter().collect()
);
for model in &models {
let actual_visibility = models.iter().map(|model| {
let gateway_core::routing::ProviderModelContent::Adapted(presentation) = &model.content
else {
panic!("plain API IDs must remain adapted")
};
assert!(presentation.supported_reasoning_efforts().is_empty());
assert_eq!(presentation.default_reasoning_effort(), None);
}
(model.model.as_str(), !presentation.hidden())
});
assert_eq!(
actual_visibility.collect::<std::collections::BTreeMap<_, _>>(),
picker_visibility.into_iter().collect()
);
}
#[tokio::test]
@@ -18,7 +18,10 @@ fn projection_attempt(
RequestAttemptContext::new(
ModelRequestId::new("req_projection").unwrap(),
ClientApiKeyId::new(client_key).unwrap(),
),
)
.with_trace(gateway_core::diagnostics::TraceContext::new(
"req_projection",
)),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
policy,
@@ -420,15 +423,35 @@ async fn strict_queue_failure_reselects_once_and_only_rebinds_after_admission()
assert!(pending.as_mut().now_or_never().is_none());
}
let result = pending.await;
let reason = if full {
QueueRejection::Full
} else {
QueueRejection::Timeout
};
let snapshot = ctx.trace().snapshot().unwrap();
let migrations = snapshot["events"]
.as_array()
.unwrap()
.iter()
.filter(|event| event["stage"] == "account.binding.migrated")
.map(|event| &event["data"])
.collect::<Vec<_>>();
if spare && enabled {
assert_eq!(
migrations,
vec![&json!({
"reason": reason.to_string(),
"fromAccountId": first.as_str(),
"toAccountId": second.as_str(),
})]
);
} else {
assert!(migrations.is_empty());
}
let expected = if spare && enabled {
assert_eq!(result.unwrap().account_id(), &second);
&second
} else {
let reason = if full {
QueueRejection::Full
} else {
QueueRejection::Timeout
};
assert!(
matches!(result, Err(CredentialSelectionError::QueueRejected(actual)) if actual == reason)
);
@@ -476,6 +476,89 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn reauthorization_binds_the_selected_slot_to_the_new_oauth_account() {
use secrecy::ExposeSecret;
let store = Arc::new(MemoryAccountStore::default());
let account_id = ProviderAccountId::new("acct_reauth").unwrap();
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_string(),
name: "local label".into(),
secret: secret("original-access"),
verified_account: account_profile("workspace-a"),
next_refresh_at: None,
enabled: true,
})
.await;
let pending = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
pending.clone(),
Arc::new(Exchanger {
id_token: id_token(serde_json::json!({
"email": "b@example.com",
"https://api.openai.com/auth": {
"chatgpt_user_id": "user-b",
"chatgpt_account_id": "workspace-b",
"chatgpt_plan_type": "plus"
}
})),
}),
store,
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation(account_id.clone(), "reauth-request"),
})
.await
.unwrap();
let state = pending
.pending
.lock()
.unwrap()
.as_ref()
.unwrap()
.state
.clone();
let completed = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: "test-owner".into(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://127.0.0.1:1455/auth/callback?code=browser&state={}",
state.expose_secret()
)),
})
.await
.unwrap();
let CompletedCodexOAuthCredential::Reauthorize(rotation) = completed.credential else {
panic!("expected reauthorization")
};
let identity = rotation.replacement_identity.unwrap();
assert_eq!(identity.upstream_user_id(), "user-b");
assert_eq!(identity.upstream_account_id(), Some("workspace-b"));
assert_eq!(rotation.profile.account_id, account_id);
assert_eq!(rotation.profile.name, "local label");
assert_eq!(rotation.profile.email.as_deref(), Some("b@example.com"));
assert_eq!(rotation.profile.plan_type.as_deref(), Some("plus"));
let credential = rotation.credential.into_parts();
assert!(!credential.preserve_profile);
let runtime = CodexCredentialCodec::decode(&credential.credential).unwrap();
assert!(runtime.principal.is_none());
assert_eq!(
runtime
.authentication
.oauth()
.unwrap()
.access_token
.expose_secret(),
"access-token-from-upstream"
);
}
#[tokio::test]
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
for name in ["", "My account"] {
@@ -276,7 +276,8 @@ async fn passive_quota_updates_keep_core_and_model_specific_buckets_independent(
assert_eq!(spark.used_percent(), Some(1.0));
let core_headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
// 文本响应的活动标记不是独立额度头,通用周窗口仍应更新原来的账号额度
("x-codex-active-limit".to_owned(), "premium".to_owned()),
("x-codex-primary-used-percent".to_owned(), "82".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
@@ -2888,6 +2888,102 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
server.verify().await;
}
#[tokio::test]
async fn image_quota_headers_keep_the_codex_weekly_window_and_access_state() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_provider_contract";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("account");
let server = MockServer::start().await;
let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
Arc::new(TestLeaseCoordinator::default()),
0,
);
let core_headers = [
("x-codex-active-limit", "codex"),
("x-codex-primary-used-percent", "95"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "1900604800"),
("x-codex-allowed", "true"),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("initial weekly quota");
let initial = quota.read_account(account.id()).await.unwrap().unwrap();
let response = br#"{"data":[{"b64_json":"AAEC"}]}"#;
// 生图响应仅用 active-limit 标明归属,窗口仍使用通用 x-codex 头
Mock::given(method("POST"))
.and(path("/codex/images/generations"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("x-codex-active-limit", "imagegen_premium")
.insert_header("x-codex-primary-used-percent", "0")
.insert_header("x-codex-primary-window-minutes", "1440")
.insert_header("x-codex-primary-reset-at", "1900000000")
.insert_header("x-codex-secondary-used-percent", "0")
.insert_header("x-codex-secondary-window-minutes", "0")
.insert_header("x-codex-secondary-reset-at", "")
.set_body_raw(response.as_slice(), "application/json"),
)
.expect(1)
.mount(&server)
.await;
let payload = RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"model":"gpt-image-2.6-flare","prompt":"a tree"}"#),
)
.unwrap();
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
payload,
));
let mut stream = provider
.execute(
planned_provider_endpoint_request("openai", operation),
context("req_image_quota", CancellationToken::new()),
)
.await
.expect("image stream");
let mut delivered = None;
while let Some(event) = stream.next().await {
let event = event.expect("image event");
if let Some(body) = event.wire_event().and_then(|wire| wire.raw_json_body()) {
delivered = Some(body.clone());
}
}
assert_eq!(delivered.as_deref(), Some(response.as_slice()));
let snapshot = quota.read_account(account.id()).await.unwrap().unwrap();
let core = snapshot
.windows()
.iter()
.find(|window| window.is_account_wide())
.unwrap();
let image = snapshot
.windows()
.iter()
.find(|window| window.source() == "imagegen_premium")
.unwrap();
assert_eq!(core, &initial.windows()[0]);
assert_eq!(image.window_seconds(), Some(86_400));
assert_eq!(image.used_percent(), Some(0.0));
assert!(!image.is_account_wide());
assert_eq!(store.account(account_id).unwrap().quota(), initial.quota());
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("next Codex quota observation");
let refreshed = quota.read_account(account.id()).await.unwrap().unwrap();
assert_eq!(refreshed.windows(), snapshot.windows());
server.verify().await;
}
#[tokio::test]
async fn image_endpoints_should_report_usage_before_delivering_the_unchanged_body() {
let response = br#"{ "created":1778832973,"data":[{"b64_json":"AAEC"}],"quality":"medium","size":"1024x1536","usage":{"input_tokens":1474,"input_tokens_details":{"image_tokens":1457,"text_tokens":17,"cached_tokens":100},"output_tokens":1372,"output_tokens_details":{"image_tokens":1372,"text_tokens":0},"total_tokens":2846},"future":9007199254740993 }"#;
@@ -4166,12 +4262,44 @@ async fn websocket_fast_path_miss_uses_http_and_keeps_background_preconnect() {
#[tokio::test]
async fn connection_failure_preserves_io_cause_without_claiming_payload_was_sent() {
for websocket in [false, true] {
use gateway_core::error::ProxyConnectionHint;
for (websocket, proxy_hint) in [
(false, None),
(true, None),
(false, Some(ProxyConnectionHint::CheckProxy)),
(true, Some(ProxyConnectionHint::Unreachable)),
(true, Some(ProxyConnectionHint::TunnelFailed)),
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_abrupt_disconnect").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let rejection = if proxy_hint == Some(ProxyConnectionHint::TunnelFailed) {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(
capture_http_request(&mut socket)
.await
.starts_with(b"CONNECT ")
);
socket
.write_all(
b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}))
} else {
drop(listener);
None
};
if proxy_hint.is_some() {
store.set_egress(
"acct_abrupt_disconnect",
Some(gateway_core::account::OutboundProxy::parse(&base_url).unwrap()),
None,
);
}
let operation = if websocket {
Operation::Generate(generate_with_persisted_session_context(
"acct_abrupt_disconnect",
@@ -4197,7 +4325,13 @@ async fn connection_failure_preserves_io_cause_without_claiming_payload_was_sent
}
};
let diagnostic = error.diagnostic().expect("connection diagnosis");
assert_eq!(diagnostic.code(), Some("connection_refused"));
assert_eq!(diagnostic.proxy_hint(), proxy_hint);
if let Some(rejection) = rejection {
rejection.await.unwrap();
}
if proxy_hint.is_none() {
assert_eq!(diagnostic.code(), Some("connection_refused"));
}
assert_eq!(diagnostic.stage(), Some("connect"));
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(!diagnostic.as_str().contains("after payload send"));
@@ -10095,7 +10229,7 @@ async fn successful_response_with_full_subscription_blocks_subsequent_requests()
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("x-codex-active-limit", "codex")
.insert_header("x-codex-active-limit", "premium")
.insert_header("x-codex-primary-used-percent", "100")
.insert_header("x-codex-primary-window-minutes", "300")
.insert_header("x-codex-primary-reset-at", "1900000000")
@@ -289,6 +289,21 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let account = |user: &str| {
use gateway_core::account::{CredentialRevision, ProviderAccount, ProviderAccountId};
ProviderAccount::new(
ProviderAccountId::new("acct_shared_slot").unwrap(),
gateway_core::routing::ProviderKind::new("openai").unwrap(),
String::new(),
Some(user.to_owned()),
"oauth".to_owned(),
CredentialRevision::new(1).unwrap(),
None,
)
.with_profile(None, Some("chatgpt-account".into()), None)
};
let replacement = backend.for_account(&account("user-b")).unwrap();
let backend = backend.for_account(&account("user-a")).unwrap();
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -309,6 +324,16 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
let mut continuation = first_request;
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
assert!(
replacement
.create_response(
&continuation,
request_context("req_reauthorized", Some("chatgpt-account"))
)
.await
.is_err(),
"同一工作区的另一个用户不能续用旧账号的连接"
);
let changed_installation = backend
.create_response(
&continuation,
@@ -424,14 +424,19 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
}
let dns_observer = Arc::new(RequestDnsObserver::default());
let headers_started_at = Instant::now();
let response = REQUEST_DNS_OBSERVER
.scope(Arc::clone(&dns_observer), builder.send())
.await
.map_err(|error| {
classify_inference_reqwest_error(error).with_transport_metrics(
inference_transport_metrics(client_cache_status, None, &dns_observer),
)
})?;
let response =
REQUEST_DNS_OBSERVER
.scope(Arc::clone(&dns_observer), builder.send())
.await
.map_err(|error| {
classify_inference_reqwest_error(
error,
request.binding().outbound_proxy().is_some(),
)
.with_transport_metrics(
inference_transport_metrics(client_cache_status, None, &dns_observer),
)
})?;
let transport_metrics = inference_transport_metrics(
client_cache_status,
Some(elapsed_millis(headers_started_at.elapsed())),
@@ -1001,7 +1006,10 @@ fn inference_http_diagnostic(error: &reqwest::Error) -> ProviderDiagnostic {
.with_io_cause(error)
}
fn classify_inference_reqwest_error(error: reqwest::Error) -> GrokInferenceTransportError {
fn classify_inference_reqwest_error(
error: reqwest::Error,
uses_account_proxy: bool,
) -> GrokInferenceTransportError {
let (kind, send_state) = if error.is_builder() {
(
GrokInferenceTransportErrorKind::Protocol,
@@ -1023,8 +1031,12 @@ fn classify_inference_reqwest_error(error: reqwest::Error) -> GrokInferenceTrans
UpstreamSendState::Ambiguous,
)
};
let failure = GrokInferenceTransportError::new(kind, send_state)
.with_diagnostic(inference_http_diagnostic(&error));
let mut diagnostic = inference_http_diagnostic(&error);
if uses_account_proxy && error.is_connect() {
diagnostic =
diagnostic.with_proxy_hint(gateway_core::error::ProxyConnectionHint::CheckProxy);
}
let failure = GrokInferenceTransportError::new(kind, send_state).with_diagnostic(diagnostic);
with_inference_reqwest_source(failure, error)
}
@@ -184,11 +184,15 @@ async fn inference_and_oauth_connect_through_each_accounts_authenticated_proxy()
.unwrap()
.with_outbound_proxy(Some(proxy)),
);
assert!(
tokio::time::timeout(Duration::from_secs(5), inference.execute(request))
.await
.unwrap()
.is_err()
let error = tokio::time::timeout(Duration::from_secs(5), inference.execute(request))
.await
.unwrap()
.expect_err("proxy rejects CONNECT");
assert_eq!(
error
.diagnostic()
.and_then(|diagnostic| diagnostic.proxy_hint()),
Some(gateway_core::error::ProxyConnectionHint::CheckProxy)
);
let connect = tokio::time::timeout(Duration::from_secs(5), captured)
.await
@@ -581,6 +585,7 @@ async fn inference_connect_failure_keeps_safe_diagnosis_without_endpoint_or_payl
.await
.expect_err("closed listener");
let diagnostic = error.diagnostic().expect("transport cause");
assert_eq!(diagnostic.proxy_hint(), None);
assert_eq!(diagnostic.stage(), Some("connect"));
assert_eq!(diagnostic.code(), Some("connection_refused"));
assert!(!diagnostic.as_str().contains("127.0.0.1"));
+2 -2
View File
@@ -4,7 +4,7 @@
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
FROM node:26.13.0-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 AS frontend-builder
FROM node:26.13.0-bookworm-slim@sha256:add5c17c14752c54d2f3d519ec46a3815684d41c33f198bcb09fb998f7dcbf68 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
# 备份客户端来源:与 compose 服务端同一固定镜像,版本一致。
FROM ${POSTGRES_IMAGE} AS pg-client
FROM debian:bookworm-slim@sha256:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 AS runtime-base
FROM debian:bookworm-slim@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc AS runtime-base
ENV TZ=UTC
+8 -4
View File
@@ -669,6 +669,9 @@ metrics_exporter = "none"
WebSocket 会话完成,进程退出时强制断开剩余连接。已入队的观测写入与后台清理保留
`host.worker_shutdown_timeout_seconds`(默认 30 秒)的收尾预算;客户端在新进程就绪后重新连接
正常关闭会在后台收尾预算内释放本实例的账号并发占位,租约清理最多等待 2 秒。
强制终止、清理超时或 Redis 清理失败时,占位由最后一次续期后的 10 分钟 TTL 回收,不影响其他实例的租约
管理端重启受理后,到进程退出期间,更新、回滚和再次重启均被拒绝;可继续查询更新状态
Compose 的 `stop_grace_period` 为 75 秒,覆盖默认 30 秒后台收尾及进程清理余量。
@@ -843,6 +846,7 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
### 升级前检查
- OpenAI 或 xAI 授权使用已保存代理,且代理的 WebSocket 连接上限非零:升级或回退后,重新发起尚未完成的授权,不复用旧回调或授权码。上限为 `0` 的在途授权和已保存的账号、凭据不受影响
- Key 配置含 `installationId`:数据库自动转换为 `installationSeed`,API 调用方须使用[当前字段](../docs/api.md#key-客户端身份)。出站安装身份及关联的会话、缓存标识会变化,已有 WebSocket 精确续写需重发完整上下文
- 数据库或备份含 `accountAutoFreeze*` 设置:升级后错误保护关闭、规则为空,需在管理端重新配置并启用;API 调用方使用 [`accountErrorProtection`](../docs/api.md#账号错误保护)。已有冻结到当前冷却截止后解除,账号并发上限保留,需调整时手动设置
@@ -864,9 +868,9 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs
切换通道不会降级,Stable 候选也必须高于当前运行版本。
下载、安装或待重启期间不能切换通道
更新候选来自公开发行附件。更新器按固定 tag 查询预发行页 `update-index`,从正文 JSON 的 `current` 读取快照版本,再读取该版本 Release 的 `version.yaml` 附件。附件的 `version` 标识快照,`published` 按大版本和通道列出可安装版本;修改源码或仅打 tag 不会触发提示
更新器读取更新仓库 `main` 分支的 `release/version.yaml`。`version` 是源码构建版本,只有 `published` 中当前大版本和通道对应的版本才参与更新检查
自定义更新仓库须提供相同入口、快照及各候选的公开 Release。使用本仓库发布工作流时自动维护索引;`update-index` 不设为 Latest,不需要额外分支
自定义更新仓库须提供这份清单和候选版本的公开 Release。发布工作流在安装包公开并通过校验后更新 `published`,需要具有写入 `main` 的权限
提示有更新前会按精确 tag 核对候选 Release、当前平台包和校验文件,无需配置 GitHub 令牌。GitHub 限流时按提示稍后重试;
也可从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载平台归档,按[手动安装](#手动安装)升级
@@ -943,7 +947,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
| 文件或目录 | 用途 |
| --- | --- |
| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 |
| `plugins/official/plugin-release-manifest.json` | 随平台安装包和镜像分发,记录宿主版本、提交与支持的插件合同 |
| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 |
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后重启,不兼容实例保持未启动,启用配置、绑定、密钥和私有数据保留
@@ -955,7 +959,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+17 -4
View File
@@ -580,7 +580,8 @@ OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
和字段缺失的区别。
API Key 上游返回完整 Codex `models` 目录时沿用该合同;仅返回普通 `data` 模型列表时使用通用画像,
未提供的推理能力保持未知,不补充推理档位。
未提供的推理能力保持未知,不补充推理档位。已知的专用生图、Live/Realtime 和语音合成模型以 `visibility: "hide"`
保留在 Codex 目录中,且不出现在默认选模列表。其他 ID 保持可见;此规则不影响普通模型列表或直接调用。
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
目录按当前路由快照的模型存在性及账号模型政策过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用画像。推理菜单读取
@@ -1383,6 +1384,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"sendState": "sent",
"proxyHint": null,
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
@@ -1398,6 +1400,9 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配
- `source` 为 `gateway`、`provider` 或 `upstream`:分别表示尚未进入 Provider、Provider 本地且未发送、
已发送/可能已发送或已经捕获到上游事实
- `gatewayErrorCode` 是 `GatewayErrorKind` 的稳定机器值,管理端据此生成中文摘要
- `proxyHint` 仅在 Provider 发送前失败且有代理诊断时返回:`invalid_configuration` 表示代理配置无效,
`unreachable` 表示无法连接代理端点,`tunnel_failed` 表示代理隧道协商失败。
`check_proxy` 表示本次使用代理且建连失败,不能据此判定故障在代理;无提示时为 `null`
- `sendState` 为 `not_sent`、`sent`、`ambiguous`,非 Provider 错误为 `null`
- `error`、`providerErrorCode`、`providerErrorType`、`upstreamStatus`、`upstreamContentType` 和
`upstreamBody` 是实际捕获的原始诊断字段;缺失时为 `null`,不会由本地猜测或翻译
@@ -1597,8 +1602,10 @@ OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上
失效后需取得新 PAT 再导入
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件
现值。回调地址只承载 `code`/`state`,不以 host/path 形式作为拒绝条件
- OpenAI 重新授权允许登录另一个上游账号。保留本地账号 ID、名称和调度设置,按新授权更新令牌、
上游用户与工作区 ID、邮箱和套餐。换号时清除旧账号的额度快照和耗尽状态,新额度由后台重新查询。
无法识别新用户或新身份已绑定其他记录时,原账号保持不变
- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
@@ -2762,6 +2769,10 @@ WebSocket 握手不主动添加专项计时请求头,客户端显式提供的
`environment.scope = export_time` 明确版本、构建与部署信息是导出时状态,不能代替事发环境
`request.routed.data.accountPolicy` 保存执行时冻结的账号策略与预算。
OpenAI 账号队满或超时后的重选记录为 `account.queue.reselect`;绑定迁移成功后,
`account.binding.migrated` 保存拒绝原因 `reason`、原账号 `fromAccountId` 和新账号 `toAccountId`。
首次认领、临时分流和失败重选不记录为绑定迁移
`attempt.failed.data.recovery` 保存 Provider 的重放安全性和恢复意图;`retry.decided` 保存当时
实际参与决策的门槛、剩余预算和发送状态,`retry.policy` 保存宿主及插件策略的停止事实。
已有的 `provider.precommit.released` 数据保留释放原因、预取字节数与等待时间,不等同于下游已提交
@@ -3103,7 +3114,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
回调保持类型、期限、取消、实例 revision、资源归属和事务一致性检查。模型调用可显式选择 Key,按所选 Key 的准入、预算、用量与计费合同执行;省略 Key 时继承现有模型父请求身份
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;代理沿用账号配置,失败不自动回退直连,重定向不自动跟随
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;出口可继承调用范围、指定账号或按代理 ID 选择,连接失败不自动回退直连,重定向不自动跟随。
显式代理选择与目录查询见 [SDK 网络回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#网络)
#### 能力绑定与范围
@@ -3227,6 +3239,7 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据
`If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。
两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe,
仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。
插件页面可通过宿主桥请求打开 HTTPS 外链,管理员在宿主确认后才打开新标签页。
页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见
[SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli)
+14 -4
View File
@@ -243,6 +243,8 @@ flowchart LR
流、取消确认与消费方共同持有同一在途租约,执行名额只覆盖首结果前的处理;调用等待与回调有效期共用各端的预算状态,请求总期限独立生效。
显式 Key 的身份快照随调用保留,每个模型执行拥有独立子调用图与结算;省略 Key 时继承现有模型父请求。页面 Responses 桥复核精确实例目标和 revision
`frontend_authentication` 使用 principal 到 Key 的映射;公开登录回调的一次性票据用于关联流程,不限制插件回调能力。接口合同见 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#宿主资源回调)
- **显式代理独立于账号**:Admin 通过窄读取端口提供当前代理配置与目录,Runtime 只投影选择信息并复用 Host 的 HTTP 发送和调用内流资源。
按代理 ID 发送时要求代理存在且启用,失败不回退直连;不关联账号凭据版本,也不持有跨请求配置锁。业务流程中的出口选择由插件维护
- **观察合同统一**:`observer` 通过一个类型化 RPC 入口接收完成与实际上游 WebSocket 事件,发布时按事件类型编译独立作用域。完成事件直接携带终态、用量、费用和耗时,不按能力声明裁剪字段;异步投递的并发、队列和载荷有界,观察失败不改变业务结果
- **维护只作用于已发布实例**:`maintenance` 由 Host 监督的 Runtime worker 调用,启用、恢复、配置发布和周期补偿共用对账入口。
发布通知只用于唤醒,事实始终来自当前快照与数据库;按发布层复用的实例身份保留在途任务,无关实例变更不重启维护。同实例串行,通知合并;自身停用、替换或恢复时取消旧任务,等取消确认或进程退出后启动替代任务。
@@ -505,6 +507,7 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
OpenAI OAuth 的连接、续接状态、Guardian 路由 Cookie 和头像缓存按本地账号、上游用户与工作区隔离,同号令牌续期不改变归属。
OpenAI Provider 统一处理 Responses 的账号归属与 turn state 隔离,HTTP 与 WS 消费同一出站请求。
全量请求缺少本地归属记录时保留客户端回传的路由 token,明确换号或可信续接状态不属于同一轮时清除;
turn state 不参与选号,也不授权其他账号状态或 response ID 的复用
@@ -839,7 +842,9 @@ Core 的 `concurrency` 拥有中立的有界等待位置、优先级与同级 FI
运行并发仍由 Redis 原子准入与账号租约裁决,等待队列只保存当前进程中的等待位置,不复制运行计数,
也不持久化正文或在重启后重放请求。每个等待 owner 最多容纳 1,024 个等待者作为资源兜底
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放,异常退出后随 TTL 回收;管理端只对普通池做有界分页读取
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放;管理端只对普通池做有界分页读取
Store 监督本进程普通池与审批池的账号租约释放任务,停机时拒绝新申请、取消持有请求并停止续期,在 2 秒预算内完成已发出申请的登记及租约释放,不等待模型生成结束,也不清理其他实例的租约。异常退出或清理失败时仍由 TTL 回收
新请求不能越过同级或更高优先级的等待者,取消或完成等待通过 Drop 回收位置并立即唤醒后继
队首每 100ms 重查容量,需要复核会话绑定或跨账号容量的非队首每 500ms 重查,其余非队首等待晋升通知
@@ -1026,6 +1031,11 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护;
空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料
OpenAI 响应头的额度窗口按头名前缀归属,普通 Responses 的 `x-codex-primary-*` 和
`x-codex-secondary-*` 更新账号通用额度。
仅在图片生成和编辑端点,若 `x-codex-active-limit` 指向具名计量项且没有该项的独立额度头,
通用窗口才归入该计量项。Provider 按计量项独立保存,具名额度不改变账号级套餐访问结论
OpenAI Provider 统一解释点数可用性,主动查询、耗尽恢复与失败回读复用同一规则。
Store 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。
放行条件与默认值见[账号额度 API](api.md#查询与账号字段)。
@@ -1294,9 +1304,9 @@ Host 在受理时持久化任务并转交后台执行,任务持有操作锁与
二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚
通道是单次查询或安装的参数,不持久化偏好,默认按运行版本推导。
Host 按固定 tag `update-index` 读取公开快照版本,只从该版本的 `version.yaml` 附件选择候选。发布工作流在仓库级发布锁内合并通道,校验公开 Release 及附件后才推进指针;待发布记录用于中断恢复,不参与客户端选版。
实例复用同版本快照,按精确 tag 缓存至多五份 Release 详情。版本接口只读本地状态,摘要与详情都须核对更高候选的发行身份、平台包和校验文件。
查询合并并发读取,附件和 API 各自限流;单轮元数据请求含排队最多 20 秒,索引附件限 128 KiB,Release 限 4 MiB。
Host 从更新仓库 `main:release/version.yaml` 的 `published` 选择候选。发布工作流持有仓库级发布锁,校验公开 Release 和平台附件后,按当前文件 SHA 合并本次版本对应的通道,不覆盖构建版本或回退已发布候选
实例共享通道表,按精确 tag 缓存至多五份 Release 详情。版本接口只读本地状态,摘要与详情都须核对更高候选的发行身份、平台包和校验文件。
查询合并并发读取,原始文件和 API 各自限流;单轮元数据请求含排队最多 20 秒,通道表限 128 KiB,Release 限 4 MiB。
安装在下载前固定通道、目标和校验值,安装包是最后一项远端资源;校验、替换和重启只依赖本地发行文件。
Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本
+1 -1
View File
@@ -78,7 +78,7 @@ flowchart LR
| 功能 | 使用方式 |
| --- | --- |
| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 |
| 管理页面 | 从插件详情或左侧插件导航打开;插件请求打开外链时,核对目标域名后可打开、复制或取消 |
| 自动维护 | 启用、重启及账号等配置变化后自动对账;失败重试,周期补漏 |
| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE |
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
+36 -15
View File
@@ -12,7 +12,7 @@
</div>
在管理端「主题」中调整颜色、字号、圆角和组件样式,保存后应用到当前浏览器。
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择预置主题即可
设置保存在浏览器本地,不会同步到服务器或其他设备。只想换配色时,选择品牌色预置即可
下文说明实现和扩展方式。颜色使用 Ant Design 十阶色板与项目的明暗角色规则,
中性表面从背景和文字 Seed 派生;Vue 组件通过 CSS Variables 与 Tailwind CSS 4 使用这些值。
@@ -138,6 +138,7 @@ Seed 是用户配置与持久化的最小事实:
```ts
type ThemeMode = 'system' | 'light' | 'dark'
type ThemePresetId = 'default' | 'soft'
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
interface ThemeCustomization {
@@ -157,7 +158,11 @@ interface ThemeCustomization {
表格行高与卡片圆角通过 `customization.component` 的 `tableRowHeight`、`cardBorderRadius` 配置,属于组件尺寸覆盖
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
完整主题预设组合品牌配色、推荐材质、全局 Seed、组件尺寸和可编辑 Token。选择时替换草稿配置,保留当前明暗模式;保存后应用到管理端
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。材质只影响表面高光、渐变与阴影,不改变配色、圆角、字号或组件尺寸
持久化键为 `codex-proxy-rs-theme`,`themePreset` 缺省为 `default`。Store 不保存完整色板或 CSS Variables,损坏的模式、预设、颜色与自定义值会在
初始化时规范化并回退到默认配置
### Map Token
@@ -201,7 +206,7 @@ Preset 的实心色使用 P6,普通 Container、Strong Container 与边界按
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
中继蓝使用稳定的浅色 / 深色中性基线;深海青、古风色与石墨预置额外提供各自的 `colorBgBase`、`colorTextBase`
星津夜航使用稳定的浅色 / 深色中性基线;水墨江汀、粉墙回廊与朱阙金瓦预置额外提供各自的 `colorBgBase`、`colorTextBase`
画像,自定义主色则只向通用中性基线注入少量色温。默认浅色采用中性锚点,默认深色使用 HSL 色调派生;带色温
主题按外观距离平滑过渡到背景和文字 Seed 的 RGB 混色结果。稳定锚点用于 Surface 与 Shadow,Component 从角色派生,
所有预置、自定义 Seed 和用户覆盖仍进入同一条算法,不在页面或组件中追加 HEX 特判。
@@ -242,14 +247,16 @@ Token 直接覆盖时不会自动重算同组件的其他状态;需要保持
| Button | `--z-button-primary-color / bg / hover-bg / active-bg`、`--z-button-secondary-bg / hover-bg / active-bg` |
| IconButton | `--z-icon-button-secondary-bg / hover-bg / active-bg` |
| Input | `--z-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
| Menu | `--z-menu-item-selected-bg` |
| Popover | `--z-popover-header-bg` |
| Menu | `--z-menu-item-selected-bg / bg-image / shadow` |
| Popover | `--z-popover-header-bg`、`--z-popover-shadow` |
| Table | `--z-table-row-bg / stripe-bg / hover-bg / selected-bg / selected-hover-bg / height` |
| Card | `--z-card-bg / border-radius / shadow` |
| Layout | `--z-layout-sider-bg / shadow` |
| Card | `--z-card-bg / bg-image / border-radius / shadow` |
| Layout | `--z-layout-sider-bg / bg-image / border-radius / shadow` |
| Scrollbar | `--z-scrollbar-thumb-bg / hover-bg` |
| BrandMark | `--z-brand-mark-bg` |
管理端侧栏由 UI 库的 `ZSidebar` 提供容器、折叠展示、两级导航与选中状态。应用提供菜单数据、品牌和底部操作插槽,在 `select` 中执行路由或插件导航;移动端遮罩与模态焦点由宿主布局持有
组件状态由 UI 库的 Component 派生层统一生成,页面不维护混色系数:
| 角色 | 状态与可读性约束 |
@@ -279,14 +286,23 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
## 预置主题
| ID | 名称 | 主色 | 交互方向 |
| --- | --- | --- | --- |
| `relay-blue` | 中继蓝 | `#5983F4` | 清晰可信的冷蓝强调 |
| `deep-teal` | 深海青 | `#0E7C72` | 冷静低饱和的青色强调 |
| `signal-violet` | 古风色 | `#A0583D` | 温厚克制的赭色强调 |
| `graphite` | 石墨 | `#525B66` | 近单色的灰色强调 |
### 表面材质
预置主题改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。
| ID | 名称 | 推荐品牌色 | 表面 |
| --- | --- | --- | --- |
| `default` | 默认 | 星津夜航 | 简洁表面与轻量阴影 |
| `soft` | 柔光 | 星津夜航 | 表面高光与分层阴影,配色跟随品牌 |
### 主题预设
| 配色 ID | 名称 | 主色 | 推荐材质与组件 |
| --- | --- | --- | --- |
| `relay-blue` | 星津夜航 | `#5983F4` | 默认材质,标准控件与表格 |
| `deep-teal` | 水墨江汀 | `#50605E` | 默认材质,6px 卡片圆角与 60px 表格行高 |
| `signal-violet` | 粉墙回廊 | `#D395A8` | 默认材质,14px 正文与 68px 表格行高 |
| `graphite` | 朱阙金瓦 | `#A34537` | 柔光,12px 卡片圆角与 54px 表格行高 |
品牌配色改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。完整预设提供的 Seed 优先于品牌基线,用户覆盖优先于预设
所有预置与自定义主题走同一条 Surface、Map、Alias 与 Component 派生链路,不存在页面级特判
## 运行时架构
@@ -349,7 +365,8 @@ UI 构建插件 `CodexProxyUI({ themePrefix: 'z' })` 同时配置变量生成与
| 区域 | 能力 |
| --- | --- |
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 预设 | 模式、完整主题预设、表面材质 |
| 全局 / 颜色 | 自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
| 全局 / 风格 | 通用圆角、阴影强度 |
| 组件 | Action、Form、Surface、Data Display、Navigation、Layout |
@@ -431,6 +448,10 @@ utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文
### 增加预置主题
在 UI 库的 `themeProfiles` 中组合配色 ID 对应的材质与 `ThemeCustomization`。设置页将其复制为可编辑草稿,保存实际配置,无需持久化额外的选中标记。材质仍由组件 Token 派生层实现
单独增加品牌配色:
1. 在 `ThemeColorPresetId` 增加稳定 ID
2. 在 `themeColorPresets` 声明名称、描述与主色 Seed
3. 不新增页面特判,确认预置可通过同一 `createTheme()` 派生
+7 -7
View File
@@ -17,23 +17,23 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.67.0",
"@lucide/vue": "^1.70.0",
"@vueuse/core": "^15.0.0",
"@vueuse/integrations": "^15.0.0",
"axios": "^1.21.0",
"circle-flags": "2.8.3",
"css-tree": "^3.2.1",
"echarts": "^6.1.0",
"es-toolkit": "^1.53.1",
"es-toolkit": "^1.53.2",
"gsap": "^3.15.1",
"pinia": "^4.1.0",
"pinia-plugin-persistedstate": "^4.7.1",
"semver": "^7.8.6",
"sortablejs": "^1.15.7",
"vue": "^3.5.44",
"vue": "^3.5.45",
"vue-i18n": "11.4.18",
"vue-router": "^5.4.0"
},
@@ -41,7 +41,7 @@
"@antfu/eslint-config": "^10.0.0",
"@intlify/eslint-plugin-vue-i18n": "4.5.1",
"@intlify/unplugin-vue-i18n": "11.2.8",
"@tailwindcss/vite": "^4.3.4",
"@tailwindcss/vite": "^4.3.5",
"@types/css-tree": "^3.2.0",
"@types/node": "^26.6.6",
"@types/semver": "^7.8.0",
@@ -51,9 +51,9 @@
"eslint": "^10.14.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.3",
"tailwindcss": "^4.3.4",
"tailwindcss": "^4.3.5",
"typescript": "^6.0.3",
"vite": "^8.3.5",
"vite": "^8.3.6",
"vue-tsc": "3.3.14"
}
}
+337 -256
View File
File diff suppressed because it is too large Load Diff
@@ -43,7 +43,6 @@ const groupMarks = computed(() =>
:aria-label="group.label"
:aria-expanded="open"
:aria-describedby="open ? id : undefined"
:title="undefined"
>
<span class="size-4 rounded-sm" :style="{ backgroundColor: group.color }" aria-hidden="true" />
</ZIconButton>
@@ -45,7 +45,6 @@ function select() {
target="_blank"
rel="noopener noreferrer"
:aria-label="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
:title="t('keys.configuration.viewOnGitHub', { name: 'CC Switch' })"
class="inline-grid size-9 shrink-0 place-items-center rounded-z-sm text-z-text-tertiary outline-none transition-colors hover:text-z-text focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-inset motion-reduce:transition-none"
>
<Github class="size-6" aria-hidden="true" />

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