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