mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fdc2b5cd | ||
|
|
dfabf0f9dd | ||
|
|
817b0f2d6d | ||
|
|
a975868046 | ||
|
|
cc2dfab19a | ||
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 | ||
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e |
@@ -10,12 +10,14 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 文档入口 |
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
@@ -29,7 +31,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
|
||||
@@ -39,18 +39,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
本仓库的常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](../dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次 PR 追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue。版本变更与升级影响按发版任务维护
|
||||
`release/notes.md`,不为普通 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
|
||||
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
|
||||
## 执行正式发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
|
||||
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
+17
-1
@@ -21,11 +21,27 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
|
||||
ignore:
|
||||
- dependency-name: aws-lc-rs
|
||||
- dependency-name: hyper
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: hyper-tls
|
||||
- dependency-name: hyper-util
|
||||
- dependency-name: native-tls
|
||||
- dependency-name: openssl
|
||||
- dependency-name: openssl-src
|
||||
- dependency-name: openssl-sys
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
- dependency-name: rustls-native-certs
|
||||
- dependency-name: rustls-pki-types
|
||||
- dependency-name: tokio
|
||||
- dependency-name: tokio-native-tls
|
||||
- dependency-name: tokio-rustls
|
||||
- dependency-name: tokio-tungstenite
|
||||
- dependency-name: tungstenite
|
||||
- dependency-name: zstd
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
@@ -205,3 +205,6 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
- 'release/notes.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -315,6 +315,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -349,7 +350,18 @@ jobs:
|
||||
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
release_dir.mkdir(parents=True)
|
||||
archives = []
|
||||
config_template = pathlib.Path("deploy/config.example.yaml")
|
||||
shutil.copy2(config_template, release_dir / config_template.name)
|
||||
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
||||
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
||||
if compose.count(default_image) != 1:
|
||||
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
||||
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
||||
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
@@ -367,6 +379,9 @@ jobs:
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
deploy_dir = package_root / "deploy"
|
||||
deploy_dir.mkdir()
|
||||
shutil.copy2(config_template, deploy_dir / config_template.name)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
@@ -375,12 +390,12 @@ jobs:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
with checksum_path.open("w", encoding="utf-8") as file:
|
||||
for archive in archives:
|
||||
file.write(f"{sha256(archive)} {archive.name}\n")
|
||||
for asset in assets:
|
||||
file.write(f"{sha256(asset)} {asset.name}\n")
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
@@ -655,25 +670,27 @@ jobs:
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
|
||||
|
||||
## 📥 Installation
|
||||
## 📥 安装
|
||||
|
||||
**Docker:**
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
Download the appropriate archive for your platform from the assets below.
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
## 📚 Documentation
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
- [GitHub Repository](https://github.com/${{ github.repository }})
|
||||
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
|
||||
@@ -40,7 +40,16 @@ jobs:
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
shell: bash
|
||||
run: |
|
||||
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
|
||||
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
|
||||
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
|
||||
audit_advisory_args=()
|
||||
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
|
||||
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
|
||||
fi
|
||||
cargo audit "${audit_advisory_args[@]}"
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -52,7 +61,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
+19
-2
@@ -76,15 +76,32 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
## 项目约定
|
||||
|
||||
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。改动使现有说明失真或缺少必要信息时,按文档职责修订对应内容;变更经过留在 PR,发布说明按维护者的发布安排更新。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
```bash
|
||||
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
|
||||
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
|
||||
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
|
||||
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
|
||||
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
|
||||
-o deploy/compose.yaml
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
|
||||
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
|
||||
-o deploy/config.example.yaml
|
||||
|
||||
install -d -m 0750 .runtime/postgres .runtime/redis
|
||||
@@ -40,6 +43,9 @@ sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
|
||||
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
|
||||
```
|
||||
|
||||
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
|
||||
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
|
||||
|
||||
分别生成数据库和 Redis 密码:
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+215
-37
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,22 +165,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
|
||||
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -206,9 +205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.144.0"
|
||||
version = "1.146.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
|
||||
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -479,9 +478,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -749,9 +749,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -808,6 +808,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1192,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1291,6 +1301,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1383,7 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1879,6 +1904,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1896,9 +1937,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2236,6 +2279,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -2291,12 +2351,66 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2604,6 +2718,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cookie",
|
||||
"flate2",
|
||||
"futures",
|
||||
@@ -2615,6 +2730,9 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -2741,7 +2859,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2840,9 +2958,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -2919,6 +3037,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -2927,9 +3046,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2941,6 +3063,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -3011,7 +3134,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3032,21 +3155,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -3130,6 +3253,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3137,7 +3273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3205,7 +3341,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3663,9 +3799,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3692,6 +3828,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -3713,7 +3870,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,7 +3899,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3811,9 +3968,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3836,6 +3993,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -4184,9 +4351,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -4419,6 +4586,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -310,6 +310,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_accept_empty_and_custom_request_locations() {
|
||||
let original = valid_config();
|
||||
let omitted = original
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("location:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_ne!(original, omitted);
|
||||
parse_config(&omitted).expect("location passthrough when omitted");
|
||||
let location_line = original
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with("location:"))
|
||||
.expect("example location");
|
||||
for empty in [" location:", " location: null", " location: ~"] {
|
||||
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
|
||||
}
|
||||
let custom = original.replace(
|
||||
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
|
||||
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
|
||||
);
|
||||
assert_ne!(original, custom);
|
||||
parse_config(&custom).expect("custom location from YAML");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_request_location_timezones() {
|
||||
let original = valid_config();
|
||||
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
|
||||
assert_ne!(original, invalid);
|
||||
assert_rejected(invalid);
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
|
||||
@@ -757,6 +757,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
|
||||
@@ -22,9 +22,9 @@ use crate::{
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
@@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountQuotaForecastReport, AdminError>;
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
@@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.profile_statistics(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider profile statistics"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
let (initial, provider) = self.provider_for_account(account_id).await?;
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
|
||||
let (profile, subscription) = futures::join!(
|
||||
provider.profile_statistics(account_id),
|
||||
provider.subscription(account_id),
|
||||
);
|
||||
let profile =
|
||||
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
|
||||
let subscription = subscription
|
||||
.map_err(|error| map_provider_error(error, "provider subscription"))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
|
||||
let current = self.load_account(account_id).await?;
|
||||
if current.account.provider_kind != initial.account.provider_kind
|
||||
|| current.account.credential_revision != initial.account.credential_revision
|
||||
|| current.account.upstream_user_id != initial.account.upstream_user_id
|
||||
|| current.account.upstream_account_id != initial.account.upstream_account_id
|
||||
{
|
||||
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
|
||||
}
|
||||
Ok(AccountPersonalInfo {
|
||||
profile,
|
||||
subscription,
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
|
||||
@@ -38,8 +38,10 @@ use gateway_admin::{
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
|
||||
@@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
|
||||
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
|
||||
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -97,6 +102,9 @@ impl FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
profile_result: Mutex::new(Ok(empty_profile_statistics())),
|
||||
subscription_result: Mutex::new(Ok(None)),
|
||||
personal_info_barrier: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +112,14 @@ impl FakeProviderAdmin {
|
||||
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
|
||||
}
|
||||
|
||||
async fn wait_personal_info_queries(&self) {
|
||||
let barrier = self.personal_info_barrier.lock().unwrap().clone();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
|
||||
*self.failure.lock().expect("provider failure") =
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
@@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
|
||||
self.record("provider.profile_statistics");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.profile_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.record("provider.subscription");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.subscription_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
}
|
||||
@@ -2429,6 +2471,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_profile_statistics() -> ProviderProfileStatistics {
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Preview user".to_owned()),
|
||||
username: None,
|
||||
image_url: None,
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(123),
|
||||
peak_tokens: None,
|
||||
longest_task_duration_ms: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage: None,
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_effort_percent: None,
|
||||
skills_explored: None,
|
||||
total_skills_used: None,
|
||||
total_threads: None,
|
||||
invocations: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_info_subscription() -> ProviderSubscription {
|
||||
ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: Utc::now(),
|
||||
will_renew: Some(true),
|
||||
billing_period: Some("monthly".to_owned()),
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let info = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
services.accounts().personal_info(&account_id),
|
||||
)
|
||||
.await
|
||||
.expect("两项查询应并发执行")
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, Some(subscription.clone()));
|
||||
}
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
|
||||
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.subscription_result.lock().unwrap() = subscription;
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_subscription_when_profile_fails() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
info.profile.unwrap_err().kind(),
|
||||
gateway_admin::model::AdminErrorKind::BadGateway
|
||||
);
|
||||
assert_eq!(info.subscription, Some(subscription));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_reject_missing_account_before_querying_provider() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.set_accounts(Vec::new());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let error = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
|
||||
assert_eq!(recorded(&events), ["store.load_account"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_discard_results_when_account_changes_during_query() {
|
||||
for change in 0..5 {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(3));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let mutate = async {
|
||||
barrier.wait().await;
|
||||
let mut accounts = store.accounts.lock().unwrap().clone();
|
||||
match change {
|
||||
0 => accounts[0].credential_revision = revision(99),
|
||||
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
|
||||
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
|
||||
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
|
||||
_ => accounts.clear(),
|
||||
}
|
||||
store.set_accounts(accounts);
|
||||
barrier.wait().await;
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::join!(services.accounts().personal_info(&account_id), mutate)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if change == 4 {
|
||||
gateway_admin::model::AdminErrorKind::NotFound
|
||||
} else {
|
||||
gateway_admin::model::AdminErrorKind::Conflict
|
||||
};
|
||||
assert_eq!(result.unwrap_err().kind(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
async fn accounts_service(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
|
||||
@@ -23,12 +23,12 @@ where
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
"/api/admin/accounts/personal-info",
|
||||
get(account_personal_info::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-statistics",
|
||||
get(account_profile_statistics::<S>),
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-avatar",
|
||||
@@ -389,7 +389,7 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_profile_statistics<S>(
|
||||
async fn account_personal_info<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
@@ -401,12 +401,12 @@ where
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_statistics(&account_id)
|
||||
.personal_info(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,15 @@ use gateway_admin::model::{
|
||||
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
|
||||
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
|
||||
StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
|
||||
@@ -653,6 +653,54 @@ pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
/// 个人资料、累计统计与订阅的统一响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPersonalInfoData {
|
||||
pub profile: Option<AccountProfileStatisticsData>,
|
||||
pub profile_error: Option<String>,
|
||||
pub subscription: Option<AccountSubscriptionData>,
|
||||
}
|
||||
|
||||
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
fn from(info: AccountPersonalInfo) -> Self {
|
||||
let (profile, profile_error) = match info.profile {
|
||||
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
|
||||
Err(error) => (None, Some(error.message().to_owned())),
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
profile_error,
|
||||
subscription: info.subscription.map(AccountSubscriptionData::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需读取的订阅安全字段,不暴露原始上游响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSubscriptionData {
|
||||
pub starts_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
fn from(subscription: ProviderSubscription) -> Self {
|
||||
Self {
|
||||
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
|
||||
expires_at: subscription.expires_at.to_rfc3339(),
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料统计响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
|
||||
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
|
||||
use gateway_protocol::openai::sse::{
|
||||
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::ResponseEncodeError;
|
||||
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAiResponsesEncoder {
|
||||
response_id: Option<String>,
|
||||
response_snapshot: Option<Value>,
|
||||
wire_terminal: Option<Value>,
|
||||
wire_failure: bool,
|
||||
}
|
||||
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
response_id: None,
|
||||
response_snapshot: None,
|
||||
wire_terminal: None,
|
||||
wire_failure: false,
|
||||
}
|
||||
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
|
||||
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
|
||||
if wire.event_type() == Some("error")
|
||||
&& let Some(data) = response_failed_sse_data_from_error_event(
|
||||
self.response_snapshot.as_ref(),
|
||||
self.response_id.as_deref(),
|
||||
wire.data(),
|
||||
)
|
||||
{
|
||||
return vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
"response.failed",
|
||||
&data.to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))];
|
||||
}
|
||||
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
|
||||
return vec![raw_sse_frame.clone()];
|
||||
}
|
||||
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
|
||||
let effective_type = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.created" | "response.in_progress" | "response.queued")
|
||||
) && let Some(response) = wire
|
||||
.data()
|
||||
.get("response")
|
||||
.filter(|value| value.is_object())
|
||||
{
|
||||
self.response_snapshot = Some(response.clone());
|
||||
}
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.completed" | "response.incomplete")
|
||||
|
||||
@@ -7,6 +7,42 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_requires_admin_and_a_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (query, authenticated, expected) in [
|
||||
("?accountId=acct_test", false, StatusCode::UNAUTHORIZED),
|
||||
("", true, StatusCode::BAD_REQUEST),
|
||||
("?accountId=bad", true, StatusCode::BAD_REQUEST),
|
||||
(
|
||||
"?accountId=acct_test&refresh=true",
|
||||
true,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
// 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。
|
||||
(
|
||||
"?accountId=acct_test",
|
||||
true,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri(format!("/api/admin/accounts/personal-info{query}"))
|
||||
.header("x-request-id", "req_personal_info");
|
||||
if authenticated {
|
||||
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
|
||||
}
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "{query}");
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_requires_admin_and_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
mod handlers;
|
||||
mod presenter;
|
||||
|
||||
mod personal_info {
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
provider_credentials::{AccountPersonalInfo, ProviderSubscription},
|
||||
};
|
||||
use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountSubscriptionData::from(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: observed,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
|
||||
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
|
||||
"observedAt": "2026-09-14T00:00:00+00:00"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_subscription_when_profile_fails() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
|
||||
subscription: Some(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: None,
|
||||
observed_at: observed,
|
||||
}),
|
||||
});
|
||||
let value = serde_json::to_value(response).unwrap();
|
||||
assert!(value["profile"].is_null());
|
||||
assert_eq!(value["profileError"], "上游服务请求失败");
|
||||
assert_eq!(
|
||||
value["subscription"]["expiresAt"],
|
||||
"2026-09-14T00:00:00+00:00"
|
||||
);
|
||||
assert_eq!(value.as_object().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
|
||||
subscription: None,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"profile": null,
|
||||
"profileError": "Provider 服务暂不可用",
|
||||
"subscription": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod query {
|
||||
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
|
||||
use gateway_api::admin::accounts::ListQuery;
|
||||
|
||||
@@ -34,6 +34,7 @@ use gateway_core::event::{
|
||||
use gateway_core::operation::{Operation, OperationKind};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
|
||||
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
|
||||
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error_data = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
|
||||
let error_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("error".to_owned()),
|
||||
error_data,
|
||||
projected_error_frame,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream error wire"),
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
started_wire,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
error_wire,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
let events = parse_sse_events(&body).expect("translated SSE should parse");
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event.as_deref() == Some("response.failed"))
|
||||
.expect("translated response.failed event");
|
||||
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
failed,
|
||||
body.matches("event: response.failed").count(),
|
||||
body.contains("event: error\n"),
|
||||
body.ends_with("data: [DONE]\n\n"),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
}),
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::diagnostics::TraceContext;
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::future::Future;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
@@ -192,7 +192,8 @@ where
|
||||
excluded_accounts: BTreeSet::new(),
|
||||
credential_recovery_attempted_accounts: BTreeSet::new(),
|
||||
recovery_account: None,
|
||||
transport_recovery: None,
|
||||
pending_retry: None,
|
||||
transient_retry_counts: BTreeMap::new(),
|
||||
current: None,
|
||||
send_state_watermark: UpstreamSendState::NotSent,
|
||||
downstream_committed_at: None,
|
||||
@@ -224,6 +225,7 @@ struct CurrentAttempt {
|
||||
stream: ProviderStream,
|
||||
metadata: ProviderCallMetadata,
|
||||
trigger: AttemptTrigger,
|
||||
transport: AttemptTransport,
|
||||
index: NonZeroU32,
|
||||
started_at: SystemTime,
|
||||
send_observed: bool,
|
||||
@@ -253,10 +255,11 @@ struct FailureFinalization {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingTransportRecovery {
|
||||
struct PendingAttemptRetry {
|
||||
account: crate::account::ProviderAccountId,
|
||||
transport: AttemptTransport,
|
||||
delay: Duration,
|
||||
transport_recovery: bool,
|
||||
}
|
||||
|
||||
/// API 可逐事件消费的 Core 执行会话。
|
||||
@@ -294,9 +297,10 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
|
||||
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
|
||||
recovery_account: Option<crate::account::ProviderAccountId>,
|
||||
/// 上游传输在提交边界前失败后的一次性同账号重试或备用传输钉选。
|
||||
/// 与凭据恢复分开保存,避免把 transport recovery 误记为 OAuth 恢复。
|
||||
transport_recovery: Option<PendingTransportRecovery>,
|
||||
/// 提交前同账号退避/传输恢复共用的等待与钉选状态;业务重试消耗路由预算。
|
||||
pending_retry: Option<PendingAttemptRetry>,
|
||||
/// 请求内按账号累计的瞬时拒绝重试次数,不能跨请求污染账号健康状态。
|
||||
transient_retry_counts: BTreeMap<crate::account::ProviderAccountId, u32>,
|
||||
current: Option<CurrentAttempt>,
|
||||
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
|
||||
send_state_watermark: UpstreamSendState,
|
||||
@@ -693,11 +697,14 @@ where
|
||||
}
|
||||
|
||||
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
|
||||
let transport_recovery = self.transport_recovery.take();
|
||||
if transport_recovery.is_none() && self.routing_attempts >= self.plan.max_attempts().get() {
|
||||
let pending_retry = self.pending_retry.take();
|
||||
let is_transport_recovery = pending_retry
|
||||
.as_ref()
|
||||
.is_some_and(|retry| retry.transport_recovery);
|
||||
if !is_transport_recovery && self.routing_attempts >= self.plan.max_attempts().get() {
|
||||
return Err(EngineError::EmptyRoutingPlan);
|
||||
}
|
||||
if let Some(recovery) = transport_recovery.as_ref()
|
||||
if let Some(recovery) = pending_retry.as_ref()
|
||||
&& !recovery.delay.is_zero()
|
||||
{
|
||||
match poll_retry_delay(recovery.delay, self.cancellation.clone(), self.deadline).await {
|
||||
@@ -738,8 +745,7 @@ where
|
||||
.ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
// 请求局部恢复钉选在此被一次性消费,只绑定本次 replay attempt;
|
||||
// 外部 required_account 每次 attempt 都重新生效。
|
||||
let is_transport_recovery = transport_recovery.is_some();
|
||||
let (pinned_account, attempt_transport) = if let Some(recovery) = transport_recovery {
|
||||
let (pinned_account, attempt_transport) = if let Some(recovery) = pending_retry {
|
||||
(Some(recovery.account), recovery.transport)
|
||||
} else {
|
||||
match &self.account_selection {
|
||||
@@ -1018,6 +1024,7 @@ where
|
||||
stream,
|
||||
metadata,
|
||||
trigger,
|
||||
transport: attempt_transport,
|
||||
index: next_attempt,
|
||||
started_at: SystemTime::now(),
|
||||
send_observed: false,
|
||||
@@ -1190,6 +1197,27 @@ where
|
||||
&& attempt_send_state != UpstreamSendState::Ambiguous
|
||||
&& provider_proved_replay_safe
|
||||
&& self.routing_attempts < self.plan.max_attempts().get();
|
||||
let transient_retry = match error.pre_delivery_retry() {
|
||||
Some(crate::error::PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries,
|
||||
initial_delay,
|
||||
max_delay,
|
||||
}) if ordinary_retry => {
|
||||
let retries = self
|
||||
.transient_retry_counts
|
||||
.entry(current.metadata.provider_account_id().clone())
|
||||
.or_default();
|
||||
if *retries < max_retries.get() {
|
||||
let multiplier = 1_u32.checked_shl(*retries).unwrap_or(u32::MAX);
|
||||
let delay = initial_delay.saturating_mul(multiplier).min(max_delay);
|
||||
*retries = retries.saturating_add(1);
|
||||
Some(delay)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let same_account_retry = error.retries_same_account()
|
||||
&& provider_proved_replay_safe
|
||||
&& self.downstream_committed_at.is_none()
|
||||
@@ -1209,7 +1237,8 @@ where
|
||||
"retryable": retryable, "continuationRetry": continuation_retry,
|
||||
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
|
||||
"ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(),
|
||||
"delayMs": transport_recovery.map(|(_, delay)| duration_ms(delay)),
|
||||
"transientRetry": transient_retry.is_some(),
|
||||
"delayMs": transient_retry.or(transport_recovery.map(|(_, delay)| delay)).map(duration_ms),
|
||||
"downstreamCommitted": self.downstream_committed_at.is_some(),
|
||||
"sendState": format!("{attempt_send_state:?}"),
|
||||
}));
|
||||
@@ -1229,11 +1258,19 @@ where
|
||||
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
|
||||
// ordinary/continuation 重试门不受影响,仍可换号。
|
||||
self.recovery_account = Some(account);
|
||||
} else if let Some(delay) = transient_retry {
|
||||
self.pending_retry = Some(PendingAttemptRetry {
|
||||
account: current.metadata.provider_account_id().clone(),
|
||||
transport: current.transport,
|
||||
delay,
|
||||
transport_recovery: false,
|
||||
});
|
||||
} else if let Some((transport, delay)) = transport_recovery {
|
||||
self.transport_recovery = Some(PendingTransportRecovery {
|
||||
self.pending_retry = Some(PendingAttemptRetry {
|
||||
account: current.metadata.provider_account_id().clone(),
|
||||
transport,
|
||||
delay,
|
||||
transport_recovery: true,
|
||||
});
|
||||
} else if !continuation_retry {
|
||||
self.excluded_accounts
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum ProviderErrorKind {
|
||||
Protocol,
|
||||
/// Provider 暂不可用。
|
||||
Unavailable,
|
||||
/// 上游明确拒绝当前请求的模型容量;不证明整个 Provider 的基础设施故障。
|
||||
UpstreamCapacityUnavailable,
|
||||
/// 请求被取消。
|
||||
Cancelled,
|
||||
/// 进程终止后由恢复流程收敛。
|
||||
@@ -71,6 +73,7 @@ impl ProviderErrorKind {
|
||||
Self::Transport => "transport",
|
||||
Self::Protocol => "protocol",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::UpstreamCapacityUnavailable => "upstream_capacity_unavailable",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::ProcessTerminated => "process_terminated",
|
||||
}
|
||||
@@ -105,6 +108,15 @@ pub enum ContinuationRecoveryDisposition {
|
||||
pub enum PreDeliveryRetry {
|
||||
/// 排除本次账号,按普通调度策略重新选号。
|
||||
AccountRotation,
|
||||
/// 上游明确拒绝后的同账号退避;耗尽后仍由普通安全重放规则决定是否换号。
|
||||
SameAccountTransientRetry {
|
||||
/// 每个账号在本请求中的重试上限,同时受总路由预算约束。
|
||||
max_retries: NonZeroU32,
|
||||
/// 指数退避的起始间隔。
|
||||
initial_delay: Duration,
|
||||
/// 单次等待上限,避免上游提示把请求拖入长时间等待。
|
||||
max_delay: Duration,
|
||||
},
|
||||
/// 固定本次账号,并按 Provider 给出的序号重试当前传输。
|
||||
SameAccountTransportRetry {
|
||||
/// Provider-owned 传输重试序号,从 1 开始。
|
||||
@@ -409,7 +421,7 @@ pub struct ProviderError {
|
||||
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
|
||||
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
|
||||
replay_safe: bool,
|
||||
pre_delivery_retry: Option<PreDeliveryRetry>,
|
||||
pre_delivery_retry: Option<Box<PreDeliveryRetry>>,
|
||||
credential_recovery_required: bool,
|
||||
retry_same_account: bool,
|
||||
sensitive_context_redacted: bool,
|
||||
@@ -557,8 +569,8 @@ impl ProviderError {
|
||||
/// Provider 选择了“客户端无感恢复优先”的传输策略。Core 在 continuation、
|
||||
/// 指定账号或下游已经进入提交状态时必须忽略它。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_retry(mut self) -> Self {
|
||||
self.pre_delivery_retry = Some(PreDeliveryRetry::AccountRotation);
|
||||
pub fn with_pre_delivery_retry(mut self) -> Self {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::AccountRotation));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -567,14 +579,14 @@ impl ProviderError {
|
||||
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
|
||||
/// 下游已提交后的隐藏重放。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_transport_fallback(mut self) -> Self {
|
||||
pub fn with_pre_delivery_transport_fallback(mut self) -> Self {
|
||||
self.set_pre_delivery_transport_fallback();
|
||||
self
|
||||
}
|
||||
|
||||
/// 原地设置同账号备用传输恢复,保留当前错误携带的原客户端响应。
|
||||
pub const fn set_pre_delivery_transport_fallback(&mut self) {
|
||||
self.pre_delivery_retry = Some(PreDeliveryRetry::SameAccountTransportFallback);
|
||||
pub fn set_pre_delivery_transport_fallback(&mut self) {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportFallback));
|
||||
}
|
||||
|
||||
/// 允许 Core 在首个客户端事件前固定原账号并重试当前 Provider 传输。
|
||||
@@ -582,7 +594,7 @@ impl ProviderError {
|
||||
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
|
||||
/// deadline/取消边界以及下游 commit barrier。
|
||||
#[must_use]
|
||||
pub const fn with_pre_delivery_transport_retry(
|
||||
pub fn with_pre_delivery_transport_retry(
|
||||
mut self,
|
||||
retry_index: NonZeroU32,
|
||||
delay: Duration,
|
||||
@@ -592,13 +604,11 @@ impl ProviderError {
|
||||
}
|
||||
|
||||
/// 原地设置同账号当前传输重试,保留当前错误携带的原客户端响应。
|
||||
pub const fn set_pre_delivery_transport_retry(
|
||||
&mut self,
|
||||
retry_index: NonZeroU32,
|
||||
delay: Duration,
|
||||
) {
|
||||
self.pre_delivery_retry =
|
||||
Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, delay });
|
||||
pub fn set_pre_delivery_transport_retry(&mut self, retry_index: NonZeroU32, delay: Duration) {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransportRetry {
|
||||
retry_index,
|
||||
delay,
|
||||
}));
|
||||
}
|
||||
|
||||
/// 要求 Core 在账号凭据已恢复后仅对原账号重放一次。
|
||||
@@ -608,6 +618,22 @@ impl ProviderError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 请求有界同账号退避;该意图本身不提供安全重放证据。
|
||||
#[must_use]
|
||||
pub fn with_transient_retry(
|
||||
mut self,
|
||||
max_retries: NonZeroU32,
|
||||
initial_delay: Duration,
|
||||
max_delay: Duration,
|
||||
) -> Self {
|
||||
self.pre_delivery_retry = Some(Box::new(PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries,
|
||||
initial_delay,
|
||||
max_delay,
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
/// 标记上游明确拒绝应先触发 OAuth 恢复,而不是直接废弃账号。
|
||||
#[must_use]
|
||||
pub const fn with_credential_recovery(mut self) -> Self {
|
||||
@@ -748,7 +774,10 @@ impl ProviderError {
|
||||
/// 返回下游提交前由 Provider 选择的隐藏恢复方式。
|
||||
#[must_use]
|
||||
pub const fn pre_delivery_retry(&self) -> Option<PreDeliveryRetry> {
|
||||
self.pre_delivery_retry
|
||||
match self.pre_delivery_retry.as_ref() {
|
||||
Some(retry) => Some(**retry),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 Provider 是否已完成凭据恢复并要求原账号重放。
|
||||
@@ -822,7 +851,7 @@ impl Clone for ProviderError {
|
||||
continuation_recovery_disposition: self.continuation_recovery_disposition,
|
||||
failure_observation: self.failure_observation.clone(),
|
||||
replay_safe: self.replay_safe,
|
||||
pre_delivery_retry: self.pre_delivery_retry,
|
||||
pre_delivery_retry: self.pre_delivery_retry.clone(),
|
||||
credential_recovery_required: self.credential_recovery_required,
|
||||
retry_same_account: self.retry_same_account,
|
||||
sensitive_context_redacted: self.sensitive_context_redacted,
|
||||
@@ -1064,6 +1093,7 @@ impl GatewayError {
|
||||
ProviderErrorKind::Transport
|
||||
| ProviderErrorKind::Protocol
|
||||
| ProviderErrorKind::Unavailable
|
||||
| ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
| ProviderErrorKind::ProcessTerminated => Self::new(
|
||||
GatewayErrorKind::UpstreamUnavailable,
|
||||
"upstream service is unavailable",
|
||||
|
||||
@@ -521,7 +521,7 @@ impl fmt::Debug for ProviderResponseHeader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Core 消费的实际上游响应事实。
|
||||
/// Core 消费的 Provider 执行观测,不会原样进入客户端响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResponseObservation {
|
||||
transport: UpstreamTransport,
|
||||
@@ -575,7 +575,7 @@ impl ProviderResponseObservation {
|
||||
self
|
||||
}
|
||||
|
||||
/// 附加 Provider 在响应中确认的服务档位。
|
||||
/// 附加 Provider 确定的统计档位,Core 不重新解释其来源。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -590,7 +590,7 @@ impl ProviderResponseObservation {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 附加合法服务档位;不适合持久化的上游观测值会被忽略。
|
||||
/// 附加 Provider 确定的统计档位;不适合持久化的值会被忽略。
|
||||
#[must_use]
|
||||
pub fn with_service_tier_if_valid(mut self, service_tier: impl Into<String>) -> Self {
|
||||
let service_tier = service_tier.into();
|
||||
|
||||
@@ -3318,6 +3318,284 @@ fn provider_owned_transport_retries_keep_the_same_account_until_fallback() {
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
fn transient_rejection() -> ProviderError {
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_replay_safe()
|
||||
.with_transient_retry(
|
||||
NonZeroU32::new(3).expect("retry budget"),
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retries_back_off_on_the_same_account_then_rotate_with_a_fresh_budget() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut scripts = (0..4)
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection())],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
scripts.push(Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: vec![Err(transient_rejection())],
|
||||
});
|
||||
scripts.push(Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
});
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("second account succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit");
|
||||
let first = ProviderAccountId::new("acct_first").expect("account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("account");
|
||||
let contexts = provider.contexts.lock().expect("contexts");
|
||||
assert_eq!(contexts.len(), 6);
|
||||
for context in &contexts[1..4] {
|
||||
assert_eq!(context.required_account(), Some(&first));
|
||||
assert!(!context.excluded_accounts().contains(&first));
|
||||
}
|
||||
assert_eq!(contexts[4].required_account(), None);
|
||||
assert!(contexts[4].excluded_accounts().contains(&first));
|
||||
assert_eq!(contexts[5].required_account(), Some(&second));
|
||||
assert!(!contexts[5].excluded_accounts().contains(&second));
|
||||
assert!(
|
||||
contexts
|
||||
.iter()
|
||||
.all(|context| context.transport() == AttemptTransport::Default)
|
||||
);
|
||||
assert_eq!(
|
||||
store.state.lock().expect("store").finalizations[0].attempt_count,
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retries_consume_the_total_routing_budget() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let max_attempts = route_plan.max_attempts();
|
||||
let scripts = (0..max_attempts.get())
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
max_attempts,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
))],
|
||||
})
|
||||
.collect();
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(
|
||||
provider.contexts.lock().expect("contexts").len(),
|
||||
max_attempts.get() as usize
|
||||
);
|
||||
assert_eq!(
|
||||
store.state.lock().expect("store").finalizations[0].attempt_count,
|
||||
max_attempts.get()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_intent_does_not_supply_replay_proof_or_override_ambiguous_send() {
|
||||
for (send_state, prove_safe) in [
|
||||
(UpstreamSendState::Sent, false),
|
||||
(UpstreamSendState::Ambiguous, true),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut error = ProviderError::new(ProviderErrorKind::Unavailable, send_state)
|
||||
.with_transient_retry(NonZeroU32::MIN, Duration::ZERO, Duration::ZERO);
|
||||
if prove_safe {
|
||||
error = error.with_replay_safe();
|
||||
}
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(error)],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_backoff_obeys_request_deadline() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
))],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_millis(100)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Deadline)
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_keeps_http_fallback_for_the_pinned_account() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_pre_delivery_transport_fallback())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("fallback succeeds after backoff");
|
||||
let contexts = provider.contexts.lock().expect("contexts");
|
||||
assert_eq!(contexts.len(), 3);
|
||||
assert_eq!(contexts[2].transport(), AttemptTransport::Fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retry_is_suppressed_for_required_accounts_and_committed_output() {
|
||||
for committed in [false, true] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut items = Vec::new();
|
||||
if committed {
|
||||
items.push(Ok(GatewayEvent::Started(ResponseMeta::new(
|
||||
"response-visible",
|
||||
"gpt-5",
|
||||
))));
|
||||
}
|
||||
items.push(Err(transient_rejection()));
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
(!committed).then(|| ProviderAccountId::new("acct_first").expect("account")),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
if committed {
|
||||
block_on(session.next_event())
|
||||
.expect("first event")
|
||||
.expect("visible event");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit output");
|
||||
}
|
||||
assert!(matches!(
|
||||
block_on(session.next_event()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
))],
|
||||
}]);
|
||||
let cancellation = CancellationToken::new();
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
cancellation.clone(),
|
||||
))
|
||||
.expect("start execution");
|
||||
let cancel = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
cancellation.cancel();
|
||||
});
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Cancelled)
|
||||
));
|
||||
cancel.join().expect("cancel task");
|
||||
assert_eq!(provider.contexts.lock().expect("contexts").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
|
||||
let operation = generate_operation();
|
||||
|
||||
@@ -863,6 +863,9 @@ fn only_provider_attributable_failures_should_affect_circuit() {
|
||||
assert!(!provider_failure_affects_circuit(
|
||||
ProviderErrorKind::ContinuationRecoveryRequired
|
||||
));
|
||||
assert!(!provider_failure_affects_circuit(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
/// 单条 SSE 事件。
|
||||
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
|
||||
"code": code,
|
||||
"message": message,
|
||||
});
|
||||
let response_id = response_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map_or_else(
|
||||
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
|
||||
ToString::to_string,
|
||||
);
|
||||
let response_id = response_id_or_generated(response_id);
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
|
||||
})
|
||||
}
|
||||
|
||||
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
|
||||
///
|
||||
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
|
||||
/// 已有 response 快照只更新失败终态字段。
|
||||
pub fn response_failed_sse_data_from_error_event(
|
||||
response: Option<&Value>,
|
||||
response_id: Option<&str>,
|
||||
error_event: &Value,
|
||||
) -> Option<Value> {
|
||||
let mut event = error_event.as_object()?.clone();
|
||||
let error = event.remove("error").filter(Value::is_object)?;
|
||||
let mut response = response
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let has_response_id = response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !has_response_id {
|
||||
response.insert(
|
||||
"id".to_owned(),
|
||||
Value::String(response_id_or_generated(response_id)),
|
||||
);
|
||||
}
|
||||
response.insert("status".to_owned(), Value::String("failed".to_owned()));
|
||||
response.insert("error".to_owned(), error);
|
||||
event.insert(
|
||||
"type".to_owned(),
|
||||
Value::String("response.failed".to_owned()),
|
||||
);
|
||||
event.insert("response".to_owned(), Value::Object(response));
|
||||
Some(Value::Object(event))
|
||||
}
|
||||
|
||||
fn response_id_or_generated(response_id: Option<&str>) -> String {
|
||||
response_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map_or_else(
|
||||
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
|
||||
ToString::to_string,
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
|
||||
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
|
||||
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use gateway_protocol::openai::sse::{
|
||||
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
|
||||
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
|
||||
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
|
||||
response_failed_sse_data_from_error_event, response_failed_sse_event,
|
||||
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sse_body_has_done(body: &str) -> bool {
|
||||
body.trim_end_matches(['\r', '\n'])
|
||||
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
|
||||
assert_eq!(data["response"]["id"], "resp_existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
|
||||
let response = json!({
|
||||
"id": "resp_existing",
|
||||
"model": "gpt-test",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
});
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "overloaded",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
|
||||
let projected = response_failed_sse_data_from_error_event(
|
||||
Some(&response),
|
||||
Some("ignored_fallback"),
|
||||
&error_event,
|
||||
)
|
||||
.expect("wrapped error event should project");
|
||||
|
||||
assert_eq!(
|
||||
projected,
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_existing",
|
||||
"model": "gpt-test",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "overloaded",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_frame_detection_should_require_exact_single_data_field() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -13,6 +13,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["clock", "serde"] }
|
||||
chrono-tz = { version = "0.10", features = ["serde"] }
|
||||
cookie = "0.18.1"
|
||||
futures = "0.3.32"
|
||||
flate2 = "1.1.9"
|
||||
@@ -22,27 +23,30 @@ gateway-protocol = { path = "../../gateway-protocol" }
|
||||
getrandom = "0.4.3"
|
||||
hex = "0.4.3"
|
||||
# TLS 依赖按官方 Core 锁文件固定,包含用于约束传递依赖的直接声明。
|
||||
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
|
||||
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
|
||||
aws-lc-rs = "=1.18.1"
|
||||
aws-lc-rs = "=1.16.2"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = "=0.27.7"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
|
||||
# 与官方 Codex Desktop 一致:reqwest 不启用压缩特性,上游请求不带 accept-encoding。
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "http2", "socks"] }
|
||||
# 与官方 Codex Desktop 一致:HTTP 默认走 native-tls,rustls 保留给自定义 CA 回退;
|
||||
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
|
||||
native-tls = "=0.2.14"
|
||||
reqwest = { version = "=0.12.28", features = ["json", "stream", "cookies", "rustls-tls-native-roots", "socks"] }
|
||||
roxmltree = "0.21.1"
|
||||
semver = "1.0.27"
|
||||
# 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。
|
||||
# reqwest 的 roots feature 仍会编译 ring;实际 provider 由 TLS 初始化显式选择。
|
||||
rustls = { version = "=0.23.36", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
|
||||
rustls-native-certs = "=0.8.4"
|
||||
rustls-pki-types = "=1.15.1"
|
||||
rustls-native-certs = "=0.8.3"
|
||||
rustls-pki-types = "=1.14.0"
|
||||
secrecy = "0.10.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "=1.53.1", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
|
||||
tokio = { version = "=1.52.3", default-features = false, features = ["fs", "macros", "rt", "sync", "time"] }
|
||||
tokio-rustls = { version = "=0.26.4", default-features = false }
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
tower-service = "0.3.3"
|
||||
@@ -56,6 +60,10 @@ uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
# 并携带 Content-Encoding: zstd;保持同版本同级别以对齐上游指纹。
|
||||
zstd = "=0.13.3"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
openssl = "=0.10.75"
|
||||
openssl-sys = { version = "=0.9.111", features = ["vendored"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
|
||||
|
||||
@@ -24,7 +24,7 @@ use gateway_admin::model::provider_credentials::{
|
||||
ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage,
|
||||
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole,
|
||||
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
|
||||
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation;
|
||||
@@ -513,6 +513,27 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.account(account_id).await?;
|
||||
self.profile_statistics
|
||||
.subscription(account_id)
|
||||
.await
|
||||
.map(|subscription| {
|
||||
subscription.map(|subscription| ProviderSubscription {
|
||||
starts_at: subscription.starts_at,
|
||||
expires_at: subscription.expires_at,
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at,
|
||||
})
|
||||
})
|
||||
.map_err(map_profile_statistics_error)
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
|
||||
@@ -8,7 +8,9 @@ use serde::Deserialize;
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::credential::CodexQuotaRefreshPolicy;
|
||||
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
|
||||
use crate::transport::profile::{
|
||||
CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
|
||||
use crate::transport::websocket::CodexWebSocketPoolConfig;
|
||||
use crate::{
|
||||
@@ -290,6 +292,9 @@ pub struct CodexWireProfileConfig {
|
||||
pub terminal: String,
|
||||
#[serde(default)]
|
||||
pub residency: Option<CodexResidency>,
|
||||
/// 为空时保留客户端的地区、环境日期和时区。
|
||||
#[serde(default)]
|
||||
pub location: Option<CodexRequestLocation>,
|
||||
pub verified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -305,6 +310,7 @@ impl Default for CodexWireProfileConfig {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
residency: None,
|
||||
location: None,
|
||||
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
|
||||
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
|
||||
}
|
||||
@@ -336,6 +342,29 @@ impl CodexWireProfileConfig {
|
||||
return Err(OpenAiConfigError::InvalidField(field));
|
||||
}
|
||||
}
|
||||
if let Some(location) = &self.location {
|
||||
for (field, value) in [
|
||||
(
|
||||
"openai.wire_profile.location.region",
|
||||
location.region.as_str(),
|
||||
),
|
||||
("openai.wire_profile.location.city", location.city.as_str()),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(OpenAiConfigError::InvalidField(field));
|
||||
}
|
||||
}
|
||||
if location.country.len() != 2
|
||||
|| !location
|
||||
.country
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_uppercase())
|
||||
{
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.location.country",
|
||||
));
|
||||
}
|
||||
}
|
||||
if semver::Version::parse(&self.codex_version).is_err() {
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.codex_version",
|
||||
@@ -367,6 +396,7 @@ impl From<CodexWireProfileConfig> for CodexWireProfile {
|
||||
arch: value.arch,
|
||||
terminal: value.terminal,
|
||||
residency: value.residency,
|
||||
location: value.location,
|
||||
verified_at: value.verified_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use crate::transport::subscription::CodexSubscription;
|
||||
use crate::transport::{
|
||||
CodexBackendClient, CodexClientError, CodexProfileAvatar, CodexProfileAvatarFetchError,
|
||||
CodexProfileStatistics, CodexRequestContext, fetch_profile_avatar,
|
||||
@@ -129,6 +130,53 @@ impl CodexCredentialProfileService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅只按需读取;不缓存、不刷新凭据,也不更新额度状态。
|
||||
pub async fn subscription(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<Option<CodexSubscription>, CodexProfileStatisticsError> {
|
||||
let (authorization, upstream_account_id, account) =
|
||||
self.account_authentication(account_id).await?;
|
||||
let Some(upstream_account_id) = upstream_account_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_id = format!("subscription_{}", Uuid::now_v7().simple());
|
||||
let subscription = CodexBackendClient::new(
|
||||
self.http.clone(),
|
||||
self.base_url.clone(),
|
||||
self.profile.clone(),
|
||||
)
|
||||
.for_account(&account)
|
||||
.map_err(map_client_error)?
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary(
|
||||
authorization.expose_secret(),
|
||||
Some(&upstream_account_id),
|
||||
&request_id,
|
||||
None,
|
||||
),
|
||||
&upstream_account_id,
|
||||
)
|
||||
.await;
|
||||
// 请求期间重新授权、换绑或删除账号时,丢弃旧身份的结果。
|
||||
let current = self
|
||||
.repository
|
||||
.store()
|
||||
.get_account(account_id)
|
||||
.await
|
||||
.map_err(|error| CodexProfileStatisticsError::Store {
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if !current.is_some_and(|current| {
|
||||
current.provider() == account.provider()
|
||||
&& current.revision() == account.revision()
|
||||
&& current.upstream_account_id() == Some(upstream_account_id.as_str())
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
pub async fn profile_statistics(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
|
||||
@@ -18,6 +18,10 @@ pub(super) struct QuotaRecovery {
|
||||
// 绑定本次耗尽事实,避免真实推理成功后再次耗尽时复用上次的恢复进度。
|
||||
exhausted_at_micros: i64,
|
||||
pending: BTreeMap<String, Option<DateTime<Utc>>>,
|
||||
// 已连续观测到"未触顶"的待解除窗口;第二次确认后解除。旧文档没有该
|
||||
// 字段时反序列化为空集,从当前耗尽事实重新积累证据。
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
candidates: BTreeMap<String, ()>,
|
||||
}
|
||||
|
||||
impl QuotaRecovery {
|
||||
@@ -59,23 +63,44 @@ impl QuotaRecovery {
|
||||
Self {
|
||||
exhausted_at_micros: exhaustion_time(state),
|
||||
pending,
|
||||
candidates: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(&mut self, refreshed: &CodexAccountQuotaSnapshot) -> bool {
|
||||
let had_baseline = !self.pending.is_empty();
|
||||
let mut candidates = BTreeMap::new();
|
||||
for window in refreshed.windows().iter().filter(|w| w.is_account_wide()) {
|
||||
if let Some(previous_reset) = self.pending.get_mut(window.key()) {
|
||||
if window_reset_recovered(window, *previous_reset) {
|
||||
self.pending.remove(window.key());
|
||||
} else if previous_reset.is_none() {
|
||||
*previous_reset = window.reset_at();
|
||||
let key = window.key();
|
||||
if let Some(previous_reset) = self.pending.get(key).copied() {
|
||||
if window_reset_recovered(window, previous_reset) {
|
||||
self.pending.remove(key);
|
||||
continue;
|
||||
}
|
||||
if previous_reset.is_none() {
|
||||
// 首次观测到该窗口只锚定 reset 基线,不构成解除证据。
|
||||
self.pending.insert(key.to_owned(), window.reset_at());
|
||||
continue;
|
||||
}
|
||||
// 同一窗口内用量回落(reset 不滚动)时"reset 前进"永远不会
|
||||
// 成立,滑动窗口的正常用量也长期高于低用量阈值;以上游连续
|
||||
// 两次观测都报告未触顶为解除证据。单次观测无法排除耗尽后
|
||||
// 立刻拉到的旧快照(reset 未滚动、用量尚未触顶)。
|
||||
let rolled_forward = window
|
||||
.reset_at()
|
||||
.is_some_and(|current| Some(current) > previous_reset);
|
||||
if !rolled_forward && !window.limit_reached() {
|
||||
if self.candidates.contains_key(key) {
|
||||
self.pending.remove(key);
|
||||
} else {
|
||||
candidates.insert(key.to_owned(), ());
|
||||
}
|
||||
}
|
||||
} else if window.limit_reached() {
|
||||
self.pending
|
||||
.insert(window.key().to_owned(), window.reset_at());
|
||||
self.pending.insert(key.to_owned(), window.reset_at());
|
||||
}
|
||||
}
|
||||
self.candidates = candidates;
|
||||
had_baseline && self.pending.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -85,15 +110,24 @@ fn exhaustion_time(state: QuotaState) -> i64 {
|
||||
}
|
||||
|
||||
fn window_reset_recovered(window: &CodexQuotaWindow, previous: Option<DateTime<Utc>>) -> bool {
|
||||
previous
|
||||
// reset 必须前进:防止耗尽后立刻拉到的旧快照(reset 未滚动)误解锁。
|
||||
if !previous
|
||||
.zip(window.reset_at())
|
||||
.is_some_and(|(previous, current)| current > previous)
|
||||
&& window
|
||||
.used_percent()
|
||||
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// reset 前进说明已进入新窗口,新窗口用量应接近零;高用量说明 reset 基线
|
||||
// 不可信(例如滑动窗口),保持锁定,等待"同窗口用量回落"的连续观测解除。
|
||||
window
|
||||
.used_percent()
|
||||
.is_some_and(|used| used < RESET_RECOVERY_MAX_USED_PERCENT)
|
||||
}
|
||||
|
||||
/// 统一手动刷新和 worker 的恢复规则;上游 allowed 不参与已耗尽窗口的解除判断。
|
||||
/// 统一手动刷新和 worker 的恢复规则。已耗尽窗口的解除有两条证据路径:
|
||||
/// reset 前进且用量低于低用量阈值(窗口已滚动),或同一窗口内连续两次观测
|
||||
/// 都未触顶(滑动窗口的用量回落,reset 不滚动)。后者防止账号被钉在旧
|
||||
/// reset 日期上;连续两次的要求排除耗尽后立刻拉到的旧快照。
|
||||
pub(super) fn reconcile_refresh(
|
||||
current: QuotaState,
|
||||
refreshed: &mut CodexAccountQuotaSnapshot,
|
||||
|
||||
@@ -320,7 +320,6 @@ impl OpenAiTokenClient {
|
||||
return Ok(self.clone());
|
||||
};
|
||||
let builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| TokenClientBuildError)?)
|
||||
.redirect(Policy::none())
|
||||
@@ -418,7 +417,6 @@ pub fn openai_token_client(
|
||||
) -> Result<OpenAiTokenClient, TokenClientBuildError> {
|
||||
ensure_rustls_provider();
|
||||
let builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(TOKEN_CONNECT_TIMEOUT)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! OpenAI 上游失败分类、恢复决策与稳定错误投影。
|
||||
|
||||
use super::*;
|
||||
use crate::transport::diagnostics::is_capacity_error;
|
||||
|
||||
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集。
|
||||
///
|
||||
/// 已归一的上游 code 优先;code 缺失时才读取结构化客户端错误的 code/type。
|
||||
/// 新增错误、裸 HTTP 状态和内部错误 kind 默认都不会进入该闭集。
|
||||
const OPENAI_ACCOUNT_SCORE_FAILURE_REASONS: &[&str] = &[
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"rate_limit_exceeded",
|
||||
"rate_limit_error",
|
||||
"server_error",
|
||||
@@ -33,6 +32,17 @@ fn openai_account_score_failure_reason(error: &ProviderError) -> Option<&str> {
|
||||
/// 返回该 OpenAI 失败是否属于 Smart 账号计分闭集。
|
||||
#[doc(hidden)]
|
||||
pub fn openai_failure_affects_account_score(error: &ProviderError) -> bool {
|
||||
let client_error = error.client_visible_upstream_error();
|
||||
if is_capacity_error(
|
||||
error
|
||||
.upstream_code()
|
||||
.map(OpaqueUpstreamValue::as_str)
|
||||
.or_else(|| client_error.and_then(ClientVisibleUpstreamError::code)),
|
||||
client_error.and_then(ClientVisibleUpstreamError::error_type),
|
||||
client_error.map(ClientVisibleUpstreamError::message),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
openai_account_score_failure_reason(error).is_some_and(is_openai_account_score_failure_reason)
|
||||
}
|
||||
|
||||
@@ -506,6 +516,30 @@ pub(super) fn apply_websocket_recovery_policy(
|
||||
failure: &mut MappedProviderFailure,
|
||||
context: WebSocketRecoveryContext<'_>,
|
||||
) {
|
||||
// 明确账号拒绝走已有换号路径,容量拒绝走请求内退避;两者都不消耗 WS 传输预算。
|
||||
if failure.error.replay_is_safe()
|
||||
&& (failure.account_failure.is_some()
|
||||
|| matches!(
|
||||
failure.error.pre_delivery_retry(),
|
||||
Some(gateway_core::error::PreDeliveryRetry::SameAccountTransientRetry { .. })
|
||||
))
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id = context.request_id,
|
||||
attempt_index = context.attempt_index,
|
||||
account_id = context.account_id,
|
||||
websocket_failure_kind = failure.error.kind().as_str(),
|
||||
websocket_failure_code = failure
|
||||
.error
|
||||
.upstream_code()
|
||||
.map_or("", OpaqueUpstreamValue::as_str),
|
||||
upstream_status_code = failure.error.upstream_status().unwrap_or_default(),
|
||||
upstream_status_code_present = failure.error.upstream_status().is_some(),
|
||||
transport_requirement = context.requirement.as_str(),
|
||||
"OpenAI upstream WebSocket returned a business rejection; deferring to request retry"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let session_budget_exhausted = context.session_affinity_key.is_some_and(|key| {
|
||||
context
|
||||
.session_transport_recovery
|
||||
@@ -1134,6 +1168,7 @@ pub(super) fn map_upstream_failure(
|
||||
replay_boundary: ReplayBoundary,
|
||||
) -> MappedProviderFailure {
|
||||
let category = failure.category();
|
||||
let capacity_unavailable = category == CodexFailureCategory::CapacityUnavailable;
|
||||
let cyber_policy_failure = failure
|
||||
.status
|
||||
.is_some_and(|status| status.is_client_error())
|
||||
@@ -1193,6 +1228,17 @@ pub(super) fn map_upstream_failure(
|
||||
if let Some(retry_after) = failure.retry_after_seconds.map(Duration::from_secs) {
|
||||
error = error.with_retry_after(retry_after);
|
||||
}
|
||||
if capacity_unavailable && error.replay_is_safe() {
|
||||
let max_delay = Duration::from_secs(8);
|
||||
error = error.with_transient_retry(
|
||||
NonZeroU32::new(3).unwrap_or(NonZeroU32::MIN),
|
||||
failure
|
||||
.retry_after_seconds
|
||||
.map_or(Duration::from_millis(500), Duration::from_secs)
|
||||
.min(max_delay),
|
||||
max_delay,
|
||||
);
|
||||
}
|
||||
if let Some(code) = failure.persistable_code() {
|
||||
error = error.with_upstream_code(OpaqueUpstreamValue::new(code.to_owned()));
|
||||
}
|
||||
@@ -1260,6 +1306,7 @@ pub(super) const fn provider_error_kind(category: CodexFailureCategory) -> Provi
|
||||
CodexFailureCategory::CloudflareChallenge
|
||||
| CodexFailureCategory::CloudflarePathBlocked
|
||||
| CodexFailureCategory::Unavailable => ProviderErrorKind::Unavailable,
|
||||
CodexFailureCategory::CapacityUnavailable => ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
CodexFailureCategory::InvalidRequest => ProviderErrorKind::InvalidRequest,
|
||||
CodexFailureCategory::PermissionDenied => ProviderErrorKind::PermissionDenied,
|
||||
CodexFailureCategory::Timeout => ProviderErrorKind::Timeout,
|
||||
@@ -1311,6 +1358,7 @@ pub(super) fn account_failure(
|
||||
| CodexFailureCategory::InvalidRequest
|
||||
| CodexFailureCategory::PermissionDenied
|
||||
| CodexFailureCategory::Timeout
|
||||
| CodexFailureCategory::CapacityUnavailable
|
||||
| CodexFailureCategory::Unavailable
|
||||
| CodexFailureCategory::Transport => None,
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ use crate::transport::diagnostics::{
|
||||
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
|
||||
};
|
||||
use crate::transport::profile::{
|
||||
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
|
||||
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexRequestLocation, CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
|
||||
@@ -141,6 +141,7 @@ pub struct CodexProvider {
|
||||
quota: Arc<CodexCredentialQuotaService>,
|
||||
account_feedback: Arc<AccountFeedbackStats>,
|
||||
client: CodexBackendClient,
|
||||
location: Option<CodexRequestLocation>,
|
||||
responses_url: Url,
|
||||
image_generations_url: Url,
|
||||
image_edits_url: Url,
|
||||
@@ -173,6 +174,7 @@ impl CodexProvider {
|
||||
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
|
||||
let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH))
|
||||
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
|
||||
let location = profile.snapshot().location;
|
||||
let client =
|
||||
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
|
||||
Ok(Self {
|
||||
@@ -181,6 +183,7 @@ impl CodexProvider {
|
||||
quota,
|
||||
account_feedback,
|
||||
client,
|
||||
location,
|
||||
responses_url,
|
||||
image_generations_url,
|
||||
image_edits_url,
|
||||
@@ -253,7 +256,8 @@ impl Provider for CodexProvider {
|
||||
..Default::default()
|
||||
};
|
||||
};
|
||||
let Ok(encoded) = encode_generate_request(request, "observability") else {
|
||||
let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref())
|
||||
else {
|
||||
return ProviderRequestObservation::default();
|
||||
};
|
||||
let semantics = encoded.semantics();
|
||||
@@ -354,8 +358,9 @@ impl Provider for CodexProvider {
|
||||
};
|
||||
let previous_session = decode_openai_session_state(generate);
|
||||
let continuation_requested = generate.native_continuation_requested();
|
||||
let mut upstream_request = encode_generate_request(generate, upstream_model.as_str())
|
||||
.map_err(map_request_error)?;
|
||||
let mut upstream_request =
|
||||
encode_generate_request(generate, upstream_model.as_str(), self.location.as_ref())
|
||||
.map_err(map_request_error)?;
|
||||
if let Some(conversation_id) = previous_session
|
||||
.as_ref()
|
||||
.and_then(|state| state.conversation_id.as_ref())
|
||||
|
||||
@@ -117,8 +117,9 @@ impl OpenAiResponseObservationState {
|
||||
if let Some(metadata) = self.provider_metadata() {
|
||||
observation = observation.with_provider_metadata(metadata);
|
||||
}
|
||||
if let Some(service_tier) = self.effective_service_tier() {
|
||||
observation = observation.with_service_tier_if_valid(service_tier.to_owned());
|
||||
// 统计与计费采用最终发送档位;响应回显只作为独立诊断事实保留。
|
||||
if let Some(service_tier) = &self.requested_service_tier {
|
||||
observation = observation.with_service_tier_if_valid(service_tier.clone());
|
||||
}
|
||||
Some(observation)
|
||||
}
|
||||
@@ -161,12 +162,6 @@ impl OpenAiResponseObservationState {
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn effective_service_tier(&self) -> Option<&str> {
|
||||
self.upstream_service_tier
|
||||
.as_deref()
|
||||
.or(self.requested_service_tier.as_deref())
|
||||
}
|
||||
|
||||
pub(super) fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
|
||||
let mut changed = false;
|
||||
for (name, value) in selected_observation_headers(updates) {
|
||||
|
||||
@@ -158,7 +158,7 @@ impl CodexCanonicalDecoder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 响应未报告实际档位时,才使用请求档位估算费用。
|
||||
/// 使用最终发送给上游的请求档位估算费用,不由响应回显覆盖。
|
||||
#[must_use]
|
||||
pub fn with_requested_service_tier(mut self, service_tier: Option<&str>) -> Self {
|
||||
self.requested_service_tier = normalize_service_tier(service_tier);
|
||||
@@ -217,7 +217,7 @@ impl CodexCanonicalDecoder {
|
||||
std::mem::take(&mut self.timing_signals)
|
||||
}
|
||||
|
||||
/// 返回本 attempt 已从上游响应生命周期帧观察到的实际服务档位。
|
||||
/// 返回本 attempt 的上游响应回显档位,仅供诊断,不作为本地计费依据。
|
||||
#[must_use]
|
||||
pub fn response_service_tier(&self) -> Option<&str> {
|
||||
self.response_service_tier.as_deref()
|
||||
@@ -861,10 +861,8 @@ impl CodexCanonicalDecoder {
|
||||
.filter(|model| !model.is_empty())
|
||||
.unwrap_or(&self.fallback_model)
|
||||
.to_owned();
|
||||
let service_tier = self
|
||||
.response_service_tier
|
||||
.as_deref()
|
||||
.or(self.requested_service_tier.as_deref());
|
||||
// 与用量统计统一按最终发送档位估算,响应回显不改变本地计价口径。
|
||||
let service_tier = self.requested_service_tier.as_deref();
|
||||
let tool_calls = billable_tool_calls(response);
|
||||
if let Some(breakdown) = usage
|
||||
.filter(|usage| billable_usage_is_complete(response, *usage))
|
||||
|
||||
@@ -70,7 +70,6 @@ pub fn build_account_http_client(
|
||||
}
|
||||
|
||||
let mut builder = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.pool_max_idle_per_host(4)
|
||||
|
||||
@@ -102,6 +102,8 @@ pub enum CodexFailureCategory {
|
||||
InvalidRequest,
|
||||
PermissionDenied,
|
||||
Timeout,
|
||||
/// 模型容量暂时不足,只影响当前请求,不证明账号健康异常。
|
||||
CapacityUnavailable,
|
||||
Unavailable,
|
||||
Transport,
|
||||
}
|
||||
@@ -243,6 +245,7 @@ impl CodexUpstreamFailure {
|
||||
| CodexFailureCategory::QuotaExhausted
|
||||
| CodexFailureCategory::CloudflareChallenge
|
||||
| CodexFailureCategory::CloudflarePathBlocked
|
||||
| CodexFailureCategory::CapacityUnavailable
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -428,6 +431,15 @@ fn classify_upstream_failure(
|
||||
let message = fields.message.to_ascii_lowercase();
|
||||
let body = body.to_ascii_lowercase();
|
||||
|
||||
// 容量拒绝可能带 400/429/503;仅用结构化错误字段识别,不能扫描任意正文。
|
||||
if is_capacity_error(
|
||||
fields.code.as_deref(),
|
||||
fields.error_type.as_deref(),
|
||||
fields.client_message.as_deref(),
|
||||
) {
|
||||
return CodexFailureCategory::CapacityUnavailable;
|
||||
}
|
||||
|
||||
// 与官方 Codex HTTP/WS 路径一致:429 只有结构化
|
||||
// `error.type=usage_limit_reached` 才能确认额度窗口耗尽;其余 429 都是临时限流。
|
||||
// SSE `response.failed` 的结构化字段形态不同,保留其 code/type 语义单独分类。
|
||||
@@ -523,9 +535,6 @@ fn classify_upstream_failure(
|
||||
if status == Some(StatusCode::TOO_MANY_REQUESTS) {
|
||||
return CodexFailureCategory::RateLimited;
|
||||
}
|
||||
if is_upstream_overload(&code) || is_upstream_overload(&message) {
|
||||
return CodexFailureCategory::Unavailable;
|
||||
}
|
||||
match status.map(|status| status.as_u16()) {
|
||||
Some(status) => match status {
|
||||
400 | 404 | 409 | 422 => CodexFailureCategory::InvalidRequest,
|
||||
@@ -662,6 +671,40 @@ fn is_cloudflare_challenge(value: &str) -> bool {
|
||||
|| value.contains("just a moment")
|
||||
}
|
||||
|
||||
fn is_upstream_overload(value: &str) -> bool {
|
||||
matches!(value, "server_is_overloaded" | "slow_down") || value.contains("server_overloaded")
|
||||
pub(crate) fn is_capacity_error(
|
||||
code: Option<&str>,
|
||||
error_type: Option<&str>,
|
||||
message: Option<&str>,
|
||||
) -> bool {
|
||||
let code = normalized(code);
|
||||
let error_type = normalized(error_type);
|
||||
// 明确额度窗口/账号错误保留原分类,不能被描述中的容量词覆盖。
|
||||
if !matches!(
|
||||
error_type.as_str(),
|
||||
"" | "server_error"
|
||||
| "service_unavailable_error"
|
||||
| "invalid_request_error"
|
||||
| "rate_limit_error"
|
||||
| "server_is_overloaded"
|
||||
| "slow_down"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if matches!(code.as_str(), "server_is_overloaded" | "slow_down") {
|
||||
return true;
|
||||
}
|
||||
if !matches!(
|
||||
code.as_str(),
|
||||
"" | "server_error" | "service_unavailable_error"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if matches!(error_type.as_str(), "server_is_overloaded" | "slow_down") {
|
||||
return true;
|
||||
}
|
||||
let message = normalized(message);
|
||||
matches!(message.as_str(), "server_is_overloaded" | "slow_down")
|
||||
|| message.contains("selected model is at capacity")
|
||||
|| message.contains("server is overloaded")
|
||||
|| message.contains("server_overloaded")
|
||||
}
|
||||
|
||||
@@ -254,5 +254,32 @@ fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
pub(super) fn websocket_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
header_pairs(headers)
|
||||
let mut pairs = header_pairs(headers);
|
||||
// WebSocket opening 的 HeaderMap 先由业务头构造,再被 tungstenite 插入协议头;
|
||||
// 这里复现官方 HeaderMap 交给 tungstenite 时的迭代顺序,最终序列化后的
|
||||
// 线级顺序由 fingerprint 测试锁定。未知扩展头仍保持相对顺序。
|
||||
pairs.sort_by_key(|(name, _)| websocket_header_order(name));
|
||||
pairs
|
||||
}
|
||||
|
||||
fn websocket_header_order(name: &str) -> usize {
|
||||
const OFFICIAL_INSERTION_ORDER: &[&str] = &[
|
||||
"version",
|
||||
"x-codex-beta-features",
|
||||
"x-client-request-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"x-codex-window-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-codex-routing-hint",
|
||||
"openai-beta",
|
||||
"originator",
|
||||
"user-agent",
|
||||
"authorization",
|
||||
"chatgpt-account-id",
|
||||
];
|
||||
OFFICIAL_INSERTION_ORDER
|
||||
.iter()
|
||||
.position(|candidate| name.eq_ignore_ascii_case(candidate))
|
||||
.unwrap_or(OFFICIAL_INSERTION_ORDER.len())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod request;
|
||||
pub mod reset_credits;
|
||||
mod response_meta;
|
||||
pub(crate) mod session;
|
||||
pub mod subscription;
|
||||
mod time;
|
||||
pub mod tls;
|
||||
pub mod usage;
|
||||
|
||||
@@ -39,6 +39,27 @@ pub enum CodexResidency {
|
||||
Us,
|
||||
}
|
||||
|
||||
/// 环境上下文与 Web Search 共用的请求地区画像。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexRequestLocation {
|
||||
pub country: String,
|
||||
pub region: String,
|
||||
pub city: String,
|
||||
pub timezone: chrono_tz::Tz,
|
||||
}
|
||||
|
||||
impl Default for CodexRequestLocation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
country: "US".to_owned(),
|
||||
region: "Ohio".to_owned(),
|
||||
city: "Piketon".to_owned(),
|
||||
timezone: chrono_tz::America::New_York,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex Desktop 上游请求身份。
|
||||
///
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
|
||||
@@ -63,6 +84,8 @@ pub struct CodexWireProfile {
|
||||
pub terminal: String,
|
||||
/// 未配置时不发送 residency 头;不随制品版本更新而改变。
|
||||
pub residency: Option<CodexResidency>,
|
||||
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
|
||||
pub location: Option<CodexRequestLocation>,
|
||||
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
|
||||
pub verified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
use std::io;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_protocol::openai::{
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use roxmltree::Document;
|
||||
use serde::Serialize as _;
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::transport::profile::CodexRequestLocation;
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY,
|
||||
};
|
||||
@@ -20,6 +23,7 @@ const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
|
||||
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
|
||||
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
|
||||
const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context";
|
||||
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
|
||||
|
||||
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
|
||||
@@ -93,13 +97,14 @@ pub enum CodexRequestEncodeError {
|
||||
pub fn encode_generate_request(
|
||||
request: &GenerateRequest,
|
||||
upstream_model: &str,
|
||||
location: Option<&CodexRequestLocation>,
|
||||
) -> Result<CodexResponsesRequest, CodexRequestEncodeError> {
|
||||
let payload = request.protocol_payload();
|
||||
if payload.protocol() != "openai" {
|
||||
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
|
||||
}
|
||||
let mut body = payload.body().clone();
|
||||
adapt_codex_responses_body(&mut body, upstream_model);
|
||||
adapt_codex_responses_body(&mut body, upstream_model, location);
|
||||
|
||||
let mut encoded = CodexResponsesRequest::from_body(body);
|
||||
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
|
||||
@@ -108,11 +113,141 @@ pub fn encode_generate_request(
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
|
||||
fn adapt_codex_responses_body(
|
||||
body: &mut Map<String, Value>,
|
||||
upstream_model: &str,
|
||||
location: Option<&CodexRequestLocation>,
|
||||
) {
|
||||
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
|
||||
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
|
||||
body.remove(*field);
|
||||
}
|
||||
if let Some(location) = location {
|
||||
align_structured_location_fields(body, Utc::now(), location);
|
||||
}
|
||||
}
|
||||
|
||||
fn align_structured_location_fields(
|
||||
body: &mut Map<String, Value>,
|
||||
now: DateTime<Utc>,
|
||||
location: &CodexRequestLocation,
|
||||
) {
|
||||
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值。
|
||||
let current_date = now
|
||||
.with_timezone(&location.timezone)
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
|
||||
for item in input {
|
||||
align_environment_context(item, ¤t_date, location.timezone.name());
|
||||
}
|
||||
}
|
||||
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
|
||||
for tool in tools {
|
||||
align_web_search_location(tool, location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return;
|
||||
}
|
||||
let content_kinds = item
|
||||
.get("internal_chat_message_metadata_passthrough")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("content_item_kinds"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.map(|kind| kind.as_str().map(str::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for (index, part) in content.iter_mut().enumerate() {
|
||||
if content_kinds
|
||||
.as_ref()
|
||||
.and_then(|kinds| kinds.get(index))
|
||||
.and_then(Option::as_deref)
|
||||
!= Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(part) = part.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if part.get("type").and_then(Value::as_str) != Some("input_text") {
|
||||
continue;
|
||||
}
|
||||
let Some(Value::String(text)) = part.get_mut("text") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(aligned) = aligned_environment_context(text, current_date, timezone) {
|
||||
*text = aligned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aligned_environment_context(text: &str, current_date: &str, timezone: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let document = Document::parse(text).ok()?;
|
||||
let root = document.root_element();
|
||||
if !root.has_tag_name("environment_context") {
|
||||
return None;
|
||||
}
|
||||
let mut replacements = root
|
||||
.children()
|
||||
.filter(|node| node.is_element())
|
||||
.filter_map(|node| {
|
||||
let replacement = match node.tag_name().name() {
|
||||
"current_date" => format!("<current_date>{current_date}</current_date>"),
|
||||
"timezone" => format!("<timezone>{timezone}</timezone>"),
|
||||
_ => return None,
|
||||
};
|
||||
Some((node.range(), replacement))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if replacements.is_empty() {
|
||||
return None;
|
||||
}
|
||||
replacements.sort_unstable_by_key(|(range, _)| std::cmp::Reverse(range.start));
|
||||
let mut aligned = text.to_owned();
|
||||
for (range, replacement) in replacements {
|
||||
aligned.replace_range(range, &replacement);
|
||||
}
|
||||
Some(aligned)
|
||||
}
|
||||
|
||||
fn align_web_search_location(tool: &mut Value, location: &CodexRequestLocation) {
|
||||
let Some(tool) = tool.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(tool_type) = tool.get("type").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
if tool_type != "web_search" && !tool_type.starts_with("web_search_") {
|
||||
return;
|
||||
}
|
||||
tool.insert(
|
||||
"user_location".to_owned(),
|
||||
json!({
|
||||
"type": "approximate",
|
||||
"country": location.country,
|
||||
"region": location.region,
|
||||
"city": location.city,
|
||||
"timezone": location.timezone.name(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn extract_request_context(request: &mut CodexResponsesRequest) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 当前绑定账号的订阅周期安全投影,与令牌有效期和额度状态无关。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{CodexBackendClient, CodexRequestContext, client::read_capped_response_body};
|
||||
|
||||
const MAX_SUBSCRIPTION_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CodexBackendClient {
|
||||
/// 可选展示查询失败只返回未知,不改变凭据或额度状态,也不重试。
|
||||
pub async fn fetch_subscription(
|
||||
&self,
|
||||
context: CodexRequestContext<'_>,
|
||||
account_id: &str,
|
||||
) -> Option<CodexSubscription> {
|
||||
if account_id.is_empty() || account_id.len() > 512 {
|
||||
return None;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut url = reqwest::Url::parse(&self.base_url).ok()?;
|
||||
let base_path = url.path().trim_end_matches('/');
|
||||
let subscription_path = if base_path.ends_with("/backend-api") {
|
||||
format!("{base_path}/subscriptions")
|
||||
} else {
|
||||
"/backend-api/subscriptions".to_owned()
|
||||
};
|
||||
url.set_path(&subscription_path);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
url.query_pairs_mut().append_pair("account_id", account_id);
|
||||
let origin = url.origin().ascii_serialization();
|
||||
let headers = self.account_request_headers(context).ok()?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::ORIGIN, &origin)
|
||||
.header(reqwest::header::REFERER, format!("{origin}/"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = read_capped_response_body(response, MAX_SUBSCRIPTION_BODY_BYTES)
|
||||
.await
|
||||
.ok()?;
|
||||
if body.limit_exceeded() {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&body.into_string()).ok()?;
|
||||
let expires_at =
|
||||
DateTime::parse_from_rfc3339(value.get("active_until")?.as_str()?.trim())
|
||||
.ok()?
|
||||
.with_timezone(&Utc);
|
||||
Some(CodexSubscription {
|
||||
starts_at: value
|
||||
.get("active_start")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok())
|
||||
.map(|value| value.with_timezone(&Utc)),
|
||||
expires_at,
|
||||
will_renew: value.get("will_renew").and_then(serde_json::Value::as_bool),
|
||||
billing_period: optional_short_text(&value, "billing_period"),
|
||||
billing_currency: optional_short_text(&value, "billing_currency"),
|
||||
observed_at: Utc::now(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_short_text(value: &serde_json::Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
!value.is_empty() && value.len() <= 64 && !value.chars().any(char::is_control)
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
@@ -660,7 +660,7 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export
|
||||
let Operation::Generate(request) = operation else {
|
||||
panic!("connection test must be a generate operation");
|
||||
};
|
||||
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4")
|
||||
let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4", None)
|
||||
.expect("official OpenAI request");
|
||||
assert_eq!(
|
||||
encoded.body().get("model").and_then(Value::as_str),
|
||||
@@ -1347,6 +1347,7 @@ fn valid_config() -> TestOpenAiConfig {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -3,7 +3,74 @@ use std::path::Path;
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use provider_openai::config::{
|
||||
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
|
||||
OpenAiConfigError,
|
||||
};
|
||||
use provider_openai::transport::profile::CodexRequestLocation;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn openai_location_should_default_to_passthrough() {
|
||||
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
|
||||
assert_eq!(profile.location, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() {
|
||||
let location: CodexRequestLocation = serde_json::from_value(json!({
|
||||
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
}))
|
||||
.expect("location configuration");
|
||||
let mut config = valid_config();
|
||||
config.wire_profile.location = Some(location.clone());
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.expect("valid location");
|
||||
assert_eq!(
|
||||
config.wire_profile_state().snapshot().location,
|
||||
Some(location)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_should_reject_invalid_location_fields() {
|
||||
for (field, value) in [
|
||||
("country", "USA"),
|
||||
("country", "us"),
|
||||
("country", ""),
|
||||
("region", " "),
|
||||
("city", ""),
|
||||
] {
|
||||
let mut config = valid_config();
|
||||
let location = config
|
||||
.wire_profile
|
||||
.location
|
||||
.insert(CodexRequestLocation::default());
|
||||
let (target, expected) = match field {
|
||||
"country" => (
|
||||
&mut location.country,
|
||||
"openai.wire_profile.location.country",
|
||||
),
|
||||
"region" => (&mut location.region, "openai.wire_profile.location.region"),
|
||||
_ => (&mut location.city, "openai.wire_profile.location.city"),
|
||||
};
|
||||
*target = value.to_owned();
|
||||
assert_eq!(
|
||||
config.resolve_and_validate(Path::new("/srv/gateway")),
|
||||
Err(OpenAiConfigError::InvalidField(expected))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_config_should_reject_unknown_timezones_and_incomplete_groups() {
|
||||
for value in [
|
||||
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "Not/A_Timezone" }),
|
||||
json!({ "timezone": "Pacific/Auckland" }),
|
||||
json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "America/New_York", "extra": true }),
|
||||
] {
|
||||
assert!(serde_json::from_value::<CodexRequestLocation>(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_builds_the_audited_wire_profile() {
|
||||
@@ -154,6 +221,7 @@ fn valid_config() -> OpenAiConfig {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -65,6 +65,7 @@ fn wire_profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "catalog-contract".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -161,6 +161,7 @@ fn selector_with_runtime(
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "selector-contract".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: chrono::Utc::now(),
|
||||
});
|
||||
let http = reqwest::Client::builder().build().expect("HTTP client");
|
||||
|
||||
@@ -6,7 +6,7 @@ use gateway_core::account::ProviderAccountId;
|
||||
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
@@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "profile-statistics-contract".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
|
||||
.single()
|
||||
@@ -59,6 +60,11 @@ async fn service(
|
||||
#[tokio::test]
|
||||
async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/profiles/me"))
|
||||
.and(header(
|
||||
@@ -227,3 +233,66 @@ async fn profile_avatar_reuses_cached_source_with_the_accounts_rotated_token() {
|
||||
"chatgpt-acct_profile_avatar"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_is_read_only_and_makes_one_bound_account_request_per_query() {
|
||||
let server = MockServer::start().await;
|
||||
let (store, service) = service("acct_subscription", &server).await;
|
||||
let account = store.account("acct_subscription").expect("account");
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/subscriptions"))
|
||||
.and(query_param("account_id", "chatgpt-acct_subscription"))
|
||||
.and(header("chatgpt-account-id", "chatgpt-acct_subscription"))
|
||||
.and(header("authorization", "Bearer token-acct_subscription"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"active_until": "2026-10-01T00:00:00Z", "will_renew": true
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
for _ in 0..2 {
|
||||
let subscription = service.subscription(account.id()).await.unwrap().unwrap();
|
||||
assert_eq!(subscription.will_renew, Some(true));
|
||||
}
|
||||
let after = store.account("acct_subscription").expect("account");
|
||||
assert_eq!(after.revision(), account.revision());
|
||||
assert_eq!(after.quota(), account.quota());
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_discards_response_when_credential_rotates_during_query() {
|
||||
let server = MockServer::start().await;
|
||||
let (store, service) = service("acct_subscription_rotation", &server).await;
|
||||
let account = store
|
||||
.account("acct_subscription_rotation")
|
||||
.expect("account");
|
||||
let requested = Arc::new(tokio::sync::Notify::new());
|
||||
let notify = Arc::clone(&requested);
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
notify.notify_one();
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until": "2026-10-01T00:00:00Z"}))
|
||||
.set_delay(std::time::Duration::from_millis(100))
|
||||
})
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (result, ()) = tokio::join!(service.subscription(account.id()), async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(3), requested.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.repository()
|
||||
.rotate_refreshed_oauth_secret(
|
||||
&account,
|
||||
secret("rotated-subscription-token"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod refresh_timing;
|
||||
mod scheduling;
|
||||
mod slots;
|
||||
mod snapshot;
|
||||
mod subscription;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
@@ -37,6 +38,7 @@ fn wire_profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "quota-contract".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -561,6 +561,102 @@ async fn an_unknown_slot_establishes_a_baseline_before_it_can_recover() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weekly_usage_falling_back_within_the_same_window_recovers_after_two_observations() {
|
||||
// 滑动周窗口的用量可以在同一窗口内回落:reset 不滚动、用量从 100% 降到
|
||||
// 正常水平。此时"reset 前进 + 低用量"永远不会成立,解除依赖连续两次
|
||||
// 观测都未触顶;单次观测无法排除耗尽后立刻拉到的旧快照,保持锁定。
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_weekly_fallout").await;
|
||||
let account = store.account("acct_weekly_fallout").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
|
||||
old["rate_limit"]["allowed"] = json!(false);
|
||||
old["rate_limit"]["limit_reached"] = json!(true);
|
||||
mount_usage(&server, old).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
|
||||
// 5h 窗口滚动前进,周窗口 reset 不变但用量回落到正常水平:只标记候选。
|
||||
let mut first_observation = usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET));
|
||||
first_observation["rate_limit"]["allowed"] = json!(true);
|
||||
mount_usage(&server, first_observation).await;
|
||||
let first = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("first fallout observation");
|
||||
assert!(first.quota().is_exhausted());
|
||||
|
||||
// 第二次连续观测仍未触顶:解除周窗口。
|
||||
let mut second_observation = usage((9, SHORT_RESET + 36_000), (18, WEEK_RESET));
|
||||
second_observation["rate_limit"]["allowed"] = json!(true);
|
||||
mount_usage(&server, second_observation).await;
|
||||
let second = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("second fallout observation");
|
||||
assert_eq!(second.quota().access(), QuotaAccessState::Allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_same_window_fallout_observation_cannot_break_the_reset_baseline() {
|
||||
// 连续性中断(观测到重新触顶)后必须重新积累两次未触顶证据。
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_weekly_fallout_gap").await;
|
||||
let account = store.account("acct_weekly_fallout_gap").expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let mut old = usage((100, SHORT_RESET), (100, WEEK_RESET));
|
||||
old["rate_limit"]["allowed"] = json!(false);
|
||||
mount_usage(&server, old).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
|
||||
mount_usage(&server, usage((0, SHORT_RESET + 18_000), (18, WEEK_RESET))).await;
|
||||
assert!(
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("first candidate")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
// 观测到重新触顶,候选证据清零。
|
||||
mount_usage(&server, usage((0, SHORT_RESET + 36_000), (100, WEEK_RESET))).await;
|
||||
assert!(
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("reached again")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
mount_usage(&server, usage((0, SHORT_RESET + 54_000), (18, WEEK_RESET))).await;
|
||||
assert!(
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("candidate restarts")
|
||||
.quota()
|
||||
.is_exhausted()
|
||||
);
|
||||
mount_usage(&server, usage((0, SHORT_RESET + 72_000), (18, WEEK_RESET))).await;
|
||||
assert_eq!(
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("second consecutive observation")
|
||||
.quota()
|
||||
.access(),
|
||||
QuotaAccessState::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monthly_and_arbitrary_periods_use_the_same_recovery_rule() {
|
||||
for seconds in [18_000, 86_400, 604_800, 864_000, 2_592_000, 7_776_000] {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 订阅展示不得随手工或后台额度检查预取。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_refresh_and_background_synchronize_do_not_query_subscription() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_quota_without_subscription").await;
|
||||
let account = store.account("acct_quota_without_subscription").unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/backend-api/subscriptions"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": { "used_percent": 20, "limit_window_seconds": 18000 }
|
||||
}
|
||||
})))
|
||||
.expect(2..)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
service.synchronize().await.unwrap();
|
||||
service.refresh_account(account.id()).await.unwrap();
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|request| request.url.path() == "/api/codex/usage")
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,9 @@ fn wire_profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "provider-contract".to_owned(),
|
||||
residency: None,
|
||||
location: serde_json::from_value(json!({
|
||||
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
})).expect("configured request location"),
|
||||
verified_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -465,6 +468,9 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_websocket_busy_replay",
|
||||
"acct_websocket_metadata_close",
|
||||
"acct_websocket_turn_state",
|
||||
"acct_ws_quota_a",
|
||||
"acct_ws_quota_b",
|
||||
"acct_ws_midstream_overload",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
@@ -662,6 +668,47 @@ async fn capture_scoped_http_request(
|
||||
requests.pop().expect("single scoped request")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_should_send_the_configured_location_to_the_upstream() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context><timezone>UTC</timezone></environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"],
|
||||
"create_time": 1789293131.822
|
||||
}
|
||||
}],
|
||||
"tools": [{"type": "web_search"}]
|
||||
}).as_object().expect("request object").clone();
|
||||
let captured = capture_scoped_http_request(
|
||||
"req_configured_location",
|
||||
"acct_provider_contract",
|
||||
"acct_provider_contract",
|
||||
body,
|
||||
Map::new(),
|
||||
)
|
||||
.await;
|
||||
let body = captured_request_body(&captured);
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/user_location"),
|
||||
Some(&json!({
|
||||
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/input/0/content/0/text"),
|
||||
Some(&json!(
|
||||
"<environment_context><timezone>Pacific/Auckland</timezone></environment_context>"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
|
||||
Some(&json!(1789293131.822))
|
||||
);
|
||||
}
|
||||
|
||||
async fn capture_turn_state_request(
|
||||
request_id: &str,
|
||||
previous_turn_id: Option<&str>,
|
||||
@@ -2327,6 +2374,114 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_midstream_error_frame_surfaces_upstream_message_after_delivery() {
|
||||
const ACCOUNT_ID: &str = "acct_ws_midstream_overload";
|
||||
const RESPONSE_ID: &str = "resp_midstream_overload";
|
||||
const OVERLOAD_MESSAGE: &str = "Our servers are currently overloaded. Please try again later.";
|
||||
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, ACCOUNT_ID).await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _request = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("WebSocket request")
|
||||
.expect("valid WebSocket request");
|
||||
// 真实生产观察:OpenAI 在流已开始后发送带原话的 `error` 帧再断连。
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.created");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.in_progress");
|
||||
tokio::time::sleep(Duration::from_millis(1_600)).await;
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": OVERLOAD_MESSAGE,
|
||||
"param": null
|
||||
},
|
||||
"sequence_number": 2
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send error frame");
|
||||
// 不发送任何终止事件,直接断开,模拟上游发完错误帧后的真实行为。
|
||||
});
|
||||
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_midstream_overload", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare midstream-failure stream");
|
||||
let mut failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("midstream error frame must surface a typed failure"),
|
||||
}
|
||||
};
|
||||
server.abort();
|
||||
|
||||
assert_eq!(
|
||||
failure.kind(),
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
);
|
||||
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
|
||||
// 交给 Core 的失败必须保留上游原话:客户端只能靠它知道失败原因。
|
||||
let visible = failure
|
||||
.client_visible_upstream_error()
|
||||
.expect("overload frame must carry a client-visible upstream error");
|
||||
assert_eq!(visible.code(), Some("server_is_overloaded"));
|
||||
assert_eq!(visible.message(), OVERLOAD_MESSAGE);
|
||||
// 原始错误帧必须随失败一起交给交付边界(SSE 侧据此翻译成 response.failed)。
|
||||
let atomic = failure.take_atomic_client_events();
|
||||
let error_frame = atomic
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
let wire = event.wire_event()?;
|
||||
(wire.event_type() == Some("error")).then(|| wire.data().clone())
|
||||
})
|
||||
.expect("atomic batch must carry the upstream error frame");
|
||||
assert_eq!(
|
||||
error_frame
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(Value::as_str),
|
||||
Some(OVERLOAD_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_idle_timeout_diagnosis_survives_ambiguous_send_wrapping() {
|
||||
const ACCOUNT_ID: &str = "acct_abrupt_disconnect";
|
||||
@@ -3124,6 +3279,60 @@ async fn downstream_websocket_new_chain_should_override_session_and_attempt_http
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_opening_account_rejection_keeps_replay_safe_without_transport_retry() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_ws_quota_a").await;
|
||||
create_account(&store, "acct_ws_quota_b").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut opening, _) = listener.accept().await.unwrap();
|
||||
let request = capture_http_request(&mut opening).await;
|
||||
assert!(String::from_utf8_lossy(&request).starts_with("GET /codex/responses"));
|
||||
// 额度耗尽拒绝通常携带小时级的 retry-after;同账号重试注定再次命中。
|
||||
let body = r#"{"error":{"message":"You have reached your usage limit.","type":"rate_limit_error"}}"#;
|
||||
opening
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 429 Too Many Requests\r\nretry-after: 129600\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_quota_rotation", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare quota-rejected stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("quota rejection must surface as a terminal error"),
|
||||
}
|
||||
};
|
||||
server.await.expect("upstream server");
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
|
||||
assert_eq!(error.upstream_status(), Some(429));
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
assert!(
|
||||
error.replay_is_safe(),
|
||||
"before-payload rejection is replay safe"
|
||||
);
|
||||
// 回放安全的账号级拒绝必须把换号决策留给 Core,不得钉死同账号传输重试
|
||||
// (旧行为会携带小时级 retry-after 的同账号重试标记,请求必然超时)。
|
||||
assert_eq!(error.pre_delivery_retry(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_turn_state_metadata_is_exposed_through_response_observation() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -4843,10 +5052,157 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
|
||||
drop(stream);
|
||||
|
||||
assert_eq!(affinity.binding_count(), 1);
|
||||
assert_eq!(observed_service_tier.as_deref(), Some("default"));
|
||||
assert_eq!(observed_service_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(upstream_service_tier.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_should_observe_and_bill_the_outbound_service_tier_on_both_transports() {
|
||||
for use_websocket in [false, true] {
|
||||
for (requested, reported, expected_cost) in [
|
||||
(Some("priority"), Some("default"), 6_875_000),
|
||||
(Some("priority"), None, 6_875_000),
|
||||
(Some("default"), Some("priority"), 3_437_500),
|
||||
(None, Some("priority"), 3_437_500),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let mut created =
|
||||
json!({"type":"response.created","response":{"id":"resp_tier","model":"gpt-5.4"}});
|
||||
let mut completed = json!({
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_tier","model":"gpt-5.4","status":"completed","output":[],
|
||||
"usage":{"input_tokens":100,"output_tokens":10,"input_tokens_details":{"cached_tokens":25,"cache_write_tokens":0},"total_tokens":110}
|
||||
}
|
||||
});
|
||||
if let Some(reported) = reported {
|
||||
created["response"]["service_tier"] = json!("auto");
|
||||
completed["response"]["service_tier"] = json!(reported);
|
||||
}
|
||||
let response_frames = format!(
|
||||
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
|
||||
);
|
||||
let (base_url, http_server, websocket_server) = if use_websocket {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("address"));
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
let request = websocket.next().await.expect("request").expect("frame");
|
||||
let request: Value = serde_json::from_str(request.to_text().expect("text"))
|
||||
.expect("request JSON");
|
||||
for event in [created, completed] {
|
||||
websocket
|
||||
.send(Message::Text(event.to_string().into()))
|
||||
.await
|
||||
.expect("response");
|
||||
}
|
||||
request
|
||||
});
|
||||
(base_url, None, Some(server))
|
||||
} else {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(response_frames.clone(), "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server.uri(), Some(server), None)
|
||||
};
|
||||
let mut body = Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
]);
|
||||
if let Some(requested) = requested {
|
||||
body.insert("service_tier".to_owned(), json!(requested));
|
||||
}
|
||||
let payload = ProtocolPayload::json_object("openai", body)
|
||||
.expect("payload")
|
||||
.with_context(Map::from_iter([(
|
||||
"use_websocket".to_owned(),
|
||||
json!(use_websocket),
|
||||
)]));
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context("req_service_tier", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
let mut observations = Vec::new();
|
||||
let mut costs = Vec::new();
|
||||
let mut raw_response = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("provider event");
|
||||
if let Some(observation) = event.response_observation() {
|
||||
observations.push(observation.clone());
|
||||
}
|
||||
for fact in event.canonical_facts() {
|
||||
if let GatewayEvent::CalculatedCost(cost) = fact {
|
||||
costs.push(cost.total().amount().scaled());
|
||||
}
|
||||
}
|
||||
if let Some(frame) = event.wire_event().and_then(|wire| wire.raw_sse_frame()) {
|
||||
raw_response.extend_from_slice(frame);
|
||||
}
|
||||
}
|
||||
let outbound = if let Some(server) = http_server {
|
||||
server.verify().await;
|
||||
let requests = server.received_requests().await.expect("upstream requests");
|
||||
captured_request_body(&requests[0])
|
||||
} else {
|
||||
websocket_server
|
||||
.expect("WebSocket server")
|
||||
.await
|
||||
.expect("server task")
|
||||
};
|
||||
assert_eq!(
|
||||
outbound.get("service_tier").and_then(Value::as_str),
|
||||
requested
|
||||
);
|
||||
assert!(!observations.is_empty());
|
||||
for observation in &observations {
|
||||
assert_eq!(
|
||||
observation.service_tier(),
|
||||
requested,
|
||||
"WebSocket={use_websocket}"
|
||||
);
|
||||
}
|
||||
let metadata: Value = serde_json::from_str(
|
||||
observations
|
||||
.last()
|
||||
.expect("final observation")
|
||||
.provider_metadata()
|
||||
.expect("metadata")
|
||||
.as_json(),
|
||||
)
|
||||
.expect("metadata JSON");
|
||||
assert_eq!(
|
||||
metadata.get("requestedServiceTier").and_then(Value::as_str),
|
||||
requested
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("upstreamServiceTier").and_then(Value::as_str),
|
||||
reported
|
||||
);
|
||||
assert_eq!(
|
||||
costs,
|
||||
vec![expected_cost],
|
||||
"WebSocket={use_websocket}, requested={requested:?}, reported={reported:?}"
|
||||
);
|
||||
assert_eq!(raw_response, response_frames.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_before_semantic_output_is_atomic_and_persists_quota_lock() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -5094,6 +5450,218 @@ async fn official_usage_limit_failure_persists_fact_without_fabricating_usage()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_retry() {
|
||||
for use_websocket in [false, true] {
|
||||
for status in [400, 429, 503] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
let body = json!({"error": {
|
||||
"code": if status == 503 { Some("server_is_overloaded") } else { None },
|
||||
"type": "server_error",
|
||||
"message": "Selected model is at capacity. Please try a different model.",
|
||||
"extension": {"preserved": true}
|
||||
}});
|
||||
Mock::given(method(if use_websocket { "GET" } else { "POST" }))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(status)
|
||||
.insert_header("retry-after", "129600")
|
||||
.set_body_json(body.clone()),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let operation = if use_websocket {
|
||||
generate_operation()
|
||||
} else {
|
||||
http_generate_operation()
|
||||
};
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context("req_capacity", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected capacity rejection"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
|
||||
assert_eq!(error.upstream_status(), Some(status));
|
||||
assert!(error.replay_is_safe());
|
||||
assert!(
|
||||
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries, initial_delay, max_delay,
|
||||
}) if max_retries.get() == 3 && initial_delay == Duration::from_secs(8) && max_delay == Duration::from_secs(8))
|
||||
);
|
||||
assert!(!provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
error.raw_upstream_error().expect("original error").as_str()
|
||||
)
|
||||
.expect("JSON"),
|
||||
body
|
||||
);
|
||||
let response = error
|
||||
.client_visible_upstream_response()
|
||||
.expect("client response");
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(response.body().as_ref(), body.to_string().as_bytes());
|
||||
let account = store.account(account_id).expect("account");
|
||||
assert_eq!(account.quota().access(), QuotaAccessState::Unknown);
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_usage_limit_rejection_preserves_quota_state_for_account_rotation() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "129600")
|
||||
.set_body_json(json!({"error": {"type": "usage_limit_reached", "message": "limit reached", "resets_at": 1_900_000_000}})))
|
||||
.expect(1).mount(&server).await;
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_quota", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected quota rejection"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(error.pre_delivery_retry(), None);
|
||||
assert_eq!(error.retry_after(), Some(Duration::from_secs(129_600)));
|
||||
assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted);
|
||||
assert_eq!(
|
||||
store.account(account_id).expect("account").quota().access(),
|
||||
QuotaAccessState::Exhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_stream_rejection_only_retries_before_semantic_output_on_both_transports() {
|
||||
for use_websocket in [false, true] {
|
||||
for semantic_output in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let mut events = vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp_capacity", "model": "gpt-5.4"}}),
|
||||
];
|
||||
if semantic_output {
|
||||
events.push(json!({"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "hello"}));
|
||||
}
|
||||
let original = json!({"type": "response.failed", "response": {"id": "resp_capacity", "error": {"code": "server_is_overloaded", "message": "Selected model is at capacity. Please try a different model."}}});
|
||||
events.push(original.clone());
|
||||
let (base_url, _http_server, websocket_server) = if use_websocket {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("address"));
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
websocket.next().await.expect("request").expect("frame");
|
||||
for event in events {
|
||||
websocket
|
||||
.send(Message::Text(event.to_string().into()))
|
||||
.await
|
||||
.expect("response");
|
||||
}
|
||||
});
|
||||
(base_url, None, Some(server))
|
||||
} else {
|
||||
let server = MockServer::start().await;
|
||||
let frames = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
format!(
|
||||
"event: {}\ndata: {event}\n\n",
|
||||
event["type"].as_str().expect("type")
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_raw(frames, "text/event-stream"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server.uri(), Some(server), None)
|
||||
};
|
||||
let operation = if use_websocket {
|
||||
generate_operation()
|
||||
} else {
|
||||
http_generate_operation()
|
||||
};
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context("req_capacity_stream", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare stream");
|
||||
let mut client_events = Vec::new();
|
||||
let mut error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(event)) => {
|
||||
if event.has_client_event() {
|
||||
client_events.push(event);
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected capacity failure"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.replay_is_safe(), !semantic_output);
|
||||
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
|
||||
assert_eq!(error.pre_delivery_retry().is_some(), !semantic_output);
|
||||
assert!(!provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
error.raw_upstream_error().expect("raw error").as_str()
|
||||
)
|
||||
.expect("JSON"),
|
||||
original
|
||||
);
|
||||
if !semantic_output {
|
||||
assert!(client_events.is_empty());
|
||||
}
|
||||
client_events.extend(error.take_atomic_client_events());
|
||||
let wire = client_events
|
||||
.last()
|
||||
.and_then(|event| event.wire_event())
|
||||
.expect("failure wire");
|
||||
assert_eq!(wire.data(), &original);
|
||||
if let Some(server) = websocket_server {
|
||||
server.await.expect("server");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -45,17 +45,21 @@ fn deliver_error_with_openai_feedback(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_score_failure_filter_should_include_server_overload_as_a_regular_reason() {
|
||||
let error = sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded"));
|
||||
|
||||
assert!(openai_failure_affects_account_score(&error));
|
||||
fn account_score_failure_filter_should_exclude_request_scoped_capacity_errors() {
|
||||
for code in ["server_is_overloaded", "slow_down", "server_error"] {
|
||||
let error = sent_error(ProviderErrorKind::Unavailable, Some(code))
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Selected model is at capacity. Please try a different model.",
|
||||
Some("server_error".to_owned()),
|
||||
Some("server_error".to_owned()),
|
||||
));
|
||||
assert!(!openai_failure_affects_account_score(&error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_score_failure_filter_should_accept_the_closed_reason_list() {
|
||||
for code in [
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"rate_limit_exceeded",
|
||||
"rate_limit_error",
|
||||
"server_error",
|
||||
@@ -181,7 +185,7 @@ fn openai_feedback_should_not_amplify_repeated_client_configuration_errors() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
|
||||
fn openai_feedback_should_not_penalize_an_account_for_server_overload() {
|
||||
let feedback = Arc::new(AccountFeedbackStats::default());
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let account = ProviderAccountId::new("acct_overloaded").expect("account");
|
||||
@@ -193,8 +197,5 @@ fn openai_feedback_should_score_server_overload_as_one_regular_failure() {
|
||||
sent_error(ProviderErrorKind::Unavailable, Some("server_is_overloaded")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &account).0,
|
||||
Some(2_000)
|
||||
);
|
||||
assert_eq!(feedback.scheduling_signals(&provider, &account).0, None);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
|
||||
fn decoder_should_bill_requested_service_tier_despite_default_response() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fast_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"default\"}}\n\n",
|
||||
@@ -236,12 +236,12 @@ fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing()
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_use_response_service_tier_when_request_omits_it() {
|
||||
fn decoder_should_bill_standard_when_request_omits_service_tier() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_response_tier_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"priority\"}}\n\n",
|
||||
@@ -257,7 +257,7 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() {
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
|
||||
fn websocket_decoder_should_bill_requested_service_tier_despite_default_response() {
|
||||
let created = websocket_event_to_sse_frame(
|
||||
r#"{"type":"response.created","response":{"id":"resp_ws_fast_cost","model":"gpt-5.4","service_tier":"default"}}"#,
|
||||
)
|
||||
@@ -366,7 +366,7 @@ fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 3_437_500
|
||||
if cost.total().amount().scaled() == 6_875_000
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -719,6 +719,36 @@ fn decoder_should_classify_official_server_overloaded_failure() {
|
||||
assert_failed_event("server_is_overloaded", "server-secret-marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_failure_preserves_original_wire_and_diagnostics() {
|
||||
for path in ["error", "response.failed"] {
|
||||
let data = if path == "error" {
|
||||
r#"{"type":"error","error":{"code":"slow_down","message":"busy","extra":123456789012345678901234567890},"extension":true}"#
|
||||
} else {
|
||||
r#"{"type":"response.failed","response":{"id":"resp_capacity","error":{"code":"server_is_overloaded","message":"busy","extra":123456789012345678901234567890}},"extension":true}"#
|
||||
};
|
||||
let raw = format!("event: {path}\r\nid: upstream-id\r\nretry: 123\r\ndata: {data}\r\n\r\n");
|
||||
let failure = CodexCanonicalDecoder::new("fallback")
|
||||
.with_raw_sse_passthrough()
|
||||
.push(raw.as_bytes())
|
||||
.expect_err("capacity failure");
|
||||
let wire = failure.events()[0].wire_event().expect("client wire");
|
||||
let expected: serde_json::Value = serde_json::from_str(data).expect("original JSON");
|
||||
assert_eq!(wire.data(), &expected);
|
||||
assert_eq!(wire.sse_id(), Some("upstream-id"));
|
||||
assert_eq!(wire.sse_retry(), Some(123));
|
||||
assert_eq!(
|
||||
wire.raw_sse_frame().map(AsRef::as_ref),
|
||||
Some(raw.as_bytes())
|
||||
);
|
||||
let CodexCanonicalError::Upstream(upstream) = failure.error() else {
|
||||
panic!("typed failure")
|
||||
};
|
||||
assert_eq!(upstream.raw_body(), data);
|
||||
assert_ne!(upstream.upstream_code.as_deref(), Some("server_error"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_classify_official_cyber_policy_as_an_invalid_request() {
|
||||
assert_failed_event("cyber_policy", "policy-secret-marker");
|
||||
@@ -975,14 +1005,19 @@ fn pricing_response_cost(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_should_follow_actual_tier_and_only_fall_back_when_it_is_absent() {
|
||||
fn billing_should_follow_requested_tier_independently_of_response_tier() {
|
||||
for (requested, actual, expected) in [
|
||||
(Some("fast"), Some("default"), Some(1_000_000_000)),
|
||||
(Some("auto"), Some("fast"), Some(2_000_000_000)),
|
||||
(Some("default"), Some("flex"), Some(500_000_000)),
|
||||
(Some("priority"), Some("default"), Some(2_000_000_000)),
|
||||
(Some("fast"), Some("default"), Some(2_000_000_000)),
|
||||
(Some("auto"), Some("fast"), None),
|
||||
(Some("default"), Some("flex"), Some(1_000_000_000)),
|
||||
(Some("flex"), Some("priority"), Some(500_000_000)),
|
||||
(Some("fast"), None, Some(2_000_000_000)),
|
||||
(Some("auto"), None, None),
|
||||
(Some("default"), Some("future"), None),
|
||||
(Some("default"), Some("future"), Some(1_000_000_000)),
|
||||
(Some("future"), Some("default"), None),
|
||||
(None, Some("priority"), Some(1_000_000_000)),
|
||||
(None, None, Some(1_000_000_000)),
|
||||
] {
|
||||
assert_eq!(
|
||||
pricing_response_cost(requested, actual, json!([]), &[]),
|
||||
@@ -1002,8 +1037,8 @@ fn billing_should_add_file_and_web_search_fees_without_tier_markup() {
|
||||
for (tier, expected) in [("default", 1_150_000_000), ("fast", 2_150_000_000)] {
|
||||
assert_eq!(
|
||||
pricing_response_cost(
|
||||
None,
|
||||
Some(tier),
|
||||
Some("default"),
|
||||
output.clone(),
|
||||
&[json!({"type": "web_search"})]
|
||||
),
|
||||
|
||||
@@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "xterm".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -20,6 +20,69 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
|
||||
.category()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_rejections_are_request_scoped_across_http_statuses() {
|
||||
for status in [400, 429, 503] {
|
||||
for body in [
|
||||
r#"{"error":{"code":"server_is_overloaded","message":"busy"}}"#,
|
||||
r#"{"error":{"code":"slow_down","type":"server_error"}}"#,
|
||||
r#"{"error":{"type":"server_is_overloaded"}}"#,
|
||||
r#"{"error":{"message":"slow_down"}}"#,
|
||||
r#"{"error":{"type":"invalid_request_error","message":"Selected model is at capacity. Please try a different model."}}"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
classify(status, body),
|
||||
CodexFailureCategory::CapacityUnavailable
|
||||
);
|
||||
for (phase, safe) in [
|
||||
(CodexUpstreamSendPhase::BeforePayload, true),
|
||||
(CodexUpstreamSendPhase::AfterPayload, true),
|
||||
(CodexUpstreamSendPhase::Ambiguous, false),
|
||||
] {
|
||||
let failure = CodexUpstreamFailure::from_response(
|
||||
reqwest::StatusCode::from_u16(status).expect("status"),
|
||||
body,
|
||||
Some(129_600),
|
||||
&CodexUpstreamDiagnostics::default(),
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
phase,
|
||||
);
|
||||
assert_eq!(failure.replay_is_safe(), safe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_words_do_not_override_quota_policy_or_arbitrary_body_fields() {
|
||||
for (status, body, expected) in [
|
||||
(
|
||||
429,
|
||||
r#"{"error":{"type":"usage_limit_reached","message":"Selected model is at capacity"}}"#,
|
||||
CodexFailureCategory::UsageLimitExhausted,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"cyber_policy","message":"Selected model is at capacity"}}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"message":"bad input"},"input":"Selected model is at capacity"}"#,
|
||||
CodexFailureCategory::InvalidRequest,
|
||||
),
|
||||
(
|
||||
429,
|
||||
r#"{"error":{"code":"rate_limit_exceeded","message":"retry later"}}"#,
|
||||
CodexFailureCategory::RateLimited,
|
||||
),
|
||||
] {
|
||||
assert_eq!(classify(status, body), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn trace_header<'a>(diagnostics: &'a CodexUpstreamDiagnostics, name: &str) -> Option<&'a str> {
|
||||
diagnostics
|
||||
.trace_headers
|
||||
|
||||
@@ -94,6 +94,7 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
|
||||
let mut request = encode_generate_request(
|
||||
&GenerateRequest::from_protocol_payload(payload),
|
||||
"gpt-routed",
|
||||
None,
|
||||
)
|
||||
.expect("opaque request headers");
|
||||
request.use_websocket = use_websocket;
|
||||
|
||||
@@ -60,6 +60,7 @@ mod profile_contract;
|
||||
mod protocol;
|
||||
mod request;
|
||||
mod reset_credits;
|
||||
mod subscription;
|
||||
mod tls;
|
||||
mod usage;
|
||||
mod websocket;
|
||||
@@ -130,6 +131,7 @@ fn test_wire_profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "transport-test".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
|
||||
.single()
|
||||
@@ -207,7 +208,11 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() {
|
||||
let original = wire_profile();
|
||||
let mut original = wire_profile();
|
||||
original.location = serde_json::from_value(serde_json::json!({
|
||||
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
}))
|
||||
.expect("configured location");
|
||||
let original_user_agent = original.user_agent();
|
||||
let state = CodexWireProfileState::new(original.clone());
|
||||
let transport = Arc::new(ReleaseTransport::new(
|
||||
@@ -228,6 +233,7 @@ async fn bundled_release_update_should_change_core_and_desktop_identity_atomical
|
||||
assert_eq!(updated.os_version, original.os_version);
|
||||
assert_eq!(updated.arch, original.arch);
|
||||
assert_eq!(updated.terminal, original.terminal);
|
||||
assert_eq!(updated.location, original.location);
|
||||
assert!(updated.verified_at > original.verified_at);
|
||||
assert_ne!(updated.user_agent(), original_user_agent);
|
||||
assert_eq!(
|
||||
@@ -344,6 +350,7 @@ fn wire_profile() -> CodexWireProfile {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
use chrono::{Datelike as _, Utc};
|
||||
use chrono_tz::America::New_York;
|
||||
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use provider_openai::encode_generate_request;
|
||||
use provider_openai::transport::profile::CodexRequestLocation;
|
||||
|
||||
#[test]
|
||||
fn encoder_should_use_the_configured_location_for_tools_and_environment_dates() {
|
||||
let location: CodexRequestLocation = serde_json::from_value(json!({
|
||||
"country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
}))
|
||||
.expect("configured location");
|
||||
let body = json!({
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>UTC</timezone></environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"],
|
||||
"create_time": 1789293131.822
|
||||
}
|
||||
}],
|
||||
"tools": [
|
||||
{"type": "web_search", "user_location": {"type": "approximate", "country": "US"}},
|
||||
{"type": "web_search_preview"}
|
||||
]
|
||||
}).as_object().expect("request object").clone();
|
||||
let before = Utc::now()
|
||||
.with_timezone(&location.timezone)
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
let encoded =
|
||||
encode_generate_request(&request(body), "gpt-test", Some(&location)).expect("encode");
|
||||
let after = Utc::now()
|
||||
.with_timezone(&location.timezone)
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
let encoded = Value::Object(encoded.body().clone());
|
||||
let environment = encoded
|
||||
.pointer("/input/0/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.expect("environment");
|
||||
assert!(environment.contains("<timezone>Pacific/Auckland</timezone>"));
|
||||
assert!(
|
||||
[before, after]
|
||||
.iter()
|
||||
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
|
||||
);
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
encoded.pointer(&format!("/tools/{index}/user_location")),
|
||||
Some(&json!({
|
||||
"type": "approximate", "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland"
|
||||
}))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
|
||||
Some(&json!(1789293131.822))
|
||||
);
|
||||
}
|
||||
|
||||
fn request(body: Map<String, Value>) -> GenerateRequest {
|
||||
GenerateRequest::from_protocol_payload(
|
||||
@@ -9,6 +67,31 @@ fn request(body: Map<String, Value>) -> GenerateRequest {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_location_fields_when_no_override_is_configured() {
|
||||
let body = json!({
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context><current_date>2020-01-01</current_date><timezone>Asia/Shanghai</timezone></environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"],
|
||||
"create_time": 1789293131.822
|
||||
}
|
||||
}],
|
||||
"tools": [
|
||||
{"type": "web_search", "user_location": {"type": "approximate", "country": "CN", "region": "Shanghai", "city": "Shanghai", "timezone": "Asia/Shanghai"}},
|
||||
{"type": "web_search_preview", "user_location": null},
|
||||
{"type": "web_search_preview_2025_03_11"},
|
||||
{"type": "function", "name": "keep_timezone", "parameters": {}}
|
||||
]
|
||||
}).as_object().expect("request object").clone();
|
||||
let encoded = encode_generate_request(&request(body.clone()), "gpt-test", None)
|
||||
.expect("encode without location override");
|
||||
assert_eq!(encoded.body().get("input"), body.get("input"));
|
||||
assert_eq!(encoded.body().get("tools"), body.get("tools"));
|
||||
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() {
|
||||
let body = Map::from_iter([
|
||||
@@ -30,7 +113,8 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_
|
||||
]);
|
||||
let request = request(body);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-routed").expect("encode wire payload");
|
||||
let encoded =
|
||||
encode_generate_request(&request, "gpt-routed", None).expect("encode wire payload");
|
||||
|
||||
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed")));
|
||||
assert!(encoded.body().get("stream").is_none());
|
||||
@@ -64,7 +148,7 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
|
||||
|
||||
for input in ["private stable prompt", "different private prompt"] {
|
||||
let encoded =
|
||||
encode_generate_request(&request(input), "gpt-routed").expect("encoded request");
|
||||
encode_generate_request(&request(input), "gpt-routed", None).expect("encoded request");
|
||||
assert!(encoded.local_conversation_id.is_none());
|
||||
}
|
||||
}
|
||||
@@ -85,7 +169,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
|
||||
),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
|
||||
assert!(encoded.body().get("stream").is_none());
|
||||
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
|
||||
@@ -105,7 +189,7 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
|
||||
("temperature".to_owned(), json!(0.2)),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
Value::Object(encoded.body().clone()),
|
||||
@@ -117,6 +201,125 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() {
|
||||
let normal_chat = "<environment_context>\n <current_date>2026-09-13</current_date>\n \
|
||||
<timezone>Asia/Shanghai</timezone>\n</environment_context>";
|
||||
let body = json!({
|
||||
"model": "client-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n <cwd>/Users/mike/Personal/workspace/PORTAL2</cwd>\n <shell>zsh</shell>\n <current_date>2026-09-13</current_date>\n <timezone>Asia/Shanghai</timezone>\n <filesystem><file_system type=\"unrestricted\" /></filesystem>\n</environment_context>"
|
||||
}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"create_time": 1789293131.822,
|
||||
"content_item_kinds": ["environments.environment_context"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": normal_chat}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["user.text"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": normal_chat}]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{"type": "web_search"},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "remember_timezone",
|
||||
"description": "Keep Asia/Shanghai unchanged"
|
||||
}
|
||||
],
|
||||
"client_metadata": {
|
||||
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
|
||||
"x-codex-ws-stream-request-start-ms": "1789293132000"
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone();
|
||||
let before = Utc::now().with_timezone(&New_York);
|
||||
|
||||
let encoded = encode_generate_request(
|
||||
&request(body),
|
||||
"gpt-test",
|
||||
Some(&CodexRequestLocation::default()),
|
||||
)
|
||||
.expect("encode");
|
||||
|
||||
let after = Utc::now().with_timezone(&New_York);
|
||||
let encoded = Value::Object(encoded.body().clone());
|
||||
let environment = encoded
|
||||
.pointer("/input/0/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.expect("environment context text");
|
||||
let expected_dates = [
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
before.year(),
|
||||
before.month(),
|
||||
before.day()
|
||||
),
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
after.year(),
|
||||
after.month(),
|
||||
after.day()
|
||||
),
|
||||
];
|
||||
assert!(
|
||||
expected_dates
|
||||
.iter()
|
||||
.any(|date| environment.contains(&format!("<current_date>{date}</current_date>")))
|
||||
);
|
||||
assert!(environment.contains("<timezone>America/New_York</timezone>"));
|
||||
assert_eq!(
|
||||
encoded.pointer("/tools/0/user_location"),
|
||||
Some(&json!({
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"region": "Ohio",
|
||||
"city": "Piketon",
|
||||
"timezone": "America/New_York"
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/1/content/0/text"),
|
||||
Some(&json!(normal_chat))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/2/content/0/text"),
|
||||
Some(&json!(normal_chat))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
|
||||
Some(&json!(1789293131.822))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/tools/1/description"),
|
||||
Some(&json!("Keep Asia/Shanghai unchanged"))
|
||||
);
|
||||
assert_eq!(
|
||||
encoded.pointer("/client_metadata"),
|
||||
Some(&json!({
|
||||
"x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}",
|
||||
"x-codex-ws-stream-request-start-ms": "1789293132000"
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_client_store_intent() {
|
||||
let request = request(Map::from_iter([
|
||||
@@ -125,7 +328,7 @@ fn encoder_should_preserve_client_store_intent() {
|
||||
("store".to_owned(), json!(true)),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(encoded.body().get("store"), Some(&json!(true)));
|
||||
}
|
||||
@@ -138,7 +341,7 @@ fn encoder_should_forward_the_client_prompt_cache_key() {
|
||||
("prompt_cache_key".to_owned(), json!("cache-route")),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
encoded.body().get("prompt_cache_key"),
|
||||
@@ -154,7 +357,7 @@ fn encoder_should_restore_conversation_fallback_after_body_and_context_values()
|
||||
("prompt_cache_key".to_owned(), json!("cache-conversation")),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
encoded.client_conversation_id.as_deref(),
|
||||
@@ -205,7 +408,7 @@ fn encoder_should_accept_metadata_session_and_thread_ids_but_ignore_legacy_conte
|
||||
),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
@@ -251,7 +454,7 @@ fn encoder_should_extract_official_websocket_context_projection() {
|
||||
),
|
||||
]));
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(encoded.turn_state.as_deref(), Some("metadata-turn-state"));
|
||||
assert_eq!(encoded.client_turn_id.as_deref(), Some("metadata-turn-id"));
|
||||
@@ -292,7 +495,7 @@ fn header_context_should_win_over_body_topline_aliases() {
|
||||
]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state"));
|
||||
assert_eq!(
|
||||
@@ -318,7 +521,7 @@ fn encoder_should_preserve_downstream_websocket_connection_identity_outside_wire
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(
|
||||
encoded.downstream_websocket_connection_id.as_deref(),
|
||||
@@ -348,7 +551,7 @@ fn body_topline_alias_should_only_fill_an_absent_header_context() {
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state"));
|
||||
assert_eq!(
|
||||
@@ -373,7 +576,7 @@ fn encoder_should_project_explicit_websocket_transport_without_touching_body() {
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert!(encoded.use_websocket);
|
||||
assert!(!encoded.force_http_sse);
|
||||
@@ -396,7 +599,7 @@ fn encoder_should_project_explicit_http_transport_without_touching_body() {
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert!(!encoded.use_websocket);
|
||||
assert!(encoded.force_http_sse);
|
||||
@@ -422,7 +625,8 @@ fn encoder_should_preserve_opaque_provider_options_without_interpreting_them() {
|
||||
.expect("OpenAI payload");
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("opaque options encode");
|
||||
let encoded =
|
||||
encode_generate_request(&request, "gpt-test", None).expect("opaque options encode");
|
||||
|
||||
assert_eq!(
|
||||
encoded.body().get("provider_options"),
|
||||
@@ -459,7 +663,7 @@ fn encoder_should_project_lite_and_memgen_options_to_transport_state_only() {
|
||||
]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
|
||||
let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode");
|
||||
|
||||
assert_eq!(encoded.responses_lite.as_deref(), Some("true"));
|
||||
assert_eq!(encoded.memgen_request.as_deref(), Some("true"));
|
||||
@@ -483,7 +687,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() {
|
||||
)]));
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let semantics = encode_generate_request(&request, "observability")
|
||||
let semantics = encode_generate_request(&request, "observability", None)
|
||||
.expect("observability request should encode")
|
||||
.semantics();
|
||||
|
||||
@@ -502,8 +706,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
|
||||
json!({"x-openai-subagent": "review"}),
|
||||
),
|
||||
]));
|
||||
let metadata_encoded =
|
||||
encode_generate_request(&metadata_request, "gpt-test").expect("encode metadata request");
|
||||
let metadata_encoded = encode_generate_request(&metadata_request, "gpt-test", None)
|
||||
.expect("encode metadata request");
|
||||
assert_eq!(metadata_encoded.subagent_kind().as_deref(), Some("review"));
|
||||
|
||||
let turn_metadata_payload = ProtocolPayload::json_object(
|
||||
@@ -519,7 +723,7 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() {
|
||||
Value::String(r#"{"subagent_kind":"worker"}"#.to_owned()),
|
||||
)]));
|
||||
let turn_metadata_request = GenerateRequest::from_protocol_payload(turn_metadata_payload);
|
||||
let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test")
|
||||
let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test", None)
|
||||
.expect("encode turn metadata request");
|
||||
assert_eq!(
|
||||
turn_metadata_encoded.subagent_kind().as_deref(),
|
||||
@@ -553,7 +757,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() {
|
||||
.expect("OpenAI payload");
|
||||
let request = GenerateRequest::from_protocol_payload(payload);
|
||||
|
||||
let semantics = encode_generate_request(&request, "observability")
|
||||
let semantics = encode_generate_request(&request, "observability", None)
|
||||
.expect("transparent OpenAI request should encode")
|
||||
.semantics();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ fn client(base_url: &str) -> CodexBackendClient {
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
|
||||
.single()
|
||||
@@ -146,6 +147,50 @@ async fn consume_should_send_exact_credit_and_redeem_request_id_without_retry()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_only_credits_should_allow_upstream_selected_consumption() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"available_count": 1,
|
||||
"credits": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "code": "reset" })))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = client(&server.uri());
|
||||
let credits = client
|
||||
.list_rate_limit_reset_credits(context())
|
||||
.await
|
||||
.expect("count-only reset credits");
|
||||
assert_eq!(credits.available_count, 1);
|
||||
assert!(credits.credits.is_empty());
|
||||
|
||||
let redeem_request_id =
|
||||
Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("redeem request ID");
|
||||
let result = client
|
||||
.consume_rate_limit_reset_credit(context(), None, redeem_request_id)
|
||||
.await
|
||||
.expect("upstream-selected reset-credit consume");
|
||||
assert_eq!(result.code, "reset");
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.method == Method::POST)
|
||||
.expect("consume request");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&request.body).expect("consume JSON"),
|
||||
json!({ "redeem_request_id": "8fbf302d-11df-4bd5-82e4-08e4b3df7874" })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_should_preserve_raw_upstream_error_and_never_retry() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::{CodexBackendClient, CodexRequestContext, test_wire_profile};
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{header, method, path, query_param},
|
||||
};
|
||||
|
||||
fn client(base: &str) -> CodexBackendClient {
|
||||
CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
base,
|
||||
test_wire_profile(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_uses_exact_bound_account_and_projects_only_safe_facts() {
|
||||
let server = MockServer::start().await;
|
||||
let account = "personal/account + A";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/subscriptions"))
|
||||
.and(query_param("account_id", account))
|
||||
.and(header("chatgpt-account-id", account))
|
||||
.and(header("authorization", "Bearer fixture"))
|
||||
.and(header("accept", "application/json"))
|
||||
.and(header("origin", server.uri()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id":"subscription-not-account", "plan_type":"enterprise", "secret":"ignored",
|
||||
"active_start":"2019-12-02T08:00:00+08:00",
|
||||
"active_until":" 2020-01-02T08:00:00+08:00 ", "will_renew":true,
|
||||
"billing_period":"monthly", "billing_currency":"USD"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = client(&format!("{}/backend-api", server.uri()))
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary(
|
||||
"Bearer fixture",
|
||||
Some(account),
|
||||
"subscription_test",
|
||||
None,
|
||||
),
|
||||
account,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.starts_at.unwrap().to_rfc3339(),
|
||||
"2019-12-02T00:00:00+00:00"
|
||||
);
|
||||
assert_eq!(result.billing_period.as_deref(), Some("monthly"));
|
||||
assert_eq!(result.billing_currency.as_deref(), Some("USD"));
|
||||
assert_eq!(result.expires_at.to_rfc3339(), "2020-01-02T00:00:00+00:00");
|
||||
assert_eq!(result.will_renew, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_failures_are_unknown_without_retry() {
|
||||
for response in [
|
||||
ResponseTemplate::new(204),
|
||||
ResponseTemplate::new(401),
|
||||
ResponseTemplate::new(403),
|
||||
ResponseTemplate::new(404),
|
||||
ResponseTemplate::new(429),
|
||||
ResponseTemplate::new(500),
|
||||
ResponseTemplate::new(200).set_body_string("not json"),
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until":"invalid","will_renew":false})),
|
||||
ResponseTemplate::new(200).set_body_json(json!({"will_renew":true})),
|
||||
ResponseTemplate::new(200).set_body_string("x".repeat(65537)),
|
||||
ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(6)),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(
|
||||
client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account"
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_unknown_renewal_is_not_false_and_empty_account_does_not_request() {
|
||||
let server = MockServer::start().await;
|
||||
for renewal in [json!(null), json!("false"), json!(false)] {
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(
|
||||
json!({"active_until":"2026-10-01T00:00:00Z","will_renew":renewal}),
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.will_renew, renewal.as_bool());
|
||||
}
|
||||
server.reset().await;
|
||||
assert!(
|
||||
client(&server.uri())
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", None, "req", None),
|
||||
""
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscription_preserves_configured_backend_path_prefix() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/proxy/backend-api/subscriptions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({"active_until":"2026-10-01T00:00:00Z"})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(
|
||||
client(&format!("{}/proxy/backend-api/", server.uri()))
|
||||
.fetch_subscription(
|
||||
CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None),
|
||||
"account"
|
||||
)
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use bytes::{Buf as _, Bytes};
|
||||
|
||||
use super::*;
|
||||
|
||||
// Captured from Desktop 26.901.51231 / Core 0.153.4 on 2026-09-06.
|
||||
// Compare wire semantics: rustls randomizes extension order and ephemeral key bytes.
|
||||
// 2026-09-14 从 Desktop 26.908.40834 / Core 0.154.0 抓取。
|
||||
// HTTP/native-tls 的扩展顺序固定;WebSocket/rustls 会随机化扩展顺序和临时密钥。
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ClientHello {
|
||||
cipher_suites: Vec<u16>,
|
||||
@@ -16,28 +16,66 @@ struct ClientHello {
|
||||
alpn: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_client_hello_should_match_official_rustls_transport() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"https://localhost:{}/",
|
||||
listener.local_addr().unwrap().port()
|
||||
);
|
||||
let client = provider_openai::transport::build_reqwest_client().unwrap();
|
||||
let (hello, response) = timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(read_client_hello(listener), client.get(url).send())
|
||||
})
|
||||
.await
|
||||
.expect("HTTP ClientHello within timeout");
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
#[test]
|
||||
fn http_client_hello_should_match_official_native_tls_transport() {
|
||||
const CASE_ENV: &str = "CODEX_PROXY_TEST_NATIVE_TLS_CLIENT_HELLO";
|
||||
const CASE_COMPLETED: &str = "native-tls-client-hello-case-completed";
|
||||
|
||||
let mut expected = official_websocket_hello();
|
||||
expected.extensions.insert(5, 16);
|
||||
expected.alpn = vec!["h2".to_owned(), "http/1.1".to_owned()];
|
||||
assert_eq!(hello, expected);
|
||||
if std::env::var_os(CASE_ENV).is_some() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let hello = runtime.block_on(async {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!(
|
||||
"https://localhost:{}/",
|
||||
listener.local_addr().unwrap().port()
|
||||
);
|
||||
let client = provider_openai::transport::build_reqwest_client().unwrap();
|
||||
let (hello, response) = timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(read_client_hello(listener), client.get(url).send())
|
||||
})
|
||||
.await
|
||||
.expect("HTTP ClientHello within timeout");
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
hello
|
||||
});
|
||||
assert_eq!(hello, official_http_hello());
|
||||
println!("\n{CASE_COMPLETED}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cargo 运行测试时可能注入 SSL_CERT_FILE,生产代码会把它视作自定义 CA 并切到 rustls;
|
||||
// 因此在清理相关环境变量的子进程里验证默认路径。
|
||||
let current_exe = std::env::current_exe().expect("current test binary path");
|
||||
let output = Command::new(current_exe)
|
||||
.arg("--exact")
|
||||
.arg("transport::tls::http_client_hello_should_match_official_native_tls_transport")
|
||||
.arg("--nocapture")
|
||||
.env(CASE_ENV, "1")
|
||||
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
|
||||
.env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV)
|
||||
.output()
|
||||
.expect("run isolated native TLS ClientHello case");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated native TLS ClientHello case failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert_eq!(
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| *line == CASE_COMPLETED)
|
||||
.count(),
|
||||
1,
|
||||
"isolated native TLS ClientHello case did not complete exactly once\nstdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -60,7 +98,27 @@ async fn websocket_client_hello_should_match_official_rustls_transport() {
|
||||
response.is_err(),
|
||||
"capture endpoint rejects TLS after ClientHello"
|
||||
);
|
||||
assert_eq!(hello, official_websocket_hello());
|
||||
let mut normalized = hello;
|
||||
normalized.extensions.sort_unstable();
|
||||
assert_eq!(normalized, official_websocket_hello());
|
||||
}
|
||||
|
||||
fn official_http_hello() -> ClientHello {
|
||||
ClientHello {
|
||||
cipher_suites: vec![
|
||||
4866, 4867, 4865, 49196, 49200, 159, 52393, 52392, 52394, 49195, 49199, 158, 49188,
|
||||
49192, 107, 49187, 49191, 103, 49162, 49172, 57, 49161, 49171, 51, 157, 156, 61, 60,
|
||||
53, 47,
|
||||
],
|
||||
extensions: vec![65281, 0, 11, 10, 35, 22, 23, 13, 43, 45, 51],
|
||||
groups: vec![4588, 29, 23, 30, 24, 25, 256, 257],
|
||||
signature_algorithms: vec![
|
||||
2309, 2310, 2308, 1027, 1283, 1539, 2055, 2056, 2074, 2075, 2076, 2057, 2058, 2059,
|
||||
2052, 2053, 2054, 1025, 1281, 1537, 771, 769, 770, 1026, 1282, 1538,
|
||||
],
|
||||
key_shares: vec![(4588, 1216), (29, 32)],
|
||||
alpn: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn official_websocket_hello() -> ClientHello {
|
||||
@@ -96,9 +154,11 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
let compression_len = usize::from(hello.get_u8());
|
||||
hello.advance(compression_len);
|
||||
let mut extensions = take_vector(&mut hello);
|
||||
let mut extension_order = Vec::new();
|
||||
let mut values = BTreeMap::new();
|
||||
while extensions.has_remaining() {
|
||||
let kind = extensions.get_u16();
|
||||
extension_order.push(kind);
|
||||
assert!(values.insert(kind, take_vector(&mut extensions)).is_none());
|
||||
}
|
||||
|
||||
@@ -124,7 +184,7 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
}
|
||||
ClientHello {
|
||||
cipher_suites,
|
||||
extensions: values.into_keys().collect(),
|
||||
extensions: extension_order,
|
||||
groups,
|
||||
signature_algorithms,
|
||||
key_shares,
|
||||
|
||||
@@ -705,6 +705,7 @@ fn profile() -> CodexWireProfileState {
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "xterm".to_owned(),
|
||||
residency: None,
|
||||
location: Default::default(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.single()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use flate2::{Decompress, FlushDecompress};
|
||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ClientFrame {
|
||||
first_byte: u8,
|
||||
masked: bool,
|
||||
mask_len: usize,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_websocket_wire_fingerprint_should_remain_aligned() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind fingerprint server");
|
||||
let address = listener.local_addr().expect("fingerprint server address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept fingerprint client");
|
||||
let opening = read_http_request(&mut stream).await;
|
||||
let websocket_key = read_header_value(&opening, "sec-websocket-key")
|
||||
.expect("WebSocket key")
|
||||
.to_owned();
|
||||
let response = format!(
|
||||
concat!(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n",
|
||||
"Connection: Upgrade\r\n",
|
||||
"Upgrade: websocket\r\n",
|
||||
"Sec-WebSocket-Accept: {}\r\n",
|
||||
"Sec-WebSocket-Extensions: permessage-deflate\r\n",
|
||||
"\r\n"
|
||||
),
|
||||
derive_accept_key(websocket_key.as_bytes())
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write upgrade response");
|
||||
|
||||
let frame = read_client_frame(&mut stream).await;
|
||||
write_server_text_frame(
|
||||
&mut stream,
|
||||
&completed_websocket_response("resp_wire_fingerprint", 1, 1),
|
||||
)
|
||||
.await;
|
||||
(opening, websocket_key, frame)
|
||||
});
|
||||
|
||||
let mut request = CodexResponsesRequest::from_body(
|
||||
json!({
|
||||
"model": "gpt-5.5",
|
||||
"instructions": "be brief",
|
||||
"input": [],
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "medium", "summary": "auto"},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": "conversation-fingerprint",
|
||||
"generate": false,
|
||||
"client_metadata": {"request_kind": "prewarm"}
|
||||
})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone(),
|
||||
);
|
||||
request.use_websocket = true;
|
||||
request.local_conversation_id = Some("conversation-fingerprint".to_owned());
|
||||
request.turn_metadata = Some(r#"{"kind":"fingerprint"}"#.to_owned());
|
||||
request.beta_features = Some("feature-a".to_owned());
|
||||
request.version = Some("26.908.40834".to_owned());
|
||||
request.codex_window_id = Some("window-fingerprint".to_owned());
|
||||
let client = CodexBackendClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("HTTP client"),
|
||||
format!("http://{address}/backend-api"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
|
||||
let result = client
|
||||
.create_response(
|
||||
&request,
|
||||
CodexRequestContext {
|
||||
trace: None,
|
||||
turn_metadata: request.turn_metadata.as_deref(),
|
||||
beta_features: request.beta_features.as_deref(),
|
||||
version: request.version.as_deref(),
|
||||
codex_window_id: request.codex_window_id.as_deref(),
|
||||
session_id: Some("session-fingerprint"),
|
||||
thread_id: Some("thread-fingerprint"),
|
||||
client_request_id: Some("request-fingerprint"),
|
||||
..request_context("req_wire_fingerprint", Some("account-fingerprint"))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
result.unwrap_or_else(|error| panic!("fingerprint WebSocket response: {error}"));
|
||||
let (opening, websocket_key, frame) = server.await.expect("fingerprint server task");
|
||||
assert_eq!(
|
||||
opening.lines().next(),
|
||||
Some("GET /backend-api/codex/responses HTTP/1.1")
|
||||
);
|
||||
// 官方 Codex Core 0.154.0(Desktop 26.908.40834)的原始 opening 抓包顺序。
|
||||
assert_eq!(
|
||||
read_header_names(&opening),
|
||||
vec![
|
||||
"host",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-version",
|
||||
"sec-websocket-key",
|
||||
"chatgpt-account-id",
|
||||
"authorization",
|
||||
"user-agent",
|
||||
"originator",
|
||||
"openai-beta",
|
||||
"version",
|
||||
"x-codex-beta-features",
|
||||
"x-client-request-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"x-codex-window-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-codex-routing-hint",
|
||||
"sec-websocket-extensions",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
read_header_value(&opening, "sec-websocket-extensions"),
|
||||
Some("permessage-deflate; client_max_window_bits")
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
read_header_value(&opening, "chatgpt-account-id"),
|
||||
read_header_value(&opening, "authorization"),
|
||||
read_header_value(&opening, "originator"),
|
||||
read_header_value(&opening, "version"),
|
||||
read_header_value(&opening, "openai-beta"),
|
||||
read_header_value(&opening, "x-client-request-id"),
|
||||
read_header_value(&opening, "x-codex-routing-hint"),
|
||||
),
|
||||
(
|
||||
Some("account-fingerprint"),
|
||||
Some("Bearer access-token"),
|
||||
Some("codex_cli_rs"),
|
||||
Some("1.2.3"),
|
||||
Some("responses_websockets=2026-02-06"),
|
||||
Some("request-fingerprint"),
|
||||
Some("model=gpt-5.5"),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
STANDARD
|
||||
.decode(websocket_key)
|
||||
.expect("base64 WebSocket key")
|
||||
.len(),
|
||||
16
|
||||
);
|
||||
|
||||
assert_eq!(frame.first_byte, 0b1100_0001, "FIN + RSV1 + text opcode");
|
||||
assert!(frame.masked, "official client frames are masked");
|
||||
assert_eq!(frame.mask_len, 4);
|
||||
let payload = decompress_permessage_deflate(&frame.payload);
|
||||
let payload: Value = serde_json::from_slice(&payload).expect("response.create JSON");
|
||||
assert_eq!(payload["type"], "response.create");
|
||||
assert_eq!(payload["generate"], false);
|
||||
assert_eq!(
|
||||
payload
|
||||
.as_object()
|
||||
.expect("response.create object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"type",
|
||||
"model",
|
||||
"instructions",
|
||||
"input",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"store",
|
||||
"stream",
|
||||
"include",
|
||||
"prompt_cache_key",
|
||||
"generate",
|
||||
"client_metadata",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async fn read_client_frame(stream: &mut TcpStream) -> ClientFrame {
|
||||
let first_byte = stream.read_u8().await.expect("frame first byte");
|
||||
let second_byte = stream.read_u8().await.expect("frame second byte");
|
||||
let payload_len = match second_byte & 0x7f {
|
||||
len @ 0..=125 => u64::from(len),
|
||||
126 => u64::from(stream.read_u16().await.expect("16-bit frame length")),
|
||||
127 => stream.read_u64().await.expect("64-bit frame length"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let masked = second_byte & 0x80 != 0;
|
||||
let mut mask = [0_u8; 4];
|
||||
if masked {
|
||||
stream.read_exact(&mut mask).await.expect("frame mask");
|
||||
}
|
||||
let mut payload = vec![0_u8; usize::try_from(payload_len).expect("frame length fits usize")];
|
||||
stream
|
||||
.read_exact(&mut payload)
|
||||
.await
|
||||
.expect("frame payload");
|
||||
if masked {
|
||||
for (index, byte) in payload.iter_mut().enumerate() {
|
||||
*byte ^= mask[index % mask.len()];
|
||||
}
|
||||
}
|
||||
ClientFrame {
|
||||
first_byte,
|
||||
masked,
|
||||
mask_len: if masked { mask.len() } else { 0 },
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn decompress_permessage_deflate(payload: &[u8]) -> Vec<u8> {
|
||||
let mut compressed = Vec::with_capacity(payload.len() + 4);
|
||||
compressed.extend_from_slice(payload);
|
||||
compressed.extend_from_slice(&[0x00, 0x00, 0xff, 0xff]);
|
||||
let mut output = vec![0_u8; 64 * 1024];
|
||||
let mut decompressor = Decompress::new(false);
|
||||
decompressor
|
||||
.decompress(&compressed, &mut output, FlushDecompress::Sync)
|
||||
.expect("decompress response.create frame");
|
||||
output.truncate(
|
||||
usize::try_from(decompressor.total_out()).expect("decompressed length fits usize"),
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
async fn write_server_text_frame(stream: &mut TcpStream, payload: &str) {
|
||||
let payload = payload.as_bytes();
|
||||
let mut frame = Vec::with_capacity(payload.len() + 10);
|
||||
frame.push(0x81);
|
||||
match payload.len() {
|
||||
len @ 0..=125 => frame.push(u8::try_from(len).expect("short frame length")),
|
||||
len @ 126..=65_535 => {
|
||||
frame.push(126);
|
||||
frame.extend_from_slice(
|
||||
&u16::try_from(len)
|
||||
.expect("16-bit frame length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
len => {
|
||||
frame.push(127);
|
||||
frame.extend_from_slice(
|
||||
&u64::try_from(len)
|
||||
.expect("64-bit frame length")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
frame.extend_from_slice(payload);
|
||||
stream
|
||||
.write_all(&frame)
|
||||
.await
|
||||
.expect("write completed frame");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use provider_openai::transport::{
|
||||
};
|
||||
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod metadata;
|
||||
|
||||
fn rate_limit_event(used_percent: u64) -> String {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
|
||||
COPY frontend ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.98-bookworm@sha256:82150a52ec202c1b14d7817e14516c392bb7f5cfebd88f1ed531cb37ebd39922 AS backend-builder
|
||||
FROM rust:1.98-bookworm@sha256:9a73a5088750b4c95158ab26629c854c3d6fc4b173cb7bc8079ad252d8ed7bfa AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
ARG TARGETARCH
|
||||
|
||||
+14
-1
@@ -48,6 +48,13 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
|
||||
模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像,
|
||||
不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。
|
||||
|
||||
`openai.wire_profile.location` 可选覆盖请求地区。省略、留空(`location:`)或设为 `null` 时,
|
||||
透传客户端原有的 Web Search `user_location`、环境日期和时区;客户端未提供的字段也不会补写。
|
||||
模板显式填写 `US / Ohio / Piketon / America/New_York`,需要透传时清空或删除该配置项。
|
||||
自定义时完整填写 `country`(两位大写国家代码)、`region`、`city` 和 `timezone`(IANA 时区);修改后重启生效。
|
||||
它统一 Responses 的 Web Search 地区与带环境标记的日期、时区,不修改普通聊天内容或 epoch 时间戳,
|
||||
也不替代 `residency` 约束或随官方版本检查变化。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -325,7 +332,13 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
|
||||
> 以下命令只适用于同一大版本内的升级,不支持跨大版本在线升级。跨大版本请使用全新的
|
||||
> `.runtime/` 数据目录重新部署,并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
|
||||
Docker 安装从安装目录拉取发布镜像并重建应用容器:
|
||||
每个 Release 独立提供 `config.example.yaml`、默认镜像固定到该版本的 `compose.yaml` 和校验和;
|
||||
各平台归档也包含 `deploy/config.example.yaml`。配置模板来自构建该版本的同一提交。
|
||||
使用二进制归档手动部署时,将模板中的 `api.asset_directory` 改为 `../web/dist`,指向归档内的静态资源。
|
||||
升级时先阅读目标版本说明,下载同一 Release 的部署附件,对比模板并合并必要配置,保留已有凭据
|
||||
和 Compose 自定义项。不要用模板覆盖 `config.yaml`,也不要从 `main` 下载模板搭配旧镜像。
|
||||
|
||||
更新部署文件后,从安装目录拉取目标版本镜像并重建应用容器:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
|
||||
@@ -86,8 +86,6 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
pids_limit: 256
|
||||
cpus: '2.0'
|
||||
mem_limit: 1g
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
volumes:
|
||||
|
||||
@@ -74,6 +74,9 @@ openai:
|
||||
terminal: 'unknown'
|
||||
# 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。
|
||||
residency: us
|
||||
# 请求地区覆盖;省略或留空(location: / location: null)时透传客户端原值。
|
||||
# 配置时完整填写四个字段;时区同时用于环境日期,不改变 epoch 时间戳。
|
||||
location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }
|
||||
verified_at: '2026-09-06T11:26:12.084+08:00'
|
||||
|
||||
xai:
|
||||
|
||||
+59
-12
@@ -228,6 +228,14 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
上游结构化错误的 message/code/type 会透传给客户端,其中内嵌的账号指纹 UUID 已脱敏。模型映射是
|
||||
全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名。
|
||||
|
||||
OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足错误时,代理在允许安全重放且
|
||||
尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始,
|
||||
上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。
|
||||
容量不足不扣 Smart 账号健康分,不触发 Provider 全局熔断,也不作为账号额度耗尽写入冷却状态。
|
||||
最终交付的上游错误仍按上述透明边界保留原始状态码、错误码和正文。
|
||||
明确额度耗尽继续走现有账号隔离与安全换号流程,
|
||||
包括 WebSocket 握手返回的 429;不会因其长 `Retry-After` 而转入同账号传输恢复等待。
|
||||
|
||||
## 4. 管理员认证
|
||||
|
||||
| 方法 | 路由 | 请求 | 说明 |
|
||||
@@ -256,7 +264,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
|
||||
| `GET` | `/api/admin/accounts/profile-statistics` | `accountId` | 实时查询 OpenAI/Codex 官方个人资料中的累计活动与使用洞察 |
|
||||
| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 OpenAI/Codex 官方个人资料、累计活动与订阅信息,不更新额度或 credential |
|
||||
| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 |
|
||||
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 |
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
@@ -514,10 +522,26 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
|
||||
上游消耗和模型组合变化仍可能造成误差;等价 USD 费用不是官方订阅价格或固定额度承诺,
|
||||
30 天折算也不是自然月额度。记录覆盖率不等于预测准确率,不输出未经校准的置信区间。
|
||||
|
||||
### OpenAI 官方个人资料统计
|
||||
### OpenAI 个人信息
|
||||
|
||||
`GET /api/admin/accounts/profile-statistics?accountId=...` 仅支持 OpenAI/Codex OAuth 账号。每次查询直接
|
||||
访问官方个人资料端点,不读取本地 usage/billing 记录,也不缓存或估算统计结果。响应 `data` 包含:
|
||||
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,当前由 OpenAI/Codex OAuth
|
||||
账号提供。后端并发读取资料统计与订阅,一次返回;每次请求均重新查询,不自动重试或
|
||||
刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。
|
||||
|
||||
响应 `data` 包含:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `profile` | object 或 null | 官方个人资料、累计统计与活动洞察;查询失败为 null |
|
||||
| `profileError` | string 或 null | 资料查询失败时的安全错误提示;成功为 null |
|
||||
| `subscription` | object 或 null | 当前绑定账号的订阅周期;无可用周期或查询失败为 null |
|
||||
|
||||
两部分的查询结果独立:资料失败时仍返回可用订阅,订阅失败时仍返回资料。账号不存在、查询参数不合法或
|
||||
无管理员会话时,仍返回标准错误;请求期间账号身份或 credential revision 改变时拒绝整份结果。
|
||||
|
||||
#### 官方资料与累计统计
|
||||
|
||||
`profile` 包含:
|
||||
|
||||
- `displayName`、`username`、`imageUrl`:官方账号资料;
|
||||
- `summary`:累计文本 Token、单日峰值 Token、最长任务时长、当前连续天数和最长连续天数;
|
||||
@@ -526,7 +550,27 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明
|
||||
以及插件与 Skill 调用排行。
|
||||
|
||||
官方未返回的字段保持 `null`,不使用本地数据补齐;`hasStatsError: true` 表示账号资料可用,但官方统计
|
||||
部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。
|
||||
部分不可用。access token 已过期或官方返回 401 时,通过 `profileError` 提示先刷新 credential 或重新授权。
|
||||
|
||||
#### 订阅信息
|
||||
|
||||
后端使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`,
|
||||
不枚举其他账号,不返回上游订阅 ID 或原始响应。
|
||||
|
||||
`subscription` 为 `null`(未获得可用订阅周期),或包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `startsAt` | RFC 3339 字符串或 null | 上游本期开始时间 |
|
||||
| `expiresAt` | RFC 3339 字符串 | 上游本期结束时间,不代表自动续费账号最终失效 |
|
||||
| `willRenew` | boolean 或 null | 自动续费状态;未知不推断为 false |
|
||||
| `billingPeriod` | string 或 null | 上游计费周期标识 |
|
||||
| `billingCurrency` | string 或 null | 上游计费币种 |
|
||||
| `observedAt` | RFC 3339 字符串 | 本次查询时间 |
|
||||
|
||||
订阅不写入额度快照或数据库,不参与账号状态或调度;单次上游查询最多 5 秒、响应最多 64 KiB,不重试。
|
||||
上游失败或未提供有效周期时返回未知,不据此标记免费、过期或禁用;请求期间账号身份或 credential
|
||||
revision 变化时丢弃结果。
|
||||
|
||||
### OpenAI 主动额度重置卡
|
||||
|
||||
@@ -848,13 +892,16 @@ Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`
|
||||
和本地用量由 Provider/Admin 投影。前端不得从套餐缺失推断免费套餐,也不得从显示时舍入的百分比推断
|
||||
触顶。滚动窗口使用相应时间范围的本地用量,独立于 Dashboard 的今日统计范围。
|
||||
|
||||
OpenAI 的 `serviceTier` 只接受上游响应生命周期事件确认的实际 `response.service_tier`;请求里的
|
||||
期望档位只保留在 request summary,不能冒充响应事实。计费展示把 `priority`/`fast` 映射为 `Fast`,
|
||||
`flex` 映射为 `Flex`,缺失或 `default` 映射为 `Default`;未知非空值原样展示。各模型、档位及长上下文
|
||||
区间使用明确登记的价格;缺少对应价格时不估算,不以固定倍数兜底。展示的倍率由所选档位与标准档位
|
||||
费用之比计算,托管工具调用费不随 Token 档位倍增。
|
||||
响应未报告实际档位时,本地费用估算回退到请求档位;该推测不写入响应 `serviceTier`,也不能确认
|
||||
上游最终采用了该档位,因此本地费用不能代替官方账单。
|
||||
OpenAI Responses 用量记录的 `serviceTier` 与本地费用估算统一采用 Provider 最终发给上游的请求
|
||||
`service_tier`,不使用响应档位覆盖或回退。例如发送 `priority`、响应回显 `default` 时,仍显示
|
||||
`Fast` 并按 Priority 价格估算。未发送档位时,`serviceTier` 保持缺失,展示与估算按标准档处理。
|
||||
计费展示把 `priority`/`fast` 映射为 `Fast`,`flex` 映射为 `Flex`,缺失或 `default`/`standard`
|
||||
映射为 `Standard`;其他非空值以首字母大写展示。各模型、档位及长上下文区间使用明确登记的价格;缺少对应
|
||||
价格时不估算(包括 `auto` 和未知档位),不以固定倍数兜底。展示的倍率由所选档位与标准档位费用之比
|
||||
计算,托管工具调用费不随 Token 档位倍增。
|
||||
Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 供诊断;发送给客户端的
|
||||
原始 `response.service_tier` 不变。用量中的 Fast 仅表示发送档位,不能证明上游实际加速,本地费用
|
||||
估算也不能代替官方账单。该口径仅作用于新记录,不回填历史档位或重算已存储费用。
|
||||
|
||||
本地计价规则只保留尚在服务的型号;已过官方关闭日期的型号不再新增本地估价。清理计价规则不删除或
|
||||
重新计算已存储的历史费用;缺少当前计价规则时,历史总额仍保留,但无法再据此补充费用拆分。
|
||||
|
||||
+13
-2
@@ -170,6 +170,8 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/
|
||||
- Provider 的一次 `execute` 只选择一个 credential 并返回一个冷流;换号、重试和 fallback 由 Core 决定。
|
||||
- `not_sent`、`sent`、`ambiguous` 是单调的上游发送边界;结果不明确时不能假定上游未收到请求。
|
||||
- downstream commit 是不可撤回的交付承诺。commit 后禁止换号、重试和 fallback。
|
||||
- Provider 可将明确容量拒绝标记为有界同账号退避,Core 在既有安全重放边界内执行,按账号维护请求内
|
||||
预算,耗尽后复用普通换号路径。该退避消耗总路由预算,与 WS 传输恢复、OAuth 刷新及账号额度冷却分开。
|
||||
- 跨 Provider 只在账号范围和能力都允许,且请求尚未到达上游或已被证明可安全重放时发生。
|
||||
- 可恢复观测写入失败不能替换已经确定的客户端协议结果。
|
||||
|
||||
@@ -268,7 +270,8 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一
|
||||
规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误;
|
||||
查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。
|
||||
|
||||
API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求
|
||||
API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排列请求配置;
|
||||
仅在实际调用方需要时提供 `RequestOptions`(`signal`、`timeout`、`silent`),并放在请求配置末尾。取消或已被新查询取代的请求
|
||||
不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。
|
||||
批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供
|
||||
一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。
|
||||
@@ -383,6 +386,12 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
|
||||
- quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与
|
||||
目录 cache 隔离,不创建套餐专属状态机。
|
||||
|
||||
OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号用例通过现有 Provider 管理端口并发读取
|
||||
个人资料统计与订阅,汇聚为一次只读响应;Provider 继续拥有各自的认证、出站代理与上游协议处理。
|
||||
订阅不由 quota、导入或后台任务触发,不持久化。汇聚结果返回前核对账号身份与 credential revision,
|
||||
避免重新授权期间展示混合身份信息;单项失败不丢弃另一项可用结果。前端只在打开「个人信息」或手动
|
||||
刷新信息时请求,关闭后取消等待,不维护两套请求状态。
|
||||
|
||||
主动额度重置是 OpenAI Provider 的不可逆上游操作:列表查询和消费都直接使用当前 Desktop 请求画像;
|
||||
卡片不写 PostgreSQL/Redis。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的消费在进程内串行;
|
||||
发送结果不明确时必须复用原键。确认成功后管理端再显式刷新卡片与 quota,不能直接改本地重置时间。
|
||||
@@ -421,7 +430,9 @@ observation、调用 metadata;最终失败的关联头不与 opening 身份混
|
||||
`generate: false` 将连接与上下文准备归类为 `prewarm`,不信任客户端单独声明的同名 metadata。
|
||||
Store 的共享用量口径排除这些预热记录,账号用量与额度预测复用同一规则;原始请求审计、响应额度
|
||||
观测和费用事实仍保留,不将未知费用改写为零,也不影响 Client Key 结算账本。
|
||||
实际 `service_tier` 只接受上游响应事件确认,不能用请求期望值替代。
|
||||
OpenAI Responses 的统计档位与本地费用估算统一使用 Provider 最终发给上游的请求 `service_tier`,
|
||||
不由响应回显覆盖;未发送档位时保留缺失值,展示与估算按标准档处理。上游响应档位独立保留在
|
||||
Provider metadata 的 `upstreamServiceTier`,不改写客户端收到的响应,也不据此断言实际加速效果。
|
||||
|
||||
Worker 由各 Bundle 贡献、由 Host 统一监督:
|
||||
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.41.0",
|
||||
"@lucide/vue": "^1.45.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"dompurify": "^3.4.14",
|
||||
"dompurify": "^3.4.15",
|
||||
"echarts": "^6.1.0",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked": "^18.0.13",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.42",
|
||||
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^9.5.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/node": "^26.5.1",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.10.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2",
|
||||
"vite": "^8.3.0",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+119
-135
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@lucide/vue':
|
||||
specifier: ^1.41.0
|
||||
version: 1.41.0(vue@3.5.42(typescript@6.0.3))
|
||||
specifier: ^1.45.0
|
||||
version: 1.45.0(vue@3.5.42(typescript@6.0.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.4.0
|
||||
version: 14.4.0(vue@3.5.42(typescript@6.0.3))
|
||||
@@ -36,8 +36,8 @@ importers:
|
||||
specifier: ^1.11.23
|
||||
version: 1.11.23
|
||||
dompurify:
|
||||
specifier: ^3.4.14
|
||||
version: 3.4.14
|
||||
specifier: ^3.4.15
|
||||
version: 3.4.15
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
@@ -48,8 +48,8 @@ importers:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
marked:
|
||||
specifier: ^18.0.11
|
||||
version: 18.0.11
|
||||
specifier: ^18.0.13
|
||||
version: 18.0.13
|
||||
pinia:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
|
||||
@@ -61,20 +61,20 @@ importers:
|
||||
version: 3.5.42(typescript@6.0.3)
|
||||
vue-router:
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
|
||||
version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1(@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3))(@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.42)(eslint-plugin-format@2.0.1(eslint@10.10.0(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.10.0(jiti@2.7.0))(globals@17.12.0))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))
|
||||
version: 4.3.3(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))
|
||||
'@types/node':
|
||||
specifier: ^26.4.1
|
||||
version: 26.4.1
|
||||
specifier: ^26.5.1
|
||||
version: 26.5.1
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.8
|
||||
version: 6.0.8(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
|
||||
version: 6.0.8(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))
|
||||
'@vue/tsconfig':
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
|
||||
@@ -94,8 +94,8 @@ importers:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue-tsc:
|
||||
specifier: ^3.3.11
|
||||
version: 3.3.11(typescript@6.0.3)
|
||||
@@ -358,8 +358,8 @@ packages:
|
||||
'@keyv/serialize@1.1.1':
|
||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||
|
||||
'@lucide/vue@1.41.0':
|
||||
resolution: {integrity: sha512-ciHtudHTeqMDzJKPI36AGboypCk8Jz3qFin3oU0nJfPc5MwXmqLXIEQvXE+yI6Sz5SnxVqREgm8rVN4KdDlnVQ==}
|
||||
'@lucide/vue@1.45.0':
|
||||
resolution: {integrity: sha512-tbMlusqK6brViI1xj8nsXAgNDyJOvNeOdOQlBI0hOWCAbnBrPRlanoQ6EygDEMv3dqwXfCZPb4a0/ImtI5KAmQ==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -367,8 +367,8 @@ packages:
|
||||
resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@oxc-project/types@0.146.0':
|
||||
resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==}
|
||||
'@oxc-project/types@0.149.0':
|
||||
resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.35.0':
|
||||
resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==}
|
||||
@@ -496,98 +496,98 @@ packages:
|
||||
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.5':
|
||||
resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==}
|
||||
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.5':
|
||||
resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==}
|
||||
'@rolldown/binding-android-arm64@1.2.8':
|
||||
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.5':
|
||||
resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==}
|
||||
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.5':
|
||||
resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==}
|
||||
'@rolldown/binding-darwin-x64@1.2.8':
|
||||
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.5':
|
||||
resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==}
|
||||
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.5':
|
||||
resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==}
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.5':
|
||||
resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==}
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.5':
|
||||
resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==}
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.5':
|
||||
resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==}
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.5':
|
||||
resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==}
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.5':
|
||||
resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==}
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.5':
|
||||
resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==}
|
||||
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.5':
|
||||
resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==}
|
||||
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.5':
|
||||
resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==}
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.5':
|
||||
resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==}
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -723,8 +723,8 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@26.4.1':
|
||||
resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==}
|
||||
'@types/node@26.5.1':
|
||||
resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
@@ -1092,8 +1092,8 @@ packages:
|
||||
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
dompurify@3.4.14:
|
||||
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
|
||||
dompurify@3.4.15:
|
||||
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
@@ -1780,8 +1780,8 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
marked@18.0.11:
|
||||
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
|
||||
marked@18.0.13:
|
||||
resolution: {integrity: sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1945,8 +1945,8 @@ packages:
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -2031,10 +2031,6 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.5:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2080,10 +2076,6 @@ packages:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -2143,8 +2135,8 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
rolldown@1.2.5:
|
||||
resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==}
|
||||
rolldown@1.2.8:
|
||||
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -2252,8 +2244,8 @@ packages:
|
||||
ufo@1.6.4:
|
||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
undici-types@8.9.0:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
@@ -2323,13 +2315,13 @@ packages:
|
||||
resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
vite@8.2.2:
|
||||
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
|
||||
vite@8.3.0:
|
||||
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.4.0 || ^0.5.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
@@ -2698,13 +2690,13 @@ snapshots:
|
||||
|
||||
'@keyv/serialize@1.1.1': {}
|
||||
|
||||
'@lucide/vue@1.41.0(vue@3.5.42(typescript@6.0.3))':
|
||||
'@lucide/vue@1.45.0(vue@3.5.42(typescript@6.0.3))':
|
||||
dependencies:
|
||||
vue: 3.5.42(typescript@6.0.3)
|
||||
|
||||
'@ota-meshi/ast-token-store@0.3.0': {}
|
||||
|
||||
'@oxc-project/types@0.146.0': {}
|
||||
'@oxc-project/types@0.149.0': {}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.35.0':
|
||||
optional: true
|
||||
@@ -2765,49 +2757,49 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.3.6': {}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.5':
|
||||
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.5':
|
||||
'@rolldown/binding-android-arm64@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.5':
|
||||
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.5':
|
||||
'@rolldown/binding-darwin-x64@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.5':
|
||||
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.5':
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.5':
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.5':
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.5':
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.5':
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.5':
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.5':
|
||||
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.5':
|
||||
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.5':
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.5':
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@@ -2885,12 +2877,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
|
||||
|
||||
'@tailwindcss/vite@4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))':
|
||||
'@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.3
|
||||
'@tailwindcss/oxide': 4.3.3
|
||||
tailwindcss: 4.3.3
|
||||
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
@@ -2914,9 +2906,9 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@26.4.1':
|
||||
'@types/node@26.5.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
@@ -3016,10 +3008,10 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))':
|
||||
'@vitejs/plugin-vue@6.0.8(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue: 3.5.42(typescript@6.0.3)
|
||||
|
||||
'@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)':
|
||||
@@ -3311,7 +3303,7 @@ snapshots:
|
||||
|
||||
diff-sequences@29.6.3: {}
|
||||
|
||||
dompurify@3.4.14:
|
||||
dompurify@3.4.15:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -3850,7 +3842,7 @@ snapshots:
|
||||
jsdoc-type-pratt-parser@9.2.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
'@types/node': 26.4.1
|
||||
'@types/node': 26.5.1
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
@@ -4003,7 +3995,7 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@18.0.11: {}
|
||||
marked@18.0.13: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
@@ -4365,7 +4357,7 @@ snapshots:
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
@@ -4452,8 +4444,6 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))):
|
||||
@@ -4493,15 +4483,9 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -4544,26 +4528,26 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rolldown@1.2.5:
|
||||
rolldown@1.2.8:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.146.0
|
||||
'@oxc-project/types': 0.149.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.5
|
||||
'@rolldown/binding-android-arm64': 1.2.5
|
||||
'@rolldown/binding-darwin-arm64': 1.2.5
|
||||
'@rolldown/binding-darwin-x64': 1.2.5
|
||||
'@rolldown/binding-freebsd-x64': 1.2.5
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.5
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.5
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.5
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.5
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.5
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.5
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.5
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.5
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.8
|
||||
'@rolldown/binding-android-arm64': 1.2.8
|
||||
'@rolldown/binding-darwin-arm64': 1.2.8
|
||||
'@rolldown/binding-darwin-x64': 1.2.8
|
||||
'@rolldown/binding-freebsd-x64': 1.2.8
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.8
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.8
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.8
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.8
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.8
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.8
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.8
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.8
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.8
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.8
|
||||
|
||||
scslre@0.3.0:
|
||||
dependencies:
|
||||
@@ -4648,7 +4632,7 @@ snapshots:
|
||||
|
||||
ufo@1.6.4: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
undici-types@8.9.0: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
@@ -4679,14 +4663,14 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.7
|
||||
|
||||
unplugin@3.3.0(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)):
|
||||
unplugin@3.3.0(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
picomatch: 4.0.7
|
||||
webpack-virtual-modules: 0.6.2
|
||||
optionalDependencies:
|
||||
rolldown: 1.2.5
|
||||
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
rolldown: 1.2.8
|
||||
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
|
||||
update-browserslist-db@1.3.2(browserslist@4.28.9):
|
||||
dependencies:
|
||||
@@ -4702,15 +4686,15 @@ snapshots:
|
||||
|
||||
verkit@0.3.2: {}
|
||||
|
||||
vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0):
|
||||
vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.5
|
||||
postcss: 8.5.26
|
||||
rolldown: 1.2.5
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.2.8
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.4.1
|
||||
'@types/node': 26.5.1
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
yaml: 2.9.0
|
||||
@@ -4729,7 +4713,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)):
|
||||
vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)))(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue-macros/common': 3.1.4(vue@3.5.42(typescript@6.0.3))
|
||||
'@vue/devtools-api': 8.2.1
|
||||
@@ -4745,13 +4729,13 @@ snapshots:
|
||||
picomatch: 4.0.7
|
||||
scule: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
unplugin: 3.3.0(rolldown@1.2.5)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0))
|
||||
unplugin: 3.3.0(rolldown@1.2.8)(vite@8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0))
|
||||
unplugin-utils: 0.3.2
|
||||
vue: 3.5.42(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.42
|
||||
pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
|
||||
vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vite: 8.3.0(@types/node@26.5.1)(jiti@2.7.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@farmfe/core'
|
||||
- '@rspack/core'
|
||||
|
||||
@@ -88,39 +88,35 @@ export function getAccountGroups(data: AccountGroupListParams, options: RequestO
|
||||
})
|
||||
}
|
||||
|
||||
export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) {
|
||||
export function createAccountGroup(data: AccountGroupCreateParam) {
|
||||
return request<AccountGroupMutationResponse>({
|
||||
url: '/api/admin/account-groups/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) {
|
||||
export function updateAccountGroup(data: AccountGroupUpdateParam) {
|
||||
return request<AccountGroupMutationResponse>({
|
||||
url: '/api/admin/account-groups/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
|
||||
export function enableAccountGroup(data: AccountGroupIdParam) {
|
||||
return request<AccountGroupMutationResponse>({
|
||||
url: '/api/admin/account-groups/enable',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
|
||||
export function disableAccountGroup(data: AccountGroupIdParam) {
|
||||
return request<AccountGroupMutationResponse>({
|
||||
url: '/api/admin/account-groups/disable',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,15 @@ export interface AccountProfileActivityInsights {
|
||||
invocations: AccountProfileInvocation[] | null
|
||||
}
|
||||
|
||||
export interface AccountSubscription {
|
||||
startsAt: string | null
|
||||
expiresAt: string
|
||||
willRenew: boolean | null
|
||||
billingPeriod: string | null
|
||||
billingCurrency: string | null
|
||||
observedAt: string
|
||||
}
|
||||
|
||||
export interface AccountProfileStatisticsResponse {
|
||||
displayName: string | null
|
||||
username: string | null
|
||||
@@ -252,6 +261,12 @@ export interface AccountProfileStatisticsResponse {
|
||||
activityInsights: AccountProfileActivityInsights
|
||||
}
|
||||
|
||||
export interface AccountPersonalInfoResponse {
|
||||
profile: AccountProfileStatisticsResponse | null
|
||||
profileError: string | null
|
||||
subscription: AccountSubscription | null
|
||||
}
|
||||
|
||||
export interface AccountResetCredit {
|
||||
id: string
|
||||
status: string | null
|
||||
@@ -393,36 +408,33 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = {
|
||||
})
|
||||
}
|
||||
|
||||
export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) {
|
||||
export function exportAccounts(data: AccountExportParam) {
|
||||
return request<unknown>({
|
||||
url: '/api/admin/accounts/export',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) {
|
||||
export function refreshAccount(data: AccountIdParam) {
|
||||
return request<AccountRefreshResponse>({
|
||||
url: '/api/admin/accounts/refresh',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) {
|
||||
export function recoverAccount(data: AccountIdParam) {
|
||||
return request<AccountRefreshResponse>({
|
||||
url: '/api/admin/accounts/recover',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountProfileStatistics(data: AccountIdParam, options: RequestOptions = {}) {
|
||||
return request<AccountProfileStatisticsResponse>({
|
||||
url: '/api/admin/accounts/profile-statistics',
|
||||
export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) {
|
||||
return request<AccountPersonalInfoResponse>({
|
||||
url: '/api/admin/accounts/personal-info',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
...options,
|
||||
@@ -507,21 +519,19 @@ export function importAccounts(data: AccountImportParam, options: RequestOptions
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) {
|
||||
export function updateAccount(data: AccountUpdateParam) {
|
||||
return request<AccountUpdateResponse>({
|
||||
url: '/api/admin/accounts/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) {
|
||||
export function batchUpdateAccounts(data: AccountBatchUpdateParam) {
|
||||
return request<AccountBatchUpdateResponse>({
|
||||
url: '/api/admin/accounts/batch-update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,20 +544,18 @@ export function deleteAccounts(data: AccountDeleteParams, options: RequestOption
|
||||
})
|
||||
}
|
||||
|
||||
export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) {
|
||||
export function startAccountOAuth(data: AccountOAuthStartParam) {
|
||||
return request<AccountOAuthStartResponse>({
|
||||
url: '/api/admin/accounts/oauth/start',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) {
|
||||
export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
|
||||
return request<AccountOAuthCompleteResponse>({
|
||||
url: '/api/admin/accounts/oauth/complete',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,56 +87,50 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
|
||||
})
|
||||
}
|
||||
|
||||
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
|
||||
export function createApiKey(data: ApiKeyCreateParam) {
|
||||
return request<ApiKeyCreateResponse>({
|
||||
url: '/api/admin/client-keys/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) {
|
||||
export function updateApiKey(data: ApiKeyUpdateParam) {
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
|
||||
export function revealApiKey(data: ApiKeyIdParam) {
|
||||
return request<ApiKeyRevealResponse>({
|
||||
url: '/api/admin/client-keys/reveal',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
|
||||
export function deleteApiKey(data: ApiKeyIdParam) {
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
|
||||
export function disableApiKey(data: ApiKeyIdParam) {
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/disable',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
|
||||
export function enableApiKey(data: ApiKeyIdParam) {
|
||||
return request<ApiKeyMutationResponse>({
|
||||
url: '/api/admin/client-keys/enable',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,12 +18,11 @@ interface LoginParam {
|
||||
password: string
|
||||
}
|
||||
|
||||
export function login(data: LoginParam, options: RequestOptions = {}) {
|
||||
export function login(data: LoginParam) {
|
||||
return request<LoginResponse>({
|
||||
url: '/api/admin/auth/login',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -96,71 +96,64 @@ export interface DownloadUrlResult {
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
export function getBackupSettings(options: RequestOptions = {}) {
|
||||
export function getBackupSettings() {
|
||||
return request<BackupSettingsView>({
|
||||
url: '/api/admin/settings/backups',
|
||||
method: 'GET',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) {
|
||||
export function updateBackupStorage(data: UpdateBackupStoragePayload) {
|
||||
return request<BackupSettingsView>({
|
||||
url: '/api/admin/settings/backups/storage/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function testBackupStorage(options: RequestOptions = {}) {
|
||||
export function testBackupStorage() {
|
||||
return request<ConnectionTestResult>({
|
||||
url: '/api/admin/settings/backups/storage/test',
|
||||
method: 'POST',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) {
|
||||
export function updateBackupSchedule(data: UpdateBackupSchedulePayload) {
|
||||
return request<BackupSettingsView>({
|
||||
url: '/api/admin/settings/backups/schedule/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getBackupRecords(params: BackupRecordsParams, options: RequestOptions = {}) {
|
||||
export function getBackupRecords(data: BackupRecordsParams, options: RequestOptions = {}) {
|
||||
return request<BackupRecordPage>({
|
||||
url: '/api/admin/settings/backups/records',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function createBackup(options: RequestOptions = {}) {
|
||||
export function createBackup() {
|
||||
return request<BackupRecord>({
|
||||
url: '/api/admin/settings/backups/create',
|
||||
method: 'POST',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) {
|
||||
export function getBackupDownloadUrl(data: { backupId: string }) {
|
||||
return request<DownloadUrlResult>({
|
||||
url: '/api/admin/settings/backups/download-url',
|
||||
method: 'POST',
|
||||
data: { backupId },
|
||||
...options,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteBackup(backupId: string, options: RequestOptions = {}) {
|
||||
export function deleteBackup(data: { backupId: string }) {
|
||||
return request<BackupRecord>({
|
||||
url: '/api/admin/settings/backups/delete',
|
||||
method: 'POST',
|
||||
data: { backupId },
|
||||
...options,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,21 +44,20 @@ interface ProxyAccountPage {
|
||||
page: ProxyPage['page']
|
||||
}
|
||||
|
||||
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
export function getProxyAccounts(data: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
return request<ProxyAccountPage>({
|
||||
url: '/api/admin/proxies/accounts',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeProxyAccount(data: { proxyId: string, accountId: string }, options: RequestOptions = {}) {
|
||||
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
|
||||
return request<{ configRevision: number }>({
|
||||
url: '/api/admin/proxies/accounts/remove',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,58 +66,53 @@ interface ProxyMutation {
|
||||
configRevision: number
|
||||
}
|
||||
|
||||
export function getProxies(params: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
export function getProxies(data: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) {
|
||||
return request<ProxyPage>({
|
||||
url: '/api/admin/proxies',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function createProxy(data: { name: string, proxyUrl: string }, options: RequestOptions = {}) {
|
||||
export function createProxy(data: { name: string, proxyUrl: string }) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }, options: RequestOptions = {}) {
|
||||
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
|
||||
export function deleteProxy(data: { id: string, revision: number }) {
|
||||
return request<{ configRevision: number }>({
|
||||
url: '/api/admin/proxies/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function testProxy(data: { id: string, revision: number }, options: RequestOptions = {}) {
|
||||
export function testProxy(data: { id: string, revision: number }) {
|
||||
return request<OutboundProxyRecord>({
|
||||
url: '/api/admin/proxies/test',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function probeProxy(data: { proxyUrl: string }, options: RequestOptions = {}) {
|
||||
export function probeProxy(data: { proxyUrl: string }) {
|
||||
return request<OutboundProxyTest>({
|
||||
url: '/api/admin/proxies/probe',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,44 +60,39 @@ export function getSettings(options: RequestOptions = {}) {
|
||||
|
||||
type UpdateSettingsParam = Omit<RuntimeSettings, 'updatedAt'>
|
||||
|
||||
export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) {
|
||||
export function updateSettings(data: UpdateSettingsParam) {
|
||||
return request<RuntimeSettings>({
|
||||
url: '/api/admin/settings/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getAdminApiKeyStatus(options: RequestOptions = {}) {
|
||||
export function getAdminApiKeyStatus() {
|
||||
return request<AdminApiKeyStatus>({
|
||||
url: '/api/admin/settings/admin-api-key',
|
||||
method: 'GET',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function regenerateAdminApiKey(options: RequestOptions = {}) {
|
||||
export function regenerateAdminApiKey() {
|
||||
return request<RegeneratedAdminApiKey>({
|
||||
url: '/api/admin/settings/admin-api-key/regenerate',
|
||||
method: 'POST',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAdminApiKey(options: RequestOptions = {}) {
|
||||
export function deleteAdminApiKey() {
|
||||
return request<DeletedAdminApiKey>({
|
||||
url: '/api/admin/settings/admin-api-key/delete',
|
||||
method: 'POST',
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function getCodexDesktopWindowsDownloads(refresh = false, options: RequestOptions = {}) {
|
||||
export function getCodexDesktopWindowsDownloads(data: { refresh?: boolean } = {}) {
|
||||
return request<CodexDesktopWindowsDownloads>({
|
||||
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
|
||||
method: 'GET',
|
||||
params: refresh ? { refresh: true } : undefined,
|
||||
...options,
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,21 +59,19 @@ interface SystemUpdateTarget {
|
||||
targetVersion?: string
|
||||
}
|
||||
|
||||
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery, options: RequestOptions = {}) {
|
||||
export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) {
|
||||
return request<SystemUpdateDetail>({
|
||||
url: '/api/admin/system/update/detail',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) {
|
||||
export function performSystemUpdate(data: SystemUpdateTarget) {
|
||||
return request<SystemUpdateAccepted>({
|
||||
url: '/api/admin/system/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountProfileStatisticsResponse } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
|
||||
import { formatCompactNumber, formatInteger } from '@/utils/number'
|
||||
|
||||
const props = defineProps<{
|
||||
profile: AccountProfileStatisticsResponse | null
|
||||
loading: boolean
|
||||
}>()
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
label: '累计 Token 数',
|
||||
value: formatMetric(props.profile?.summary.totalTextTokens),
|
||||
title: formatExact(props.profile?.summary.totalTextTokens),
|
||||
},
|
||||
{
|
||||
label: '峰值 Token 数',
|
||||
value: formatMetric(props.profile?.summary.peakTokens),
|
||||
title: formatExact(props.profile?.summary.peakTokens),
|
||||
},
|
||||
{
|
||||
label: '最长聊天时长',
|
||||
value: formatDuration(props.profile?.summary.longestTaskDurationMs),
|
||||
},
|
||||
{
|
||||
label: '当前连续天数',
|
||||
value: formatDays(props.profile?.summary.currentStreakDays),
|
||||
},
|
||||
{
|
||||
label: '最长连续天数',
|
||||
value: formatDays(props.profile?.summary.longestStreakDays),
|
||||
},
|
||||
])
|
||||
|
||||
function formatMetric(value: number | null | undefined) {
|
||||
return value == null ? '—' : formatCompactNumber(value)
|
||||
}
|
||||
|
||||
function formatExact(value: number | null | undefined) {
|
||||
return value == null ? undefined : `${formatInteger(value)} Tokens`
|
||||
}
|
||||
|
||||
function formatDays(value: number | null | undefined) {
|
||||
return value == null ? '—' : `${formatInteger(value)} 天`
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null | undefined) {
|
||||
if (value == null)
|
||||
return '—'
|
||||
const totalMinutes = Math.floor(value / 60_000)
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
if (days > 0)
|
||||
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
|
||||
if (hours > 0)
|
||||
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
|
||||
if (totalMinutes > 0)
|
||||
return `${totalMinutes} 分`
|
||||
return `${Math.max(0, Math.floor(value / 1000))} 秒`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="min-w-0 overflow-x-auto py-1" aria-label="累计活动摘要" :aria-busy="loading">
|
||||
<dl class="grid min-w-130 grid-cols-5">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
|
||||
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
|
||||
<BaseSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
|
||||
<template v-else>
|
||||
{{ metric.value }}
|
||||
</template>
|
||||
</dd>
|
||||
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
|
||||
{{ metric.label }}
|
||||
</dt>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</template>
|
||||
@@ -5,93 +5,39 @@ import { UserRound } from '@lucide/vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { accountProfileAvatarUrl } from '@/api'
|
||||
import { formatCompactNumber, formatInteger } from '@/utils/number'
|
||||
import { stablePresetVisualToneClass } from '../../utils/visualTone'
|
||||
import AccountPlanBadge from '../AccountPlanBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
profile: AccountProfileStatisticsResponse
|
||||
profile: AccountProfileStatisticsResponse | null
|
||||
}>()
|
||||
|
||||
const imageFailed = shallowRef(false)
|
||||
const displayName = computed(
|
||||
() => props.profile.displayName || props.profile.username || props.account.email || props.account.name || 'Codex 用户',
|
||||
() => props.profile?.displayName || props.profile?.username || props.account.email || props.account.name || 'Codex 用户',
|
||||
)
|
||||
const username = computed(() => (props.profile.username ? `@${props.profile.username.replace(/^@/, '')}` : null))
|
||||
const username = computed(() => (props.profile?.username ? `@${props.profile?.username.replace(/^@/, '')}` : null))
|
||||
const accountIdentity = computed(() => props.account.email?.trim() || props.account.accountId?.trim())
|
||||
const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperCase() || null)
|
||||
const avatarToneClass = computed(() =>
|
||||
stablePresetVisualToneClass(props.account.id || accountIdentity.value || displayName.value),
|
||||
)
|
||||
const avatarUrl = computed(() => {
|
||||
const sourceUrl = props.profile.imageUrl?.trim()
|
||||
const sourceUrl = props.profile?.imageUrl?.trim()
|
||||
return sourceUrl ? accountProfileAvatarUrl(props.account.id, sourceUrl) : null
|
||||
})
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
label: '累计 Token 数',
|
||||
value: formatMetric(props.profile.summary.totalTextTokens),
|
||||
title: formatExact(props.profile.summary.totalTextTokens),
|
||||
},
|
||||
{
|
||||
label: '峰值 Token 数',
|
||||
value: formatMetric(props.profile.summary.peakTokens),
|
||||
title: formatExact(props.profile.summary.peakTokens),
|
||||
},
|
||||
{
|
||||
label: '最长聊天时长',
|
||||
value: formatDuration(props.profile.summary.longestTaskDurationMs),
|
||||
},
|
||||
{
|
||||
label: '当前连续天数',
|
||||
value: formatDays(props.profile.summary.currentStreakDays),
|
||||
},
|
||||
{
|
||||
label: '最长连续天数',
|
||||
value: formatDays(props.profile.summary.longestStreakDays),
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
avatarUrl,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
function formatMetric(value: number | null) {
|
||||
return value === null ? '—' : formatCompactNumber(value)
|
||||
}
|
||||
|
||||
function formatExact(value: number | null) {
|
||||
return value === null ? undefined : `${formatInteger(value)} Tokens`
|
||||
}
|
||||
|
||||
function formatDays(value: number | null) {
|
||||
return value === null ? '—' : `${formatInteger(value)} 天`
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null) {
|
||||
if (value === null)
|
||||
return '—'
|
||||
const totalMinutes = Math.floor(value / 60_000)
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
if (days > 0)
|
||||
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
|
||||
if (hours > 0)
|
||||
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
|
||||
if (totalMinutes > 0)
|
||||
return `${totalMinutes} 分`
|
||||
return `${Math.max(0, Math.floor(value / 1000))} 秒`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
|
||||
class="flex min-w-0 items-center"
|
||||
aria-labelledby="profile-display-name"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3.5">
|
||||
@@ -127,18 +73,5 @@ function formatDuration(value: number | null) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 overflow-x-auto py-1">
|
||||
<dl class="grid min-w-130 grid-cols-5">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
|
||||
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
|
||||
{{ metric.value }}
|
||||
</dd>
|
||||
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
|
||||
{{ metric.label }}
|
||||
</dt>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,34 +4,9 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
|
||||
|
||||
<template>
|
||||
<div class="grid gap-8 sm:gap-10" role="status" aria-live="polite" aria-busy="true">
|
||||
<span class="sr-only">正在加载官方个人资料</span>
|
||||
<span class="sr-only">正在加载累计活动</span>
|
||||
|
||||
<section
|
||||
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3.5">
|
||||
<BaseSkeleton shape="circle" class="size-14 shrink-0" />
|
||||
<div class="grid min-w-0 flex-1 gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseSkeleton shape="text" class="h-4 w-20" />
|
||||
<BaseSkeleton class="h-5 w-9 rounded-full opacity-70" />
|
||||
</div>
|
||||
<BaseSkeleton shape="text" class="w-24 opacity-60" />
|
||||
<BaseSkeleton shape="text" class="w-full max-w-64 opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 overflow-x-auto py-1">
|
||||
<div class="grid min-w-130 grid-cols-5">
|
||||
<div v-for="metric in 5" :key="metric" class="grid justify-items-center gap-2 px-2 sm:px-3">
|
||||
<BaseSkeleton shape="text" class="h-4 w-14" />
|
||||
<BaseSkeleton shape="text" class="w-20 opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4">
|
||||
<section class="grid min-w-0 gap-4 overflow-hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<BaseSkeleton shape="text" class="h-4 w-20" />
|
||||
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountSubscription } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
subscription: AccountSubscription
|
||||
}>()
|
||||
|
||||
const renewal = computed(() => {
|
||||
if (props.subscription?.willRenew === true)
|
||||
return '自动续费'
|
||||
if (props.subscription?.willRenew === false)
|
||||
return '不自动续费'
|
||||
return '未知'
|
||||
})
|
||||
const billingPeriod = computed(() => {
|
||||
const period = props.subscription?.billingPeriod
|
||||
if (!period)
|
||||
return null
|
||||
const labels: Record<string, string> = { month: '按月', monthly: '按月', year: '按年', yearly: '按年', annual: '按年' }
|
||||
return labels[period.toLowerCase()] ?? period
|
||||
})
|
||||
const billing = computed(() => [billingPeriod.value, props.subscription?.billingCurrency].filter(Boolean).join(' · ') || '未知')
|
||||
const dates = computed(() => [
|
||||
{ label: '订阅开始', value: props.subscription?.startsAt },
|
||||
{ label: '订阅结束', value: props.subscription?.expiresAt },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="min-w-0 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5" aria-label="订阅">
|
||||
<dl class="m-0 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-[1.3fr_1.3fr_1fr_1fr]">
|
||||
<div v-for="date in dates" :key="date.label" class="min-w-0">
|
||||
<dt class="text-cp-xs text-cp-text-secondary">
|
||||
{{ date.label }}
|
||||
</dt>
|
||||
<dd class="m-0 mt-1.5 font-mono text-cp-sm leading-relaxed tabular-nums text-cp-text">
|
||||
<time v-if="date.value" :datetime="date.value">{{ formatDateTime(date.value, '未知', 'Asia/Shanghai') }}</time>
|
||||
<span v-else>未知</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-cp-xs text-cp-text-secondary">
|
||||
续费方式
|
||||
</dt>
|
||||
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed" :class="subscription.willRenew === true ? 'text-cp-success' : 'text-cp-text'">
|
||||
{{ renewal }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-cp-xs text-cp-text-secondary">
|
||||
计费
|
||||
</dt>
|
||||
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed break-words text-cp-text">
|
||||
{{ billing }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</template>
|
||||
@@ -34,7 +34,7 @@ const monthLabels = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-labelledby="profile-token-activity-title">
|
||||
<section class="min-w-0" aria-labelledby="profile-token-activity-title">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 id="profile-token-activity-title" class="m-0 text-cp-lg font-heavy text-cp-text">
|
||||
@@ -47,7 +47,7 @@ const monthLabels = computed(() =>
|
||||
<BaseEmpty
|
||||
v-if="dailyUsage === null"
|
||||
title="暂无 Token 活动"
|
||||
description="官方个人资料没有返回每日 Token 活动。"
|
||||
description="本次未获取到每日 Token 活动。"
|
||||
size="sm"
|
||||
surface="none"
|
||||
/>
|
||||
@@ -55,10 +55,11 @@ const monthLabels = computed(() =>
|
||||
<div v-else class="overflow-x-auto pb-1" role="img" :aria-label="`Token 活动热力图,${rangeLabel}`">
|
||||
<div class="min-w-190">
|
||||
<div class="mb-1.5 grid gap-1" style="grid-template-columns: repeat(52, minmax(0, 1fr))">
|
||||
<!-- 首尾月份可能只占一周;标签固定在同一行,避免跨列占位触发自动换行。 -->
|
||||
<span
|
||||
v-for="(month, monthIndex) in monthLabels"
|
||||
:key="`${month.weekIndex}-${month.label}`"
|
||||
class="h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
|
||||
class="row-start-1 h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
|
||||
:class="monthIndex === monthLabels.length - 1 ? 'text-right' : undefined"
|
||||
:style="{
|
||||
gridColumn: `${month.weekIndex + (monthIndex === monthLabels.length - 1 ? 0 : 1)} / span 2`,
|
||||
|
||||
@@ -6,72 +6,69 @@ import { toRef } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { useAccountProfileStatistics } from '../../composables/useAccountProfileStatistics'
|
||||
import { useAccountPersonalInfo } from '../../composables/useAccountPersonalInfo'
|
||||
import AccountProfileActivityInsights from './ActivityInsights.vue'
|
||||
import AccountProfileMetrics from './Metrics.vue'
|
||||
import AccountProfileHero from './ProfileHero.vue'
|
||||
import AccountProfileSkeleton from './Skeleton.vue'
|
||||
import AccountSubscription from './Subscription.vue'
|
||||
import AccountProfileTokenActivity from './TokenActivity.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
}>()
|
||||
const props = defineProps<{ account: AccountRow }>()
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const accountId = toRef(() => props.account.id)
|
||||
const { profile, loading, error, load } = useAccountProfileStatistics(accountId, open)
|
||||
const { profile, subscription, loading, error, load } = useAccountPersonalInfo(accountId, open)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="个人资料"
|
||||
description="来自 Codex 官方个人资料的累计活动与使用洞察"
|
||||
size="xl"
|
||||
>
|
||||
<AccountProfileSkeleton v-if="loading && !profile" />
|
||||
<BaseModal v-model="open" title="个人信息" size="xl">
|
||||
<div class="flex min-h-0 min-w-0 flex-col gap-6 pb-2">
|
||||
<div class="grid min-w-0 grid-cols-1 gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center">
|
||||
<AccountProfileHero :account="account" :profile="profile" />
|
||||
<AccountProfileMetrics :profile="profile" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<BaseEmpty
|
||||
v-else-if="error && !profile"
|
||||
title="个人资料加载失败"
|
||||
:description="error"
|
||||
:icon="TriangleAlert"
|
||||
surface="none"
|
||||
>
|
||||
<template #action>
|
||||
<BaseButton size="sm" :loading="loading" @click="load(true)">
|
||||
重试
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseEmpty>
|
||||
|
||||
<div v-else-if="profile" class="flex min-h-0 flex-col gap-8 pb-2 sm:gap-10">
|
||||
<AccountProfileHero :account="account" :profile="profile" />
|
||||
<section v-if="subscription" class="grid min-w-0 grid-cols-1 gap-4" aria-labelledby="profile-subscription-title">
|
||||
<h3 id="profile-subscription-title" class="m-0 text-cp font-heavy text-cp-text">
|
||||
订阅信息
|
||||
</h3>
|
||||
<AccountSubscription :subscription="subscription" />
|
||||
</section>
|
||||
|
||||
<AccountProfileSkeleton v-if="loading && !profile" />
|
||||
<BaseEmpty
|
||||
v-if="profile.hasStatsError"
|
||||
title="个人统计暂不可用"
|
||||
description="官方个人资料已加载,但本次没有返回统计数据。"
|
||||
v-else-if="error && !profile"
|
||||
title="个人资料加载失败"
|
||||
:description="error"
|
||||
:icon="TriangleAlert"
|
||||
surface="none"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
|
||||
<AccountProfileActivityInsights :insights="profile.activityInsights" />
|
||||
<template v-else-if="profile">
|
||||
<BaseEmpty
|
||||
v-if="profile.hasStatsError"
|
||||
title="个人统计暂不可用"
|
||||
description="本次未获取到统计数据,可以点击刷新信息重试。"
|
||||
:icon="TriangleAlert"
|
||||
surface="none"
|
||||
/>
|
||||
<template v-else>
|
||||
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
|
||||
<AccountProfileActivityInsights :insights="profile.activityInsights" />
|
||||
</template>
|
||||
<p v-if="error" role="status" class="m-0 text-cp-sm text-cp-warning">
|
||||
本次信息刷新未完成,保留已获取的结果。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" @click="open = false">
|
||||
关闭
|
||||
</BaseButton>
|
||||
<BaseButton :loading="loading" @click="load(true)">
|
||||
<template #loading>
|
||||
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<BaseButton :aria-busy="loading" :aria-disabled="loading" @click="load">
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" />
|
||||
<RefreshCw class="size-4" :class="loading ? 'animate-spin motion-reduce:animate-none' : undefined" />
|
||||
</template>
|
||||
刷新资料
|
||||
刷新信息
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -60,12 +60,18 @@ const triggerLabel = computed(() => {
|
||||
return hasSnapshot.value ? `查看主动重置卡,最近查询 ${availableCount.value} 张可用` : '查看主动重置卡'
|
||||
})
|
||||
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
|
||||
const confirmCreditTitle = computed(() => creditTitle(consumptionCredit.value))
|
||||
const confirmCreditTitle = computed(() => consumptionCredit.value
|
||||
? creditTitle(consumptionCredit.value)
|
||||
: '使用一次重置(由上游选择)')
|
||||
const creditItems = computed(() => availableCredits.value.map(credit => ({
|
||||
id: credit.id,
|
||||
title: creditTitle(credit),
|
||||
expiry: expiryLabel(credit.expiresAt),
|
||||
})))
|
||||
const showCountOnlyAction = computed(() => !loadError.value
|
||||
&& hasSnapshot.value
|
||||
&& availableCount.value > 0
|
||||
&& creditItems.value.length === 0)
|
||||
const countLabel = computed(() => {
|
||||
if (!hasSnapshot.value)
|
||||
return loading.value ? '查询中' : '待查询'
|
||||
@@ -159,7 +165,10 @@ function handleRequestConsume(creditId: string) {
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
|
||||
<div class="flex items-center gap-3 pt-4 pr-3 pb-1 pl-4">
|
||||
<div
|
||||
class="flex items-center gap-3 pt-4 pr-3 pl-4"
|
||||
:class="showCountOnlyAction ? 'pb-4' : 'pb-1'"
|
||||
>
|
||||
<h3 class="m-0 min-w-0 flex-1 text-cp-sm font-heavy text-cp-text">
|
||||
使用限额重置
|
||||
</h3>
|
||||
@@ -227,7 +236,7 @@ function handleRequestConsume(creditId: string) {
|
||||
</ul>
|
||||
|
||||
<BaseEmpty
|
||||
v-else
|
||||
v-else-if="!showCountOnlyAction"
|
||||
:icon="TicketCheck"
|
||||
size="sm"
|
||||
surface="none"
|
||||
@@ -238,12 +247,23 @@ function handleRequestConsume(creditId: string) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template v-if="showConfirm" #footer>
|
||||
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
|
||||
返回
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
|
||||
{{ ambiguous ? '再次确认' : '确认重置' }}
|
||||
<template v-if="showConfirm || showCountOnlyAction" #footer>
|
||||
<template v-if="showConfirm">
|
||||
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
|
||||
返回
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
|
||||
{{ ambiguous ? '再次确认' : '确认重置' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<BaseButton
|
||||
v-else
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="loading || consuming || ambiguous || !canRequestConsume"
|
||||
@click="requestConsume"
|
||||
>
|
||||
使用一次重置(由上游选择)
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -48,7 +48,7 @@ const profileOpen = shallowRef(false)
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<BaseIconButton
|
||||
v-if="account.provider === 'openai'"
|
||||
label="查看个人资料"
|
||||
label="查看个人信息"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:pressed="profileOpen"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { AccountPersonalInfoResponse } from '@/api'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { getAccountPersonalInfo } from '@/api'
|
||||
import { useRequestState } from '@/composables/useRequestState'
|
||||
|
||||
export function useAccountPersonalInfo(accountId: Ref<string>, open: Ref<boolean>) {
|
||||
const info = shallowRef<AccountPersonalInfoResponse | null>(null)
|
||||
const request = useRequestState()
|
||||
const { loading } = request
|
||||
const profile = computed(() => info.value?.profile ?? null)
|
||||
const subscription = computed(() => info.value?.subscription ?? null)
|
||||
const error = computed(() => request.error.value || info.value?.profileError || '')
|
||||
|
||||
async function load() {
|
||||
const targetAccountId = accountId.value
|
||||
if (!open.value || !targetAccountId || loading.value)
|
||||
return
|
||||
|
||||
const version = request.start()
|
||||
try {
|
||||
const result = await getAccountPersonalInfo({ accountId: targetAccountId }, { signal: request.signal })
|
||||
if (!request.isCurrent(version))
|
||||
return
|
||||
// 单项失败仍更新其他信息;仅在当前打开周期保留上次可用的统计。
|
||||
info.value = { ...result, profile: result.profile ?? info.value?.profile ?? null }
|
||||
}
|
||||
catch (cause) {
|
||||
request.fail(version, cause)
|
||||
}
|
||||
finally {
|
||||
request.finish(version)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开或切换账号只请求一次;关闭取消等待,刷新按钮复用同一入口。
|
||||
watch([open, accountId], ([isOpen]) => {
|
||||
request.invalidate()
|
||||
info.value = null
|
||||
request.error.value = ''
|
||||
if (isOpen)
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
profile,
|
||||
subscription,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { getAccountProfileStatistics } from '@/api'
|
||||
import { useRequestState } from '@/composables/useRequestState'
|
||||
|
||||
export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<boolean>) {
|
||||
const profile = shallowRef<Awaited<ReturnType<typeof getAccountProfileStatistics>> | null>(null)
|
||||
const loadedAccountId = shallowRef('')
|
||||
const request = useRequestState()
|
||||
const { loading, error } = request
|
||||
|
||||
async function load(force = false) {
|
||||
const targetAccountId = accountId.value
|
||||
if (!targetAccountId)
|
||||
return
|
||||
if (!force && profile.value && loadedAccountId.value === targetAccountId)
|
||||
return
|
||||
|
||||
const version = request.start()
|
||||
try {
|
||||
const result = await getAccountProfileStatistics({ accountId: targetAccountId }, { signal: request.signal })
|
||||
if (!request.isCurrent(version))
|
||||
return
|
||||
profile.value = result
|
||||
loadedAccountId.value = targetAccountId
|
||||
}
|
||||
catch (cause) {
|
||||
request.fail(version, cause)
|
||||
}
|
||||
finally {
|
||||
request.finish(version)
|
||||
}
|
||||
}
|
||||
|
||||
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
|
||||
const previousAccountId = previousValues?.[1]
|
||||
if (nextAccountId !== previousAccountId) {
|
||||
request.invalidate()
|
||||
profile.value = null
|
||||
loadedAccountId.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
if (isOpen)
|
||||
void load()
|
||||
else
|
||||
request.invalidate()
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
profile,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { errorMessage } from '@/utils/async'
|
||||
|
||||
interface PendingResetCreditOperation {
|
||||
accountId: string
|
||||
creditId: string
|
||||
credit: AccountResetCredit
|
||||
creditId?: string
|
||||
credit?: AccountResetCredit
|
||||
redeemRequestId: string
|
||||
hasTransportFailure: boolean
|
||||
}
|
||||
@@ -104,17 +104,21 @@ export function useAccountResetCredits(options: {
|
||||
availableCredits.value.find(credit => credit.id === selectedCreditId.value),
|
||||
)
|
||||
const consumptionCredit = computed(() =>
|
||||
pendingOperation.value?.credit ?? selectedCredit.value,
|
||||
pendingOperation.value ? pendingOperation.value.credit : selectedCredit.value,
|
||||
)
|
||||
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
|
||||
const canStartConsume = computed(() =>
|
||||
hasSnapshot.value && !loadError.value && availableCount.value > 0
|
||||
&& (selectedCredit.value !== undefined || availableCredits.value.length === 0),
|
||||
)
|
||||
const canRequestConsume = computed(() =>
|
||||
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
|
||||
ambiguous.value || canStartConsume.value,
|
||||
)
|
||||
|
||||
function reconcileSelectedCredit() {
|
||||
const operation = pendingOperation.value
|
||||
if (operation) {
|
||||
selectedCreditId.value = operation.creditId
|
||||
selectedCreditId.value = operation.creditId ?? ''
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,10 +167,10 @@ export function useAccountResetCredits(options: {
|
||||
|
||||
const credit = selectedCredit.value
|
||||
// 确认发送时才建立操作;仅打开或取消确认弹窗不占用账号的消费状态。
|
||||
const operation = target.pendingOperation ?? (credit && availableCount.value > 0
|
||||
const operation = target.pendingOperation ?? (canStartConsume.value
|
||||
? {
|
||||
accountId: target.accountId,
|
||||
creditId: credit.id,
|
||||
creditId: credit?.id,
|
||||
credit,
|
||||
redeemRequestId: generateRedeemRequestId(),
|
||||
hasTransportFailure: false,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
|
||||
},
|
||||
{
|
||||
key: 'accessTokenExpiresAtDisplay',
|
||||
label: '过期时间',
|
||||
label: '令牌过期',
|
||||
kind: 'datetime',
|
||||
sortable: 'expiresAt',
|
||||
format: value => optionalAccountCell(value),
|
||||
|
||||
@@ -61,7 +61,7 @@ export function buildProfileActivityGrid(
|
||||
})
|
||||
weeks.push({
|
||||
key: dateKey(weekStart),
|
||||
monthLabel: monthLabel(weekDates, weekIndex),
|
||||
monthLabel: monthLabel(weekDates),
|
||||
cells,
|
||||
})
|
||||
}
|
||||
@@ -117,12 +117,10 @@ function activityLevel(value: number, maxValue: number): ProfileActivityLevel {
|
||||
return Math.min(4, Math.max(1, Math.ceil(value / maxValue * 4))) as ProfileActivityLevel
|
||||
}
|
||||
|
||||
function monthLabel(weekDates: Date[], weekIndex: number) {
|
||||
function monthLabel(weekDates: Date[]) {
|
||||
const firstDayOfMonth = weekDates.find(date => date.getUTCDate() === 1)
|
||||
if (weekIndex !== 0 && !firstDayOfMonth)
|
||||
return null
|
||||
const date = firstDayOfMonth ?? weekDates[0]
|
||||
return `${(date?.getUTCMonth() ?? 0) + 1}月`
|
||||
// 窗口开头不补不完整月份的标签,避免与紧接着的月份挤在一起。
|
||||
return firstDayOfMonth ? `${firstDayOfMonth.getUTCMonth() + 1}月` : null
|
||||
}
|
||||
|
||||
function localCalendarDate(value: Date) {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export function useClientDownloads() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
downloads.value = await getCodexDesktopWindowsDownloads(refresh)
|
||||
downloads.value = await getCodexDesktopWindowsDownloads({ refresh })
|
||||
}
|
||||
catch (cause: unknown) {
|
||||
error.value = errorMessage(cause)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user