Compare commits

...
11 Commits
Author SHA1 Message Date
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
75 changed files with 622 additions and 560 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(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 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. 从失败版本提交的父提交重新接上后续功能和修复,跳过失败的 `chore(release): prepare vX.Y.Z`。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
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): publish vX.Y.Z` 提交;重复执行或较旧版本未推进任何通道时无提交
- 通道表写入失败时,确认该版本已公开、工作流能写入 `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(release): prepare v<目标版本>`,旧标题为 `chore(release): v<目标版本>`),即使同名 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): publish v#{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"
@@ -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>,
}
@@ -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!("")),
@@ -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` 接口:
@@ -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 {
@@ -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()
@@ -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);
@@ -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)
);
+4 -3
View File
@@ -843,6 +843,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 +865,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 +944,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
| 文件或目录 | 用途 |
| --- | --- |
| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 |
| `plugins/official/plugin-release-manifest.json` | 随平台安装包和镜像分发,记录宿主版本、提交与支持的插件合同 |
| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 |
- 下载更新时校验发行身份,插件合同不兼容不阻止安装。点击重启时检查已安装目标版本,列出不兼容插件的名称和原因;确认后重启,不兼容实例保持未启动,启用配置、绑定、密钥和私有数据保留
+7 -1
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 当前目录生成通用画像。推理菜单读取
@@ -2762,6 +2763,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 +3232,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)
+3 -3
View File
@@ -1294,9 +1294,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 |
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
+1 -1
View File
@@ -243,7 +243,7 @@ Token 直接覆盖时不会自动重算同组件的其他状态;需要保持
| 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` |
| 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` |
+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.8.1",
"@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.8.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4(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/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4':
resolution: {gitHosted: true, integrity: sha512-fQX6Xns/LbCYb9mFPzWXm34Y904pTywi66eBOGIBAW12eRKdVcMJQRfNHTbYDKsYIXpZ/fii6HwQqv+/T3XXKA==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4}
version: 0.8.1
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/5f4c2adbe67f8fc08069f21b02ceed05a37ea0e4(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",
@@ -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",
@@ -145,8 +145,7 @@
},
"capacity": {
"concurrentRequestsLimit": "账号并发占用 {value},上限 {value2}",
"viewConcurrencyDetails": "{label},查看并发明细",
"viewDetails": "{label},查看明细"
"viewConcurrencyDetails": "{label},查看并发明细"
},
"concurrency": {
"title": "并发明细",
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型响应读取失败",
"duplicateManagementRequest": "插件管理请求序号重复",
"managementConcurrencyLimit": "插件管理请求已达并发上限",
"openExternalLink": "打开外部链接",
"openLink": "打开",
"copyLink": "复制链接",
"externalLinkPending": "请先处理当前外部链接",
"externalLinkRequiresClick": "请在插件页面中点击链接或按钮",
"invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址",
"invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源",
"unregisteredAsset": "插件页面引用了未注册资源:{path}",
"htmlSubresource": "插件 HTML 资源不能作为子资源加载",
@@ -145,8 +145,7 @@
},
"capacity": {
"concurrentRequestsLimit": "賬號併發佔用 {value},上限 {value2}",
"viewConcurrencyDetails": "{label},查看併發明細",
"viewDetails": "{label},查看明細"
"viewConcurrencyDetails": "{label},查看併發明細"
},
"concurrency": {
"title": "併發明細",
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型響應讀取失敗",
"duplicateManagementRequest": "插件管理請求序號重複",
"managementConcurrencyLimit": "插件管理請求已達併發上限",
"openExternalLink": "開啟外部連結",
"openLink": "開啟",
"copyLink": "複製連結",
"externalLinkPending": "請先處理目前的外部連結",
"externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕",
"invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址",
"invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源",
"unregisteredAsset": "插件頁面引用了未註冊資源:{path}",
"htmlSubresource": "插件 HTML 資源不能作爲子資源加載",
@@ -472,7 +472,7 @@ onBeforeUnmount(() => {
? '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"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<span>{{ versionLabel }}</span>
@@ -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>
@@ -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
@@ -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)
@@ -23,7 +23,7 @@ const invalid = computed(() => props.rows.filter(row => row.error))
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="bottom-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('proxies.batchCreate.formatDescription')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('proxies.batchCreate.formatDescription')" :aria-expanded="open" class="size-6!">
<CircleHelp class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -37,7 +37,7 @@ const affinityOptions = computed(() => ([
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.affinity.accountAffinityInstructions')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.affinity.accountAffinityInstructions')" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -20,7 +20,7 @@ const model = defineModel<string>('model', { required: true })
{{ t('settings.warmup.title') }}
<ZPopover trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.warmup.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.warmup.helpTitle')" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -50,7 +50,7 @@ const queueFailureOptions = computed(() => [
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.autoReview.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.autoReview.helpTitle')" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -79,7 +79,7 @@ const queueFailureOptions = computed(() => [
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.retryAfter.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.retryAfter.helpTitle')" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -105,7 +105,7 @@ const queueFailureOptions = computed(() => [
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.queueFailureHelpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.queueFailureHelpTitle')" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -112,7 +112,7 @@ function confirm() {
{{ field.label }}
<ZPopover trigger="hover-click" placement="top-start">
<template #reference="{ open: helpOpen }">
<ZIconButton variant="link" :aria-label="t('settings.scheduling.about', { label: field.label })" :title="undefined" :aria-expanded="helpOpen" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.scheduling.about', { label: field.label })" :aria-expanded="helpOpen" class="size-6!">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
@@ -138,7 +138,7 @@ function canDelete(record: BackupRecord): boolean {
<span>{{ t('settings.backup.backupRecords') }}</span>
<ZPopover trigger="hover-click" placement="bottom-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.backup.backupScopeDescription')" :title="undefined" :aria-expanded="open" class="size-6!">
<ZIconButton variant="link" :aria-label="t('settings.backup.backupScopeDescription')" :aria-expanded="open" class="size-6!">
<CircleHelp class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
@@ -34,7 +34,6 @@ const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required:
<ZIconButton
variant="link"
:aria-label="t('settings.clientVersion.openGuide')"
:title="undefined"
class="size-6!"
@click="emit('help')"
>
@@ -103,7 +103,6 @@ function removeRule(id: string) {
<template #reference="{ open }">
<ZIconButton
variant="link"
:title="undefined"
size="small"
:aria-label="t('settings.errorProtection.matchingHelpTitle')"
:aria-expanded="open"
@@ -113,7 +113,7 @@ function remove(rule: PrivacyRule) {
<ZSelect v-model="model.onError" size="small" class="w-34" :options="failureOptions" :aria-label="t('settings.privacy.ruleExecutionFailed')" :disabled="disabled" />
<ZPopover trigger="hover-click" placement="top-end">
<template #reference="{ open: helpOpen }">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.ruleExecutionInstructions')" :title="undefined" :aria-expanded="helpOpen">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.ruleExecutionInstructions')" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
@@ -62,7 +62,7 @@ watch(() => step.value.action, (action) => {
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.fieldPathSyntax')" :title="undefined" class="-my-2" :aria-expanded="helpOpen">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.fieldPathSyntax')" class="-my-2" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
@@ -84,7 +84,7 @@ watch(() => step.value.action, (action) => {
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.regularSyntaxDescription')" :title="undefined" class="-my-2" :aria-expanded="helpOpen">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.regularSyntaxDescription')" class="-my-2" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
@@ -114,7 +114,7 @@ function moveWithKeyboard(index: number, offset: -1 | 1) {
</h3>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.helpTitle')" :title="undefined" class="size-5!" :aria-expanded="helpOpen">
<ZIconButton variant="link" size="small" :aria-label="t('settings.privacy.steps.helpTitle')" class="size-5!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
@@ -76,7 +76,7 @@ const providerOptions = computed(() => props.providers.map(value => ({
<div class="flex items-center gap-1">
<ZPopover v-model="syncInfoOpen" placement="bottom-end" trigger="hover-click">
<template #reference>
<ZIconButton variant="link" :title="undefined" :aria-label="t('settings.pricing.synchronizationInstructions')" size="small" :aria-expanded="syncInfoOpen" :aria-controls="syncInfoOpen ? syncInfoId : undefined">
<ZIconButton variant="link" :aria-label="t('settings.pricing.synchronizationInstructions')" size="small" :aria-expanded="syncInfoOpen" :aria-controls="syncInfoOpen ? syncInfoId : undefined">
<CircleAlert class="size-3.5" />
</ZIconButton>
</template>
@@ -18,7 +18,6 @@ const infoId = useId()
variant="link"
size="small"
:aria-label="t('settings.pricing.unitHelp')"
:title="undefined"
:aria-expanded="infoOpen"
:aria-controls="infoOpen ? infoId : undefined"
>
@@ -85,7 +85,7 @@ const boardStyle = computed<CSSProperties>(() => ({
<ZButton
size="small" variant="text"
class="min-w-14 px-2! font-mono tabular-nums"
:title="t('appearance.preview.canvas.recovery')"
:aria-label="t('appearance.preview.canvas.recovery')"
@click="resetScale"
>
{{ scalePercent }}%
@@ -20,7 +20,7 @@ function download() {
</script>
<template>
<ZButton type="primary" :aria-label="t('usage.diagnostics.downloadDiagnosticPackage')" :title="t('usage.diagnostics.downloadDiagnosticPackage')" :disabled="!active || (!requestId && !errorId)" @click="download">
<ZButton type="primary" :aria-label="t('usage.diagnostics.downloadDiagnosticPackage')" :disabled="!active || (!requestId && !errorId)" @click="download">
<template #icon>
<Download :size="16" />
</template>
+12 -16
View File
@@ -1,24 +1,20 @@
# v3.42.0
# v3.42.1
## 账号调度与额度
## 新增功能
- Key 和账号可以分别设置排队超时。开启「尝试换号」后,OpenAI 或 xAI 账号队满或超时时,网关会再找一次能立即接单的账号;没有空位则返回原错误。Key 队列、固定账号、原生续写和必须跟随主会话的子线程不会因此换号
- OpenAI 调度减少了大号池选号和排队时的数据库与 Redis 读取。优先亲和模式下,主账号短时繁忙时临时分流,主账号失效或额度长期耗尽时,在后备账号接单后迁移绑定。没有显式会话 ID 的 Chat 和 Responses 请求,也可在同一 Key 下按缓存键或对话开头匹配账号;宽松模式仍按普通策略选号
- Codex OAuth 账号的「自动使用点数」默认关闭。需要在套餐额度耗尽后继续使用点数时,请在「账号管理 → 展开账号 → 额度重置」为对应账号开启。只有上游确认点数可用且没有支出限制时才会继续调度
- 插件管理页:打开外部链接前显示目标域名,可选择打开、复制链接或取消
## OpenAI 请求与安装身份
## 优化改进
- 修复部分 Chat 客户端经 Codex OAuth 转发时,因携带默认采样参数或 `stream_options.include_usage` 而收到 400 的问题。旧式 token 上限会转为 `max_output_tokens`;显式截断、非空停止序列等策略仍交由上游校验。新增的字段处理仅用于 OAuth
- OpenAI Key 可配置安装身份种子。同一把 Key 使用同一账号时安装身份保持稳定;换 Key 或换账号时使用不同身份。未配置种子的 Key 继续使用账号原有身份
- OpenAI 调度:因账号队满或超时而换绑会话时,诊断记录会显示原因及换绑前后的账号
## 系统更新与管理端
## 问题修复
- 检查更新时按通道读取已发布版本,不再逐页遍历 GitHub Release。发现更高候选时按精确 tag 核对公开 Release、当前平台包和校验文件后才提示;遇到 GitHub 限流时显示可重试时间。安装包下载后,校验、安装和重启不再需要访问 GitHub
- 编辑账号模型限制时,取消勾选的自定义模型会保留到本次编辑结束,可以在保存前重新勾选。账号分组色块支持悬停或点击查看组名及停用状态
- 账号授权:修复使用设有 WebSocket 连接上限的已保存代理时,OpenAI 或 xAI 授权回调被误判为代理配置已变更的问题
- Codex 选模:上游只返回模型 ID 时,默认列表隐藏已识别的生图、实时和语音合成模型,仍可按 ID 直接调用
- 管理端:改善下拉菜单与浮层的定位和切换效果
## 升级事项
## 升级须知
- 从 v3.41.2 升级时,两层排队超时都继承原值,换号默认关闭。两层独立计时,先后在两层排队的总等待时间可能变长。通过管理 API 或插件 SDK 更新运行设置时,用 `keyQueueTimeoutSeconds`、`accountQueueTimeoutSeconds` 和 `switchAccountOnQueueFailure` 替换 `concurrencyWaitTimeoutSeconds`
- 已配置的 OpenAI Key 安装 ID 会自动转为种子;管理 API 调用方请将 `installationId` 改为 `installationSeed`。这些 Key 的出站安装身份及关联会话、缓存标识会变化,已有 WebSocket 精确续写需重新发送完整上下文
- 使用自定义更新仓库的实例,请使用固定的 `update-index` 预发行页和版本 Release 的 `version.yaml` 附件提供通道索引。本仓库的发布工作流会自动维护,不需要额外分支
- 从 v3.41.2 正式版升级只新增迁移 0039。若源码部署执行过中间版 main 的 `0039_queue_timeouts.sql`(包括随后执行过 0040 或 0041 的实例),其迁移记录与发行包不兼容。请勿直接覆盖升级或修改数据库中的校验和;应先在独立数据库验证恢复或数据迁移方案
- 使用已保存代理且 WebSocket 连接上限非零时,升级或回退后请重新发起尚未完成的 OpenAI 或 xAI 授权;已保存的账号和凭据不受影响
- 自定义更新仓库:新版从 `main` 分支的 `release/version.yaml` 读取 `published` 通道表,发布工作流需要写入 `main` 的权限
+2 -2
View File
@@ -59,7 +59,7 @@ unless content.match?(/^version:/)
exit 1
end
content = content.sub(/^version:\s*.*$/, %(version: "#{version}"))
content = content.sub(/^version:\s*.*$/, %(version: #{version}))
File.write(path, content)
RUBY
}
@@ -167,7 +167,7 @@ update_version_file "$version"
git add -- "$VERSION_FILE" "$NOTES_FILE" "$PLUGIN_DEPRECATIONS_FILE"
git commit \
-m "chore(release): $tag" \
-m "chore(release): prepare $tag" \
-m "$SIGNATURE"
git tag -a "$tag" -m "$tag"
+7 -1
View File
@@ -1 +1,7 @@
version: "3.42.0"
version: 3.42.1
published:
'3':
stable: 3.42.0
rc: 3.42.0
beta: 3.42.0
alpha: 3.42.0