Compare commits

...
22 Commits
Author SHA1 Message Date
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
129 changed files with 2034 additions and 1503 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
View File
@@ -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
+8 -20
View File
@@ -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
@@ -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 -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#镜像升级与源码构建),不要覆盖原配置
@@ -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>,
}
@@ -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",
@@ -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:?}"),
}
}
@@ -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))
@@ -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;
@@ -692,7 +692,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` 接口:
@@ -328,7 +328,7 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
limit_ids.insert(active_limit.clone());
}
limit_ids.insert("codex".to_string());
let limits = limit_ids
let mut limits = limit_ids
.into_iter()
.filter_map(|limit_id| {
let prefix = format!("x-{}", limit_id.replace('_', "-"));
@@ -336,6 +336,16 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
.map(|details| (limit_id, details))
})
.collect::<BTreeMap<_, _>>();
// 生图等响应只通过 active-limit 标明通用 x-codex 窗口的归属
// 有独立具名头时保留各桶原始事实,避免把真正的 Codex 窗口一起迁移
if let Some(active_limit) = active_limit.as_ref()
&& active_limit != "codex"
&& !limits.contains_key(active_limit)
&& let Some(mut details) = limits.remove("codex")
{
details.limit_id.clone_from(active_limit);
limits.insert(active_limit.clone(), details);
}
let credits = parse_credits_from_lookup(&normalized);
let plan_type = lookup_non_empty(&normalized, "x-codex-plan-type");
let promo_message = lookup_non_empty(&normalized, "x-codex-promo-message");
@@ -540,6 +540,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 +557,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 +587,38 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
);
}
#[test]
fn parse_rate_limit_headers_should_assign_generic_windows_to_the_active_limit() {
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_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"));
assert_eq!(
parse_rate_limit_headers(&rate_limits_to_header_pairs(&parsed)),
Some(parsed)
);
}
#[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
@@ -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(),
@@ -1164,7 +1168,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);
}
}
}
@@ -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());
}
@@ -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(_)
@@ -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!(
"{}:{}",
@@ -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"] {
@@ -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 }"#;
@@ -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,
+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 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+11 -3
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 当前目录生成通用画像。推理菜单读取
@@ -1597,8 +1598,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 +2765,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` 数据保留释放原因、预取字节数与等待时间,不等同于下游已提交
@@ -3227,6 +3234,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)
+11 -4
View File
@@ -505,6 +505,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 +840,9 @@ Core 的 `concurrency` 拥有中立的有界等待位置、优先级与同级 FI
运行并发仍由 Redis 原子准入与账号租约裁决,等待队列只保存当前进程中的等待位置,不复制运行计数,
也不持久化正文或在重启后重放请求。每个等待 owner 最多容纳 1,024 个等待者作为资源兜底
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放,异常退出后随 TTL 回收;管理端只对普通池做有界分页读取
账号并发明细由 Redis 租约关联的最小入口事实投影,不读取请求历史。事实与租约原子创建,按 fencing 校验续期和释放;管理端只对普通池做有界分页读取
Store 监督本进程普通池与审批池的账号租约释放任务,停机时拒绝新申请、取消持有请求并停止续期,在 2 秒预算内完成已发出申请的登记及租约释放,不等待模型生成结束,也不清理其他实例的租约。异常退出或清理失败时仍由 TTL 回收
新请求不能越过同级或更高优先级的等待者,取消或完成等待通过 Drop 回收位置并立即唤醒后继
队首每 100ms 重查容量,需要复核会话绑定或跨账号容量的非队首每 500ms 重查,其余非队首等待晋升通知
@@ -1026,6 +1029,10 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护;
空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料
OpenAI 响应头的额度窗口由 `gateway-protocol` 按上游计量项归属解析;仅通过 `x-codex-active-limit`
标明具名额度时,通用 `x-codex-*` 窗口归入该计量项。Provider 按计量项独立保存,生图等具名额度
不覆盖默认 Codex 窗口,也不改变账号级套餐访问结论
OpenAI Provider 统一解释点数可用性,主动查询、耗尽恢复与失败回读复用同一规则。
Store 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。
放行条件与默认值见[账号额度 API](api.md#查询与账号字段)。
@@ -1294,9 +1301,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()` 派生
+1 -1
View File
@@ -17,7 +17,7 @@
},
"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.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.67.0",
+6 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.8.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
specifier: github:zyycn/codex-proxy-ui#v0.9.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -443,9 +443,9 @@ packages:
resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249':
resolution: {gitHosted: true, integrity: sha512-XNPErDXqq9OxvgLgSb0gVRGmI9Yf+Vx0dTjoZKzaAaCOX9exSirU5kEr9mJBeuo9E1DDw8817xV0B5Od9zH+5A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249}
version: 0.8.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86':
resolution: {gitHosted: true, integrity: sha512-RfPWnA41PRi4Q9gBPXK4jyik+9XZDLKaRMF12/Y4au6wQd5+7C+fIBVc6iROQFSxTW43EmEl8Gsx3tbNXE0w/Q==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86}
version: 0.9.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -3487,7 +3487,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/d6100447010715c1203e67a4195fc6d875ff6249(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/61ceb0c0130463510fb922a554a398dee6b71a86(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))(yaml@2.9.0)':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
@@ -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" />
@@ -15,7 +15,6 @@ const { t } = useI18n({ useScope: 'global' })
<ZIconButton
size="default"
:aria-label="loading ? t('common.location.resolving') : t('common.location.detect')"
:title="loading ? t('common.location.resolving') : t('common.location.detect')"
:aria-busy="loading"
:disabled="disabled || loading"
@click="emit('detect')"
@@ -146,7 +146,7 @@ defineExpose({ focusField })
<template v-if="field.description" #label-extra>
<ZPopover trigger="hover-click" placement="top-start">
<template #reference>
<ZIconButton variant="link" :aria-label="t('common.form.fieldHelp', { label: field.label })" class="size-3.5!" :title="undefined">
<ZIconButton variant="link" :aria-label="t('common.form.fieldHelp', { label: field.label })" class="size-3.5!">
<Info class="size-3.5" />
</ZIconButton>
</template>
+1 -2
View File
@@ -145,8 +145,7 @@
},
"capacity": {
"concurrentRequestsLimit": "Concurrent requests: {value}, limit: {value2}",
"viewConcurrencyDetails": "{label}, view concurrency details",
"viewDetails": "{label}, view details"
"viewConcurrencyDetails": "{label}, view concurrency details"
},
"concurrency": {
"title": "Concurrency details",
+10 -4
View File
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
"themePresets": {
"material": "Surface material",
"title": "Theme preset",
"default": {
"label": "Default"
},
"soft": {
"label": "Soft glow"
}
},
"settings": {
"global": "Global",
"components": "Components",
@@ -177,7 +187,6 @@
"scrollBarSlider": "Scroll bar slider",
"scrollBarHover": "Scroll bar Hover",
"componentTokenComponentFamily": "Component token family",
"description": "Adjust component tokens for {label}, unchanged values continue to use the global seed and alias algorithms",
"noMatchingComponentToken": "No matching component tokens",
"kindLabel": "Component tokens"
},
@@ -204,8 +213,6 @@
"themeMode": "Theme mode",
"globalTokenClassification": "Global token categories",
"brandColorLabel": "Preset theme · Brand color · colorPrimary",
"brandColor": "Brand color",
"presetThemes": "Preset themes",
"brandMainColor": "Brand main color",
"paletteDescription": "Color scales and overall appearance",
"semanticColorsTitle": "Semantic and neutral colors",
@@ -215,7 +222,6 @@
"basicSpacing": "Basic spacing",
"sizeStep": "Size step",
"styleToken": "Style tokens",
"surfaceDescription": "Borderless surfaces, rounded corners, and layered shadows",
"universalFillet": "Universal fillet",
"shadowIntensity": "Shadow intensity"
}
@@ -16,7 +16,6 @@
"switchLanguage": "Switch language ({value})",
"languageLoadFailed": "Unable to load language, reload to try again",
"mainLabel": "Main navigation",
"pluginsLabel": "Plugin navigation",
"pluginManagement": "Manage",
"online": "Online",
"sidebar": {
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "Model response read failed",
"duplicateManagementRequest": "Duplicate plugin management request ID",
"managementConcurrencyLimit": "Plugin management concurrency limit reached",
"openExternalLink": "Open external link",
"openLink": "Open",
"copyLink": "Copy link",
"externalLinkPending": "An external link is already awaiting confirmation",
"externalLinkRequiresClick": "Click a link or button on the plugin page",
"invalidExternalLink": "Use an absolute HTTPS URL without a username or password",
"invalidEntryPoint": "Plugin page entry point is not a registered HTML resource",
"unregisteredAsset": "Plugin page references an unregistered resource: {path}",
"htmlSubresource": "Plugin HTML resources cannot be loaded as subresources",
+1 -1
View File
@@ -629,7 +629,7 @@
"access": "Security",
"backups": "Backups"
},
"description": "Manage scheduling, upstreams, pricing, security, history retention, and backups",
"description": "Manage scheduling, upstreams, pricing, security, retention and backups",
"sectionsLabel": "Settings sections",
"undoAllBasicSettingsChanges": "Undo all basic settings changes",
"unsavedChanges": {
@@ -145,8 +145,7 @@
},
"capacity": {
"concurrentRequestsLimit": "账号并发占用 {value},上限 {value2}",
"viewConcurrencyDetails": "{label},查看并发明细",
"viewDetails": "{label},查看明细"
"viewConcurrencyDetails": "{label},查看并发明细"
},
"concurrency": {
"title": "并发明细",
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "切换浅色模式",
"switchToDark": "切换暗黑模式",
"themePresets": {
"material": "表面材质",
"title": "主题预设",
"default": {
"label": "默认"
},
"soft": {
"label": "柔光"
}
},
"settings": {
"global": "全局",
"components": "组件",
@@ -177,7 +187,6 @@
"scrollBarSlider": "滚动条滑块",
"scrollBarHover": "滚动条 Hover",
"componentTokenComponentFamily": "Component Token 组件族",
"description": "调整 {label} 的 Component Token,未修改的值继续由全局 Seed / Alias 算法生成",
"noMatchingComponentToken": "没有匹配的 Component Token",
"kindLabel": "组件 Token"
},
@@ -204,8 +213,6 @@
"themeMode": "主题模式",
"globalTokenClassification": "全局 Token 分类",
"brandColorLabel": "预置主题 品牌色 colorPrimary",
"brandColor": "品牌色",
"presetThemes": "预置主题",
"brandMainColor": "品牌主色",
"paletteDescription": "完整色阶与界面基调",
"semanticColorsTitle": "功能色与中性色",
@@ -215,7 +222,6 @@
"basicSpacing": "基础间距",
"sizeStep": "尺寸步长",
"styleToken": "风格 Token",
"surfaceDescription": "固定无边设计,圆角塑形,阴影分层",
"universalFillet": "通用圆角",
"shadowIntensity": "阴影强度"
}
@@ -16,7 +16,6 @@
"switchLanguage": "切换语言({value})",
"languageLoadFailed": "语言加载失败,请刷新重试",
"mainLabel": "主导航",
"pluginsLabel": "插件导航",
"pluginManagement": "插件管理",
"online": "在线",
"sidebar": {
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型响应读取失败",
"duplicateManagementRequest": "插件管理请求序号重复",
"managementConcurrencyLimit": "插件管理请求已达并发上限",
"openExternalLink": "打开外部链接",
"openLink": "打开",
"copyLink": "复制链接",
"externalLinkPending": "请先处理当前外部链接",
"externalLinkRequiresClick": "请在插件页面中点击链接或按钮",
"invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址",
"invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源",
"unregisteredAsset": "插件页面引用了未注册资源:{path}",
"htmlSubresource": "插件 HTML 资源不能作为子资源加载",
@@ -629,7 +629,7 @@
"access": "安全与访问",
"backups": "数据备份"
},
"description": "配置网关调度、上游与定价,管理访问安全、历史保留与备份",
"description": "管理调度、上游、定价、安全、数据留存与备份",
"sectionsLabel": "设置分区",
"undoAllBasicSettingsChanges": "撤销全部基础设置更改",
"unsavedChanges": {
@@ -145,8 +145,7 @@
},
"capacity": {
"concurrentRequestsLimit": "賬號併發佔用 {value},上限 {value2}",
"viewConcurrencyDetails": "{label},查看併發明細",
"viewDetails": "{label},查看明細"
"viewConcurrencyDetails": "{label},查看併發明細"
},
"concurrency": {
"title": "併發明細",
@@ -2,6 +2,16 @@
"appearance": {
"switchToLight": "切換淺色模式",
"switchToDark": "切換暗黑模式",
"themePresets": {
"material": "表面材質",
"title": "主題預設",
"default": {
"label": "預設"
},
"soft": {
"label": "柔光"
}
},
"settings": {
"global": "全局",
"components": "組件",
@@ -177,7 +187,6 @@
"scrollBarSlider": "滾動條滑塊",
"scrollBarHover": "滾動條 Hover",
"componentTokenComponentFamily": "Component Token 組件族",
"description": "調整 {label} 的 Component Token,未修改的值繼續由全局 Seed / Alias 算法生成",
"noMatchingComponentToken": "沒有匹配的 Component Token",
"kindLabel": "組件 Token"
},
@@ -204,8 +213,6 @@
"themeMode": "主題模式",
"globalTokenClassification": "全局 Token 分類",
"brandColorLabel": "預置主題 品牌色 colorPrimary",
"brandColor": "品牌色",
"presetThemes": "預置主題",
"brandMainColor": "品牌主色",
"paletteDescription": "完整色階與界面基調",
"semanticColorsTitle": "功能色與中性色",
@@ -215,7 +222,6 @@
"basicSpacing": "基礎間距",
"sizeStep": "尺寸步長",
"styleToken": "風格 Token",
"surfaceDescription": "固定無邊設計,圓角塑形,陰影分層",
"universalFillet": "通用圓角",
"shadowIntensity": "陰影強度"
}
@@ -16,7 +16,6 @@
"switchLanguage": "切換語言({value})",
"languageLoadFailed": "語言加載失敗,請刷新重試",
"mainLabel": "主導航",
"pluginsLabel": "插件導航",
"pluginManagement": "插件管理",
"online": "在線",
"sidebar": {
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型響應讀取失敗",
"duplicateManagementRequest": "插件管理請求序號重複",
"managementConcurrencyLimit": "插件管理請求已達併發上限",
"openExternalLink": "開啟外部連結",
"openLink": "開啟",
"copyLink": "複製連結",
"externalLinkPending": "請先處理目前的外部連結",
"externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕",
"invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址",
"invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源",
"unregisteredAsset": "插件頁面引用了未註冊資源:{path}",
"htmlSubresource": "插件 HTML 資源不能作爲子資源加載",
@@ -629,7 +629,7 @@
"access": "安全與訪問",
"backups": "數據備份"
},
"description": "配置網關調度、上游與定價,管理訪問安全、歷史保留與備份",
"description": "管理調度、上游、定價、安全、資料留存與備份",
"sectionsLabel": "設置分區",
"undoAllBasicSettingsChanges": "撤銷全部基礎設置更改",
"unsavedChanges": {
+141 -517
View File
@@ -1,14 +1,13 @@
<script setup lang="ts">
import type { SidebarItem } from '@codex-proxy/ui'
import type { RouteLocationRaw } from 'vue-router'
import type { PluginManagementView } from '@/api'
import { ZIconButton, ZMotionIcon, ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { ZIconButton, ZMotionIcon, ZSidebar } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Blocks,
BringToFront,
ChartNoAxesColumn,
ChevronDown,
FolderTree,
KeyRound,
LayoutDashboard,
@@ -23,13 +22,10 @@ import {
Sun,
Users,
} from '@lucide/vue'
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { gsap } from 'gsap'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { isNavigationFailure, NavigationFailureType, useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
import { useAuthStore } from '@/stores/modules/auth'
@@ -38,17 +34,13 @@ import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useThemeStore } from '@/stores/modules/theme'
import { pluginPageLocation, shortPluginInstanceId } from '@/utils/plugin'
const props = withDefaults(
defineProps<{
collapsed?: boolean
mobile?: boolean
}>(),
{
collapsed: false,
mobile: false,
},
)
const props = withDefaults(defineProps<{
collapse?: boolean
mobile?: boolean
}>(), {
collapse: false,
mobile: false,
})
const emit = defineEmits<{
close: []
navigate: []
@@ -57,7 +49,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n({ useScope: 'global' })
const sidebar = useTemplateRef<InstanceType<typeof ZSidebar>>('sidebar')
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -68,137 +60,87 @@ const { pageViews: pluginPageViews } = storeToRefs(pluginViewsStore)
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
const pluginMenuId = useId()
const pluginGroupExpanded = shallowRef(route.path.startsWith('/plugins'))
const isCollapsed = computed(() => !props.mobile && props.collapse)
const navItems = computed(() => ([
{
label: t('navigation.overview'),
icon: LayoutDashboard,
path: '/',
},
{
label: t('navigation.accounts'),
icon: Users,
path: '/accounts',
},
{
label: t('navigation.proxies'),
icon: Network,
path: '/proxies',
},
{
label: t('navigation.groups'),
icon: FolderTree,
path: '/groups',
},
{
label: t('navigation.apiKeys'),
icon: KeyRound,
path: '/keys',
},
{
label: t('navigation.usage'),
icon: ChartNoAxesColumn,
path: '/usage',
},
{
label: t('navigation.plugins'),
icon: Puzzle,
path: '/plugins',
},
{
label: t('navigation.appearance'),
icon: Palette,
path: '/theme',
},
{
label: t('navigation.settings'),
icon: Settings,
path: '/settings',
},
]))
const pluginNavIndex = navItems.value.findIndex(item => item.path === '/plugins')
const pluginMenuVisible = computed(() => pluginGroupExpanded.value && !isCollapsed.value)
const pluginViewNameCounts = computed(() => pluginPageViews.value.reduce((counts, view) => {
const title = view.pages[0]?.title ?? view.name
counts.set(title, (counts.get(title) ?? 0) + 1)
return counts
}, new Map<string, number>()))
const pluginChildrenHeight = computed(() => pluginMenuVisible.value
? (pluginPageViews.value.length + 1) * 44
: 0)
function isActive(path: string) {
if (path === '/')
return route.path === '/'
return route.path.startsWith(path)
}
const activeNavIndex = computed(() => {
const index = navItems.value.findIndex(item => isActive(item.path))
return Math.max(0, index)
})
const activePluginIndex = computed(() => route.name === 'plugins'
? 0
: pluginPageViews.value.findIndex(isPluginViewActive) + 1)
const activeNavIndicatorStyle = computed(() => {
const inPluginMenu = isActive('/plugins') && pluginMenuVisible.value
const offset = inPluginMenu
? 50 + activePluginIndex.value * 44
: activeNavIndex.value > pluginNavIndex
? pluginChildrenHeight.value
: 0
// 主菜单与子菜单共享选中框,位置和尺寸在同一次过渡中变化
return {
transform: `translate3d(${inPluginMenu ? 24 : 0}px, ${activeNavIndex.value * 58 + offset}px, 0)`,
width: inPluginMenu ? 'calc(100% - 24px)' : '100%',
height: inPluginMenu ? '40px' : '46px',
}
})
const navFeedbackMuted = shallowRef(false)
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
() => {
navFeedbackMuted.value = false
const navItems = computed<SidebarItem[]>(() => [
{
index: '/',
label: t('navigation.overview'),
icon: LayoutDashboard,
},
300,
{ immediate: false },
)
function muteNavFeedbackDuringMove() {
navFeedbackMuted.value = true
stopNavFeedbackRestore()
restoreNavFeedback()
}
function navigate(path: string) {
muteNavFeedbackDuringMove()
void router.push(path)
emit('navigate')
}
function togglePluginGroup() {
if (isCollapsed.value) {
navigate('/plugins')
return
{
index: '/accounts',
label: t('navigation.accounts'),
icon: Users,
},
{
index: '/proxies',
label: t('navigation.proxies'),
icon: Network,
},
{
index: '/groups',
label: t('navigation.groups'),
icon: FolderTree,
},
{
index: '/keys',
label: t('navigation.apiKeys'),
icon: KeyRound,
},
{
index: '/usage',
label: t('navigation.usage'),
icon: ChartNoAxesColumn,
},
{
index: 'plugins',
label: t('navigation.plugins'),
icon: Puzzle,
children: [
{
index: '/plugins',
label: t('navigation.pluginManagement'),
icon: BringToFront,
},
...pluginPageViews.value.map(view => ({
index: `plugin:${view.target.instanceId}`,
label: pluginViewLabel(view),
icon: Blocks,
})),
],
},
{
index: '/theme',
label: t('navigation.appearance'),
icon: Palette,
},
{
index: '/settings',
label: t('navigation.settings'),
icon: Settings,
},
])
const activeIndex = computed(() => {
if (route.path.startsWith('/plugins')) {
if (route.name === 'plugins')
return '/plugins'
const view = pluginPageViews.value.find(isPluginViewActive)
return view
? `plugin:${view.target.instanceId}`
: 'plugins'
}
pluginGroupExpanded.value = !pluginGroupExpanded.value
}
function navigatePluginView(view: PluginManagementView) {
const page = view.pages[0]
if (page)
navigate(router.resolve(pluginPageLocation(view, page)).fullPath)
}
return navItems.value.find(item => item.index !== '/' && route.path.startsWith(item.index))?.index ?? '/'
})
function isPluginViewActive(view: PluginManagementView) {
const value = route.params.instanceId
const routeInstanceId = Array.isArray(value)
? value[0]
: value
return route.name === 'plugin-page' && routeInstanceId === view.target.instanceId
return route.name === 'plugin-page' && route.params.instanceId === view.target.instanceId
}
function pluginViewLabel(view: PluginManagementView) {
@@ -211,6 +153,24 @@ function pluginViewLabel(view: PluginManagementView) {
: view.name}`
}
async function handleSelect(index: string) {
// 路由守卫可能取消跳转,菜单保持当前页,直到实际路由改变
sidebar.value?.updateActiveIndex(activeIndex.value)
let target: RouteLocationRaw = index === 'plugins'
? '/plugins'
: index
if (index.startsWith('plugin:')) {
const view = pluginPageViews.value.find(item => `plugin:${item.target.instanceId}` === index)
const page = view?.pages[0]
if (!view || !page)
return
target = pluginPageLocation(view, page)
}
const failure = await router.push(target)
if (!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
emit('navigate')
}
function openSystemUpdate() {
emit('openSystemUpdate')
}
@@ -222,401 +182,65 @@ async function handleLogout() {
emit('navigate')
}
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const collapsedSidebarWidth = 88
const expandedSidebarWidth = 251
const sidebarWidth = computed(() => (isCollapsed.value ? collapsedSidebarWidth : expandedSidebarWidth))
const brandLabelVisible = shallowRef(!isCollapsed.value)
const themeToggleLabel = computed(() => (effectiveTheme.value === 'dark'
const themeToggleLabel = computed(() => effectiveTheme.value === 'dark'
? t('appearance.switchToLight')
: t('appearance.switchToDark')))
: t('appearance.switchToDark'))
const versionText = computed(() => version.value?.version.trim() ?? '')
const hasVersionLabel = computed(() => versionText.value.length > 0)
const versionLabel = computed(() => `v${versionText.value}`)
const updateButtonLabel = computed(() => (hasUpdate.value
const updateButtonLabel = computed(() => hasUpdate.value
? t('navigation.update.available')
: t('navigation.update.open')))
function prefersReducedMotion() {
return preferredMotion.value === 'reduce'
}
function animateSidebarLabels(collapsed: boolean) {
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (!labels?.length) {
return
}
if (prefersReducedMotion()) {
gsap.set(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
})
return
}
gsap.to(labels, {
opacity: collapsed ? 0 : 1,
x: collapsed
? -6
: 0,
duration: collapsed ? 0.16 : 0.2,
ease: collapsed ? 'power2.in' : 'power3.out',
stagger: collapsed ? 0 : 0.018,
overwrite: true,
})
}
function hideBrandLabel() {
const label = brandLabelEl.value
if (label) {
gsap.killTweensOf(label)
gsap.set(label, {
opacity: 0,
x: -6,
})
}
brandLabelVisible.value = false
}
function animateBrandLabelEnter() {
const label = brandLabelEl.value
if (!label) {
return
}
if (prefersReducedMotion()) {
gsap.set(label, {
opacity: 1,
x: 0,
})
return
}
gsap.fromTo(
label,
{
opacity: 0,
x: -6,
},
{
opacity: 1,
x: 0,
duration: 0.2,
ease: 'power3.out',
overwrite: true,
},
)
}
function animateSidebarWidth(collapsed: boolean) {
if (!sidebarEl.value) {
return
}
const targetWidth = collapsed ? collapsedSidebarWidth : expandedSidebarWidth
const currentWidth = sidebarEl.value.getBoundingClientRect().width
if (prefersReducedMotion()) {
gsap.set(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
})
return
}
gsap.set(sidebarEl.value, {
width: currentWidth,
flexBasis: currentWidth,
})
gsap.to(sidebarEl.value, {
width: targetWidth,
flexBasis: targetWidth,
duration: 0.34,
ease: 'power3.out',
overwrite: true,
})
}
function animateNavSignal() {
const signal = navSignalEl.value
if (!signal)
return
gsap.killTweensOf(signal)
if (prefersReducedMotion()) {
gsap.set(signal, { opacity: 0, xPercent: -70 })
return
}
gsap.fromTo(
signal,
{ opacity: 0.48, xPercent: -70 },
{
opacity: 0,
xPercent: 120,
duration: 0.52,
ease: 'power2.out',
overwrite: true,
},
)
}
: t('navigation.update.open'))
onMounted(() => {
void pluginViewsStore.ensureLoaded().catch(() => undefined)
gsap.set(sidebarEl.value, {
width: sidebarWidth.value,
flexBasis: sidebarWidth.value,
})
if (isCollapsed.value) {
hideBrandLabel()
}
else {
gsap.set(brandLabelEl.value, {
opacity: 1,
x: 0,
})
}
animateSidebarLabels(isCollapsed.value)
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
})
watch(
() => isCollapsed.value,
async (collapsed) => {
if (collapsed) {
hideBrandLabel()
}
else {
brandLabelVisible.value = true
}
animateSidebarLabels(Boolean(collapsed))
animateSidebarWidth(Boolean(collapsed))
await nextTick()
if (!collapsed) {
animateBrandLabelEnter()
}
},
)
watch(
() => route.path,
async (path, previousPath) => {
if (path === previousPath)
return
if (path.startsWith('/plugins'))
pluginGroupExpanded.value = true
muteNavFeedbackDuringMove()
await nextTick()
animateNavSignal()
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
stopNavFeedbackRestore()
const targets = [
sidebarEl.value,
brandLabelEl.value,
navSignalEl.value,
].filter((target): target is HTMLElement =>
Boolean(target),
)
gsap.killTweensOf(targets)
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (labels?.length) {
gsap.killTweensOf(labels)
}
})
</script>
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--z-layout-sider-bg) shadow-z-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
]"
<ZSidebar
ref="sidebar"
class="z-20"
:class="mobile ? 'flex' : 'hidden min-[961px]:flex'"
:items="navItems"
:default-active="activeIndex"
:collapse="isCollapsed"
:navigation-label="t('navigation.mainLabel')"
@select="handleSelect"
>
<div
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
>
<template #brand-icon>
<ZMotionIcon
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-z"
>
<AppBrandMark class="block size-11 select-none" />
</ZMotionIcon>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<strong class="text-base leading-[1.1] font-heavy text-z-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-z-text-secondary"> Rust build </span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-z-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
:class="[
hasUpdate
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
]"
:title="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
</button>
</span>
</template>
<template #brand>
<strong class="text-base leading-[1.1] font-heavy text-z-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-z-text-secondary"> Rust build </span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-z-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-z-bg-container"
:class="[
hasUpdate
? 'bg-z-success-container text-z-success-on-container hover:bg-z-success-container-hover'
: 'bg-z-fill-quaternary text-z-text-quaternary hover:bg-z-fill-tertiary hover:text-z-text-secondary',
]"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-z-success" />
</button>
</span>
</div>
<ZScrollbar class="my-6 w-full flex-1">
<div class="px-4">
<!-- 焦点环绘制在按钮内侧,避免被滚动区和折叠菜单裁切 -->
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" :aria-label="t('navigation.mainLabel')">
<span
class="pointer-events-none absolute left-0 top-0 overflow-hidden rounded-z bg-z-menu-item-selected-bg transition-[transform,width,height] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span
ref="navSignal"
class="absolute inset-y-0 left-0 w-2/3 [background:linear-gradient(90deg,transparent,color-mix(in_srgb,var(--z-color-info)_9%,transparent),transparent)]"
/>
</span>
<template v-for="item in navItems" :key="item.label">
<div v-if="item.path === '/plugins'" class="relative z-10 grid min-w-0">
<ZPopover trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="w-full">
<template #reference="{ close }">
<button
type="button"
class="inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 bg-transparent text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'font-bold text-z-text transition-none'
: 'font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'font-semibold text-z-text-secondary transition-none'
: 'font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-expanded="isCollapsed ? undefined : pluginGroupExpanded"
:aria-controls="isCollapsed ? undefined : pluginMenuId"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); togglePluginGroup()"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'hidden' : 'w-auto'"
>
{{ item.label }}
</span>
<span
v-if="!isCollapsed"
class="sidebar-label inline-flex size-4 shrink-0"
aria-hidden="true"
>
<ChevronDown
class="size-4 transition-transform duration-200 motion-reduce:transition-none"
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
/>
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
<div
:id="pluginMenuId"
class="grid transition-[grid-template-rows] duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="pluginMenuVisible ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:inert="!pluginMenuVisible"
:aria-label="t('navigation.pluginsLabel')"
>
<div class="min-h-0 overflow-hidden">
<div class="relative ml-3 grid gap-1 pt-1 pl-3">
<button
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="route.name === 'plugins'
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="route.name === 'plugins' ? 'page' : undefined"
@click="navigate('/plugins')"
>
<BringToFront class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ t('navigation.pluginManagement') }}</span>
</button>
<button
v-for="view in pluginPageViews"
:key="view.target.instanceId"
type="button"
class="relative inline-flex h-10 min-w-0 cursor-pointer items-center gap-2 rounded-z border-0 bg-transparent px-3 text-left text-z-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline motion-reduce:transition-none"
:class="isPluginViewActive(view)
? 'font-emphasis text-z-text'
: navFeedbackMuted ? 'font-semibold text-z-text-secondary' : 'font-semibold text-z-text-secondary hover:bg-z-fill-quaternary hover:text-z-text'"
:aria-current="isPluginViewActive(view) ? 'page' : undefined"
@click="navigatePluginView(view)"
>
<Blocks class="block size-4 shrink-0" />
<span class="sidebar-label truncate leading-none">{{ pluginViewLabel(view) }}</span>
</button>
</div>
</div>
</div>
</div>
<ZPopover v-else trigger="hover" placement="right" role="tooltip" :show-after="800" :disabled="!isCollapsed" :enterable="false" class="relative z-10 w-full">
<template #reference="{ close }">
<button
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-z border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-z-control-outline"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-z-text transition-none'
: 'bg-transparent font-bold text-z-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-z-text-secondary transition-none'
: 'bg-transparent font-semibold text-z-text-secondary transition-colors duration-200 hover:bg-z-fill-quaternary hover:text-z-text',
]"
:aria-label="isCollapsed ? item.label : undefined"
@click="close(); navigate(item.path)"
>
<component :is="item.icon" class="shrink-0" :size="20" />
<span
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
>
{{ item.label }}
</span>
</button>
</template>
<span class="block px-3 py-2 text-z-sm whitespace-nowrap">{{ item.label }}</span>
</ZPopover>
</template>
</nav>
</div>
</ZScrollbar>
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
</template>
<template #footer>
<div
class="bg-z-fill-quaternary"
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
:class="isCollapsed ? 'grid gap-1 rounded-z p-1' : 'grid min-h-11 grid-cols-[4.25rem_auto] items-center justify-between rounded-z-lg px-2'"
>
<span
v-if="!isCollapsed"
@@ -626,7 +250,7 @@ onBeforeUnmount(() => {
{{ t('navigation.online') }}
</span>
<div class="flex shrink-0 items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<div :class="isCollapsed ? 'grid justify-items-center gap-1' : 'flex shrink-0 items-center gap-1 [&_button]:max-w-8'">
<ZIconButton
v-if="isCollapsed && hasUpdate"
type="success" variant="plain"
@@ -674,6 +298,6 @@ onBeforeUnmount(() => {
</ZIconButton>
</div>
</div>
</div>
</aside>
</template>
</ZSidebar>
</template>
@@ -318,7 +318,7 @@ watch(
class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3.5 gap-y-3 rounded-z bg-z-warning-container px-4 py-5 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
>
<span class="grid size-9 place-items-center rounded-z bg-z-warning-container-active text-z-warning-on-container" aria-hidden="true">
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-[18px]" />
<component :is="updateInfo.retryAt ? Hourglass : TriangleAlert" class="size-4.5" />
</span>
<p class="m-0 min-w-0 wrap-anywhere text-z font-bold leading-tight text-z-warning-on-container">
{{ updateInfo.warning }}
@@ -393,7 +393,7 @@ watch(
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-z-fill-quaternary px-2 text-z-xs leading-none font-bold text-z-text-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
:title="updateStreamError || undefined"
>
<i
class="size-1.5 rounded-full"
+3 -2
View File
@@ -69,14 +69,15 @@ watch(
<template>
<div class="relative flex h-dvh overflow-hidden bg-z-bg-layout" :inert="mobileSidebarOpen">
<AppSidebar
:collapsed="sidebarCollapsed"
:collapse="sidebarCollapsed"
@toggle="toggleSidebar"
@open-system-update="systemUpdateOpen = true"
/>
<FloatingSidebarToggle v-show="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<ZScrollbar ref="pageScrollbarRef">
<!-- 让主滚动区自身绘制不透明底色,浏览器可在合成线程滚动 -->
<ZScrollbar ref="pageScrollbarRef" class="[&>.z-scrollbar-hidden]:bg-z-bg-layout">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
+15 -2
View File
@@ -5,6 +5,7 @@ import type {
ThemeColorId,
ThemeCustomization,
ThemeMode,
ThemePresetId,
} from '@codex-proxy/ui/theme'
import { normalizeHexColor } from '@codex-proxy/ui'
@@ -13,9 +14,11 @@ import {
createTheme,
defaultThemeColorId,
defaultThemeMode,
defaultThemePresetId,
defaultThemePrimaryColor,
isThemeColorId,
isThemeMode,
isThemePresetId,
normalizeThemeCustomization,
resolveThemeScheme,
} from '@codex-proxy/ui/theme'
@@ -30,6 +33,7 @@ interface ThemeTransitionOrigin {
}
interface ThemeConfigurationInput {
preset: ThemePresetId
mode: ThemeMode
color: ThemeColorId
customColor: string
@@ -49,6 +53,7 @@ export const useThemeStore = defineStore(
'theme',
() => {
const themeMode = shallowRef<ThemeMode>(defaultThemeMode)
const themePreset = shallowRef<ThemePresetId>(defaultThemePresetId)
const themeColor = shallowRef<ThemeColorId>(defaultThemeColorId)
const customThemeColor = shallowRef(defaultThemePrimaryColor)
const themeCustomization = ref<ThemeCustomization>({})
@@ -64,6 +69,7 @@ export const useThemeStore = defineStore(
const resolvedTheme = computed(() =>
createTheme({
scheme: effectiveTheme.value,
preset: themePreset.value,
colorId: themeColor.value,
primaryColor: customThemeColor.value,
customization: themeCustomization.value,
@@ -87,6 +93,8 @@ export const useThemeStore = defineStore(
}
function normalizePersistedTheme(): void {
if (!isThemePresetId(themePreset.value))
themePreset.value = defaultThemePresetId
if (!isThemeMode(themeMode.value))
themeMode.value = defaultThemeMode
if (!isThemeColorId(themeColor.value))
@@ -237,10 +245,11 @@ export const useThemeStore = defineStore(
event?: MouseEvent,
): boolean {
const customColor = normalizeHexColor(configuration.customColor)
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
if (!isThemePresetId(configuration.preset) || !isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
return false
requestThemeTransition(event)
themePreset.value = configuration.preset
themeMode.value = configuration.mode
themeColor.value = configuration.color
customThemeColor.value = customColor
@@ -251,6 +260,7 @@ export const useThemeStore = defineStore(
function resetTheme(event?: MouseEvent): void {
const unchanged
= themeMode.value === defaultThemeMode
&& themePreset.value === defaultThemePresetId
&& themeColor.value === defaultThemeColorId
&& customThemeColor.value === defaultThemePrimaryColor
&& Object.keys(themeCustomization.value).length === 0
@@ -260,6 +270,7 @@ export const useThemeStore = defineStore(
}
requestThemeTransition(event)
themeMode.value = defaultThemeMode
themePreset.value = defaultThemePresetId
themeColor.value = defaultThemeColorId
customThemeColor.value = defaultThemePrimaryColor
themeCustomization.value = {}
@@ -288,6 +299,7 @@ export const useThemeStore = defineStore(
return {
themeMode,
themePreset,
themeColor,
customThemeColor,
themeCustomization,
@@ -308,6 +320,7 @@ export const useThemeStore = defineStore(
key: 'codex-proxy-rs-theme',
pick: [
'themeMode',
'themePreset',
'themeColor',
'customThemeColor',
'themeCustomization',
@@ -317,5 +330,5 @@ export const useThemeStore = defineStore(
)
function themeSignature(theme: Theme): string {
return `${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
return `${theme.preset}:${theme.scheme}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
}
@@ -24,7 +24,6 @@ const label = computed(() => t('accounts.capacity.concurrentRequestsLimit', { va
<ZButton
variant="text" size="small" :type="full ? 'warning' : 'default'"
:aria-label="t('accounts.capacity.viewConcurrencyDetails', { label })"
:title="t('accounts.capacity.viewDetails', { label })"
class="relative top-0.75 -m-0.5 h-auto! px-0.5! py-0.5! whitespace-nowrap font-mono text-[9px]! font-normal! tabular-nums hover:bg-z-bg-text-hover!"
@click.stop="$emit('showDetails')"
>
@@ -257,7 +257,6 @@ watch(search, () => {
:aria-label="t('accounts.modelAccess.catalogHelpTitle')"
:aria-describedby="open ? id : undefined"
:aria-expanded="open"
:title="undefined"
@keydown="handleCatalogHelpKeydown($event, open, close)"
>
<CircleAlert class="size-[1em] text-z-xs" aria-hidden="true" />
@@ -62,7 +62,6 @@ const copyWithToast = useCopyText()
<ZIconButton
variant="solid"
size="small"
:title="t('accounts.onboarding.copyLink')"
:aria-label="t('accounts.onboarding.copyLink')"
:disabled="disabled"
@click="copyWithToast(authUrl, { successText: t('accounts.onboarding.authorizationLinkCopied') })"
@@ -70,7 +70,6 @@ function handleRetentionKeydown(event: KeyboardEvent) {
<template #reference>
<ZIconButton
variant="link"
:title="undefined"
:aria-label="t('accounts.importTasks.taskRetentionInstructions')"
size="small"
:aria-expanded="retentionOpen"
@@ -117,7 +117,6 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<template #reference>
<ZIconButton
variant="link"
:title="undefined"
:aria-label="t('accounts.quotaForecast.forecastDescription')"
size="small"
:aria-expanded="explanationOpen"
@@ -73,7 +73,7 @@ watch(() => props.accountId, () => {
</h3>
<ZPopover trigger="hover" placement="bottom-start" role="tooltip">
<template #reference="{ open, id }">
<ZIconButton variant="link" :title="undefined" size="small" class="size-6! p-0!" :aria-label="t('accounts.quotaReset.automaticUsageRules')" :aria-describedby="open ? id : undefined">
<ZIconButton variant="link" size="small" class="size-6! p-0!" :aria-label="t('accounts.quotaReset.automaticUsageRules')" :aria-describedby="open ? id : undefined">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
+2 -2
View File
@@ -213,7 +213,7 @@ const {
</script>
<template>
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<div class="flex min-h-0 w-full flex-col xl:h-[calc(100dvh-3rem)] xl:flex-none!">
<ZPageHeader
:title="t('navigation.accounts')"
:description="t('accounts.page.description')"
@@ -222,7 +222,7 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<ZCard
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
class="mt-4 flex flex-col xl:min-h-80 xl:flex-1"
>
<template #header>
<AccountFilters
@@ -163,7 +163,7 @@ const authProtocol = computed(() => {
const runtimeEnvironment = computed(() => {
const target = profile.value?.target
if (!target)
return { primary: '—', details: [] as string[], title: '—' }
return { primary: '—', details: [] as string[] }
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
const primary = [target.osType, target.osVersion].filter(present).join(' ')
@@ -171,7 +171,6 @@ const runtimeEnvironment = computed(() => {
return {
primary: primary || '—',
details,
title: [primary, ...details].filter(Boolean).join(' · '),
}
})
@@ -238,13 +237,12 @@ watch(
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
<strong
:aria-label="profile.version"
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
:title="profile.version"
class="inline-flex max-w-full min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 font-mono leading-none tabular-nums"
>
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-z-text">
{{ versionParts.release }}
</span>
<span v-if="versionParts.prerelease" class="truncate text-z-sm font-bold text-z-text-secondary">
<span v-if="versionParts.prerelease" class="text-z-sm font-bold wrap-anywhere text-z-text-secondary">
{{ versionParts.prerelease }}
</span>
</strong>
@@ -263,10 +261,7 @@ watch(
<ShieldCheck class="size-3.25 text-z-text-tertiary" />
{{ t('dashboard.clientIdentity.authProtocol') }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
:title="authProtocol"
>
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
{{ authProtocol }}
</dd>
</div>
@@ -276,10 +271,7 @@ watch(
<Monitor class="size-3.25 text-z-text-tertiary" />
{{ profile.provider === 'openai' ? t('dashboard.clientIdentity.emulatedEnvironment') : t('dashboard.clientIdentity.environment') }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text"
:title="runtimeEnvironment.title"
>
<dd class="mt-2 mb-0 font-mono text-z-lg leading-snug font-bold wrap-anywhere tabular-nums text-z-text">
{{ runtimeEnvironment.primary }}
<span v-if="runtimeEnvironment.details.length" class="text-z-xs font-emphasis text-z-text-secondary">
<template v-for="detail in runtimeEnvironment.details" :key="detail"> · {{ detail }} </template>
@@ -292,10 +284,7 @@ watch(
<Terminal class="size-3.25 text-z-text-tertiary" />
{{ attribute.label }}
</dt>
<dd
class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text"
:title="attribute.value"
>
<dd class="mt-2 mb-0 font-mono text-[16px] leading-snug font-heavy wrap-anywhere tabular-nums text-z-text">
{{ attribute.value }}
</dd>
</div>
@@ -54,7 +54,7 @@ const metrics = computed(() => [
</ZMotionIcon>
<span class="sr-only">{{ t('usage.summary.consumeTokens') }}</span>
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text" :title="formatCompactNumber(summary.totalTokens)">
<strong class="min-w-0 font-mono text-[32px] leading-[1.05] font-heavy wrap-anywhere tabular-nums text-z-text">
<AnimatedMetricValue :value="formatInteger(summary.totalTokens)" :raw-value="summary.totalTokens" :formatter="formatInteger" />
</strong>
<span class="shrink-0 font-mono text-z-sm leading-none whitespace-nowrap text-z-text-tertiary">≈ {{ formatCompactNumber(summary.totalTokens) }}</span>
@@ -22,7 +22,6 @@ const label = computed(() => effectiveTheme.value === 'dark'
type="button"
class="relative inline-grid h-9 w-20 shrink-0 cursor-pointer grid-cols-2 place-items-center rounded-[20px] border-0 bg-z-bg-text-hover p-0 outline-none focus-visible:ring-2 focus-visible:ring-z-control-outline"
:aria-label="label"
:title="label"
@click="themeStore.toggleTheme($event)"
>
<Sun
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { PluginManagementView } from '@/api'
import { ZButton, ZEmpty, ZTag } from '@codex-proxy/ui'
import { ZButton, ZCard, ZEmpty, ZTag } from '@codex-proxy/ui'
import { Blocks, ExternalLink } from '@lucide/vue'
import { computed } from 'vue'
@@ -40,10 +40,12 @@ function openView(view: PluginManagementView) {
/>
<div v-else class="grid content-start gap-3 overflow-y-auto sm:grid-cols-2 xl:grid-cols-3">
<article
<ZCard
v-for="view in pageViews"
:key="view.target.instanceId"
class="flex min-w-0 flex-col gap-3 rounded-z-card bg-z-bg-container p-4 shadow-z-card"
as="article"
padding="compact"
class="flex min-w-0 flex-col gap-3"
>
<div class="flex min-w-0 items-start gap-3">
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-z bg-z-primary-container text-z-primary-on-container">
@@ -72,7 +74,7 @@ function openView(view: PluginManagementView) {
</template>
{{ t('plugins.extensions.openPage') }}
</ZButton>
</article>
</ZCard>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ZButton, ZDialog } from '@codex-proxy/ui'
import { onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCopyText } from '@/composables/useCopyText'
const props = defineProps<{ link: URL }>()
defineEmits<{ closed: [] }>()
const { t } = useI18n({ useScope: 'global' })
const copyText = useCopyText()
const open = shallowRef(false)
// 确认框按请求挂载,挂载后打开才会触发入场动画
onMounted(() => {
open.value = true
})
function openLink() {
window.open(props.link.href, '_blank', 'noopener,noreferrer')
open.value = false
}
</script>
<template>
<ZDialog
v-model="open"
:title="t('plugins.frame.openExternalLink')"
width="28rem"
@closed="$emit('closed')"
>
<template #description>
<bdi dir="ltr" class="break-all">{{ link.host }}</bdi>
</template>
<template #footer>
<ZButton @click="open = false">
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton @click="copyText(link.href)">
{{ t('plugins.frame.copyLink') }}
</ZButton>
<ZButton type="primary" @click="openLink">
{{ t('plugins.frame.openLink') }}
</ZButton>
</template>
</ZDialog>
</template>
@@ -26,7 +26,6 @@ function handleKeydown(event: KeyboardEvent) {
:aria-label="label"
size="small"
class="size-5!"
:title="undefined"
:aria-expanded="open"
:aria-describedby="open ? descriptionId : undefined"
@keydown="handleKeydown"
@@ -5,6 +5,7 @@ import { useEventListener, useResizeObserver } from '@vueuse/core'
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
import { usePluginFrameSession } from '../composables/usePluginFrameSession'
import PluginExternalLinkDialog from './PluginExternalLinkDialog.vue'
import PluginPageLoading from './PluginPageLoading.vue'
const props = defineProps<{
@@ -20,7 +21,7 @@ const themeStore = useThemeStore()
const containerRef = useTemplateRef<HTMLDivElement>('container')
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
const viewportHeight = shallowRef(0)
const { srcdoc, loading, loadError, contentHeight, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
const { srcdoc, loading, loadError, contentHeight, externalLink, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
view: () => props.view,
page: () => props.page,
iframe: iframeRef,
@@ -73,5 +74,6 @@ onMounted(pushLayout)
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
<PluginExternalLinkDialog v-if="externalLink" :link="externalLink" @closed="externalLink = undefined" />
</div>
</template>
@@ -53,6 +53,7 @@ export function usePluginFrameSession(options: {
ZNotification.error({ message, deduplicate: true })
})
const contentHeight = shallowRef(0)
const externalLink = shallowRef<URL>()
const session = shallowRef<FrameSession>()
let loadController: AbortController | undefined
let previousViewportHeight = -1
@@ -62,6 +63,7 @@ export function usePluginFrameSession(options: {
loadController = undefined
const current = session.value
session.value = undefined
externalLink.value = undefined
srcdoc.value = ''
contentHeight.value = 0
previousViewportHeight = -1
@@ -290,6 +292,20 @@ export function usePluginFrameSession(options: {
}, '*', transfer)
}
function requestExternalLink(value: unknown) {
if (externalLink.value)
throw new Error(t('plugins.frame.externalLinkPending'))
// 短暂激活只能用于限制提示,打开地址仍须管理员在宿主内确认
if (!navigator.userActivation?.isActive || document.activeElement !== options.iframe.value)
throw new Error(t('plugins.frame.externalLinkRequiresClick'))
const url = typeof value === 'string'
? URL.parse(value)
: null
if (!url || url.protocol !== 'https:' || url.username || url.password)
throw new Error(t('plugins.frame.invalidExternalLink'))
externalLink.value = url
}
async function handleBridgeMessage(event: MessageEvent) {
const current = session.value
const source = event.source
@@ -324,7 +340,7 @@ export function usePluginFrameSession(options: {
current.models.cancel(frame, event.data.id)
return
}
if (!['request', 'callback-ticket'].includes(event.data.type))
if (!['request', 'callback-ticket', 'open-external'].includes(event.data.type))
return
if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) {
postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest'))
@@ -336,9 +352,16 @@ export function usePluginFrameSession(options: {
}
current.requestIds.add(event.data.id)
try {
if (event.data.type === 'request')
if (event.data.type === 'request') {
await handleRouteRequest(current, frame, event.data)
else await handleCallbackTicket(current, frame, event.data)
}
else if (event.data.type === 'callback-ticket') {
await handleCallbackTicket(current, frame, event.data)
}
else {
requestExternalLink(event.data.payload)
postResult(current, frame, event.data.id, true)
}
}
catch (error) {
if (session.value !== current || current.controller.signal.aborted)
@@ -412,6 +435,7 @@ export function usePluginFrameSession(options: {
loading,
loadError,
contentHeight,
externalLink,
frameKey,
handleFrameLoad,
pushLayout,
@@ -9,7 +9,7 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'open-external' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error'
export interface PluginBridgeMessage {
@@ -63,6 +63,7 @@ export interface PluginHostBridge {
readonly theme: 'light' | 'dark'
request: (input: PluginManagementRequest) => Promise<PluginManagementReply>
callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise<PluginCallbackTicket>
openExternal: (url: string) => Promise<void>
resourceUrl: (path: string) => string
readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise<Response> }>
}
@@ -80,7 +80,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
isDisposed: () => disposed,
})
function invoke<T>(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise<T> {
function invoke<T>(kind: 'request' | 'callback-ticket' | 'open-external', payload: unknown, transfer: Transferable[] = []): Promise<T> {
if (disposed)
return Promise.reject(new Error('Plugin page is closed'))
if (pending.size >= maximumInFlight)
@@ -254,6 +254,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
get theme() { return currentTheme },
request,
callbackTicket,
openExternal: url => invoke<void>('open-external', url),
models: Object.freeze({ responses: modelBridge.responses }),
resourceUrl(path: string) {
const key = String(path)

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