Compare commits

...
16 Commits
Author SHA1 Message Date
ZyyandCodex ba3baddbda chore(release): v3.41.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:21:27 +08:00
ZyyandCodex 09b22a9550 fix(openai): honor account allowlists for unlisted models
Let OpenAI accounts send explicitly allowlisted model IDs upstream while retaining catalog checks for other accounts. Update the account editor and connection test to support custom IDs.

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:18:08 +08:00
ZyyandCodex 04a48e848d chore(deps): update mio to 1.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:25:06 +08:00
ZyyandCodex 1655ecc972 style(usage): clarify tooltip success rate formatting
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:06:22 +08:00
ZyyandCodex 4527db7b5d feat(usage): show cancelled and incomplete requests separately
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:04:07 +08:00
ZyyandCodex 2b7a64dae6 docs: preserve task evidence and clarify completion rules
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 22:06:09 +08:00
ZyyandCodex 0c07aac1d6 chore(release): v3.41.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 22:01:26 +08:00
ZyyandCodex 3e33e84a45 fix(observability): unify request error statistics
Count incomplete requests as errors, exclude recovered and cancelled requests from success rates, and preserve gaps for missing samples.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 21:56:12 +08:00
ZyyandCodex c73e7126a2 fix(openai): clarify OAuth connection failures and extend timeouts
Distinguish connection failures from uncertain authorization results and retain redacted diagnostic causes. Increase token connection timeout to 30 seconds and total request timeout to 60 seconds.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 21:50:08 +08:00
ZyyandCodex 1d88c9a4a5 fix(websocket): close idle clients when upstream connections end
Notify idle clients with Close 1000 after upstream loss while preserving settlement and queued requests. Align idle transport termination detection with Codex and document the reconnect contract.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 18:48:38 +08:00
蓝芷怡andCodex 26dbc12010 fix(accounts): show full model names in the account catalog (#616)
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 15:17:55 +08:00
dependabot[bot] 2dc6bf0150 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#614)
Bumps the minor-and-patch group in /backend with 4 updates: [zip](https://github.com/zip-rs/zip2), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [redis](https://github.com/redis-rs/redis-rs) and [crc32fast](https://github.com/srijs/rust-crc32fast).


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

Updates `aws-sdk-s3` from 1.156.0 to 1.157.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `redis` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.8.0...redis-1.8.1)

Updates `crc32fast` from 1.5.3 to 1.5.4
- [Commits](https://github.com/srijs/rust-crc32fast/compare/v1.5.3...v1.5.4)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.157.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: crc32fast
  dependency-version: 1.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:11:46 +08:00
dependabot[bot] f7f64ead3b chore(deps): bump the minor-and-patch group in /frontend with 6 updates (#613)
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.66.0` | `1.67.0` |
| [gsap](https://github.com/greensock/GSAP) | `3.15.0` | `3.15.1` |
| [semver](https://github.com/npm/node-semver) | `7.8.5` | `7.8.6` |
| [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) | `11.4.16` | `11.4.18` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.3` | `4.3.4` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.3` | `4.3.4` |


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

Updates `gsap` from 3.15.0 to 3.15.1
- [Commits](https://github.com/greensock/GSAP/compare/3.15.0...3.15.1)

Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

Updates `vue-i18n` from 11.4.16 to 11.4.18
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/v11.4.18/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v11.4.18/packages/vue-i18n)

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

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

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: gsap
  dependency-version: 3.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-i18n
  dependency-version: 11.4.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:11:02 +08:00
dependabot[bot] 37749001db chore(deps): bump taiki-e/install-action (#612)
Bumps the minor-and-patch group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


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

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.89.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:10:31 +08:00
ZyyandCodex 272534a3b6 perf(host): reduce startup and restart latency
Read the local running version during bootstrap without waiting for a
remote release check. Reuse the restart handoff pipe on Unix so the
replacement starts as soon as the old service releases its resources.

Keep shutdown cleanup budgets and bounded listener retries. Update the
handoff regression coverage and lifecycle documentation.

Validation: Rustfmt, strict workspace Clippy, 252 Host and bootstrap
tests, and isolated Linux startup and consecutive restart measurements.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 14:15:58 +08:00
ZyyandCodex b9bcb4e086 ci: streamline builds and release validation
Share binary builds and dependency caches, run Windows platform checks in parallel, and reuse verified CI evidence for version-only releases. Reuse prebuilt CI image inputs and add read-only release preflight.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 13:03:59 +08:00
73 changed files with 1857 additions and 1055 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
## 参考仓库的优先级
@@ -45,7 +45,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交前确认已覆盖[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 测试维护
+1 -1
View File
@@ -18,7 +18,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
@@ -31,7 +31,7 @@ release/publish "$release_version"
```
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只读预检使用 `release/publish --check "$release_version"`。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
@@ -43,6 +43,7 @@ release/publish "$release_version"
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
@@ -26,6 +26,9 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
+76
View File
@@ -0,0 +1,76 @@
name: Build binary
description: Build a native release binary with shared dependency caching and timings
inputs:
rust:
description: Pinned Rust toolchain
required: true
target:
description: Rust target triple
required: true
platform:
description: Release platform name, such as linux-amd64
required: true
binary:
description: Executable filename
default: codex-proxy-rs
rust-image:
description: Pinned Linux builder image; empty for native builds
default: ''
runs:
using: composite
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ inputs.rust }}
targets: ${{ inputs.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: release-${{ inputs.target }}-${{ inputs.rust-image }}
cache-bin: false
# 只缓存依赖,避免随版本变化的 workspace 产物挤占缓存
save-if: ${{ github.event_name != 'pull_request' }}
- name: Build binary
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ inputs.target }}
BUILD_PLATFORM: ${{ inputs.platform }}
BUILD_BINARY: ${{ inputs.binary }}
RUST_IMAGE: ${{ inputs.rust-image }}
run: |
set -euo pipefail
if [ -n "$RUST_IMAGE" ]; then
mkdir -p "$HOME/.cargo"
docker run --rm --user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo -e HOME=/tmp \
-e CPR_VERSION -e CPR_GIT_SHA -e CPR_BUILD_TIME -e CPR_BUILD_TYPE \
-v "$HOME/.cargo:/cargo" -v "$PWD/..:/workspace" \
-w /workspace/backend "$RUST_IMAGE" \
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
else
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
fi
"target/$BUILD_TARGET/release/$BUILD_BINARY" --help
"target/$BUILD_TARGET/release/$BUILD_BINARY" --version
mkdir -p "../dist/binaries/$BUILD_PLATFORM"
cp "target/$BUILD_TARGET/release/$BUILD_BINARY" "../dist/binaries/$BUILD_PLATFORM/$BUILD_BINARY"
- name: Upload build timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: build-timings-${{ inputs.platform }}
path: backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
+1 -1
View File
@@ -43,4 +43,4 @@ runs:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
retention-days: 7
-24
View File
@@ -1,24 +0,0 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
ARG CACHE_STAMP
RUN --mount=type=bind,target=/cache \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& tar -xzf /cache/registry.tar.gz -C /usr/local/cargo/registry \
&& tar -xzf /cache/git.tar.gz -C /usr/local/cargo/git \
&& tar -xzf /cache/target.tar.gz -C /app/backend/target
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS snapshot
ARG CACHE_STAMP
# 直接压缩 mount 内容,构建层和 runner 只保存归档,避免多份展开的编译产物占满磁盘
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& mkdir -p /cache \
&& tar -czf /cache/registry.tar.gz -C /usr/local/cargo/registry . \
&& tar -czf /cache/git.tar.gz -C /usr/local/cargo/git . \
&& tar -czf /cache/target.tar.gz -C /app/backend/target .
-31
View File
@@ -1,31 +0,0 @@
name: Cargo cache mounts
description: Restore or export a validated Cargo snapshot for a BuildKit builder
inputs:
operation:
description: Cache operation, restore or snapshot
required: true
builder:
description: Buildx builder name
required: true
path:
description: Cargo snapshot directory on the runner
required: true
architecture:
description: Cargo target directory architecture, amd64 or arm64
required: true
outputs:
usable:
description: Whether the restored or exported snapshot contains Cargo dependencies
value: ${{ steps.cache.outputs.usable }}
runs:
using: composite
steps:
- id: cache
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
OPERATION: ${{ inputs.operation }}
BUILDER: ${{ inputs.builder }}
CACHE_PATH: ${{ inputs.path }}
ARCHITECTURE: ${{ inputs.architecture }}
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 4 ]; then
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
exit 2
fi
operation="$1"
builder="$2"
directory="$(realpath -m "$3")"
architecture="$4"
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
usable() {
# 流式检查归档中的非空依赖文件,校验时不在 runner 展开编译产物
python3 - "$1" "$architecture" <<'PY'
import pathlib
import re
import sys
import tarfile
root = pathlib.Path(sys.argv[1])
architecture = sys.argv[2]
def contains(filename, pattern):
with tarfile.open(root / filename, mode="r|gz") as archive:
return any(
member.isfile() and member.size > 0
and re.fullmatch(pattern, member.name.removeprefix("./"))
for member in archive
)
try:
valid = (
(root / "git.tar.gz").stat().st_size > 0
and contains("registry.tar.gz", r"cache/[^/]+/[^/]+\.crate")
and contains("target.tar.gz", rf"{architecture}/release/deps/[^/]+\.rlib")
)
except (OSError, tarfile.TarError):
valid = False
sys.exit(0 if valid else 1)
PY
}
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
# mount 的内容不参与层指纹,用时间戳重跑归档操作;--no-cache 会重置 cache mount
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
valid=false
if [ "$operation" = restore ]; then
if usable "$directory"; then
"${command[@]}" --output type=cacheonly "$directory"
valid=true
else
echo "No usable Cargo snapshot; cached image layers may still be available"
fi
else
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
mkdir -p "$(dirname "$directory")"
temporary="$(mktemp -d "${directory}.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
mkdir "$temporary"/{context,snapshot}
# tar 流避开 local exporter 的逐文件同步阻塞,只接收 /cache 下的压缩归档
# 直接导出打包阶段,避免再创建一层归档副本
# https://github.com/moby/buildkit/issues/2950
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
| tar -xf - -C "$temporary/snapshot" --strip-components=1 cache/
if usable "$temporary/snapshot"; then
rm -rf "$directory"
mv "$temporary/snapshot" "$directory"
valid=true
fi
fi
echo "Cargo snapshot for $architecture: usable=$valid"
if [ "$valid" = true ]; then du -sh "$directory"; fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
-1
View File
@@ -70,7 +70,6 @@ updates:
- package-ecosystem: docker
directories:
- /deploy
- /.github/actions/cargo-cache
labels: []
schedule:
interval: weekly
+193
View File
@@ -0,0 +1,193 @@
require 'fileutils'
require 'json'
require 'open3'
require 'tmpdir'
require_relative 'release-quality'
# 使用临时 Git 仓库验证远端状态和只读边界,不访问真实 GitHub 或修改当前仓库
ROOT = File.expand_path('../..', __dir__)
def assert(condition, message)
raise message unless condition
end
def git(*arguments)
output, error, status = Open3.capture3('git', *arguments)
raise "git #{arguments.first}: #{error}" unless status.success?
output.strip
end
def commit(message)
git('add', '.')
git('commit', '-qm', message)
end
Dir.mktmpdir('release-checks-') do |directory|
remote = File.join(directory, 'remote.git')
system('git', 'init', '--bare', '-q', remote, exception: true)
checkout = File.join(directory, 'checkout')
FileUtils.mkdir_p(checkout)
Dir.chdir(checkout) do
git('init', '-q', '-b', 'main')
git('config', 'user.email', 'test@example.invalid')
git('config', 'user.name', 'Release test')
files = %w[
release/publish release/version.yaml release/notes.md release/platforms.yaml
.github/scripts/toolchains.rb .github/scripts/release-metadata.rb
backend/rust-toolchain.toml deploy/Dockerfile
backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json
backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json
]
files.each do |path|
FileUtils.mkdir_p(File.dirname(path))
FileUtils.cp(File.join(ROOT, path), path)
end
commit('initial source')
assert(ReleaseQuality.evidence('owner/repo').nil?, 'root commit should require full checks')
git('remote', 'add', 'origin', remote)
git('push', '-qu', 'origin', 'main')
File.write('release/notes.md', "# v99.0.0\n\nFixture notes\n")
bin = File.join(directory, 'bin')
FileUtils.mkdir_p(bin)
File.write(File.join(bin, 'gh'), <<~RUBY)
#!/usr/bin/env ruby
case ARGV
when ['auth', 'status'] then exit 0
when ['repo', 'view', #{remote.inspect}, '--json', 'nameWithOwner', '--jq', '.nameWithOwner']
puts 'owner/repo'
else
abort "unexpected gh mutation: \#{ARGV.inspect}"
end
RUBY
File.chmod(0o755, File.join(bin, 'gh'))
environment = { 'PATH' => "#{bin}:#{ENV.fetch('PATH')}" }
snapshot = lambda do
[git('show-ref'), git('status', '--porcelain'), File.binread('.git/index'),
files.to_h { |path| [path, File.binread(path)] },
git('ls-remote', 'origin'), File.exist?('.git/FETCH_HEAD')]
end
check = lambda do |success, message = nil|
before = snapshot.call
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
assert(status.success? == success, "preflight outcome: #{output}#{error}")
assert("#{output}#{error}".include?(message), "missing error: #{message}; #{error}") if message
assert(snapshot.call == before, 'preflight modified files, refs, index, or FETCH_HEAD')
end
check.call(true, 'Preflight passed')
File.write('deploy/Dockerfile', File.read('deploy/Dockerfile') + "\n# fixture\n")
check.call(false, 'unstaged changes')
git('checkout', '--', 'deploy/Dockerfile')
git('tag', 'v99.0.0')
check.call(false, 'local tag already exists')
git('push', '-q', 'origin', 'refs/tags/v99.0.0')
git('tag', '-d', 'v99.0.0')
check.call(false, 'remote tag already exists')
git('push', '-q', 'origin', ':refs/tags/v99.0.0')
# 未履行的兼容窗口必须在只读预检中失败,不能等待提交版本后再发现
deprecations = 'backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json'
previous = File.read(deprecations)
File.write(deprecations, JSON.generate([{
capability: 'removed-fixture', version: 1, replacement_version: 2, remaining_releases: 7
}]))
git('add', deprecations)
git('commit', '-qm', 'invalid lifecycle fixture')
check.call(false, 'replacement v2 must be available')
File.write(deprecations, previous)
commit('restore lifecycle and notes')
File.write('release/notes.md', "# v99.0.0\n\nUpdated fixture notes\n")
# 远端前进但没有本地对象时,提示同步而不隐式 fetch
other = File.join(directory, 'other')
git('clone', '-q', '-b', 'main', remote, other)
Dir.chdir(other) do
git('config', 'user.email', 'test@example.invalid')
git('config', 'user.name', 'Release test')
File.write('remote-only', 'change')
commit('remote advance')
git('push', '-q')
end
check.call(false, 'unseen commits')
git('fetch', '-q', 'origin')
check.call(false, 'diverged')
FileUtils.mv(remote, "#{remote}.hidden")
# 读取远端失败不能当作 tag 不存在,仍须保持本地不变
before = [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')]
_, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
assert(!status.success? && error.include?('failed to read remote refs'), 'remote failure was ignored')
assert(before == [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')], 'remote failure changed local state')
FileUtils.mv("#{remote}.hidden", remote)
File.write('release/version.yaml', "version: \"99.0.0\"\n")
commit('release metadata')
parent = git('rev-parse', 'HEAD^')
run = { 'head_sha' => parent, 'conclusion' => 'success', 'head_repository' => { 'full_name' => 'owner/repo' }, 'id' => 42 }
job = { 'name' => 'quality / backend', 'conclusion' => 'success', 'html_url' => 'https://example.invalid/42' }
runs = [run]
jobs = [job]
original_capture = ReleaseQuality.method(:capture)
ReleaseQuality.define_singleton_method(:capture) do |*arguments|
if arguments.first == 'gh'
endpoint = arguments.find { |argument| argument.start_with?('repos/') }
if endpoint.include?('/workflows/ci.yml/runs?')
assert(endpoint.include?("event=push&branch=main&head_sha=#{parent}"), 'wrong CI evidence scope')
JSON.generate('workflow_runs' => runs)
else
assert(endpoint == 'repos/owner/repo/actions/runs/42/jobs?per_page=100', 'wrong run jobs')
jobs.map { |entry| JSON.generate(entry) }.join("\n")
end
else
original_capture.call(*arguments)
end
end
assert(ReleaseQuality.evidence('owner/repo') == job['html_url'], 'valid CI evidence not reused')
%w[skipped failure cancelled].each do |conclusion|
job['conclusion'] = conclusion
assert(ReleaseQuality.evidence('owner/repo').nil?, "reused #{conclusion} backend")
end
job['conclusion'] = 'success'
jobs = []
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused missing backend job')
jobs = [job]
run['conclusion'] = 'failure'
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused failed CI run')
run['conclusion'] = nil
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused unfinished CI run')
run['conclusion'] = 'success'
run['head_repository']['full_name'] = 'fork/repo'
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused foreign repository CI')
run['head_repository']['full_name'] = 'owner/repo'
runs = []
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused absent CI run')
runs = [run]
output, _, status = Open3.capture3(environment.merge('GITHUB_REPOSITORY' => 'owner/repo'), 'ruby', File.join(ROOT, '.github/scripts/release-quality.rb'))
assert(status.success? && output.strip == 'run_backend=true', 'API failure did not fall back to full checks')
File.write('backend/source.rs', '// changed source')
commit('source changed')
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused CI across source changes')
merge = git('commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-p', 'HEAD^', '-m', 'merge fixture')
git('update-ref', 'HEAD', merge)
assert(ReleaseQuality.evidence('owner/repo').nil?, 'merge commit should require full checks')
# 在同一个隔离仓库走实际提交与原子推送路径,dispatch 仍由本地替身接收
git('merge', '--no-edit', 'origin/main')
File.write('release/notes.md', "# v100.0.0\n\nPublish fixture notes\n")
dispatch = File.join(directory, 'dispatch.json')
mock = File.join(bin, 'gh')
File.write(mock, File.read(mock).sub("else\n", <<~RUBY))
when ['workflow', 'run', 'release.yml', '--repo', 'owner/repo', '--ref', 'main', '--raw-field', 'tag=v100.0.0']
File.write(#{dispatch.inspect}, ARGV.join(' '))
else
RUBY
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '100.0.0')
assert(status.success?, "publish failed: #{output}#{error}")
assert(File.exist?(dispatch), 'workflow was not dispatched')
assert(git('log', '-1', '--format=%s') == 'chore(release): v100.0.0', 'wrong release commit')
assert(git('status', '--porcelain').empty?, 'release left tracked changes')
assert(git('rev-parse', 'HEAD') == git('rev-parse', 'v100.0.0^{commit}'), 'tag differs from release commit')
assert(git('ls-remote', 'origin', 'refs/tags/v100.0.0').split.first == git('rev-parse', 'v100.0.0'), 'tag not pushed')
end
end
puts 'Release preflight and CI evidence checks passed'
+1
View File
@@ -82,6 +82,7 @@ module ReleaseMetadata
'platform_matrix' => JSON.generate('include' => entries),
'docker_matrix' => JSON.generate('include' => docker),
'non_docker_matrix' => JSON.generate('include' => native),
'windows_matrix' => JSON.generate('include' => entries.select { |entry| entry['os'] == 'windows' }),
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
'previous_tag' => previous,
'prerelease' => prerelease?(value),
+44
View File
@@ -0,0 +1,44 @@
require 'json'
require 'open3'
# 仅复用同一源码的主分支 CI,缺少证据时完整检查发行提交
module ReleaseQuality
def self.capture(*command)
output, error, status = Open3.capture3(*command)
raise "#{command.first} failed: #{error.strip}" unless status.success?
output
end
def self.evidence(repository)
parents = capture('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
return nil unless parents.length == 2
parent = parents.last
changes = capture('git', 'diff', '--name-only', '-z', parent, 'HEAD').split("\0")
return nil if changes.empty? || (changes - %w[release/version.yaml release/notes.md]).any?
# 按最新一次运行判断,失败或仍在执行时不回退到旧的成功记录
endpoint = "repos/#{repository}/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=#{parent}&per_page=1"
run = JSON.parse(capture('gh', 'api', endpoint)).fetch('workflow_runs').first
return nil unless run && run.fetch('head_sha') == parent && run.fetch('conclusion') == 'success'
return nil unless run.fetch('head_repository').fetch('full_name') == repository
rows = capture('gh', 'api', '--paginate', "repos/#{repository}/actions/runs/#{run.fetch('id')}/jobs?per_page=100", '--jq', '.jobs[] | @json')
job = rows.lines.map { |line| JSON.parse(line) }.find { |entry| entry.fetch('name') == 'quality / backend' }
return nil unless job && job.fetch('conclusion') == 'success'
job.fetch('html_url')
end
end
if $PROGRAM_NAME == __FILE__
begin
evidence = ReleaseQuality.evidence(ENV.fetch('GITHUB_REPOSITORY'))
rescue StandardError => error
warn "CI evidence unavailable; running full release checks: #{error.message}"
evidence = nil
end
puts "run_backend=#{evidence.nil?}"
if ENV['GITHUB_STEP_SUMMARY']
message = evidence ? "后端质量检查复用版本提交父提交的 [CI 结果](#{evidence});仅版本与发布说明不同" : '未找到可复用的后端 CI 结果,完整检查发行提交'
File.open(ENV.fetch('GITHUB_STEP_SUMMARY'), 'a') { |file| file.puts(message) }
end
end
+83 -40
View File
@@ -7,13 +7,58 @@ on:
description: Exact commit SHA to build and validate
required: true
type: string
source_build:
description: Validate the complete Docker source build
type: boolean
default: true
permissions:
contents: read
security-events: write
jobs:
frontend:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- uses: ./.github/actions/build-frontend
with:
upload-artifact: 'true'
binary:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/build-binary
with:
rust: ${{ steps.toolchains.outputs.rust }}
target: x86_64-unknown-linux-gnu
platform: linux-amd64
rust-image: ${{ steps.toolchains.outputs.rust_image }}
env:
CPR_GIT_SHA: ${{ inputs.ref }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-linux-amd64
path: dist/binaries/linux-amd64/
if-no-files-found: error
retention-days: 7
container:
needs: [frontend, binary]
if: always() && !cancelled()
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
@@ -21,6 +66,14 @@ jobs:
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
# 上游失败必须传递给已有的容器检查,不能让必需状态变成 skipped
- name: Require prebuilt inputs
if: ${{ !inputs.source_build }}
env:
FRONTEND_RESULT: ${{ needs.frontend.result }}
BINARY_RESULT: ${{ needs.binary.result }}
run: test "$FRONTEND_RESULT" = success && test "$BINARY_RESULT" = success
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -30,41 +83,51 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Restore Cargo snapshot
id: cargo-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Download frontend
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: ${{ runner.temp }}/container-cargo
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
key: container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
restore-keys: |
container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-
name: frontend-dist
path: dist/docker/web/dist
- name: Restore Cargo mounts to BuildKit
id: cargo-restore
uses: ./.github/actions/cargo-cache
- name: Download binary
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
operation: restore
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
name: codex-proxy-rs-linux-amd64
path: dist/docker-artifacts/codex-proxy-rs-linux-amd64
- name: Release restored Cargo snapshot
run: rm -rf "$RUNNER_TEMP/container-cargo"
- name: Prepare prebuilt image
if: ${{ !inputs.source_build }}
env:
MATRIX_OS: linux
MATRIX_ARCH: amd64
MATRIX_BINARY: codex-proxy-rs
run: |
ruby -r './.github/scripts/release-metadata' -e '
ReleaseMetadata.seal_manifest(
"version" => ReleaseMetadata.version(YAML.load_file("release/version.yaml").fetch("version")),
"git_sha" => ReleaseMetadata.git("rev-parse", "HEAD")
)'
ruby .github/scripts/package-release.rb image
- name: Build runtime image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
target: runtime
target: ${{ inputs.source_build && 'runtime' || 'runtime-prebuilt' }}
build-args: |
CPR_PREBUILT_DIR=dist/docker
CPR_GIT_SHA=${{ inputs.ref }}
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
runtime-prebuilt
cache-from: type=gha,scope=runtime-${{ inputs.source_build && 'source' || 'prebuilt' }}
cache-to: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && format('type=gha,mode=max,scope=runtime-{0}', inputs.source_build && 'source' || 'prebuilt') || '' }}
- name: Verify runtime image
uses: ./.github/actions/verify-container
@@ -72,23 +135,3 @@ jobs:
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
- name: Export Cargo snapshot
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
id: cargo-snapshot
uses: ./.github/actions/cargo-cache
with:
operation: snapshot
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Save Cargo snapshot
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
+23 -5
View File
@@ -28,6 +28,7 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
CARGO_PROFILE_DEV_DEBUG: '1'
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
@@ -111,7 +112,9 @@ jobs:
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
shared-key: quality
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
- name: Rust format
working-directory: backend
@@ -122,7 +125,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -131,7 +134,7 @@ jobs:
working-directory: backend
run: |
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
cargo nextest list --test main --locked --timings --list-type binaries-only --message-format json \
> "$RUNNER_TEMP/nextest-binaries.json"
- name: Configure PostgreSQL and Redis test authentication
@@ -173,10 +176,22 @@ jobs:
- name: Rust tests
working-directory: backend
run: |
cargo nextest run \
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--test-threads 8 --no-tests fail
--test-threads 8 --no-tests fail --profile ci
- name: Upload test and build reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: backend-quality-reports
path: |
backend/target/nextest/ci/junit.xml
backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
- name: Store tests with Redis ACL authentication
working-directory: backend
@@ -244,3 +259,6 @@ jobs:
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
- name: Verify release preflight and CI evidence
run: ruby .github/scripts/release-checks-test.rb
+25 -2
View File
@@ -29,6 +29,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source_container: ${{ steps.filter.outputs.source_container }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -67,10 +68,30 @@ jobs:
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/verify-container/**'
- '.github/actions/cargo-cache/**'
- '.github/actions/build-binary/**'
- '.github/actions/build-frontend/**'
- '.github/scripts/toolchains.rb'
- '.github/scripts/package-release.rb'
- '.github/scripts/release-metadata.rb'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- '!**/*.md'
source_container:
- 'deploy/Dockerfile'
- 'deploy/Dockerfile.dockerignore'
- '.dockerignore'
- 'backend/**/Cargo.toml'
- 'backend/Cargo.lock'
- 'backend/rust-toolchain.toml'
- 'backend/**/build.rs'
- 'backend/.cargo/**'
- 'frontend/package.json'
- 'frontend/pnpm-lock.yaml'
- 'frontend/pnpm-workspace.yaml'
- 'frontend/.npmrc'
- 'frontend/patches/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
quality:
needs: changes
@@ -78,7 +99,8 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
run_backend: ${{ needs.changes.outputs.backend == 'true' }}
run_frontend: ${{ needs.changes.outputs.frontend == 'true' }}
# 预构建容器复用同一次前端检查产物,源码 Docker 构建仍独立验证
run_frontend: ${{ needs.changes.outputs.frontend == 'true' && (needs.changes.outputs.container != 'true' || needs.changes.outputs.source_container == 'true') }}
container:
needs: changes
@@ -89,3 +111,4 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source_build: ${{ needs.changes.outputs.source_container == 'true' }}
+91 -79
View File
@@ -23,6 +23,9 @@ jobs:
release-info:
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: read
actions: read
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
@@ -32,12 +35,14 @@ jobs:
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
windows_matrix: ${{ steps.version.outputs.windows_matrix }}
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
rust: ${{ steps.toolchains.outputs.rust }}
rust_image: ${{ steps.toolchains.outputs.rust_image }}
run_backend: ${{ steps.quality.outputs.run_backend }}
steps:
- name: Checkout
@@ -56,21 +61,88 @@ jobs:
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Check reusable backend CI evidence
id: quality
env:
GH_TOKEN: ${{ github.token }}
run: ruby .github/scripts/release-quality.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
if-no-files-found: error
retention-days: 1
retention-days: 7
quality:
needs: [release-info]
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
run_backend: ${{ needs.release-info.outputs.run_backend == 'true' }}
run_frontend: false
windows:
needs: release-info
name: windows-tests (${{ matrix.arch }})
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.windows_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
RUST_MIN_STACK: '16777216'
RUSTFLAGS: '-C target-feature=+crt-static'
CARGO_PROFILE_DEV_DEBUG: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: platform-tests-${{ matrix.target }}
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-nextest@0.9.143
fallback: none
- name: Verify Windows platform behavior
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ matrix.target }}
# 同一次构建统一两个 package 的依赖特性,保留原有平台用例和静态运行库
run: |
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" --locked --target "$BUILD_TARGET" \
-p gateway-admin -p gateway-host --test main --no-tests fail --profile ci \
-E '(package(=gateway-admin) & test(/^use_case::plugins::official::/)) | (package(=gateway-host) & test(/^system_update::/))'
- name: Upload test report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-tests-${{ matrix.arch }}
path: backend/target/nextest/ci/junit.xml
if-no-files-found: ignore
retention-days: 7
security:
needs: release-info
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
with:
ref: ${{ needs.release-info.outputs.git_sha }}
frontend:
needs: release-info
runs-on: ubuntu-26.04
@@ -103,40 +175,15 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
mkdir -p "$HOME/.cargo" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
docker run --rm \
--platform "${{ matrix.dockerPlatform }}" \
--user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo \
-e HOME=/tmp \
-e CPR_VERSION="${CPR_VERSION}" \
-e CPR_GIT_SHA="${CPR_GIT_SHA}" \
-e CPR_BUILD_TIME="${CPR_BUILD_TIME}" \
-e CPR_BUILD_TYPE="${CPR_BUILD_TYPE}" \
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
"${RUST_IMAGE}" \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
rust-image: ${{ needs.release-info.outputs.rust_image }}
env:
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
@@ -148,7 +195,7 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
build-binary:
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
@@ -165,61 +212,26 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
env:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Verify Windows platform behavior
if: runner.os == 'Windows'
shell: bash
working-directory: backend
env:
RUST_MIN_STACK: '16777216'
run: |
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
- name: Upload binary artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
@@ -273,7 +285,7 @@ jobs:
name: release-assets
path: dist/release
if-no-files-found: error
retention-days: 1
retention-days: 7
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
compression-level: 0
@@ -385,14 +397,14 @@ jobs:
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
path: dist/digests/*.txt
if-no-files-found: error
retention-days: 1
retention-days: 7
publish:
concurrency:
group: release-publication
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
needs: [release-info, quality, windows, security, package-assets, build-docker-image]
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
+10 -4
View File
@@ -1,6 +1,12 @@
name: Security
on:
workflow_call:
inputs:
ref:
description: Exact commit SHA to audit
required: true
type: string
push:
branches:
- main
@@ -14,7 +20,7 @@ permissions:
contents: read
concurrency:
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
group: security-${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -25,10 +31,10 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -44,7 +50,7 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+9 -2
View File
@@ -12,9 +12,9 @@
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -23,6 +23,13 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
### 任务延续与收尾
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
<!-- CODEGRAPH_START -->
## CodeGraph
+4
View File
@@ -232,6 +232,10 @@ git diff --check
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
常规容器检查与发行构建共用二进制构建入口,并复用已检查的前端产物;Dockerfile、工具链、依赖或构建配置变化时执行完整源码镜像构建,定期安全检查也覆盖源码构建。两条路径均验证容器运行、安全扫描与关闭行为
发行构建与质量检查并行,Windows 两种架构的插件与系统更新测试独立运行。仅当发行提交只修改版本与发布说明,且其父提交有本仓库主分支成功的后端 CI 时复用该结果;缺少证据则完整重跑。对外发布等待质量、Windows 测试、依赖审计和镜像验证全部成功,构建耗时与测试报告保留 7 天
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
+13 -12
View File
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.156.0"
version = "1.157.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1072c9aa20159b9b417115342d38c4c53108fc98e5359fcb86b7d4575747eb59"
checksum = "6ea9794c741520d8baec9462bcd0ad12164d4f5f78d008a8da9d6a0b7222caa8"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -503,9 +503,9 @@ dependencies = [
[[package]]
name = "aws-smithy-schema"
version = "1.1.0"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1081,9 +1081,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.3"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -2729,9 +2729,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -3578,9 +3578,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.8.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7acd809e80afa754c63f586955699d8990efe1c4b6613183ee5f027a11fbc8b5"
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
dependencies = [
"arc-swap",
"arcstr",
@@ -5646,14 +5646,15 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "9.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"libc",
"memchr",
"nix",
"typed-path",
@@ -4,7 +4,7 @@ use std::{path::PathBuf, sync::Arc};
use gateway_admin::ports::provider::ProviderAdminRegistry;
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
use gateway_host::{HostBundle, HostConfig, outbound::HttpClient};
use gateway_store::{StoreBundle, StoreConfig};
use super::BootstrapError;
@@ -33,6 +33,8 @@ impl Environment {
let timezone = host.timezone;
let store = store.with_timezone(timezone);
let plugin_cache = host.runtime_data_dir().join("plugins");
// 插件兼容检查只需本地运行版本,管理端版本查询还会等待远端发行服务
let version = host.system_update.version.clone();
let host = match mode {
Mode::Server => gateway_host::initialize(host).await?,
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
@@ -44,12 +46,6 @@ impl Environment {
Mode::Help => gateway_store::initialize_read_only(store).await?,
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = Arc::new(HttpClient::new()?);
Ok(Self {
host,
@@ -510,10 +510,20 @@ impl RequestHealth {
)
}
/// 账号与健康时间线使用百分数,洞察接口使用 0 到 1 的比例
#[must_use]
pub fn success_rate(self) -> Option<f64> {
self.success_ratio().map(|ratio| ratio * 100.0)
}
pub(crate) fn success_ratio(self) -> Option<f64> {
let total = self.eligible_requests();
(total > 0).then(|| self.success_requests as f64 / total as f64 * 100.0)
(total > 0).then(|| self.success_requests as f64 / total as f64)
}
pub(crate) fn error_ratio(self) -> Option<f64> {
let total = self.eligible_requests();
(total > 0).then(|| self.non_success_requests() as f64 / total as f64)
}
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
@@ -1081,7 +1091,9 @@ pub struct DiagnosticObservation {
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
@@ -1169,7 +1181,7 @@ pub struct TrendPoint {
pub bucket_start: DateTime<Utc>,
pub granularity: Granularity,
pub metrics: RequestMetrics,
pub service_failure_count: u64,
pub error_count: u64,
pub average_latency_ms: Option<u64>,
pub average_first_token_latency_ms: Option<u64>,
pub cached_token_rate: f64,
@@ -1187,7 +1199,7 @@ pub struct TrendSummary {
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub total_tokens: u64,
pub service_failure_count: u64,
pub error_count: u64,
pub average_latency_ms: Option<u64>,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
@@ -1351,7 +1363,7 @@ pub struct UsageInsightsHealthPoint {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
}
/// 洞察健康汇总;失败数已排除调用方错误
@@ -1363,7 +1375,7 @@ pub struct UsageInsightsHealth {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub success_rate: Option<f64>,
pub completion_rate: f64,
pub points: Vec<UsageInsightsHealthPoint>,
}
@@ -1550,7 +1562,7 @@ pub struct DiagnosticsItem {
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
@@ -356,9 +356,16 @@ impl ObservabilityService for DefaultObservabilityService {
.items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let health = RequestHealth::from_counts(
item.success_count,
item.failure_count,
item.cancelled_count,
item.incomplete_count,
item.caller_error_count,
);
let non_completion_count =
item.cancelled_count.saturating_add(item.incomplete_count);
let non_completion_rate = rate_or_zero(non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
account_upstream_peer: peers.get(&item.key).copied(),
@@ -374,8 +381,8 @@ impl ObservabilityService for DefaultObservabilityService {
account_plan_type: item.account_plan_type,
request_count: item.request_count,
success_count: item.success_count,
error_count: item.failure_count,
error_rate,
error_count: health.non_success_requests(),
error_rate: health.error_ratio(),
request_share: rate_or_zero(
item.request_count,
observation.total_request_count,
@@ -383,7 +390,7 @@ impl ObservabilityService for DefaultObservabilityService {
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_count,
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
@@ -576,7 +583,6 @@ fn build_usage_insights(
let requests = &overview.requests;
let health_window = RequestHealth::from_metrics(requests);
let failed_requests = health_window.failed_requests;
let health_requests = health_window.eligible_requests();
let health = UsageInsightsHealth {
total_requests: requests.request_count,
success_requests: requests.success_count,
@@ -584,7 +590,7 @@ fn build_usage_insights(
cancelled_requests: requests.cancelled_count,
incomplete_requests: requests.incomplete_count,
caller_error_requests: requests.caller_error_count,
success_rate: rate_or_zero(requests.success_count, health_requests),
success_rate: health_window.success_ratio(),
completion_rate: rate_or_zero(
requests
.success_count
@@ -603,10 +609,7 @@ fn build_usage_insights(
cancelled_requests: point.metrics.cancelled_count,
incomplete_requests: point.metrics.incomplete_count,
caller_error_requests: point.metrics.caller_error_count,
error_rate: rate_or_zero(
health_window.non_success_requests(),
health_window.eligible_requests(),
),
error_rate: health_window.error_ratio(),
}
})
.collect(),
@@ -1215,11 +1218,9 @@ fn trend(
}
}
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
let service_failure_count = RequestHealth::from_metrics(&metrics).failed_requests;
let success_rate = ratio(
metrics.success_count,
RequestHealth::from_metrics(&metrics).eligible_requests(),
);
let health = RequestHealth::from_metrics(&metrics);
let error_count = health.non_success_requests();
let success_rate = health.success_ratio();
let cache_hit_request_rate = ratio(
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
@@ -1248,7 +1249,7 @@ fn trend(
output_tokens: metrics.output_tokens,
cached_input_tokens: metrics.cached_input_tokens,
total_tokens: metrics.total_tokens,
service_failure_count,
error_count,
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
@@ -1267,11 +1268,11 @@ fn trend(
}
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
let service_failure_count = RequestHealth::from_metrics(&point.metrics).failed_requests;
let health = RequestHealth::from_metrics(&point.metrics);
TrendPoint {
bucket_start: point.bucket_start,
granularity: point.granularity,
service_failure_count,
error_count: health.non_success_requests(),
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
average_first_token_latency_ms: average(
point.metrics.first_token_latency_sum_ms,
@@ -1285,10 +1286,7 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
point.metrics.cache_hit_request_count,
point.metrics.cache_eligible_request_count,
),
success_rate: ratio(
point.metrics.success_count,
RequestHealth::from_metrics(&point.metrics).eligible_requests(),
),
success_rate: health.success_ratio(),
metrics: point.metrics,
cost_coverage: point.cost_coverage,
costs: point.costs,
@@ -364,21 +364,26 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
let services = observability_services(store.clone()).await;
let fixtures = [
(162, 0, 2, 0, 0, 98.780_487_804_878_05),
(297, 0, 2, 5, 0, 99.331_103_678_929_76),
(467, 1, 5, 0, 0, 98.731_501_057_082_45),
(95, 2, 3, 4, 1, 95.0),
(100, 0, 0, 0, 0, 100.0),
(162, 0, 2, 0, 0, 0, Some(98.780_487_804_878_05)),
(297, 0, 2, 5, 0, 0, Some(99.331_103_678_929_76)),
(467, 1, 5, 0, 0, 0, Some(98.731_501_057_082_45)),
(95, 2, 3, 4, 1, 7, Some(95.0)),
(100, 0, 0, 0, 0, 0, Some(100.0)),
(0, 0, 0, 0, 0, 0, None),
(0, 0, 0, 4, 3, 2, None),
(0, 1, 2, 0, 0, 0, Some(0.0)),
];
for (index, (success, failed, incomplete, cancelled, caller_error, expected_percent)) in
fixtures.into_iter().enumerate()
for (
index,
(success, failed, incomplete, cancelled, caller_error, running, expected_percent),
) in fixtures.into_iter().enumerate()
{
let range = observation_range(
now + Duration::seconds(i64::try_from(index).expect("fixture index fits i64") * 2),
);
let metrics = RequestMetrics {
request_count: success + failed + incomplete + cancelled + caller_error,
request_count: success + failed + incomplete + cancelled + caller_error + running,
success_count: success,
failure_count: failed + caller_error,
incomplete_count: incomplete,
@@ -399,6 +404,27 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
providers: Vec::new(),
});
let mut item = diagnostic(
"model",
success + failed + incomplete + cancelled + caller_error + running,
);
item.success_count = success;
item.failure_count = failed + caller_error;
item.incomplete_count = incomplete;
item.cancelled_count = cancelled;
item.caller_error_count = caller_error;
store.replace_diagnostics(DiagnosticsObservation {
analytics: Default::default(),
total_request_count: item.request_count,
items: vec![item],
});
let diagnostics = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.unwrap();
assert_eq!(diagnostics.items[0].error_count, failed + incomplete);
let dashboard = services
.observability()
.dashboard_summary(range, TrendKind::Usage)
@@ -410,23 +436,12 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
.iter()
.find(|point| point.bucket_start == current_slot)
.expect("current health slot");
assert_eq!(point.status, HealthStatus::Stable);
assert_eq!(dashboard.trend.summary.error_count, failed + incomplete);
assert_eq!(dashboard.trend.points[0].error_count, failed + incomplete);
assert_eq!(point.failed_requests, failed);
assert_eq!(point.incomplete_requests, incomplete);
assert_eq!(point.cancelled_requests, cancelled);
assert_eq!(point.caller_error_requests, caller_error);
for reliability in [timeline.reliability_percent, point.reliability_percent] {
assert!((reliability.expect("health reliability") - expected_percent).abs() < 1e-10);
}
for success_rate in [
dashboard.trend.summary.success_rate,
dashboard.trend.points[0].success_rate,
] {
assert!(
(success_rate.expect("trend success rate") * 100.0 - expected_percent).abs()
< 1e-10
);
}
let insights = services
.observability()
@@ -435,11 +450,31 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
.expect("usage insights");
assert_eq!(insights.health.failed_requests, failed);
assert_eq!(insights.health.incomplete_requests, incomplete);
assert!((insights.health.success_rate * 100.0 - expected_percent).abs() < 1e-10);
assert!(
(insights.health.points[0].error_rate * 100.0 - (100.0 - expected_percent)).abs()
< 1e-10
);
for actual in [
timeline.reliability_percent,
point.reliability_percent,
dashboard
.trend
.summary
.success_rate
.map(|rate| rate * 100.0),
dashboard.trend.points[0]
.success_rate
.map(|rate| rate * 100.0),
insights.health.success_rate.map(|rate| rate * 100.0),
insights.health.points[0]
.error_rate
.map(|rate| (1.0 - rate) * 100.0),
diagnostics.items[0]
.error_rate
.map(|rate| (1.0 - rate) * 100.0),
] {
match (actual, expected_percent) {
(Some(actual), Some(expected)) => assert!((actual - expected).abs() < 1e-10),
(None, None) => {}
_ => panic!("unexpected rate: {actual:?}, expected {expected_percent:?}"),
}
}
}
}
@@ -678,7 +713,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
assert_eq!(insights.health.total_requests, 10);
assert_eq!(insights.health.failed_requests, 2);
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.success_rate, Some(0.75));
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(
@@ -833,7 +868,7 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
assert_eq!(result.items[1].key, "single_failure");
assert_eq!(result.items[0].error_count, 0);
assert_eq!(result.items[1].error_count, 1);
assert_eq!(result.items[1].error_rate, 1.0);
assert_eq!(result.items[1].error_rate, Some(1.0));
}
#[tokio::test]
@@ -1565,7 +1600,9 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
cancelled_count: 0,
incomplete_count: 0,
caller_error_count: 0,
retry_count: 0,
retried_request_count: 0,
account_provider_kind: None,
@@ -701,8 +701,8 @@ pub(crate) fn trend_point_view(
cached_input_tokens_value: point.metrics.cached_input_tokens,
cache_hit_rate_value: point.cached_token_rate,
tokens_value: point.metrics.total_tokens,
errors: format_compact_number(point.service_failure_count),
errors_value: point.service_failure_count,
errors: format_compact_number(point.error_count),
errors_value: point.error_count,
latency_value: point.average_latency_ms,
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
@@ -765,7 +765,7 @@ pub(crate) fn trend_summary_view(
TrendKind::Errors => vec![
TrendSummaryView {
metric: TrendMetric::Errors,
value: format_compact_number(summary.service_failure_count),
value: format_compact_number(summary.error_count),
ratio: None,
},
TrendSummaryView {
@@ -656,7 +656,7 @@ pub struct OverviewHealthPointView {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
}
/// 洞察健康摘要
@@ -669,7 +669,7 @@ pub struct OverviewHealthView {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub success_rate: Option<f64>,
pub completion_rate: f64,
pub request_change_rate: Option<f64>,
pub success_rate_change: Option<f64>,
@@ -879,7 +879,7 @@ pub struct DiagnosticItemView {
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
@@ -170,6 +170,7 @@ pub(super) async fn forward_response(
})
.collect::<Vec<_>>();
let mut interrupt = super::interrupt::ResponseInterrupt::default();
let mut reconnect_after_terminal = false;
let first = match next_body_input(
connection,
body.as_mut(),
@@ -177,6 +178,7 @@ pub(super) async fn forward_response(
&mut interrupt,
&request_id,
false,
&mut reconnect_after_terminal,
)
.await
{
@@ -231,6 +233,7 @@ pub(super) async fn forward_response(
&mut interrupt,
&request_id,
terminal_write.is_some(),
&mut reconnect_after_terminal,
)
.await
{
@@ -252,7 +255,11 @@ pub(super) async fn forward_response(
.filter(|_| terminal_write == Some(WriteOutcome::Written));
let outcome = replay.commit(&capture, delivered);
body.close().await;
return outcome;
return if reconnect_after_terminal {
ForwardOutcome::ReconnectIfIdle
} else {
outcome
};
}
BodyInput::Frame(Ok(None)) => {
return fail_body(
@@ -348,6 +355,7 @@ async fn next_body_input(
interrupt: &mut super::interrupt::ResponseInterrupt,
request_id: &Arc<str>,
terminal_seen: bool,
reconnect_after_terminal: &mut bool,
) -> BodyInput {
// 处理控制帧时继续持有同一个读取 future,不能取消正在加工正文的中间件
let frame = body.next_frame();
@@ -383,8 +391,11 @@ async fn next_body_input(
}
}
event = response_control.receive(), if terminal_seen => {
if forward_control_event(connection, response_control, event).await == ForwardOutcome::Disconnect {
return BodyInput::Disconnect;
match forward_control_event(connection, response_control, event).await {
ForwardOutcome::Disconnect => return BodyInput::Disconnect,
// 终态写出后仍需完成结算,失效通知留到响应收尾后再触发空闲关闭
ForwardOutcome::ReconnectIfIdle => *reconnect_after_terminal = true,
ForwardOutcome::Continue => {}
}
}
frame = &mut frame => return BodyInput::Frame(frame),
@@ -824,7 +835,7 @@ pub(super) async fn forward_control_event(
) -> ForwardOutcome {
let Ok(payload) = event else {
control.clear();
return ForwardOutcome::Continue;
return ForwardOutcome::ReconnectIfIdle;
};
if connection
.send_text(payload, WriteContext::connection(FramePhase::Data))
@@ -201,7 +201,8 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
event
}
event = response_control.receive() => {
if forward_control_event(&mut connection, &response_control, event).await == ForwardOutcome::Disconnect {
let outcome = forward_control_event(&mut connection, &response_control, event).await;
if disconnect_after_forward(&mut connection, outcome, reconnect_for_replay).await {
break;
}
continue;
@@ -415,12 +416,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
&response_control,
)
.await;
if outcome == ForwardOutcome::Disconnect
|| (outcome == ForwardOutcome::ReconnectIfIdle
&& reconnect_for_replay
&& !connection.is_expired()
&& connection.close_for_replay_if_idle().await)
{
if disconnect_after_forward(&mut connection, outcome, reconnect_for_replay).await {
break;
}
if connection.is_expired() {
@@ -432,6 +428,18 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
connection.log_summary(request_count);
}
async fn disconnect_after_forward(
connection: &mut ResponsesWebSocketConnection,
outcome: ForwardOutcome,
reconnect_for_replay: bool,
) -> bool {
outcome == ForwardOutcome::Disconnect
|| (outcome == ForwardOutcome::ReconnectIfIdle
&& reconnect_for_replay
&& !connection.is_expired()
&& connection.close_for_replay_if_idle().await)
}
// 尚未建立模型请求的拒绝也保留原文入口,用返回给客户端的 correlation ID 检索
fn trace_rejected_request(
correlation_id: &str,
@@ -757,7 +757,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
cancelled_count: 0,
incomplete_count: 0,
caller_error_count: 0,
retry_count: 3,
retried_request_count: 1,
cost_coverage: CostCoverage::default(),
@@ -190,6 +190,8 @@ async fn client_retry_budget_should_flow_through_http_and_live_websocket() {
struct ControlTransport {
id: String,
active: std::sync::atomic::AtomicBool,
closed: CancellationToken,
unavailable_observed: tokio::sync::Notify,
requests: Mutex<Vec<String>>,
sender: tokio::sync::mpsc::Sender<Value>,
receiver: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Value>>,
@@ -201,6 +203,8 @@ impl ControlTransport {
Self {
id,
active: std::sync::atomic::AtomicBool::new(true),
closed: CancellationToken::new(),
unavailable_observed: tokio::sync::Notify::new(),
requests: Mutex::default(),
sender,
receiver: tokio::sync::Mutex::new(receiver),
@@ -231,13 +235,15 @@ impl ResponseControlTransport for ControlTransport {
.map_err(|_| ResponseControlUnavailable)
}
async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
self.receiver
.lock()
.await
.recv()
.await
.map(|event| event.to_string())
.ok_or(ResponseControlUnavailable)
tokio::select! {
() = self.closed.cancelled() => {
self.unavailable_observed.notify_one();
Err(ResponseControlUnavailable)
}
event = async { self.receiver.lock().await.recv().await } => {
event.map(|event| event.to_string()).ok_or(ResponseControlUnavailable)
}
}
}
}
@@ -674,6 +680,140 @@ async fn late_interrupt_after_terminal_is_sent_upstream_and_keeps_the_connection
socket.close(None).await.unwrap();
}
#[tokio::test]
async fn unavailable_idle_upstream_closes_client_and_allows_a_fresh_request() {
let provider = Arc::new(InterruptProvider::default());
let admissions = Arc::new(InterruptAdmissions::default());
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
send(&mut socket, create.clone()).await;
assert_eq!(next(&mut socket).await["type"], "response.created");
provider.complete.notify_one();
assert_eq!(next(&mut socket).await["type"], "response.completed");
tokio::time::timeout(Duration::from_secs(5), async {
while admissions.active.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
provider
.transport
.lock()
.unwrap()
.as_ref()
.unwrap()
.closed
.cancel();
assert_replay_close(&mut socket).await;
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
assert_eq!(provider.store.finalizations.lock().unwrap().len(), 1);
let tokio_tungstenite::MaybeTlsStream::Plain(transport) = socket.get_ref() else {
panic!("test server must use plain TCP");
};
let mut request = format!("ws://{}/v1/responses", transport.peer_addr().unwrap())
.into_client_request()
.unwrap();
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer sk_ws_interrupt"),
);
let (mut reconnected, _) = connect_async(request).await.unwrap();
send(&mut reconnected, create).await;
assert_eq!(
next(&mut reconnected).await["response"]["id"],
"resp_interrupt_1"
);
provider.complete.notify_one();
assert_eq!(next(&mut reconnected).await["type"], "response.completed");
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
reconnected.close(None).await.unwrap();
}
#[tokio::test]
async fn unavailable_upstream_waits_for_settlement_and_preserves_queued_requests() {
for queued in [false, true] {
let provider = Arc::new(InterruptProvider::default());
let (release, wait_for_release) = tokio::sync::oneshot::channel();
let admissions = Arc::new(InterruptAdmissions {
release_gate: Mutex::new(Some(wait_for_release)),
..Default::default()
});
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
send(&mut socket, create.clone()).await;
assert_eq!(next(&mut socket).await["type"], "response.created");
provider.complete.notify_one();
assert_eq!(next(&mut socket).await["type"], "response.completed");
tokio::time::timeout(
Duration::from_secs(5),
admissions.release_started.notified(),
)
.await
.unwrap();
let transport = provider.transport.lock().unwrap().clone().unwrap();
transport.closed.cancel();
tokio::time::timeout(
Duration::from_secs(5),
transport.unavailable_observed.notified(),
)
.await
.unwrap();
if queued {
send(&mut socket, create).await;
}
socket
.send(ClientMessage::Ping(vec![1].into()))
.await
.unwrap();
let reply = tokio::time::timeout(Duration::from_secs(5), socket.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert!(matches!(reply, ClientMessage::Pong(_)));
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
assert_eq!(admissions.active.load(Ordering::SeqCst), 1);
release.send(()).unwrap();
if queued {
assert_eq!(
next(&mut socket).await["response"]["id"],
"resp_interrupt_1"
);
provider.complete.notify_one();
assert_eq!(next(&mut socket).await["type"], "response.completed");
// 旧连接的失效不能关闭已绑定新上游的客户端
socket
.send(ClientMessage::Ping(vec![2].into()))
.await
.unwrap();
let reply = tokio::time::timeout(Duration::from_secs(5), socket.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert!(matches!(reply, ClientMessage::Pong(_)));
socket.close(None).await.unwrap();
} else {
assert_replay_close(&mut socket).await;
assert_eq!(admissions.active.load(Ordering::SeqCst), 0);
assert_eq!(provider.store.finalizations.lock().unwrap().len(), 1);
}
}
}
async fn assert_replay_close(socket: &mut TestSocket) {
let message = tokio::time::timeout(Duration::from_secs(5), socket.next())
.await
.expect("idle upstream loss must notify the client")
.unwrap()
.unwrap();
assert!(matches!(message, ClientMessage::Close(Some(frame))
if frame.code == tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal
&& frame.reason == "Reconnect with full input."));
}
#[tokio::test]
async fn late_interrupt_during_terminal_settlement_does_not_abort_or_reorder_requests() {
let provider = Arc::new(InterruptProvider::default());
+2 -2
View File
@@ -11,8 +11,8 @@ use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLif
const DRAINING_BIT: usize = 1usize << (usize::BITS - 1);
const ACTIVE_MASK: usize = !DRAINING_BIT;
/// 自重启交接没有显式握手:替换进程按 CPR_RESTART_DELAY_MS 估算等待,
/// 可能在旧进程释放监听端口前尝试绑定,因此对 AddrInUse 保留有限重试窗口
/// 从使用固定延迟交接的版本升级时,旧进程可能尚未释放监听端口,
/// 因此对 AddrInUse 保留有限重试窗口
const BIND_RETRY_WINDOW: Duration = Duration::from_secs(10);
const BIND_RETRY_MAX_DELAY: Duration = Duration::from_secs(1);
@@ -5,14 +5,8 @@ use std::fs;
use std::io;
use std::process::{ChildStdin, Command, Stdio};
#[cfg(unix)]
use super::config::environment_value;
use super::{OperationError, SystemUpdateConfig, internal};
#[cfg(unix)]
const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS";
#[cfg(unix)]
const DEFAULT_RESTART_DELAY_MS: u64 = 1_200;
const RESTART_HANDOFF_ARGUMENT: &str = "--restart-handoff";
/// 组合根在创建运行时前接收重启交接,并取得不含内部交接标记的 CLI 参数
@@ -47,39 +41,16 @@ pub(crate) fn spawn_replacement(
}
}
#[cfg(unix)]
let mut command = {
let delay_ms = environment_value(RESTART_DELAY_ENV)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
let delay = format!("{}.{:03}", delay_ms / 1_000, delay_ms % 1_000);
let mut command = Command::new("/bin/sh");
command
.arg("-c")
.arg("sleep \"$1\"; shift; exec \"$@\"")
.arg("codex-proxy-rs-restart")
.arg(delay)
.arg(&executable)
.args(env::args_os().skip(1))
.stdin(Stdio::null());
command
};
#[cfg(not(unix))]
let mut command = {
let mut command = Command::new(&executable);
command
.arg(RESTART_HANDOFF_ARGUMENT)
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
.args(
env::args_os()
.skip(1)
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
)
.stdin(Stdio::piped());
command
};
let mut command = Command::new(&executable);
command
.arg(RESTART_HANDOFF_ARGUMENT)
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
.args(
env::args_os()
.skip(1)
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
)
.stdin(Stdio::piped())
.spawn()
.map(|mut child| child.stdin.take())
.map_err(|error| internal("无法启动替换进程").with_source(error))
@@ -260,7 +260,7 @@ async fn assert_system_operations_rejected(service: &ProcessSystemOperations, re
}
#[tokio::test]
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
async fn restart_should_wait_for_service_release_before_starting_replacement() {
let fixture = Fixture::new();
let replacement_marker = fixture.root.path().join("replacement-ran");
fs::copy(
@@ -285,20 +285,17 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
.await
.expect("shutdown requested after spawn");
#[cfg(windows)]
{
tokio::time::timeout(Duration::from_secs(5), async {
while !fixture.root.path().join("replacement-waiting").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("replacement waiting for old service");
assert!(
!replacement_marker.exists(),
"new service must wait for resource release"
);
}
tokio::time::timeout(Duration::from_secs(5), async {
while !fixture.root.path().join("replacement-waiting").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("replacement waiting for old service");
assert!(
!replacement_marker.exists(),
"new service must wait for resource release"
);
drop(service);
tokio::time::timeout(Duration::from_secs(2), async {
while !replacement_marker.is_file() {
@@ -326,7 +323,6 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
"system_update::operations::restart_should_use_startup_path_after_running_executable_is_renamed",
])
.env(CHILD_ENV, "1")
.env("CPR_RESTART_DELAY_MS", "1200")
.output()
.expect("isolated restart test");
assert!(
@@ -359,13 +355,16 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
let backup = fixture.backup();
fs::rename(fixture.executable(), &backup).expect("back up running executable");
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
fixture.write_executable(
"#!/bin/sh\ncat >/dev/null\nprintf new > \"${0%/*}/replacement-version\"\n",
);
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
service
.restart(Arc::new(AllowingUpdatePreflight))
.await
.expect("restart accepted");
drop(service);
let marker = fixture.root.path().join("replacement-version");
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.is_file() {
@@ -141,7 +141,7 @@ fn counter_expressions() -> Vec<(String, String)> {
let mut counters = vec![
("request_count".to_owned(), "count(*)".to_owned()),
("completed_request_count".to_owned(), "count(*) filter(where is_completed_usage)".to_owned()),
("caller_error_count".to_owned(), "count(*) filter(where client_status_code between 400 and 499)".to_owned()),
("caller_error_count".to_owned(), "count(*) filter(where outcome='failed' and client_status_code between 400 and 499)".to_owned()),
("attempt_count".to_owned(), "coalesce(sum(attempt_count),0)".to_owned()),
("retry_count".to_owned(), "coalesce(sum(greatest(attempt_count-1,0)),0)".to_owned()),
("retried_request_count".to_owned(), "count(*) filter(where attempt_count>1)".to_owned()),
@@ -347,7 +347,14 @@ fn push_summary_aggregation_from(
let counters = selected_counter_expressions(counter_names)
.into_iter()
.map(|(name, _)| {
let expression = format!("coalesce(sum(metric.{name}),0)");
// 汇总按终态分行,取消请求的 499 不属于调用方失败
// 直接筛选已有桶计数,历史统计无需重建
let expression = if source == "matching" && name == "caller_error_count" {
"coalesce(sum(metric.caller_error_count) filter(where outcome='failed'),0)"
.to_owned()
} else {
format!("coalesce(sum(metric.{name}),0)")
};
(name, expression)
})
.collect::<Vec<_>>();
@@ -156,7 +156,7 @@ impl MetricState {
}
}
let client_status: Option<i32> = get(row, "client_status_code")?;
if client_status.is_some_and(|value| (400..=499).contains(&value)) {
if outcome == "failed" && client_status.is_some_and(|value| (400..=499).contains(&value)) {
self.increment("caller_error_count", 1)?;
}
let attempts = u64::try_from(get::<i32>(row, "attempt_count")?)
@@ -602,6 +602,7 @@ impl<'a> AnalyticsSnapshot<'a> {
"latency_ms_sum",
"cancelled_count",
"incomplete_count",
"caller_error_count",
"retry_count",
"retried_request_count",
"provider_reported_count",
@@ -650,10 +651,9 @@ impl<'a> AnalyticsSnapshot<'a> {
first_token_p95_ms: state
.percentile("first_token_ms", 0.95)
.map(|value| value.round() as u64),
non_completion_count: metrics
.cancelled_count
.checked_add(metrics.incomplete_count)
.ok_or_else(|| invalid("non-completion count overflow"))?,
cancelled_count: metrics.cancelled_count,
incomplete_count: metrics.incomplete_count,
caller_error_count: metrics.caller_error_count,
retry_count: state.count("retry_count"),
retried_request_count: state.count("retried_request_count"),
cost_coverage: state.coverage(),
@@ -1002,7 +1002,7 @@ impl<'a> AnalyticsSnapshot<'a> {
} else {
"'{}'::jsonb as distributions"
};
query.push(format!(" as output_bucket,{name} as name,counts,{costs},{distribution_sql},last_used_at from {table} a where collection="))
query.push(format!(" as output_bucket,{name} as name,outcome,counts,{costs},{distribution_sql},last_used_at from {table} a where collection="))
.push_bind(collection).push(" and schema_version=").push_bind(SCHEMA_VERSION)
.push(" and histogram_version=").push_bind(HISTOGRAM_VERSION);
push_summary_buckets(&mut query, &buckets, seconds);
@@ -290,7 +290,9 @@ fn admin_diagnostic_observation(
average_latency_ms: observation.average_latency_ms,
latency_p95_ms: observation.latency_p95_ms,
first_token_p95_ms: observation.first_token_p95_ms,
non_completion_count: observation.non_completion_count,
cancelled_count: observation.cancelled_count,
incomplete_count: observation.incomplete_count,
caller_error_count: observation.caller_error_count,
retry_count: observation.retry_count,
retried_request_count: observation.retried_request_count,
cost_coverage: admin_cost_coverage(observation.cost_coverage),
@@ -388,7 +388,9 @@ pub struct DiagnosticObservation {
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
@@ -372,12 +372,108 @@ async fn provider_counts_match_for_rollups_boundaries_and_filtered_facts() {
);
assert_eq!(openai.retry_count, 1);
assert_eq!(openai.retried_request_count, 1);
assert_eq!(openai.non_completion_count, 0);
assert_eq!(openai.cancelled_count + openai.incomplete_count, 0);
assert_eq!(openai.cost_coverage.unavailable_count, 1);
}
database.close().await;
}
#[tokio::test]
async fn health_counters_match_for_rollups_and_facts_without_counting_recovery_or_cancelled_499() {
let Some(database) = TestDatabase::create("analytics_health_counters").await else {
return;
};
let start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
for (id, outcome, status) in [
("health_success", "succeeded", 200),
("health_failed", "failed", 502),
("health_incomplete", "incomplete", 200),
("health_caller", "failed", 400),
("health_cancelled", "cancelled", 499),
("health_recovered", "failed", 502),
] {
seed(&database.pool, id, start + TimeDelta::minutes(5), &[]).await;
sqlx::query("update model_requests set outcome=$2,client_status_code=$3 where id=$1")
.bind(id)
.bind(outcome)
.bind(status)
.execute(&database.pool)
.await
.unwrap();
}
sqlx::query("update model_requests set recovered_at=completed_at,recovery_request_id='health_success',recovery_attempt_count=1,request_observation_json=request_observation_json||jsonb_build_object('recovery',jsonb_build_object('retryDelayMs',0,'totalLatencyMs',1)) where id='health_recovered'")
.execute(&database.pool).await.unwrap();
drain_analytics(&database.pool).await;
// 模拟旧桶曾把取消的 499 计入调用方错误,无需重建也应得到相同结果
sqlx::raw_sql("update request_analytics_15m set counts=jsonb_set(counts,'{caller_error_count}','1') where outcome='cancelled'; update request_analytics_1h set counts=jsonb_set(counts,'{caller_error_count}','1') where outcome='cancelled'")
.execute(&database.pool).await.unwrap();
let repo = observability_repository(&database.pool);
let full = ObservabilityRange::new(start, start + TimeDelta::hours(1)).unwrap();
// 完整小时桶、十五分钟桶、边界事实与筛选回退复用同一批请求
for (range, filter) in [
(full, UsageRecordFilter::default()),
(
ObservabilityRange::new(start, start + TimeDelta::minutes(15)).unwrap(),
UsageRecordFilter::default(),
),
(
ObservabilityRange::new(start + TimeDelta::minutes(4), start + TimeDelta::minutes(6))
.unwrap(),
UsageRecordFilter::default(),
),
(
full,
UsageRecordFilter {
search: Some("model_".to_owned()),
..Default::default()
},
),
] {
let snapshot = repo
.usage_insights_observation(
range,
filter.clone(),
gateway_admin::model::observability::Granularity::Hour,
true,
)
.await
.unwrap();
let counts = snapshot.overview.requests;
assert_eq!(
(
counts.request_count,
counts.success_count,
counts.failure_count,
counts.incomplete_count,
counts.cancelled_count,
counts.caller_error_count
),
(5, 1, 2, 1, 1, 1)
);
let diagnostic = repo
.usage_diagnostics(range, filter, DiagnosticDimension::Model, 100)
.await
.unwrap();
assert_eq!(diagnostic.total_request_count, 5);
assert_eq!(diagnostic.items.len(), 1);
let counts = &diagnostic.items[0];
assert_eq!(
(
counts.request_count,
counts.success_count,
counts.failure_count,
counts.incomplete_count,
counts.cancelled_count,
counts.caller_error_count
),
(5, 1, 2, 1, 1, 1)
);
}
database.close().await;
}
#[tokio::test]
async fn insights_share_bucket_states_without_merging_exact_percentiles() {
let Some(database) = TestDatabase::create("analytics_shared_insights").await else {
@@ -1867,12 +1867,15 @@ fn log_import_failure(stage: &'static str, error: &'static str) {
}
fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange;
use CodexOAuthAdminError as Error;
use ProviderAdminErrorKind as Kind;
let (kind, message) = match error {
let (kind, message) = match &error {
Error::InvalidInput => (Kind::Invalid, "OpenAI 授权参数不合法,请重新发起授权"),
Error::CallbackRejected => (Kind::Invalid, "OpenAI 授权回调校验失败,请重新发起授权"),
Error::TokenRejected => (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权"),
Error::TokenRejected | Error::Exchange(Exchange::Rejected) => {
(Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权")
}
Error::Credential => (Kind::Invalid, "OpenAI 授权返回的凭据不完整或无效"),
Error::NotFound | Error::FlowExpired => (
Kind::NotFound,
@@ -1882,29 +1885,51 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
Kind::Conflict,
"OpenAI 授权流程正在处理或已被更新,请先检查授权状态",
),
Error::Ambiguous => (
Error::Exchange(Exchange::Ambiguous { .. }) => (
Kind::Ambiguous,
"OpenAI 授权结果未知,请先核对账号状态,不要立即重复提交",
),
Error::UpstreamUnavailable => (
Error::Exchange(Exchange::Connection {
timed_out: true, ..
}) => (
Kind::Unavailable,
"连接 OpenAI 授权服务超时,请检查出站连接",
),
Error::Exchange(Exchange::Connection {
timed_out: false, ..
}) => (
Kind::Unavailable,
"无法连接 OpenAI 授权服务,请检查出站连接",
),
Error::Exchange(Exchange::Unavailable) => (
Kind::Unavailable,
"OpenAI 授权服务暂不可用,请检查出站连接后重试",
),
Error::StorageUnavailable => (Kind::Unavailable, "OpenAI 授权状态存储暂不可用,请稍后重试"),
};
provider_admin_error(kind).with_public_message(message)
provider_admin_error(kind)
.with_public_message(message)
.with_source(error)
}
const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange;
match error {
CodexOAuthAdminError::InvalidInput => "invalid_input",
CodexOAuthAdminError::NotFound => "not_found",
CodexOAuthAdminError::Conflict => "conflict",
CodexOAuthAdminError::FlowExpired => "flow_expired",
CodexOAuthAdminError::CallbackRejected => "callback_rejected",
CodexOAuthAdminError::TokenRejected => "token_rejected",
CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable",
CodexOAuthAdminError::Ambiguous => "ambiguous",
CodexOAuthAdminError::TokenRejected
| CodexOAuthAdminError::Exchange(Exchange::Rejected) => "token_rejected",
CodexOAuthAdminError::Exchange(Exchange::Unavailable) => "upstream_unavailable",
CodexOAuthAdminError::Exchange(Exchange::Connection {
timed_out: true, ..
}) => "connect_timeout",
CodexOAuthAdminError::Exchange(Exchange::Connection {
timed_out: false, ..
}) => "connect_failed",
CodexOAuthAdminError::Exchange(Exchange::Ambiguous { .. }) => "ambiguous",
CodexOAuthAdminError::StorageUnavailable => "storage_unavailable",
CodexOAuthAdminError::Credential => "credential",
}
@@ -318,7 +318,7 @@ pub enum CodexOAuthPendingClaimOutcome {
InProgress,
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[derive(Debug, thiserror::Error)]
pub enum CodexOAuthAdminError {
#[error("invalid Codex OAuth admin input")]
InvalidInput,
@@ -332,10 +332,8 @@ pub enum CodexOAuthAdminError {
CallbackRejected,
#[error("Codex OAuth token exchange was rejected")]
TokenRejected,
#[error("Codex OAuth upstream is unavailable")]
UpstreamUnavailable,
#[error("Codex OAuth exchange send state is ambiguous")]
Ambiguous,
#[error("Codex OAuth token exchange failed")]
Exchange(#[source] AuthorizationCodeExchangeError),
#[error("Codex OAuth pending storage is unavailable")]
StorageUnavailable,
#[error("Codex OAuth account mutation failed")]
@@ -702,7 +700,7 @@ impl CodexOAuthAdminService {
proxy,
)
.await
.map_err(map_exchange_error)?;
.map_err(CodexOAuthAdminError::Exchange)?;
let mut secret = tokens.secret;
if secret.refresh_token.is_none() {
secret.refresh_token = fallback_refresh_token;
@@ -905,14 +903,6 @@ fn map_authorization_settlement_error(error: CodexOAuthPendingStoreError) -> Adm
}
}
fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminError {
match error {
AuthorizationCodeExchangeError::Rejected => CodexOAuthAdminError::TokenRejected,
AuthorizationCodeExchangeError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
AuthorizationCodeExchangeError::Ambiguous => CodexOAuthAdminError::Ambiguous,
}
}
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
CodexOAuthAdminError::Credential
}
@@ -9,10 +9,10 @@ use gateway_core::error::ErrorSource;
use gateway_core::account::{
AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountErrorReason,
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus,
CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence,
RotationStrategy,
AccountFeedbackStats, AccountModelAccessMode, AccountQuotaSignals, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence, RotationStrategy,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
@@ -635,10 +635,19 @@ impl CodexCredentialSelector {
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
let catalog_excluded = match model {
CodexSelectionModel::Requested(model) if !diagnostic => self
.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now()),
let catalog_excluded = match (model, request.attempt.account_scope()) {
(CodexSelectionModel::Requested(model), Some(scope)) if !diagnostic => {
// 候选已通过冻结的模型政策,白名单精确命中的 ID 交给上游验证
let mut excluded =
self.catalog
.scheduling_exclusions(&accounts, model, SystemTime::now());
excluded.retain(|id| {
scope.directory().account(id).is_none_or(|account| {
account.model_access().mode() != AccountModelAccessMode::Allowlist
})
});
excluded
}
_ => Default::default(),
};
let mut catalog_rejected = 0_usize;
@@ -15,8 +15,9 @@ use std::fmt;
use std::time::Duration;
const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024;
const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
// 总期限为连接后的响应留出预算,避免先于连接期限触发而丢失连接失败分类
const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
/// Codex Desktop 使用的官方 OAuth public client
pub const OFFICIAL_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -332,15 +333,24 @@ impl fmt::Debug for AuthorizationTokenSet {
}
}
/// Authorization Code exchange 的低基数失败
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
/// Authorization Code exchange 的失败分类及受控原因链
#[derive(Debug, Clone, thiserror::Error)]
pub enum AuthorizationCodeExchangeError {
#[error("authorization code was rejected")]
Rejected,
#[error("authorization code exchange is unavailable")]
Unavailable,
#[error("authorization code connection failed (request URL redacted)")]
Connection {
timed_out: bool,
#[source]
source: gateway_core::error::ErrorSource,
},
#[error("authorization code exchange send state is ambiguous")]
Ambiguous,
Ambiguous {
#[source]
source: gateway_core::error::ErrorSource,
},
}
#[async_trait]
@@ -646,15 +656,23 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
.send()
.await
.map_err(|error| {
if is_safe_to_retry_refresh_transport(&error) {
AuthorizationCodeExchangeError::Unavailable
// 连接阶段包含代理隧道和 TLS 握手,此时授权码尚未发往上游
if error.is_connect() {
AuthorizationCodeExchangeError::Connection {
timed_out: error.is_timeout(),
source: error.without_url().into(),
}
} else {
AuthorizationCodeExchangeError::Ambiguous
AuthorizationCodeExchangeError::Ambiguous {
source: error.without_url().into(),
}
}
})?;
let (status, body) = read_bounded_response(response)
.await
.map_err(|_| AuthorizationCodeExchangeError::Ambiguous)?;
let (status, body) = read_bounded_response(response).await.map_err(|error| {
AuthorizationCodeExchangeError::Ambiguous {
source: error.into(),
}
})?;
if !status.is_success() {
return Err(match status.as_u16() {
429 | 500..=599 => AuthorizationCodeExchangeError::Unavailable,
@@ -58,14 +58,20 @@ impl ResponseControlTransport for PumpControl {
}
async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
let message = tokio::select! {
biased;
() = self.cancellation.cancelled() => return Err(ResponseControlUnavailable),
message = async { self.rx_message.lock().await.recv().await } => message,
};
match message {
Some(Ok(Message::Text(text))) => Ok(text.to_string()),
_ => Err(ResponseControlUnavailable),
loop {
let message = tokio::select! {
biased;
() = self.cancellation.cancelled() => return Err(ResponseControlUnavailable),
message = async { self.rx_message.lock().await.recv().await } => message,
};
match message {
Some(Ok(Message::Text(text))) => return Ok(text.to_string()),
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => {
return Err(ResponseControlUnavailable);
}
// 空闲控制通道只交付文本,其他数据帧不能代替连接终止信号
Some(Ok(_)) => {}
}
}
}
}
@@ -833,6 +833,113 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
assert_eq!(pending.values.lock().expect("OAuth pending").len(), 1);
}
#[tokio::test]
async fn oauth_connection_failures_preserve_safe_diagnostics_and_release_the_claim() {
use std::error::Error as _;
use tokio::io::AsyncReadExt as _;
for timed_out in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let proxy_task = if timed_out {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
let count = socket.read(&mut request).await.unwrap();
assert!(request[..count].starts_with(b"CONNECT "));
std::future::pending::<()>().await;
}))
} else {
drop(listener);
None
};
let pending = Arc::new(TestOAuthPending::default());
let mut config = valid_config();
config.config.auth.oauth_token_endpoint =
"https://oauth.example.invalid/oauth/token?PRIVATE_URL_MARKER".to_owned();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(Arc::new(MemoryAccountStore::default()), pending.clone()),
Arc::new(gateway_core::account::UpstreamInfoReader::default()),
)
.await
.unwrap();
let context = MutationContext {
actor: MutationActor::System,
request_id: "oauth-connection-failure".to_owned(),
};
let started = bundle
.admin_provider()
.start_authorization(
PendingAuthorizationMutation::new(
ProviderKind::new("openai").unwrap(),
AuthorizationMutationTarget::Create {
name: String::new(),
},
AuthorizationOwnerBinding::from_context(&context),
)
.with_outbound_proxy(Some(
gateway_core::account::OutboundProxy::parse(&format!(
"http://proxy-user:PRIVATE_PROXY_PASSWORD@{address}"
))
.unwrap(),
)),
)
.await
.unwrap();
let state = {
let values = pending.values.lock().unwrap();
let (_, payload, _, _) = values.values().next().unwrap();
payload.expose_to_provider()["state"]
.as_str()
.unwrap()
.to_owned()
};
let error = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context,
flow_id: started.flow_id,
callback_url: format!(
"http://127.0.0.1:1455/auth/callback?code=PRIVATE_CODE&state={state}"
),
})
.await
.unwrap_err();
if let Some(task) = proxy_task {
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
}
assert_eq!(error.kind(), ProviderAdminErrorKind::Unavailable);
assert_eq!(
error.public_message(),
Some(if timed_out {
"连接 OpenAI 授权服务超时,请检查出站连接"
} else {
"无法连接 OpenAI 授权服务,请检查出站连接"
}),
);
let mut source = error.source();
let transport = loop {
let cause = source.expect("transport cause must reach the admin boundary");
if let Some(transport) = cause.downcast_ref::<reqwest::Error>() {
break transport;
}
source = cause.source();
};
assert!(transport.is_connect());
assert_eq!(transport.is_timeout(), timed_out);
assert!(transport.url().is_none());
assert!(!format!("{error:?} {error}").contains("PRIVATE_"));
let details =
gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap();
assert!(!details.as_str().contains("PRIVATE_"));
let values = pending.values.lock().unwrap();
assert!(values.values().next().unwrap().3.is_none());
}
}
#[tokio::test]
async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() {
let accounts = Arc::new(MemoryAccountStore::default());
@@ -567,5 +567,5 @@ async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable
let error = complete("header.not-base64.signature".to_owned())
.await
.expect_err("official local payload parsing rejects malformed id tokens");
assert_eq!(error, CodexOAuthAdminError::TokenRejected);
assert!(matches!(error, CodexOAuthAdminError::TokenRejected));
}
@@ -365,6 +365,68 @@ async fn authorization_code_exchange_keeps_upstream_access_and_refresh_tokens_ve
);
}
#[tokio::test]
async fn authorization_code_response_timeouts_remain_ambiguous_without_exposing_request_material() {
use std::error::Error as _;
use std::time::Duration;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
for partial_body in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
let count = socket.read(&mut request).await.unwrap();
assert!(request[..count].starts_with(b"POST "));
if partial_body {
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"access_token\":\"PRIVATE_BODY").await.unwrap();
}
std::future::pending::<()>().await;
});
let client = OpenAiTokenClient::new(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_millis(250))
.build()
.unwrap(),
TokenClientConfig {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("http://{address}/oauth/token?PRIVATE_URL_MARKER"),
},
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
);
let error = client
.exchange_authorization_code(AuthorizationCodeGrant {
code: SecretString::from("PRIVATE_CODE"),
code_verifier: SecretString::from("PRIVATE_VERIFIER"),
})
.await
.unwrap_err();
server.abort();
assert!(server.await.unwrap_err().is_cancelled());
assert!(matches!(
error,
AuthorizationCodeExchangeError::Ambiguous { .. }
));
let mut source = error.source();
let transport = loop {
let cause = source.expect("transport cause must survive classification");
if let Some(transport) = cause.downcast_ref::<reqwest::Error>() {
break transport;
}
source = cause.source();
};
assert!(transport.is_timeout());
assert!(!transport.is_connect());
assert!(transport.url().is_none());
assert!(!format!("{error:?} {error}").contains("PRIVATE_"));
let details =
gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap();
assert!(!details.as_str().contains("PRIVATE_"));
}
}
#[tokio::test]
async fn authorization_code_exchange_requires_id_token_and_json_response() {
let missing_id = MockServer::start().await;
@@ -393,7 +455,7 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
})
.await
.expect_err("invalid token response fails closed");
assert_eq!(error, AuthorizationCodeExchangeError::Rejected);
assert!(matches!(error, AuthorizationCodeExchangeError::Rejected));
}
}
@@ -141,8 +141,11 @@ async fn unlisted_models_remain_available_without_catalog_discovery() {
}
#[tokio::test]
async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_accounts() {
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
async fn account_allowlists_override_catalog_omissions_but_weight_affinity_and_pinning_do_not() {
use gateway_core::account::{
AccountModelAccess,
AccountModelAccessMode::{All, Allowlist, Denylist},
};
use provider_openai::credential::ResponsesTransport;
for (primary_api, secondary_api) in [(false, false), (true, true), (true, false), (false, true)]
@@ -264,52 +267,45 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
}
}
assert_eq!(affinity.binding_count(), 1);
// 目录资格不能被固定账号、模型允许列表或另一个账号的目录放宽
for (model, required, disable_supported) in [
("gpt-6-astra", Some("acct_scope_new"), false),
("custom-unlisted", None, false),
("gpt-6-astra", None, true),
let primary = "acct_provider_contract";
let secondary = "acct_scope_new";
// 目录外许可只属于明确添加该模型的账号,不能借用同池账号的白名单
for (model, required, primary_mode, secondary_mode, expected) in [
("gpt-6-astra", Some(secondary), All, All, None),
("custom-unlisted", None, All, All, None),
("gpt-6.1-sol", None, Allowlist, All, Some(primary)),
("gpt-6-astra", None, Denylist, Allowlist, Some(secondary)),
(
"custom-unlisted",
None,
Denylist,
Allowlist,
Some(secondary),
),
("gpt-6-luna", Some(primary), Denylist, Allowlist, None),
] {
let scope = if disable_supported {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
(
ProviderAccountId::new("acct_provider_contract").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Denylist,
vec![model.to_owned()],
)
.unwrap(),
),
),
(
ProviderAccountId::new("acct_scope_new").unwrap(),
RuntimeAccount::new(
ProviderKind::new("openai").unwrap(),
BTreeSet::new(),
)
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec![model.to_owned()],
)
.unwrap(),
),
),
]))),
ClientRoutingScope::all_accounts(),
))
} else {
contract_account_scope()
};
let accounts = [(primary, primary_mode), (secondary, secondary_mode)]
.into_iter()
.map(|(id, mode)| {
let access = if mode == All {
AccountModelAccess::all()
} else {
AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap()
};
(
ProviderAccountId::new(id).unwrap(),
RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new())
.with_model_access(access),
)
})
.collect();
let scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(accounts)),
ClientRoutingScope::all_accounts(),
));
let attempt = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_catalog_rejected").unwrap(),
ModelRequestId::new("req_account_catalog_policy").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::MIN,
@@ -325,19 +321,26 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
CancellationToken::new(),
);
let acquisitions = leases.requests.lock().unwrap().len();
let error = provider
let result = provider
.clone()
.execute(
planned_request_for_model("openai", operation(model), model),
attempt,
)
.await
.err()
.expect("catalog and model access must both permit the account");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
.await;
if let Some(expected) = expected {
let mut stream = result.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), expected);
while let Some(event) = stream.next().await {
event.unwrap();
}
} else {
let error = result.err().expect("account must be excluded");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(leases.requests.lock().unwrap().len(), acquisitions);
}
}
let sent = native
let sent: Vec<_> = native
.received_requests()
.await
.unwrap()
@@ -345,8 +348,16 @@ async fn catalog_membership_and_model_access_precede_weight_affinity_and_fixed_a
.chain(api_primary.received_requests().await.unwrap())
.chain(api_secondary.received_requests().await.unwrap())
.filter(|request| request.method == "POST")
.count();
assert_eq!(sent, 2);
.map(|request| {
captured_request_body(&request)["model"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(sent.len(), 5);
assert!(sent.iter().any(|model| model == "gpt-6.1-sol"));
assert!(sent.iter().any(|model| model == "custom-unlisted"));
}
}
@@ -208,6 +208,23 @@ fn interrupt_context(control: ResponseControl, previous: Option<&str>) -> Attemp
#[tokio::test]
async fn response_controls_use_the_original_socket_through_terminal_and_revoke_on_reuse() {
for terminal in [
None,
Some(Message::Close(None)),
Some(Message::Close(Some(CloseFrame {
code: CloseCode::Normal,
reason: "".into(),
}))),
Some(Message::Close(Some(CloseFrame {
code: CloseCode::Restart,
reason: "service restart".into(),
}))),
] {
assert_response_control_lifecycle(terminal).await;
}
}
async fn assert_response_control_lifecycle(terminal: Option<Message>) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -268,6 +285,11 @@ async fn response_controls_use_the_original_socket_through_terminal_and_revoke_o
late,
json!({"type":"response.interrupt", "response_id":id,"mode":"future_mode", "extra":1})
);
socket
.send(Message::Binary(vec![0xff].into()))
.await
.unwrap();
socket.send(Message::Ping(vec![1].into())).await.unwrap();
socket
.send(Message::Text(
json!({"type":"future.idle_ack", "original":late})
@@ -278,6 +300,9 @@ async fn response_controls_use_the_original_socket_through_terminal_and_revoke_o
.unwrap();
}
let _ = finished.await;
if let Some(terminal) = terminal {
socket.send(terminal).await.unwrap();
}
});
let provider = provider_with_base_url(&store, base_url);
let mut session_state = None;
+23 -9
View File
@@ -494,8 +494,11 @@ Responses WebSocket 接受文本消息,`response.create` 在同一连接串行
| WS → SSE | 上游本次响应结束,下游 WS 通常保留;大包回退可触发正常重连 |
| WS → WS | 保留当前绑定的上游 WS,支持下一轮续接 |
活跃客户端 WS 的绑定不受普通空闲回收期限约束;上游关闭、总寿命到期或连接被回收仍可能使连接内续接失效,
客户端按既有 `previous_response_not_found` 合同完整重放。具体心跳和回收参数见[部署与运维](../deploy/README.md#公网访问)
活跃客户端 WS 的绑定不受普通空闲回收期限约束;上游 Close(不限关闭码)、EOF、传输读写失败、总寿命到期或连接被回收仍可能使连接内续接失效,
网关在收到绑定上游的失效通知且下游空闲时,以关闭码 1000 要求客户端重连并发送完整输入。
终态后的结算先完成,已接收或正在处理的客户端消息先按原合同执行;启用 WS 帧中间件时保留连接。
续接请求与失效通知同时到达时,仍按 `previous_response_not_found` 合同完整重放。
具体心跳和回收参数见[部署与运维](../deploy/README.md#公网访问)
Codex OAuth 上游 WS 首包未声明搜索时,后续新增 `web_search` 声明需要新连接,非生成预热也属于首包。
HTTP/SSE 与 WS 入站共用此判断;新链在发送前重新建连,依赖原连接的增量请求返回
@@ -576,7 +579,7 @@ xAI 没有 Codex 原生目录,使用官方 CLI proxy 当前目录生成通用
已知模型只路由到明确提供该模型的 Provider,账号不可用时不转发给其他平台的未知模型通道。
未知模型只在当前范围与路由约束下存在唯一 Provider 透传候选时发送;无法确定归属时返回不可用错误,
可通过 Client Key 分组限定目标平台。OpenAI OAuth、API Key 上游和 xAI 账号均叠加有效目录资格与账号模型政策,
可通过 Client Key 分组限定目标平台。Provider 在选号时检查账号模型政策与目录资格,
具体边界见[账号模型限制](#账号模型限制);ChatGPT OAuth 账号不接受 Grok 模型
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 的 OAuth 目录按账号 ID 排序,使用首个成功读取的
@@ -1223,14 +1226,16 @@ Key 预算及其访问状态随互联默认公开,不要求开启 `shareQuota`
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
管理端刷新目录只更新目录,不修改模型限制。白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
管理端刷新目录只更新目录,不修改模型限制。全选与取消全选只作用于上游目录中符合当前搜索条件的模型。
白名单的清理按钮列出当前目录未返回的已选 ID,默认不选中任何项,
由管理员确认后从当前表单移除,保存账号后生效。目录未就绪、刷新失败或返回空目录时不能清理,清理也不能将白名单置空。
图像、搜索、插件适配模型和映射目标可能不在目录中,目录缺失本身不代表模型失效。
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则
原生 Responses 选号同时检查有效模型目录与 `modelAccess`,OAuth、API Key 上游和 xAI 使用相同资格规则:
有效目录未包含请求模型时排除该账号,目录包含模型也不能绕过禁用或黑白名单;权重、亲和和固定账号均不能放宽资格。
原生 Responses 默认排除有效目录未列出目标模型的账号。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游 ID 时,
允许通过该账号请求目录外模型,由上游确认是否可用。此设置只对配置它的账号生效,已有白名单同样适用。
账号禁用、Key 分组、认证类型、黑名单和模型冷却仍参与选号。xAI 账号使用有效套餐目录与账号模型政策的交集。
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
@@ -1248,7 +1253,7 @@ OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐
Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。
模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
Search 的选号限制不约束插件在选号后主动改写正文。
管理员连接测试不受账号模型政策、目录资格筛选与模型拒绝冷却限制;连接测试成功只证明指定账号的上游能力。
管理员连接测试的选项包含上游目录和账号白名单中的 ID。连接测试跳过普通选号的模型政策、目录资格筛选与模型拒绝冷却,成功表示指定账号可调用测试使用的端点与模型。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
@@ -1553,6 +1558,12 @@ OAuth start 使用:
账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId`
重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用
OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上游请求总超时为 60 秒
- 授权码交换未能建立连接时返回 `50301`,提示区分连接超时与其他连接失败
- 无法确认发送结果或未完整接收响应时返回 `50202`,应先核对账号状态,不立即重复提交授权码
- 代理出口探测成功不代表 OpenAI 授权端点可达
### OpenAI 身份、额度与状态
- OAuth 文件导入接受 camelCase 与 snake_case 的三个 token 字段,内部统一保存为
@@ -2620,8 +2631,11 @@ OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_updat
原生续接未再次更新时沿用已关联响应的配置,否则取请求顶层 `reasoning.effort`。
该字段是请求观测,不改写上游请求;网关未持有的续接历史无法用于推断档位
请求健康可用性与服务成功率按 `成功 /(成功 + 服务失败 + 未完成)` 计算
未完成保留独立计数,客户端取消与调用方错误不计入该分母,中间尝试失败不重复计为最终失败请求
首页趋势、请求健康与热点诊断统一口径:错误为服务失败与未完成之和,成功率为 `成功 /(成功 + 错误)`
已恢复记录、客户端取消与调用方错误不计入,进行中不参与比例;调用方错误仅指失败终态的 4xx,不含取消请求的 499
无有效样本时,成功率与错误率返回 `null`,趋势线保留断点
请求健康保留各终态的独立计数,热点诊断的 `errorCount`、`errorRate` 使用相同口径,中间尝试失败不重复计入
`completionRate` 表示成功或失败终态占总请求的比例,`nonCompletionCount`、`nonCompletionRate` 表示取消与未完成的数量及其占总请求的比例
健康时间线以 15 分钟分桶,可用性不低于 95% 且有效样本不少于 10 个时显示稳定
没有成功且服务失败与未完成合计不少于 3 个时显示不可达,其他不足 10 个有效样本的时段显示低样本
有效样本为零时显示无有效样本,尚未到达的时间格显示未来
+5 -4
View File
@@ -517,7 +517,7 @@ credential state 调度,不以是否存在上游用户 ID 推断可用性;OA
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth 按套餐、API Key 按账号与凭据版本匹配目录。
未知目录或未知套餐保留上游验证;管理员配置的模型权限仍由 Core 与选号链路执行
目录与账号白名单的优先级见[路由与账号范围](#6-路由账号范围与-continuation)
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
@@ -747,8 +747,8 @@ OpenAI Provider 在选号时排除 Grok 模型的 ChatGPT OAuth 账号,API Key
OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐限制,Images 和 Live 共用 OAuth 套餐检查。
资格检查复用账号持久事实,在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一检查
原生 Responses 在评分、亲和和租约前将目录资格与冻结的模型政策取交集:有效目录未包含请求模型时排除账号,
目录包含模型不能解除管理员限制。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
原生 Responses 在评分、亲和和租约前检查冻结的账号模型政策。OpenAI 账号白名单精确命中目标 ID 时允许上游验证,
其余 OpenAI 账号及 xAI 账号按有效目录排除未列出的模型。OpenAI OAuth 与 xAI 按已知套餐匹配,API Key 按账号与凭据版本匹配。
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
@@ -1221,6 +1221,7 @@ Worker 按探测结果、凭据版本及冻结代次解除或顺延保护;旧
启动只有在配置、PostgreSQL、Redis、Provider、Core、Admin、API 和 Worker 全部初始化成功后才进入服务。
Core 与 Store 在各自的探针上声明接收请求所需的依赖。API 的就绪检查只运行这些探针,
综合健康检查另纳入 Host 文件日志与关键 Worker 状态,并记录故障变化。
插件兼容检查使用本地运行版本;远端发行查询由管理端版本与更新操作按需触发,不阻塞启动。
客户端下载解析由 Host 的 `ClientDistributionResolver` 实现,组合根注入 Admin;只在管理员请求时访问外部来源,
不阻塞启动。失败时使用官方稳定地址,临时直链不持久化或代理下载,字段与来源规则见 [客户端下载 API](api.md#windows-客户端下载)
@@ -1265,7 +1266,7 @@ Compose 的 `stop_grace_period` 必须覆盖后台关闭预算并留出进程清
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
Host 在重启操作锁内重新检查,Admin 复核风险确认与配置版本,不改写插件配置;重启后 Runtime 逐实例加载并隔离失败。
重启受理后保持排他状态与跨进程文件锁,直到旧服务释放;延迟触发关闭仅用于送达受理响应。
Windows 替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
二进制部署的替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
+6 -6
View File
@@ -20,7 +20,7 @@
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.66.0",
"@lucide/vue": "^1.67.0",
"@vueuse/core": "^15.0.0",
"@vueuse/integrations": "^15.0.0",
"axios": "^1.21.0",
@@ -28,20 +28,20 @@
"css-tree": "^3.2.1",
"echarts": "^6.1.0",
"es-toolkit": "^1.53.1",
"gsap": "^3.15.0",
"gsap": "^3.15.1",
"pinia": "^4.1.0",
"pinia-plugin-persistedstate": "^4.7.1",
"semver": "^7.8.5",
"semver": "^7.8.6",
"sortablejs": "^1.15.7",
"vue": "^3.5.44",
"vue-i18n": "11.4.16",
"vue-i18n": "11.4.18",
"vue-router": "^5.4.0"
},
"devDependencies": {
"@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.3",
"@tailwindcss/vite": "^4.3.4",
"@types/css-tree": "^3.2.0",
"@types/node": "^26.6.6",
"@types/semver": "^7.8.0",
@@ -51,7 +51,7 @@
"eslint": "^10.14.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.3",
"tailwindcss": "^4.3.3",
"tailwindcss": "^4.3.4",
"typescript": "^6.0.3",
"vite": "^8.3.5",
"vue-tsc": "3.3.14"
+149 -239
View File
@@ -204,8 +204,8 @@ importers:
specifier: ^5.3.0
version: 5.3.0
'@lucide/vue':
specifier: ^1.66.0
version: 1.66.0(vue@3.5.44(typescript@6.0.3))
specifier: ^1.67.0
version: 1.67.0(vue@3.5.44(typescript@6.0.3))
'@vueuse/core':
specifier: ^15.0.0
version: 15.0.0(vue@3.5.44(typescript@6.0.3))
@@ -228,8 +228,8 @@ importers:
specifier: ^1.53.1
version: 1.53.1
gsap:
specifier: ^3.15.0
version: 3.15.0
specifier: ^3.15.1
version: 3.15.1
pinia:
specifier: ^4.1.0
version: 4.1.0(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3))
@@ -237,8 +237,8 @@ importers:
specifier: ^4.7.1
version: 4.7.1(pinia@4.1.0(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3)))
semver:
specifier: ^7.8.5
version: 7.8.5
specifier: ^7.8.6
version: 7.8.6
sortablejs:
specifier: ^1.15.7
version: 1.15.7
@@ -246,8 +246,8 @@ importers:
specifier: ^3.5.44
version: 3.5.44(typescript@6.0.3)
vue-i18n:
specifier: 11.4.16
version: 11.4.16(vue@3.5.44(typescript@6.0.3))
specifier: 11.4.18
version: 11.4.18(vue@3.5.44(typescript@6.0.3))
vue-router:
specifier: ^5.4.0
version: 5.4.0(@vue/compiler-sfc@3.5.44)(pinia@4.1.0(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3)))(rolldown@1.2.8)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
@@ -260,10 +260,10 @@ importers:
version: 4.5.1(eslint@10.14.0(jiti@2.7.0))(jsonc-eslint-parser@3.3.0)(vue-eslint-parser@10.4.1(eslint@10.14.0(jiti@2.7.0)))(yaml-eslint-parser@2.1.0)
'@intlify/unplugin-vue-i18n':
specifier: 11.2.8
version: 11.2.8(typescript@6.0.3)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))
version: 11.2.8(typescript@6.0.3)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))
specifier: ^4.3.4
version: 4.3.4(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))
'@types/css-tree':
specifier: ^3.2.0
version: 3.2.0
@@ -292,8 +292,8 @@ importers:
specifier: ^2.6.3
version: 2.6.3(eslint@10.14.0(jiti@2.7.0))(globals@17.13.0)
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
specifier: ^4.3.4
version: 4.3.4
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -758,10 +758,18 @@ packages:
resolution: {integrity: sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==}
engines: {node: '>= 22'}
'@intlify/core-base@11.4.18':
resolution: {integrity: sha512-uryU7clROKIJsCLM8I8J6GA4pw9EVI5BCptnFQslpUXGPj/dCrlrixOaV/6gL1jLXZe3Hl8YY3yNi24bgfhvBw==}
engines: {node: '>= 22'}
'@intlify/devtools-types@11.4.16':
resolution: {integrity: sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==}
engines: {node: '>= 22'}
'@intlify/devtools-types@11.4.18':
resolution: {integrity: sha512-J7/PW4wSSijVzR8j578q2+/HICPbA09RhCIMZsLHEL8PlRkdXXVadhunrxLOre8cBe8dDK54922frpa0Z1UimA==}
engines: {node: '>= 22'}
'@intlify/eslint-plugin-vue-i18n@4.5.1':
resolution: {integrity: sha512-iAX/zbg48uF8BvjfJKGKUOCdxntcJsNsh5iXePh9ivMDu+AdzfN7EHsdKyohxXTKcc8HaU1bW/SW++BQ+C0HUQ==}
engines: {node: '>=18.0.0'}
@@ -775,10 +783,18 @@ packages:
resolution: {integrity: sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==}
engines: {node: '>= 22'}
'@intlify/message-compiler@11.4.18':
resolution: {integrity: sha512-9v2s0+mIuJaFOyWBfZBHrV/g9VLFH/4osx/zaQ52BgT6FQkjvTWHWyNriCMRqWs3PEuBKysmwTUwmNpg9iPjJw==}
engines: {node: '>= 22'}
'@intlify/shared@11.4.16':
resolution: {integrity: sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==}
engines: {node: '>= 22'}
'@intlify/shared@11.4.18':
resolution: {integrity: sha512-8q7UR/eAFP0xOZndaClKYeh/lWkcIPOZkWsU85CMR9BYLjTeaENpAFWfFBvuUI8fFz0BDtZvR/fgbwiiQaqcDg==}
engines: {node: '>= 22'}
'@intlify/unplugin-vue-i18n@11.2.8':
resolution: {integrity: sha512-5jZnxlx9kLziDfORa2ESMdWohl4T24hf7KvgktsoTfYXvdLknbCIwMjQQch9XCMTOSnPeepmH5PCEW8ivM5OoQ==}
engines: {node: '>= 22.13'}
@@ -839,8 +855,8 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@lucide/vue@1.66.0':
resolution: {integrity: sha512-cyALYgAzCD2uZ557pM5I/dmFAyTKqtwY7PrdU67gzITkTH+V635u7Uv7VBpQ5hqvmlGqRtbqSDx08LCJHU+GAw==}
'@lucide/vue@1.67.0':
resolution: {integrity: sha512-f4MC7czjbnFWOGyN3Ul0uJ6SLjj2cDIBpJpxc5NHyj9E3gE7mBqhHsJcFDno3vLuAyXbE9CTEqIqO3Qdk1tNvw==}
peerDependencies:
vue: '>=3.0.1'
@@ -1202,71 +1218,70 @@ packages:
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/node@4.3.4':
resolution: {integrity: sha512-b7QK/QG04v3j04NbpG82XLCedRyulnkuwUDwRa9oK/ypnJIzMapz2Wg+0TltVczjUzDaJJHIa1+jj9aMUOFMTQ==}
'@tailwindcss/oxide-android-arm64@4.3.3':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
'@tailwindcss/oxide-android-arm64@4.3.4':
resolution: {integrity: sha512-qkjxt+EFocOi3mtsUS3ndlj6yj4SHubwBdvBynLnS8T4mvSzX0Enn0CpypJKmBEtczUAXOHVZKA2zTGzSNU2Sg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.3.3':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
'@tailwindcss/oxide-darwin-arm64@4.3.4':
resolution: {integrity: sha512-i/awUA/7mJQAbA3AzCW8aQtUh5goMieUdF1zOoclLaJUCxDvsXQ8VJqpQZY3dfcHS6PJjd3tw25nNneD/mEviQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.3.3':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
'@tailwindcss/oxide-darwin-x64@4.3.4':
resolution: {integrity: sha512-q56V9U6AnoYz/yj/alkDLhSQ9AZoXQSEl93f7WETa0+32CudbqM57BXdYXfMYPKudueXKaA+MG6L/0KRseF7Jg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.3.3':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
'@tailwindcss/oxide-freebsd-x64@4.3.4':
resolution: {integrity: sha512-r0W7A+Z9HTHh7bwihJl45E/zZKLjxD50/jToVF/E/RZHSl8+EWO+4yKKJpPMcUooT9hdoip3Zx1uWnbMg4N79g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4':
resolution: {integrity: sha512-3aih/sRxzeYfi2Bkh+bGYNf2YU9Llamb2mpA7BNF1obYzdgEeWDAMQzK6BSwzLhs/eTdOaSur92qrGfchagUtQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
'@tailwindcss/oxide-linux-arm64-gnu@4.3.4':
resolution: {integrity: sha512-ktJSrPGQou4OqL88rDEx5CkeM9BjzrwZvYo8u7B7VL1zkBa2XBm351o4MQrek95qikb2M++26Y7w7vbH6Mgl8g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
'@tailwindcss/oxide-linux-arm64-musl@4.3.4':
resolution: {integrity: sha512-rt3hjUxT7utAtcB+df6wHyaXDyxhE7Zd1x7L/nvB5Jb2qBb8dZwFUoWqeRja1BaF5OlSqkKSjBmVXj9OsevmCQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
'@tailwindcss/oxide-linux-x64-gnu@4.3.4':
resolution: {integrity: sha512-XE3jO2yOOWjttpeGvpwf3/b274NMi3QapQSAI/EzsgrmYNan1Tw8dXT9JQkN7K0OgvuqiC8mcCyNvGzEkpVJPQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
'@tailwindcss/oxide-linux-x64-musl@4.3.4':
resolution: {integrity: sha512-PaAZ2FjJaZk9SQLYtKIfQWiFm/xtl7pX2MKPgCzn0mS/VU54yu+vP457AJdB2XFLxU3gO2abLCt8N7Lg4EbKNg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@tailwindcss/oxide-wasm32-wasi@4.3.4':
resolution: {integrity: sha512-bjO9WmoM687p8AoCIyw1DUqzpAs4JICLvNmIPxIWDmhw8IOsdubtWQLShyJmISnDKtRy1BBApHVOUl6sNFQQJA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
@@ -1275,24 +1290,24 @@ packages:
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
'@tailwindcss/oxide-win32-arm64-msvc@4.3.4':
resolution: {integrity: sha512-jCrwCvWVtt7ruMXp6dFPd5KBNU8o1tWtEdZbTL/w/hvD3lUYZA0E2mmfvTzW4QLHIx5wGQU9HeY7997R1QTrEg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
'@tailwindcss/oxide-win32-x64-msvc@4.3.4':
resolution: {integrity: sha512-cge1wcedn1FrRSXL22lF5sNDYr8c9+VImk7zFYLPs12jkDmchSrZNfzWqOKub+5Hb/aYRdDEqERGGDKONBlOzQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.3.3':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
'@tailwindcss/oxide@4.3.4':
resolution: {integrity: sha512-riqsRw2sLF7B1sNCCkTl3KYXEqvhTiABzZ/4oxxHfZ3c3Td60bCBfOrUuUGOiHagd3E7V+zG46c+XBDWZGFBcg==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.3.3':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
'@tailwindcss/vite@4.3.4':
resolution: {integrity: sha512-wGukGWb8SVuN7+FJvSS7M5eSJWDIWwVSvUvj1q41eTIhGPsS2DAgFWjA7YURJNwjHSgty59Hz8iMUiyO+YMHBQ==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
@@ -2300,8 +2315,8 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
gsap@3.15.1:
resolution: {integrity: sha512-63yjWRFWj3QRP1UOuD8kcidCcy3nxb1mMRpwHr/xJRvPfm9GtWOXFAYUiL0ro1fgPq2nY53LR+98pAgy9BumBg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
@@ -2451,73 +2466,36 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
@@ -2525,13 +2503,6 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
@@ -2539,13 +2510,6 @@ packages:
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
@@ -2553,13 +2517,6 @@ packages:
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
@@ -2567,34 +2524,18 @@ packages:
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
@@ -3065,8 +3006,8 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
semver@7.8.6:
resolution: {integrity: sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==}
engines: {node: '>=10'}
hasBin: true
@@ -3133,8 +3074,8 @@ packages:
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
engines: {node: ^14.18.0 || >=16.0.0}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
tailwindcss@4.3.4:
resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
@@ -3334,8 +3275,8 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-i18n@11.4.16:
resolution: {integrity: sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==}
vue-i18n@11.4.18:
resolution: {integrity: sha512-NMPOTrqSUVPJxSuiYi0uJ2q0bZovoGuQb13hY9lR+ywb/FOtJRoXqxPRcO/64CDed4tkk9lZ6hRPG7HBP1nxvg==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
@@ -3550,14 +3491,14 @@ snapshots:
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
'@lucide/vue': 1.66.0(vue@3.5.44(typescript@6.0.3))
'@lucide/vue': 1.67.0(vue@3.5.44(typescript@6.0.3))
'@rc-component/async-validator': 6.0.0
'@vueuse/core': 15.0.0(vue@3.5.44(typescript@6.0.3))
'@vueuse/integrations': 15.0.0(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(sortablejs@1.15.7)(vue@3.5.44(typescript@6.0.3))
dayjs: 1.11.23
dompurify: 3.4.17
es-toolkit: 1.53.1
gsap: 3.15.0
gsap: 3.15.1
marked: 18.1.0
marked-alert: 2.1.2(marked@18.1.0)
postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.30)(yaml@2.9.0)
@@ -3813,7 +3754,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@intlify/bundle-utils@11.2.8(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))':
'@intlify/bundle-utils@11.2.8(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))':
dependencies:
'@intlify/message-compiler': 11.4.16
'@intlify/shared': 11.4.16
@@ -3823,7 +3764,7 @@ snapshots:
source-map-js: 1.2.2
yaml-eslint-parser: 1.3.2
optionalDependencies:
vue-i18n: 11.4.16(vue@3.5.44(typescript@6.0.3))
vue-i18n: 11.4.18(vue@3.5.44(typescript@6.0.3))
'@intlify/core-base@11.4.16':
dependencies:
@@ -3831,11 +3772,22 @@ snapshots:
'@intlify/message-compiler': 11.4.16
'@intlify/shared': 11.4.16
'@intlify/core-base@11.4.18':
dependencies:
'@intlify/devtools-types': 11.4.18
'@intlify/message-compiler': 11.4.18
'@intlify/shared': 11.4.18
'@intlify/devtools-types@11.4.16':
dependencies:
'@intlify/core-base': 11.4.16
'@intlify/shared': 11.4.16
'@intlify/devtools-types@11.4.18':
dependencies:
'@intlify/core-base': 11.4.18
'@intlify/shared': 11.4.18
'@intlify/eslint-plugin-vue-i18n@4.5.1(eslint@10.14.0(jiti@2.7.0))(jsonc-eslint-parser@3.3.0)(vue-eslint-parser@10.4.1(eslint@10.14.0(jiti@2.7.0)))(yaml-eslint-parser@2.1.0)':
dependencies:
'@eslint/eslintrc': 3.3.7
@@ -3853,7 +3805,7 @@ snapshots:
json5: 2.2.3
jsonc-eslint-parser: 3.3.0
parse5: 7.3.0
semver: 7.8.5
semver: 7.8.6
synckit: 0.11.13
vue-eslint-parser: 10.4.1(eslint@10.14.0(jiti@2.7.0))
yaml-eslint-parser: 2.1.0
@@ -3865,13 +3817,20 @@ snapshots:
'@intlify/shared': 11.4.16
source-map-js: 1.2.2
'@intlify/message-compiler@11.4.18':
dependencies:
'@intlify/shared': 11.4.18
source-map-js: 1.2.2
'@intlify/shared@11.4.16': {}
'@intlify/unplugin-vue-i18n@11.2.8(typescript@6.0.3)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))':
'@intlify/shared@11.4.18': {}
'@intlify/unplugin-vue-i18n@11.2.8(typescript@6.0.3)(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@intlify/bundle-utils': 11.2.8(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))
'@intlify/bundle-utils': 11.2.8(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))
'@intlify/shared': 11.4.16
'@intlify/vue-i18n-extensions': 9.0.0(@intlify/shared@11.4.16)(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))
'@intlify/vue-i18n-extensions': 9.0.0(@intlify/shared@11.4.16)(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))
'@rollup/pluginutils': 5.4.0
'@typescript-eslint/scope-manager': 8.72.0
'@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3)
@@ -3883,20 +3842,20 @@ snapshots:
vue: 3.5.44(typescript@6.0.3)
optionalDependencies:
vite: 8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0)
vue-i18n: 11.4.16(vue@3.5.44(typescript@6.0.3))
vue-i18n: 11.4.18(vue@3.5.44(typescript@6.0.3))
transitivePeerDependencies:
- rollup
- supports-color
- typescript
'@intlify/vue-i18n-extensions@9.0.0(@intlify/shared@11.4.16)(vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))':
'@intlify/vue-i18n-extensions@9.0.0(@intlify/shared@11.4.16)(vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)))(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@babel/parser': 7.29.9
'@vue/compiler-dom': 3.5.44
optionalDependencies:
'@intlify/shared': 11.4.16
vue: 3.5.44(typescript@6.0.3)
vue-i18n: 11.4.16(vue@3.5.44(typescript@6.0.3))
vue-i18n: 11.4.18(vue@3.5.44(typescript@6.0.3))
'@isaacs/cliui@8.0.2':
dependencies:
@@ -3934,7 +3893,7 @@ snapshots:
'@keyv/serialize@1.1.1': {}
'@lucide/vue@1.66.0(vue@3.5.44(typescript@6.0.3))':
'@lucide/vue@1.67.0(vue@3.5.44(typescript@6.0.3))':
dependencies:
vue: 3.5.44(typescript@6.0.3)
@@ -4121,72 +4080,72 @@ snapshots:
estraverse: 5.3.0
picomatch: 4.0.7
'@tailwindcss/node@4.3.3':
'@tailwindcss/node@4.3.4':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.5
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
lightningcss: 1.33.0
magic-string: 1.4.3
source-map-js: 1.2.2
tailwindcss: 4.3.3
tailwindcss: 4.3.4
'@tailwindcss/oxide-android-arm64@4.3.3':
'@tailwindcss/oxide-android-arm64@4.3.4':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.3.3':
'@tailwindcss/oxide-darwin-arm64@4.3.4':
optional: true
'@tailwindcss/oxide-darwin-x64@4.3.3':
'@tailwindcss/oxide-darwin-x64@4.3.4':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.3.3':
'@tailwindcss/oxide-freebsd-x64@4.3.4':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
'@tailwindcss/oxide-linux-arm64-gnu@4.3.4':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
'@tailwindcss/oxide-linux-arm64-musl@4.3.4':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
'@tailwindcss/oxide-linux-x64-gnu@4.3.4':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
'@tailwindcss/oxide-linux-x64-musl@4.3.4':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
'@tailwindcss/oxide-wasm32-wasi@4.3.4':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
'@tailwindcss/oxide-win32-arm64-msvc@4.3.4':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
'@tailwindcss/oxide-win32-x64-msvc@4.3.4':
optional: true
'@tailwindcss/oxide@4.3.3':
'@tailwindcss/oxide@4.3.4':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/oxide-android-arm64': 4.3.4
'@tailwindcss/oxide-darwin-arm64': 4.3.4
'@tailwindcss/oxide-darwin-x64': 4.3.4
'@tailwindcss/oxide-freebsd-x64': 4.3.4
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.4
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.4
'@tailwindcss/oxide-linux-arm64-musl': 4.3.4
'@tailwindcss/oxide-linux-x64-gnu': 4.3.4
'@tailwindcss/oxide-linux-x64-musl': 4.3.4
'@tailwindcss/oxide-wasm32-wasi': 4.3.4
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.4
'@tailwindcss/oxide-win32-x64-msvc': 4.3.4
'@tailwindcss/vite@4.3.3(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))':
'@tailwindcss/vite@4.3.4(vite@8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
'@tailwindcss/node': 4.3.4
'@tailwindcss/oxide': 4.3.4
tailwindcss: 4.3.4
vite: 8.3.5(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0)
'@types/css-tree@3.2.0': {}
@@ -4298,7 +4257,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.72.0
debug: 4.4.3
minimatch: 10.2.6
semver: 7.8.5
semver: 7.8.6
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -4795,12 +4754,12 @@ snapshots:
eslint-compat-utils@0.5.1(eslint@10.14.0(jiti@2.7.0)):
dependencies:
eslint: 10.14.0(jiti@2.7.0)
semver: 7.8.5
semver: 7.8.6
eslint-compat-utils@0.6.5(eslint@10.14.0(jiti@2.7.0)):
dependencies:
eslint: 10.14.0(jiti@2.7.0)
semver: 7.8.5
semver: 7.8.6
eslint-config-flat-gitignore@2.4.0(eslint@10.14.0(jiti@2.7.0)):
dependencies:
@@ -4880,7 +4839,7 @@ snapshots:
html-entities: 2.6.0
object-deep-merge: 2.0.1
parse-imports-exports: 0.2.4
semver: 7.8.5
semver: 7.8.6
spdx-expression-parse: 5.0.0
to-valid-identifier: 1.0.0
optionalDependencies:
@@ -4914,7 +4873,7 @@ snapshots:
globrex: 0.1.2
ignore: 5.3.2
js-yaml: 5.5.0
semver: 7.8.5
semver: 7.8.6
optionalDependencies:
typescript: 6.0.3
@@ -4989,7 +4948,7 @@ snapshots:
quote-js-string: 0.1.0
regjsparser: 0.13.4
reserved-identifiers: 1.2.0
semver: 7.8.5
semver: 7.8.6
strip-indent: 4.1.1
yaml: 2.9.1
optionalDependencies:
@@ -5010,7 +4969,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 7.1.6
semver: 7.8.5
semver: 7.8.6
vue-eslint-parser: 10.4.1(eslint@10.14.0(jiti@2.7.0))
xml-name-validator: 5.0.0
optionalDependencies:
@@ -5252,7 +5211,7 @@ snapshots:
graceful-fs@4.2.11: {}
gsap@3.15.0: {}
gsap@3.15.1: {}
has-symbols@1.1.0: {}
@@ -5364,7 +5323,7 @@ snapshots:
acorn: 8.18.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.8.5
semver: 7.8.6
jsonc-eslint-parser@3.3.0:
dependencies:
@@ -5385,88 +5344,39 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
@@ -6167,7 +6077,7 @@ snapshots:
scule@1.3.0: {}
semver@7.8.5: {}
semver@7.8.6: {}
shebang-command@2.0.0:
dependencies:
@@ -6223,7 +6133,7 @@ snapshots:
dependencies:
'@pkgr/core': 0.3.6
tailwindcss@4.3.3: {}
tailwindcss@4.3.4: {}
tapable@2.3.3: {}
@@ -6362,15 +6272,15 @@ snapshots:
eslint-visitor-keys: 5.0.1
espree: 11.2.0
esquery: 1.7.0
semver: 7.8.5
semver: 7.8.6
transitivePeerDependencies:
- supports-color
vue-i18n@11.4.16(vue@3.5.44(typescript@6.0.3)):
vue-i18n@11.4.18(vue@3.5.44(typescript@6.0.3)):
dependencies:
'@intlify/core-base': 11.4.16
'@intlify/devtools-types': 11.4.16
'@intlify/shared': 11.4.16
'@intlify/core-base': 11.4.18
'@intlify/devtools-types': 11.4.18
'@intlify/shared': 11.4.18
'@vue/devtools-api': 6.6.4
vue: 3.5.44(typescript@6.0.3)
+3 -3
View File
@@ -257,7 +257,7 @@ export interface UsageOverviewHealthPoint {
cancelledRequests: number
incompleteRequests: number
callerErrorRequests: number
errorRate: number
errorRate: number | null
}
export interface UsageOverviewHealth {
@@ -267,7 +267,7 @@ export interface UsageOverviewHealth {
cancelledRequests: number
incompleteRequests: number
callerErrorRequests: number
successRate: number
successRate: number | null
completionRate: number
requestChangeRate: number | null
successRateChange: number | null
@@ -386,7 +386,7 @@ export interface UsageDiagnosticItem {
requestCount: number
successCount: number
errorCount: number
errorRate: number
errorRate: number | null
requestShare: number
averageLatencyMs: number | null
latencyP95Ms: number | null
+9 -7
View File
@@ -186,19 +186,21 @@
"reserve": "Reserve",
"allowlist": "Allowlist",
"blocklist": "Blocklist",
"modelCatalog": "Model catalog",
"refreshModel": "Refresh model",
"modelRestrictionMode": "Model restriction mode",
"modelRestrictionMode": "Model restrictions",
"catalogHelpTitle": "Model catalog details",
"catalogObservedAtHelp": "The time shows when the upstream catalog was last successfully fetched",
"catalogDescription": "The catalog lists model IDs, verify endpoint support with an actual request — a connection test only checks the endpoint it uses",
"searchOrAddModels": "Search or add models",
"searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add",
"selectModel": "Select model",
"notInCurrentModelCatalog": "{id} (not in the current model catalog)",
"modelCatalogMissingHint": "The current upstream catalog does not list this model",
"searchOrEnterId": "Search models or enter a full ID",
"addModel": "Add model",
"customModels": "Custom",
"selectedModels": "Selected models",
"upstreamCatalog": "Upstream catalog",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"addToDenylistHint": "Saving blocks this ID on the account",
"loadingModel": "Loading model",
"noMatchingModels": "No matching models",
"noModels": "No models",
"selectOrAddAtLeastOneModel": "Please select or add at least one model",
"configureUpToModels": "Configure up to 256 models",
+1 -1
View File
@@ -54,7 +54,7 @@
"firstTokenP95Ms": "TTFT P95",
"latencyP95Ms": "Latency P95",
"outputThroughputP50": "Output throughput P50",
"errors": "Errors",
"errors": "Error count",
"successRate": "Success rate",
"totalRequests": "Total requests",
"requestCount": "Requests",
+7 -7
View File
@@ -16,14 +16,14 @@
"serviceSuccessRate": "Service success",
"notCompleted": "Incomplete",
"requestVolume": "Requests",
"noServiceResult": "No service result",
"title": "Health",
"description": "Request outcomes",
"descriptionWithGranularity": "Request outcomes · {granularityText}",
"completionRate": "Completion",
"abnormalEnd": "Abnormal",
"loading": "Loading requests",
"empty": "No requests in this range"
"empty": "No requests in this range",
"errors": "Errors",
"noSamples": "No samples",
"completionRate": "Completion"
},
"billing": {
"serviceLevel": "Service tier",
@@ -233,13 +233,13 @@
"diagnosticDimension": "Diagnostic dimension",
"noDiagnosticDataYet": "No diagnostic data yet",
"successful": "Successful: {count}",
"failed": "Failed: {count}",
"failureRate": "Failure rate: {rate}",
"retryRate": "Retry rate: {rate}",
"retries": "Retries: {count}",
"loading": "Loading diagnostics",
"empty": "No diagnostics in this range",
"download": "Download"
"download": "Download",
"errors": "Errors: {count}",
"errorRate": "Error rate: {rate}"
},
"cost": {
"savings": "Savings",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名单",
"blocklist": "黑名单",
"modelCatalog": "模型目录",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目录说明",
"catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间",
"catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加",
"selectModel": "选择模型",
"notInCurrentModelCatalog": "{id}(当前目录未返回)",
"modelCatalogMissingHint": "当前上游目录未返回此模型",
"searchOrEnterId": "搜索模型或输入完整 ID",
"addModel": "添加模型",
"customModels": "自定义",
"selectedModels": "已选模型",
"upstreamCatalog": "上游目录",
"selectAll": "全选",
"deselectAll": "取消全选",
"addToDenylistHint": "保存后,此账号将拒绝该模型",
"loadingModel": "加载模型中",
"noMatchingModels": "无匹配模型",
"noModels": "暂无模型",
"selectOrAddAtLeastOneModel": "请至少选择或添加一个模型",
"configureUpToModels": "最多配置 256 个模型",
+7 -7
View File
@@ -16,14 +16,14 @@
"serviceSuccessRate": "服务成功率",
"notCompleted": "未完成",
"requestVolume": "请求量",
"noServiceResult": "无服务结果",
"title": "请求健康",
"description": "请求状态分布",
"descriptionWithGranularity": "请求状态分布 · {granularityText}",
"completionRate": "完成率",
"abnormalEnd": "非正常结束",
"loading": "正在加载请求数据",
"empty": "当前范围暂无请求"
"empty": "当前范围暂无请求",
"errors": "错误",
"noSamples": "无样本",
"completionRate": "完成率"
},
"billing": {
"serviceLevel": "服务档位",
@@ -233,13 +233,13 @@
"diagnosticDimension": "诊断维度",
"noDiagnosticDataYet": "暂无诊断数据",
"successful": "成功 {count}",
"failed": "失败 {count} 次",
"failureRate": "失败率 {rate}",
"retryRate": "重试率 {rate}",
"retries": "重试 {count}",
"loading": "正在加载诊断数据",
"empty": "当前范围暂无诊断数据",
"download": "下载"
"download": "下载",
"errors": "错误 {count} 次",
"errorRate": "错误率 {rate}"
},
"cost": {
"savings": "节省",
@@ -186,19 +186,21 @@
"reserve": "保留",
"allowlist": "白名單",
"blocklist": "黑名單",
"modelCatalog": "模型目錄",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"modelRestrictionMode": "模型限制",
"catalogHelpTitle": "模型目錄說明",
"catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間",
"catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加",
"selectModel": "選擇模型",
"notInCurrentModelCatalog": "{id}(當前目錄未返回)",
"modelCatalogMissingHint": "當前上游目錄未返回此模型",
"searchOrEnterId": "搜索模型或輸入完整 ID",
"addModel": "添加模型",
"customModels": "自定義",
"selectedModels": "已選模型",
"upstreamCatalog": "上游目錄",
"selectAll": "全選",
"deselectAll": "取消全選",
"addToDenylistHint": "保存後,此賬號將拒絕該模型",
"loadingModel": "加載模型中",
"noMatchingModels": "無匹配模型",
"noModels": "暫無模型",
"selectOrAddAtLeastOneModel": "請至少選擇或添加一個模型",
"configureUpToModels": "最多配置 256 個模型",
+7 -7
View File
@@ -16,14 +16,14 @@
"serviceSuccessRate": "服務成功率",
"notCompleted": "未完成",
"requestVolume": "請求量",
"noServiceResult": "無服務結果",
"title": "請求健康",
"description": "請求狀態分佈",
"descriptionWithGranularity": "請求狀態分佈 · {granularityText}",
"completionRate": "完成率",
"abnormalEnd": "非正常結束",
"loading": "正在加載請求數據",
"empty": "當前範圍暫無請求"
"empty": "當前範圍暫無請求",
"errors": "錯誤",
"noSamples": "無樣本",
"completionRate": "完成率"
},
"billing": {
"serviceLevel": "服務檔位",
@@ -233,13 +233,13 @@
"diagnosticDimension": "診斷維度",
"noDiagnosticDataYet": "暫無診斷數據",
"successful": "成功 {count}",
"failed": "失敗 {count} 次",
"failureRate": "失敗率 {rate}",
"retryRate": "重試率 {rate}",
"retries": "重試 {count}",
"loading": "正在加載診斷數據",
"empty": "當前範圍暫無診斷數據",
"download": "下載"
"download": "下載",
"errors": "錯誤 {count} 次",
"errorRate": "錯誤率 {rate}"
},
"cost": {
"savings": "節省",
@@ -82,7 +82,7 @@ async function requestSave() {
<ZDialog
v-model="open"
:title="t('accounts.editor.title')"
width="42rem"
width="52rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
@closed="emit('afterLeave')"
>
@@ -127,7 +127,7 @@ async function requestSave() {
>
<template v-if="isOpenAiOAuthAccount(account)" #name-extra>
<ZFormItem :label="t('accounts.editor.transportLabel')">
<ZSegmented v-model="oauthTransport" class="w-full" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
<ZSegmented v-model="oauthTransport" class="w-40" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
</ZFormItem>
</template>
</AccountSettingsFields>
@@ -1,15 +1,16 @@
<script setup lang="ts">
import type { AccountModelAccess } from '@/api'
import { ZCheckbox, ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ZButton, ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { CircleAlert, RefreshCw, Search, TriangleAlert } from '@lucide/vue'
import { CircleAlert, ListChecks, ListMinus, Plus, RefreshCw, Search } from '@lucide/vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getAccountModels, refreshAccountModels } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
import { accountModelAccessError, accountModelIdError } from '../utils/modelAccess'
import AccountModelCleanupPopover from './AccountModelCleanupPopover.vue'
import AccountModelOption from './AccountModelOption.vue'
const props = withDefaults(defineProps<{
accountId?: string
@@ -28,7 +29,8 @@ watch(model, () => formItem.value?.clearValidate())
const catalog = ref<Array<{ id: string, label: string }>>()
const catalogObservedAtDisplay = ref<string>()
const search = ref('')
const inputError = ref('')
const searchInput = useTemplateRef<InstanceType<typeof ZInput>>('searchInput')
const selectionError = ref('')
const request = useRequestState()
const { loading, error } = request
const modes = computed(() => [
@@ -56,7 +58,7 @@ const modes = computed(() => [
const mode = computed({
get: () => model.value?.mode ?? 'preserve',
set: (value: string) => {
inputError.value = ''
selectionError.value = ''
if (value === 'preserve') {
model.value = undefined
return
@@ -72,29 +74,40 @@ const mode = computed({
},
})
const restricted = computed(() => mode.value === 'allowlist' || mode.value === 'denylist')
const canAdd = computed(() => Boolean(search.value.trim()) && !model.value?.models.includes(search.value.trim()))
const selectedIds = computed(() => new Set(model.value?.models))
const catalogReady = computed(() => catalog.value !== undefined && !loading.value && !error.value)
const missingModels = computed(() => {
// 空目录和失败不能作为批量清理依据,目录缺失也不等于独立端点或适配模型无效
if (mode.value !== 'allowlist' || !catalogReady.value || !catalog.value?.length)
return []
const ids = new Set(catalog.value.map(item => item.id))
const extraIds = computed(() => {
const ids = new Set(catalog.value?.map(item => item.id))
return (model.value?.models ?? []).filter(id => !ids.has(id))
})
const models = computed(() => {
const entries = new Map((catalog.value ?? []).map(item => [
item.id,
{ ...item, unavailable: false },
]))
for (const id of model.value?.models ?? []) {
if (!entries.has(id))
entries.set(id, { id, label: id, unavailable: catalogReady.value })
}
const query = search.value.trim().toLowerCase()
return [
...entries.values(),
].filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query))
})
// 目录已就绪且非空时才能按目录清理白名单
const missingModels = computed(() => mode.value === 'allowlist' && catalogReady.value && catalog.value?.length
? extraIds.value
: [])
const query = computed(() => search.value.trim().toLowerCase())
const catalogResults = computed(() => (catalog.value ?? [])
.filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query.value))
.map(item => item.id))
const allResultsSelected = computed(() => catalogResults.value.every(id => selectedIds.value.has(id)))
const groups = computed(() => [
{
key: 'selected',
label: catalogReady.value
? t('accounts.modelAccess.customModels')
: t('accounts.modelAccess.selectedModels'),
ids: extraIds.value.filter(id => id.toLowerCase().includes(query.value)),
},
{
key: 'catalog',
label: t('accounts.modelAccess.upstreamCatalog'),
ids: catalogResults.value,
},
].filter(group => group.ids.length))
const newModelId = computed(() => groups.value.length
? ''
: search.value.trim())
const inputError = computed(() => selectionError.value || (newModelId.value && accountModelIdError(newModelId.value)))
const canAdd = computed(() => Boolean(newModelId.value) && !inputError.value)
function removeMissingModels(ids: string[]) {
if (props.disabled || model.value?.mode !== 'allowlist')
@@ -107,8 +120,8 @@ function removeMissingModels(ids: string[]) {
...model.value,
models: model.value.models.filter(id => !removed.has(id)),
}
inputError.value = accountModelAccessError(next) ?? ''
if (!inputError.value)
selectionError.value = accountModelAccessError(next) ?? ''
if (!selectionError.value)
model.value = next
}
@@ -118,34 +131,41 @@ function restoreCleanupFocus() {
refreshButton.value?.focus()
}
function select(id: string, selected: boolean) {
function select(modelIds: string[], selected: boolean) {
if (props.disabled || !model.value || !restricted.value)
return
const ids = new Set(model.value.models)
if (selected)
ids.add(id)
else ids.delete(id)
const ids = new Set(selectedIds.value)
for (const id of modelIds) {
if (selected)
ids.add(id)
else ids.delete(id)
}
const next = {
...model.value,
models: [...ids],
}
inputError.value = selected
selectionError.value = selected
? accountModelAccessError(next) ?? ''
: ''
if (!inputError.value)
if (!selectionError.value)
model.value = next
}
function addModel() {
if (props.disabled || !restricted.value || !canAdd.value)
return
const id = search.value.trim()
inputError.value = accountModelIdError(id) ?? ''
if (inputError.value)
return
select(id, true)
if (!inputError.value)
select([newModelId.value], true)
if (!selectionError.value) {
search.value = ''
searchInput.value?.focus()
}
}
function handleSearchEnter(event: KeyboardEvent) {
if (event.isComposing)
return
event.preventDefault()
addModel()
}
function handleCatalogHelpKeydown(event: KeyboardEvent, open: boolean, close: () => void) {
@@ -188,22 +208,22 @@ watch([
catalog.value = undefined
catalogObservedAtDisplay.value = undefined
search.value = ''
inputError.value = ''
selectionError.value = ''
error.value = ''
void load()
}, { immediate: true })
watch(search, () => {
inputError.value = ''
selectionError.value = ''
})
</script>
<template>
<div class="grid gap-4.5">
<div class="@container grid gap-4.5">
<ZFormItem ref="modelAccessItem" :prop="formProp">
<template #label>
<span class="inline-flex flex-wrap items-end gap-x-2 gap-y-1">
<span>{{ t('accounts.modelAccess.modelCatalog') }}</span>
<span>{{ t('accounts.modelAccess.modelRestrictionMode') }}</span>
<span v-if="restricted && catalogObservedAtDisplay" class="text-z-xs leading-none font-normal text-z-text-tertiary tabular-nums">
{{ catalogObservedAtDisplay }}
</span>
@@ -248,63 +268,59 @@ watch(search, () => {
</div>
</ZFormItem>
<template v-if="restricted">
<ZInput v-model="search" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterIdAndPressEnter')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<div class="flex min-w-0 items-center gap-2">
<ZInput ref="searchInput" v-model="search" class="min-w-0 flex-1" :aria-label="t('accounts.modelAccess.searchOrAddModels')" :placeholder="t('accounts.modelAccess.searchOrEnterId')" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter="handleSearchEnter">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</ZInput>
<ZButton class="shrink-0" :disabled="disabled || !canAdd" @click="addModel">
<Plus class="size-4" aria-hidden="true" />
{{ t('accounts.modelAccess.addModel') }}
</ZButton>
</div>
<p v-if="inputError" class="m-0 text-z-xs text-z-error-text" role="alert">
{{ inputError }}
</p>
<ZScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" :aria-label="t('accounts.modelAccess.selectModel')" :aria-busy="loading || undefined">
<div
v-for="item in models"
:key="item.id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="model?.models.includes(item.id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<ZCheckbox
class="min-h-11 min-w-0 flex-1 px-3 py-2.5"
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:title="item.id"
:disabled="disabled"
@update:model-value="select(item.id, $event)"
>
<template #label>
<span class="block truncate">{{ item.id }}</span>
</template>
</ZCheckbox>
<ZPopover v-if="item.unavailable" class="mr-1 shrink-0" trigger="hover-click" placement="top" role="tooltip">
<template #reference="{ open, id }">
<ZIconButton
variant="link"
size="small"
:aria-label="t('accounts.modelAccess.notInCurrentModelCatalog', { id: item.id })"
:aria-describedby="open ? id : undefined"
:aria-expanded="open"
:title="undefined"
>
<TriangleAlert class="size-3.5 text-z-warning-text" aria-hidden="true" />
</ZIconButton>
</template>
<div class="max-w-64 space-y-1 px-3 py-2 text-z-sm wrap-anywhere">
<p class="m-0 font-emphasis text-z-text">
{{ item.id }}
</p>
<p class="m-0 text-z-text-secondary">
{{ t('accounts.modelAccess.modelCatalogMissingHint') }}
</p>
<ZScrollbar v-if="groups.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid gap-3 p-1" :aria-busy="loading || undefined">
<div v-for="group in groups" :key="group.key" role="group" :aria-label="group.label">
<div class="mb-1.5 flex items-center justify-between gap-2">
<span class="text-z-xs font-medium text-z-text-secondary">{{ group.label }}</span>
<ZIconButton
v-if="group.key === 'catalog'"
type="primary"
variant="link"
size="small"
class="-my-1 size-6!"
:aria-label="allResultsSelected ? t('accounts.modelAccess.deselectAll') : t('accounts.modelAccess.selectAll')"
:disabled="disabled || loading"
@click="select(group.ids, !allResultsSelected)"
>
<ListMinus v-if="allResultsSelected" aria-hidden="true" />
<ListChecks v-else aria-hidden="true" />
</ZIconButton>
</div>
<div class="grid grid-cols-2 gap-2 @xl:grid-cols-3 @3xl:grid-cols-4">
<div
v-for="id in group.ids"
:key="id"
class="flex min-w-0 items-center rounded-z transition-colors duration-150 motion-reduce:transition-none"
:class="selectedIds.has(id) ? 'bg-z-primary-container text-z-primary-on-container' : 'bg-z-fill-quaternary text-z-text-secondary hover:bg-z-fill-tertiary hover:text-z-text'"
>
<AccountModelOption :model-value="selectedIds.has(id)" :label="id" :disabled="disabled" @update:model-value="select([id], $event)" />
</div>
</ZPopover>
</div>
</div>
</div>
</ZScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-z-sm text-z-text-tertiary" role="status">
{{ t('accounts.modelAccess.loadingModel') }}
</p>
<ZEmpty v-else-if="!error" :title="search ? t('accounts.modelAccess.noMatchingModels') : t('accounts.modelAccess.noModels')" :icon="search ? Search : undefined" size="small" surface="none" />
<p v-else-if="mode === 'denylist' && canAdd" class="m-0 text-z-xs text-z-text-secondary">
{{ t('accounts.modelAccess.addToDenylistHint') }}
</p>
<ZEmpty v-else-if="!error && !search" :title="t('accounts.modelAccess.noModels')" size="small" surface="none" />
</template>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ZCheckbox, ZPopover } from '@codex-proxy/ui'
import { shallowRef, useTemplateRef } from 'vue'
defineProps<{
label: string
disabled?: boolean
}>()
const selected = defineModel<boolean>({ required: true })
const labelText = useTemplateRef<HTMLSpanElement>('labelText')
const open = shallowRef(false)
function updateOpen(nextOpen: boolean) {
const text = labelText.value
open.value = nextOpen && Boolean(text && text.scrollWidth > text.clientWidth)
}
function dismissTooltip(event: KeyboardEvent, close: () => void) {
if (!open.value)
return
event.preventDefault()
event.stopPropagation()
close()
}
</script>
<template>
<ZPopover :model-value="open" class="min-w-0 flex-1" trigger="hover" placement="top" role="tooltip" :hover-delay="240" @update:model-value="updateOpen">
<template #reference="{ close }">
<ZCheckbox v-model="selected" class="min-h-11 min-w-0 flex-1 px-3 py-2.5" :label="label" :disabled="disabled" @keydown.esc="dismissTooltip($event, close)">
<template #label>
<span ref="labelText" class="block truncate">{{ label }}</span>
</template>
</ZCheckbox>
</template>
<div class="max-w-96 px-3 py-2 text-z-sm wrap-anywhere">
{{ label }}
</div>
</ZPopover>
</template>
@@ -427,10 +427,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
)
if (!modelsRequest.isCurrent(requestId))
return
applyConnectionTestModels(result, refresh)
applyConnectionTestModels(result.models, account, refresh)
if (refresh) {
ZNotification.success({
message: t('accounts.connectionTest.modelsRefreshed', { count: connectionTestModelOptions.value.length }),
message: t('accounts.connectionTest.modelsRefreshed', { count: result.models.length }),
})
}
else if (!connectionTestSelectedModel.value) {
@@ -441,28 +441,25 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (!modelsRequest.isCurrent(requestId))
return
connectionTestError.value = errorMessage(error)
if (!refresh) {
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
}
if (!refresh)
applyConnectionTestModels([], account)
}
finally {
modelsRequest.finish(requestId)
}
}
function applyConnectionTestModels(result: AccountModelsResponse, preserveSelection = false) {
function applyConnectionTestModels(catalog: AccountModelsResponse['models'], account: Account, preserveSelection = false) {
const previousSelection = preserveSelection ? connectionTestSelectedModel.value : ''
connectionTestModelOptions.value = []
for (const model of result.models ?? []) {
connectionTestModelOptions.value.push({
label: model.label || model.id,
value: model.id,
})
const labels = new Map(catalog.map(model => [model.id, model.label || model.id]))
if (account.modelAccess.mode === 'allowlist') {
for (const id of account.modelAccess.models) {
if (!labels.has(id))
labels.set(id, id)
}
}
connectionTestSelectedModel.value = connectionTestModelOptions.value.some(
model => model.value === previousSelection,
)
connectionTestModelOptions.value = Array.from(labels, ([value, label]) => ({ value, label }))
connectionTestSelectedModel.value = labels.has(previousSelection)
? previousSelection
: connectionTestModelOptions.value[0]?.value || ''
}
@@ -254,7 +254,9 @@ export function useRequestTrendChart(options: {
case 'errors':
return point?.errors ?? value
case 'successRate':
return zeroAwareRateText(point, 'successRate', 'requestsValue') ?? value
return point?.successRateValue == null
? t('usage.health.noSamples')
: trendPointText(point, 'successRate') ?? value
case 'requests':
return point?.requests ?? value
default:
@@ -342,7 +344,7 @@ export function useRequestTrendChart(options: {
),
...gapLineSeries(
'successRate',
activeSeriesValues('successRateValue'),
seriesValues('successRateValue'),
trendColor('successRate'),
{ width: 2.4, yAxisIndex: 1 },
),
+2 -2
View File
@@ -115,8 +115,8 @@ export function dashboardTrendView(trend: DashboardTrendResponse | null) {
const summary = trend.summary.map(item => ({
...trendMetricView(item.metric),
value: trend.kind === 'errors' && item.ratio !== null
? item.ratio
value: trend.kind === 'errors' && item.metric === 'successRate'
? item.ratio ?? t('usage.health.noSamples')
: item.value,
}))
return { points: trend.points, summary }
@@ -60,7 +60,7 @@ const resultDimensionLabel = computed(
)
const dimensionNameSizes: Record<string, TableColumnSize> = {
model: 'xl',
model: 'lg',
account: '2xl',
apiKey: 'md',
provider: 'lg',
@@ -90,25 +90,26 @@ const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayIte
key: 'requestCount',
label: t('usage.diagnostics.request'),
kind: 'numeric',
size: 'xs',
size: 'md',
},
{
key: 'errorCount',
label: t('common.status.failed'),
label: t('usage.health.errors'),
kind: 'numeric',
size: 'xs',
align: 'center',
size: 'md',
},
{
key: 'firstTokenP95Ms',
label: t('usage.diagnostics.performance'),
kind: 'numeric',
size: 'lg',
size: 'md',
},
{
key: 'estimatedCost',
label: t('usage.fields.cost'),
kind: 'numeric',
size: 'sm',
size: 'md',
},
]))
@@ -228,8 +229,8 @@ function diagnosticNameDisplay(name: string) {
<strong
class="font-mono font-bold tabular-nums"
:class="row.errorCount > 0 ? 'text-z-error-text' : 'text-z-text-quaternary'"
:aria-label="t('usage.diagnostics.failed', { count: formatCompactNumber(row.errorCount) })"
:title="t('usage.diagnostics.failureRate', { rate: formatPercent(row.errorRate) })"
:aria-label="t('usage.diagnostics.errors', { count: formatCompactNumber(row.errorCount) })"
:title="t('usage.diagnostics.errorRate', { rate: formatPercent(row.errorRate) })"
>
{{ formatCompactNumber(row.errorCount) }}
</strong>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
import type { EChartsOption } from 'echarts'
import type { getUsageRecordInsightsOverview } from '@/api'
import { ZCard, ZEmpty } from '@codex-proxy/ui'
@@ -10,14 +10,12 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseChart from '@/components/charts/BaseChart.vue'
import { chartTooltipContent, tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { healthEligibleRequestCount } from '@/components/usage/shared/health'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber, formatPercent } from '@/utils/format'
import {
usageCategoryAxis,
usageGapAwareLineSeries,
usageLegend,
usageTooltip,
usageTooltipItem,
@@ -68,28 +66,12 @@ const description = computed(() => props.loading || !granularityText.value
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const chartPoints = points.value
const activePointCount = chartPoints.filter(point => requestCount(point) > 0).length
const successRateSeries = usageGapAwareLineSeries(
t('usage.health.serviceSuccessRate'),
chartPoints.map(serviceSuccessRate),
theme.success,
{ yAxisIndex: 1 },
)
const series: Array<BarSeriesOption | LineSeriesOption> = [
const series = [
outcomeSeries(t('common.status.success'), chartPoints.map(point => point.successRequests), theme.success),
outcomeSeries(t('usage.health.point.serviceFailed'), chartPoints.map(point => point.failedRequests), theme.danger),
outcomeSeries(t('usage.status.cancelled'), chartPoints.map(point => point.cancelledRequests), theme.info),
outcomeSeries(t('usage.health.notCompleted'), chartPoints.map(point => point.incompleteRequests), theme.warning),
outcomeSeries(t('usage.health.point.callerError'), chartPoints.map(point => point.callerErrorRequests), theme.normal),
...successRateSeries.map((item, index) => index === 0
? {
...item,
symbol: activePointCount <= 16
? 'circle'
: 'none',
symbolSize: 4,
}
: item),
]
return {
@@ -102,27 +84,13 @@ const chartOption = computed<EChartsOption>(() => {
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, [
t('common.status.success'),
t('usage.health.point.serviceFailed'),
t('usage.status.cancelled'),
t('usage.health.notCompleted'),
t('usage.health.point.callerError'),
t('usage.health.serviceSuccessRate'),
]),
legend: usageLegend(theme, series.map(item => item.name)),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
chartPoints.map(point => point.label),
theme,
),
yAxis: [
usageValueAxis(theme, value => formatCompactNumber(value)),
usageValueAxis(theme, value => formatPercent(value), {
min: 0,
max: 1,
splitLine: false,
}),
],
yAxis: usageValueAxis(theme, value => formatCompactNumber(value)),
series,
}
})
@@ -131,13 +99,10 @@ function requestCount(point: HealthPoint) {
return Math.max(0, point.totalRequests ?? 0)
}
function serviceSuccessRate(point: HealthPoint) {
const total = healthEligibleRequestCount(point)
if (total > 0)
return 1 - point.errorRate
return requestCount(point) === 0
? 0
: null
function serviceSuccessRateText(rate: number | null) {
return rate == null
? t('usage.noValidSample')
: formatPercent(rate)
}
function outcomeSeries(name: string, data: number[], color: string) {
@@ -157,7 +122,9 @@ function formatTooltip(params: unknown) {
if (!point)
return ''
const successRate = serviceSuccessRate(point)
const successRate = point.errorRate == null
? null
: 1 - point.errorRate
const theme = palette.value
return chartTooltipContent(point.label, [
@@ -169,9 +136,7 @@ function formatTooltip(params: unknown) {
usageTooltipItem(t('usage.health.point.callerError'), formatCompactNumber(point.callerErrorRequests), theme.normal),
usageTooltipItem(
t('usage.health.serviceSuccessRate'),
successRate == null
? t('usage.health.noServiceResult')
: formatPercent(successRate),
serviceSuccessRateText(successRate),
theme.success,
),
])
@@ -187,11 +152,11 @@ function formatTooltip(params: unknown) {
>
<template #body>
<div class="grid min-h-66" :class="hasData ? 'gap-3' : 'h-full'">
<div v-if="hasData && health" class="grid grid-cols-3 gap-2 rounded-xl bg-z-fill-quaternary/45 p-2">
<div v-if="hasData && health" class="grid grid-cols-2 gap-2 rounded-xl bg-z-fill-quaternary/45 p-2 sm:grid-cols-4">
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.serviceSuccessRate') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-green-text">
{{ formatPercent(health.successRate) }}
{{ serviceSuccessRateText(health.successRate) }}
</strong>
</div>
<div class="grid gap-1 px-2">
@@ -201,9 +166,15 @@ function formatTooltip(params: unknown) {
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.abnormalEnd') }}</span>
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.status.cancelled') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-blue-text">
{{ formatCompactNumber(health.cancelledRequests) }}
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-z-text-quaternary">{{ t('usage.health.notCompleted') }}</span>
<strong class="font-mono text-z-base font-heavy tabular-nums text-z-orange-text">
{{ formatCompactNumber(health.cancelledRequests + health.incompleteRequests) }}
{{ formatCompactNumber(health.incompleteRequests) }}
</strong>
</div>
</div>
+8 -23
View File
@@ -1,27 +1,12 @@
# v3.41.0
# v3.41.2
## 升级事项
## OpenAI 账号模型白名单
- 从 v3.40.0 升级没有新增数据库迁移
- 管理 API 的账号模型用量会返回仅有失败记录的模型;没有成功用量时,`lastUsedAt` 为 `null`,没有有效健康样本时,`successRate` 为 `null`
- 更改或清除 Key 的安装 ID 覆盖,若生效 ID 随之变化,已有 WebSocket 续接需重新发送完整上下文
- 修复原生 Responses 请求被模型目录拦截的问题。OpenAI OAuth 或 API Key 账号的白名单精确包含映射后的上游模型 ID 时,即使目录尚未收录,也会允许该账号向上游请求,由上游确认是否可用
- 升级后已有白名单自动生效。目录外模型的放行只对配置该 ID 的账号生效,混合号池中的其他账号仍按各自的模型限制和有效目录选号
## 账号、代理与密钥
## 管理端
- 修复账号展开后的模型成功率始终显示 100% 的问题。成功率按成功、服务失败和未完成请求计算,排除取消、调用方 4xx 和已恢复的中间记录;仅失败时显示 0%,无有效样本时显示 `—`。调用数、Token、费用和最近使用时间仍只统计完整交付的成功用量
- 账号创建、编辑和批量编辑支持测试直连或已保存代理的出口,显示耗时及 IPv4 / IPv6。禁用代理时按实际直连出口测试,批量“保持当前”时禁测;出口测试不保存账号设置,也不验证账号权限或额度
- 代理的“关联账号”弹窗支持搜索、跨页多选并批量分配账号,一次最多 1000 个。分配会替换所选账号的原代理,保留凭据、分组与调度设置
- 客户端 Key 可单独配置或生成 OpenAI 安装 ID,同时继承全局 UA。留空时沿用账号安装身份;固定后,换账号仍使用该 ID
- 上游明确关闭共享或不支持 CPR 互联时,账号恢复显示为普通 API Key;临时失败保留已有识别结果。可通过“刷新额度”重新识别互联
## Chat Completions 与 Responses
- 扩展 Chat 的 function/custom 工具调用与结果往返、文件和音频输入,以及 Responses 原生参数映射。同语义字段交给 Provider 和上游判断;`stop`、`seed`、penalties 等无对应 Responses 控制语义的参数接受后忽略,不保证生效。无法保留输入、客户端动作或续接语义的请求仍明确拒绝
- Chat 的 JSON/SSE 响应保留公开推理、图片结果、logprobs、结构化引用和实际 Token 明细。推理与图片字段属于网关扩展,需要客户端支持;推理及压缩密文不作为可读正文返回,搜索与其他工具仍需分开请求
- 修复完整历史请求因缺少本地归属记录而丢失 `x-codex-turn-state` 的问题,覆盖 HTTP 和 WebSocket。明确换号或可信续接状态表明已换轮时仍清除该值,不改变 response ID 的续接授权边界
## 诊断与管理界面
- 数据面 HTTP 的鉴权、解码、未知路由、方法错误及执行前中间件失败记录为运维错误,可按入口 ID 查询,不伪造模型请求或用量
- 显式开启 `request_dump` 后,按实际消费过程捕获入口请求与响应的原始头和正文,标注未消费或读取失败的不完整转储。该开关默认关闭,转储包含凭据与用户内容,仅用于受控排障
- 调整模型访问、插件安装和错误保护表单的校验时机,避免切换模式或新增未填完的配置时立即报错。修复移动端登录表单居中,精简重复提示与错误文案
- 编辑账号时分组展示自定义模型和上游目录。搜索到已有模型时可直接勾选,输入未匹配的有效 ID 时可点击「添加模型」或按回车添加。全选和取消全选只作用于当前搜索命中的上游目录项
- 账号连接测试可以选择白名单中的自定义模型,方便验证指定账号能否实际调用该模型
- 请求健康卡片分别展示已取消和未完成的请求数。悬停图表可查看对应时段的服务成功率,缺少有效样本时显示「无有效样本」
+34 -8
View File
@@ -13,6 +13,7 @@ usage() {
cat <<USAGE
Usage:
release/publish <version>
release/publish --check <version>
Example:
release/publish 0.1.4
@@ -20,6 +21,7 @@ Example:
Before publishing, replace release/notes.md with the new version's notes,
starting with "# v<version>". Leave the notes uncommitted; this command commits
them together with release/version.yaml. Commit other tracked changes first.
Use --check to validate without changing files, Git refs, or remote state.
USAGE
}
@@ -67,6 +69,14 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0
fi
check_only=false
if [[ "${1:-}" == "--check" ]]; then
check_only=true
# 只读检查也禁止 Git 的可选索引刷新
export GIT_OPTIONAL_LOCKS=0
shift
fi
[[ "$#" -eq 1 ]] || {
usage >&2
exit 2
@@ -83,6 +93,8 @@ version="$1"
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
cd "$repo_root"
version="$(ruby .github/scripts/release-metadata.rb validate "$version")"
ruby .github/scripts/toolchains.rb >/dev/null
ruby -r './.github/scripts/release-metadata' -e 'ReleaseMetadata.platforms'
git ls-files --error-unmatch "$VERSION_FILE" >/dev/null 2>&1 || die "$VERSION_FILE is not tracked"
@@ -117,12 +129,20 @@ if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
die "local tag already exists: $tag"
fi
git fetch "$remote"
if [[ "$check_only" == false ]]; then
git fetch "$remote"
fi
upstream="$remote/$remote_branch"
local_head="$(git rev-parse HEAD)"
upstream_head="$(git rev-parse "$upstream")"
merge_base="$(git merge-base HEAD "$upstream")"
remote_refs="$(git ls-remote --refs "$remote" "$merge_ref" "refs/tags/$tag")" || die "failed to read remote refs"
upstream_head="$(awk -v ref="$merge_ref" '$2 == ref { print $1 }' <<< "$remote_refs")"
[[ -n "$upstream_head" ]] || die "upstream branch does not exist: $upstream"
if awk -v ref="refs/tags/$tag" '$2 == ref { found = 1 } END { exit !found }' <<< "$remote_refs"; then
die "remote tag already exists: $tag"
fi
git cat-file -e "$upstream_head^{commit}" 2>/dev/null || die "upstream has unseen commits; run git fetch $remote first"
merge_base="$(git merge-base HEAD "$upstream_head")" || die "branch has no common history with $upstream"
if [[ "$local_head" != "$upstream_head" && "$merge_base" != "$upstream_head" ]]; then
if [[ "$merge_base" == "$local_head" ]]; then
@@ -131,8 +151,15 @@ if [[ "$local_head" != "$upstream_head" && "$merge_base" != "$upstream_head" ]];
die "branch has diverged from $upstream"
fi
if git ls-remote --exit-code --tags "$remote" "refs/tags/$tag" >/dev/null 2>&1; then
die "remote tag already exists: $tag"
if [[ "$check_only" == true ]]; then
# 预检调用与正式更新相同的纯函数,不写回兼容窗口
ruby -r './backend/crates/gateway-plugin/runtime/plugin-api-lifecycle' -e '
deprecations, compatibility, version = ARGV
PluginApiLifecycle.advance(JSON.parse(File.read(deprecations)), JSON.parse(File.read(compatibility)), version)
' "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
echo "Preflight passed: $APP_NAME $version on $branch -> $repository ($remote_branch)"
echo "No files, Git refs, or remote state changed. CI and release artifact validation still run after dispatch."
exit 0
fi
ruby backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
@@ -146,8 +173,7 @@ git commit \
git tag -a "$tag" -m "$tag"
git push --atomic "$remote" "HEAD:$remote_branch" "refs/tags/$tag"
# Dispatch from the long-lived release branch instead of the tag so GitHub
# Actions caches remain reusable across releases.
# 从长期发行分支触发,使 Actions 缓存可跨版本复用
if ! gh workflow run release.yml \
--repo "$repository" \
--ref "$remote_branch" \
@@ -158,7 +184,7 @@ if ! gh workflow run release.yml \
fi
cat <<DONE
Published $APP_NAME $version
Queued release for $APP_NAME $version
Commit: $(git rev-parse --short HEAD)
Tag: $tag
Release workflow dispatched from: $remote_branch
+1 -1
View File
@@ -1 +1 @@
version: "3.41.0"
version: "3.41.2"