mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 08:06:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
debba0e13c | ||
|
|
a0836ec3f2 | ||
|
|
dc0cdf2930 | ||
|
|
d76bb57b8c | ||
|
|
6434edd554 | ||
|
|
3a90d8d0b6 | ||
|
|
f3236d0410 | ||
|
|
ccb7357914 | ||
|
|
c0ad2ae656 | ||
|
|
798d4f518e | ||
|
|
6819682176 | ||
|
|
b2d4a7e833 | ||
|
|
01006380ee | ||
|
|
c79e032575 | ||
|
|
96cf46cca8 | ||
|
|
ec605b3d1c | ||
|
|
890fb3299b | ||
|
|
a18f0a2e11 | ||
|
|
a1ba6c0b61 | ||
|
|
5c11b62f93 | ||
|
|
748c77da6a | ||
|
|
d0180b74b3 | ||
|
|
4669a29b46 | ||
|
|
e63c5fa28d | ||
|
|
1acd4a5f59 | ||
|
|
6659d0c1f9 |
@@ -16,7 +16,7 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
@@ -25,6 +25,12 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
|
||||
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库的稳定版、实验版发版,维护中文发布说明。用于“发版”“发布实验版/exp 版本”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
---
|
||||
|
||||
# 发版
|
||||
@@ -12,6 +12,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
|
||||
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
|
||||
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
|
||||
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
|
||||
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
|
||||
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
|
||||
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
|
||||
|
||||
@@ -19,7 +20,9 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
|
||||
稳定版从 `main` 发布。实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
|
||||
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
|
||||
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
|
||||
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
|
||||
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
|
||||
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
|
||||
|
||||
@@ -38,6 +41,20 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
|
||||
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
|
||||
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
|
||||
|
||||
## 发行通道核对
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
|
||||
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
|
||||
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
|
||||
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
|
||||
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
|
||||
|
||||
## 准备版本与说明
|
||||
|
||||
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
|
||||
@@ -53,7 +70,7 @@ description: 准备或执行当前仓库的稳定版、实验版发版,维护
|
||||
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
|
||||
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
|
||||
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
|
||||
涉及数据库迁移时,对照同一大版本的上一正式 Release 检查已发布迁移是否保持不变,并验证升级路径;
|
||||
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
|
||||
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
|
||||
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
|
||||
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
|
||||
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
|
||||
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
|
||||
编号在仓库内唯一;多个实验分支共享 Git tag 命名空间,不能分别占用相同版本号。
|
||||
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`。
|
||||
同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。
|
||||
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。
|
||||
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
|
||||
不为统一名称移动 tag 或替换原有产物。
|
||||
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
|
||||
@@ -24,9 +24,10 @@
|
||||
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
|
||||
|
||||
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
|
||||
本项目实验构建禁用应用内一键更新,用户按部署说明手动升级。
|
||||
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
|
||||
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
|
||||
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
|
||||
默认 `stable` 更新渠道忽略实验版;主动配置为 `preview` 的用户可能看到预发行。
|
||||
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
|
||||
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
|
||||
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
|
||||
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
|
||||
@@ -45,7 +46,7 @@
|
||||
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
|
||||
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
|
||||
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
|
||||
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及手动更新方式。
|
||||
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。
|
||||
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
|
||||
|
||||
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
|
||||
@@ -56,7 +57,7 @@
|
||||
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
|
||||
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
|
||||
|
||||
发布前记录 GitHub Latest 的版本与 Docker `latest` 摘要;发布后核对二者仍对应稳定版。
|
||||
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
|
||||
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
|
||||
previous_tag: ${{ steps.version.outputs.previous_tag }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
build_type: ${{ steps.version.outputs.build_type }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
require "yaml"
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
|
||||
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
|
||||
|
||||
def fail(message)
|
||||
warn(message)
|
||||
@@ -161,6 +162,7 @@ jobs:
|
||||
puts "docker_platforms=#{docker_platforms.join(",")}"
|
||||
puts "previous_tag=#{previous_tag}"
|
||||
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
|
||||
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
|
||||
RUBY
|
||||
|
||||
quality:
|
||||
@@ -221,7 +223,7 @@ jobs:
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
CPR_BUILD_TYPE: release
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -274,7 +276,7 @@ jobs:
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
CPR_BUILD_TYPE: release
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -40,16 +40,7 @@ jobs:
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
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[@]}"
|
||||
run: cargo audit
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
|
||||
|
||||
## 快速预览
|
||||
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计。
|
||||
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
|
||||
|
||||
| 登录信息 | 值 |
|
||||
| 登录信息 | 默认值 |
|
||||
| --- | --- |
|
||||
| 地址 | <https://codex-proxy-rs.ainz.cc> |
|
||||
| 登录身份 | 管理员 |
|
||||
@@ -31,17 +31,17 @@
|
||||
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
|
||||
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息。
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
|
||||
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)。
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
|
||||
@@ -66,32 +66,33 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级。
|
||||
忽略传入的管理员密码,也不执行版本升级
|
||||
|
||||
### 手动安装
|
||||
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)
|
||||
|
||||
### 登录管理端
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
|
||||
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
|
||||
### 添加账号与客户端密钥
|
||||
|
||||
1. 在「账号」中添加账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
1. 在「账号」中添加账号,完成授权或导入
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息。
|
||||
**其他 Responses API 客户端**:填写以下信息:
|
||||
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
@@ -122,11 +123,10 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
|
||||
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
|
||||
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)。
|
||||
明确的 Bug 或功能需求请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),无需先发讨论。
|
||||
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
|
||||
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
|
||||
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源
|
||||
|
||||
Generated
+9
-8
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,14 +165,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3157,9 +3158,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3195,9 +3196,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
@@ -17,8 +17,8 @@ pub struct GatewayConfig {
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
#[serde(default)]
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
@@ -46,9 +46,6 @@ impl LoadableConfig for GatewayConfig {
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
self.xai
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("xai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -64,7 +61,6 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
@@ -74,7 +70,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
let provider_ports = store.provider_ports();
|
||||
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
|
||||
host.report_startup_ready("OpenAI Provider");
|
||||
let mut xai = provider_xai::initialize(xai, provider_ports).await?;
|
||||
let mut xai = provider_xai::initialize(provider_ports).await?;
|
||||
host.report_startup_ready("xAI Provider");
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
|
||||
|
||||
@@ -196,10 +196,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
"crates/providers/openai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
(
|
||||
"crates/providers/xai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
("crates/providers/xai", &["credential", "transport"]),
|
||||
];
|
||||
|
||||
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
|
||||
|
||||
@@ -291,9 +291,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
"/client",
|
||||
"/api",
|
||||
"/openai",
|
||||
"/openai/wire_profile",
|
||||
"/xai",
|
||||
"/xai/wire_profile",
|
||||
] {
|
||||
let mut extended = document.clone();
|
||||
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
|
||||
@@ -364,7 +361,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
|
||||
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
@@ -380,6 +377,26 @@ fn config_loader_should_reject_missing_explicit_fields() {
|
||||
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_allow_missing_provider_configuration() {
|
||||
let mut document = valid_config_document();
|
||||
document.as_object_mut().unwrap().remove("openai");
|
||||
document.as_object_mut().unwrap().remove("xai");
|
||||
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_provider_identity_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
|
||||
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("removed identities ignored");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("legacy-invalid"));
|
||||
assert!(!debug.contains("legacy-client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unsupported_schema_version() {
|
||||
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
|
||||
@@ -444,15 +461,6 @@ fn config_loader_should_reject_missing_client_session_ttl() {
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replacen(
|
||||
" residency: us",
|
||||
" residency: us\n codex_version: 'latest'",
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_disabled_all_log_outputs() {
|
||||
assert_rejected(
|
||||
@@ -468,31 +476,10 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replacen(
|
||||
" residency: us",
|
||||
" residency: us\n desktop_build: 'build'",
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_request_location_setting() {
|
||||
let original = valid_config();
|
||||
parse_config(&original).expect("location belongs to runtime settings");
|
||||
for value in [
|
||||
"null",
|
||||
"{}",
|
||||
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
|
||||
] {
|
||||
let legacy = original.replacen(
|
||||
" residency: us",
|
||||
&format!(" residency: us\n location: {value}"),
|
||||
1,
|
||||
);
|
||||
assert_ne!(legacy, original);
|
||||
parse_config(&legacy).expect("removed request location does not prevent startup");
|
||||
}
|
||||
fn config_loader_should_validate_openai_residency() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["residency"] = serde_json::json!("invalid");
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -346,7 +346,7 @@ pub async fn initialize(
|
||||
config.session_ttl_minutes,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier,
|
||||
client_key_verifier.clone(),
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
@@ -370,12 +370,14 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let system = Arc::new(DefaultSystemService::new(system));
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
client_key_verifier,
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
system.clone(),
|
||||
));
|
||||
let profile_provider = Arc::clone(&openai);
|
||||
let openai = Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
@@ -409,7 +411,7 @@ pub async fn initialize(
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
Arc::clone(&profile_provider),
|
||||
registry.clone(),
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
@@ -421,11 +423,10 @@ pub async fn initialize(
|
||||
settings: Arc::new(DefaultSettingsService::new(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
profile_provider,
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
system,
|
||||
openai,
|
||||
xai,
|
||||
import_tasks,
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct ClientKeyListQuery {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -110,7 +111,7 @@ pub struct ClientKeyPage {
|
||||
pub next_cursor: Option<ClientKeyCursor>,
|
||||
}
|
||||
|
||||
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
|
||||
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeySecret {
|
||||
pub record: ClientKeyRecord,
|
||||
@@ -146,6 +147,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -158,6 +160,7 @@ pub struct CreateClientKey {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -183,6 +186,7 @@ impl fmt::Debug for NewClientKey {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
|
||||
@@ -312,6 +312,7 @@ pub struct UsageCalculatedBillingFact {
|
||||
/// Provider 已确认的逐项费用与单价。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedBillingBreakdown {
|
||||
pub long_context_billing_applied: bool,
|
||||
pub image: Option<ImageBillingBreakdown>,
|
||||
pub custom_multiplier_bps: u32,
|
||||
pub input_amount: CurrencyCost,
|
||||
@@ -546,6 +547,7 @@ pub struct DashboardObservation {
|
||||
/// 使用记录表格的窄读模型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageListRecord {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
@@ -554,6 +556,8 @@ pub struct UsageListRecord {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -775,6 +779,7 @@ pub struct DiagnosticObservation {
|
||||
/// 统一运维错误记录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsError {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub source: String,
|
||||
pub event_id: String,
|
||||
pub request_id: Option<String>,
|
||||
|
||||
@@ -108,7 +108,7 @@ impl AccountQuotaForecast {
|
||||
let percent = window
|
||||
.used_percent
|
||||
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
|
||||
let usage = sample.map(|sample| &sample.usage);
|
||||
let usage = sample.map(|sample| &sample.cycle_usage);
|
||||
let usd = usage
|
||||
.filter(|usage| usage.known_cost_count > 0)
|
||||
.map(|usage| usage.usd)
|
||||
@@ -175,14 +175,21 @@ impl AccountQuotaForecast {
|
||||
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
|
||||
let capacity_factor = 100.0 / sample.sampled_percent;
|
||||
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
|
||||
let factor = self.target_seconds as f64 / seconds as f64;
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
@@ -197,8 +204,8 @@ fn estimate(value: f64, factor: f64) -> Option<f64> {
|
||||
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
|
||||
}
|
||||
|
||||
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
|
||||
estimate(value as f64, factor)
|
||||
fn estimate_tokens(value: f64, factor: f64) -> Option<u64> {
|
||||
estimate(value, factor)
|
||||
.filter(|value| value.round() < u64::MAX as f64)
|
||||
.map(|value| value.round() as u64)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct QuotaForecastSample {
|
||||
pub observation_count: usize,
|
||||
pub pending_request_count: u64,
|
||||
pub discontinuous: bool,
|
||||
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
|
||||
pub cycle_usage: QuotaForecastUsage,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
@@ -113,12 +115,13 @@ pub fn select_forecast_sample(
|
||||
current: QuotaForecastPoint,
|
||||
mut history: Vec<QuotaForecastPoint>,
|
||||
pending_request_count: u64,
|
||||
interrupted: bool,
|
||||
) -> QuotaForecastSample {
|
||||
history.retain(|point| {
|
||||
start_at <= point.observed_at
|
||||
&& point.observed_at < current.observed_at
|
||||
&& point.used_percent.is_finite()
|
||||
&& (0.0..100.0).contains(&point.used_percent)
|
||||
&& (0.0..=100.0).contains(&point.used_percent)
|
||||
});
|
||||
history.sort_by_key(|point| point.observed_at);
|
||||
let mut sample = QuotaForecastSample {
|
||||
@@ -131,10 +134,13 @@ pub fn select_forecast_sample(
|
||||
block_count: 0,
|
||||
observation_count: history.len(),
|
||||
pending_request_count,
|
||||
discontinuous: false,
|
||||
discontinuous: interrupted,
|
||||
cycle_usage: current.usage.clone(),
|
||||
usage: current.usage.clone(),
|
||||
};
|
||||
history.push(current);
|
||||
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
|
||||
let mut cycle_baseline = interrupted.then(|| &history[0]);
|
||||
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
|
||||
let mut high_water: Option<&QuotaForecastPoint> = None;
|
||||
for point in &history {
|
||||
@@ -149,6 +155,7 @@ pub fn select_forecast_sample(
|
||||
}
|
||||
sample.discontinuous = true;
|
||||
anchors.clear();
|
||||
cycle_baseline = Some(point);
|
||||
}
|
||||
}
|
||||
high_water = Some(point);
|
||||
@@ -162,6 +169,13 @@ pub fn select_forecast_sample(
|
||||
let Some(current) = history.last() else {
|
||||
return sample;
|
||||
};
|
||||
if let Some(baseline) = cycle_baseline {
|
||||
let Some(usage) = current.usage.difference(&baseline.usage) else {
|
||||
sample.discontinuous = true;
|
||||
return sample;
|
||||
};
|
||||
sample.cycle_usage = usage;
|
||||
}
|
||||
if anchors.len() < 2 {
|
||||
return sample;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use gateway_core::account::RotationStrategy;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
@@ -50,6 +51,7 @@ pub struct RuntimeSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -126,3 +128,16 @@ impl fmt::Debug for RegeneratedAdminApiKey {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSettings {
|
||||
pub fn client_profile(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Option<&gateway_core::account::OpaqueProviderData> {
|
||||
match provider {
|
||||
"openai" => self.openai_client_profile.as_ref(),
|
||||
"xai" => self.xai_client_profile.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ pub struct SystemOperationState {
|
||||
pub struct SystemUpdateStatus {
|
||||
pub previous_version: Option<String>,
|
||||
pub current_version: Option<String>,
|
||||
pub need_restart: bool,
|
||||
pub operation: SystemOperationState,
|
||||
}
|
||||
|
||||
@@ -103,7 +104,6 @@ pub enum SystemOperationAccepted {
|
||||
operation_id: String,
|
||||
deployment_mode: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
target_version: String,
|
||||
},
|
||||
Rollback {
|
||||
|
||||
@@ -167,6 +167,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
}
|
||||
|
||||
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
|
||||
/// 未保存的长上下文计费标记不得从当前价格反推。
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
input: &ProviderBillingInput,
|
||||
|
||||
@@ -548,19 +548,39 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let result = self
|
||||
.accounts
|
||||
.recover_account(&account_id, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?;
|
||||
let (stored, provider) = self.provider_for_account(&account_id).await?;
|
||||
let config_revision = if stored.account.enabled {
|
||||
self.accounts
|
||||
.recover_account(&account_id, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?
|
||||
.config_revision
|
||||
} else {
|
||||
// 停用只表示不参与调度,重新启用不能抹除已观测的额度、凭据或冷却事实。
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
account_ids: vec![account_id.to_string()],
|
||||
enabled: Some(true),
|
||||
concurrency_limit: None,
|
||||
weight: None,
|
||||
model_access: None,
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "enable provider account"))?
|
||||
.config_revision
|
||||
};
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
let account = self.load_directory_item(&account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
config_revision: result.config_revision,
|
||||
config_revision,
|
||||
account,
|
||||
})
|
||||
}
|
||||
@@ -740,7 +760,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
usage: point.usage,
|
||||
});
|
||||
}
|
||||
let mut sample = select_forecast_sample(
|
||||
let sample = select_forecast_sample(
|
||||
window.key.clone(),
|
||||
query.range.start,
|
||||
QuotaForecastPoint {
|
||||
@@ -750,13 +770,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
},
|
||||
points,
|
||||
history.pending_request_count,
|
||||
interrupted,
|
||||
);
|
||||
if interrupted
|
||||
&& sample.method
|
||||
== crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative
|
||||
{
|
||||
sample.discontinuous = true;
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
Ok(AccountQuotaForecastReport {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub trait ClientKeyService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultClientKeyService {
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
store: Arc<dyn ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
@@ -67,12 +67,12 @@ impl DefaultClientKeyService {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
snapshot,
|
||||
profile_provider,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +113,18 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
if let Some(profile) = &command.openai_client_profile_override {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile_override),
|
||||
("xai", &command.xai_client_profile_override),
|
||||
] {
|
||||
if let Some(profile) = profile {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
.require(&kind)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
@@ -132,6 +140,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
openai_client_profile_override: command.openai_client_profile_override,
|
||||
xai_client_profile_override: command.xai_client_profile_override,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
@@ -156,10 +165,18 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
if let Some(Some(profile)) = &command.openai_client_profile_override {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile_override),
|
||||
("xai", &command.xai_client_profile_override),
|
||||
] {
|
||||
if let Some(Some(profile)) = profile {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
.require(&kind)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
let id = command.id.clone();
|
||||
let (config_revision, record) = self
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
|
||||
//! Key 自助查询从统一会话或只读 Key 校验取得范围,复用现有观测和额度账本。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -6,10 +6,11 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
AuthService, SystemService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
client_keys::ClientKeySecret,
|
||||
key_usage::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
@@ -17,15 +18,30 @@ use crate::{
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
budget::ClientBudgetStatus,
|
||||
execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
|
||||
use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
|
||||
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
|
||||
|
||||
async fn config(&self, session_id: Option<&str>)
|
||||
-> Result<Option<ClientKeySecret>, AdminError>;
|
||||
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
@@ -41,20 +57,26 @@ pub trait KeyUsageService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
verifier,
|
||||
keys,
|
||||
observations,
|
||||
system,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +107,46 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError> {
|
||||
let id = match self.verifier.verify_client_key(plaintext) {
|
||||
Ok(id) => id,
|
||||
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
|
||||
Err(ClientAuthenticationError::SnapshotUnavailable) => {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Unavailable,
|
||||
"密钥验证暂时不可用",
|
||||
));
|
||||
}
|
||||
};
|
||||
self.keys
|
||||
.get_client_key(&id)
|
||||
.await
|
||||
.map(|key| key.filter(|key| key.enabled).map(|key| key.budget))
|
||||
.map_err(|error| map_store_error(error, "key usage budget"))
|
||||
}
|
||||
|
||||
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
|
||||
if self.key_id(session_id).await?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.system.version().await.map(Some)
|
||||
}
|
||||
|
||||
async fn config(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<ClientKeySecret>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 明文只按服务端会话绑定的 Key 读取,禁用或删除后不再提供配置。
|
||||
self.keys
|
||||
.reveal_client_key(&id)
|
||||
.await
|
||||
.map(|secret| secret.filter(|secret| secret.record.enabled))
|
||||
.map_err(|error| map_store_error(error, "key usage config"))
|
||||
}
|
||||
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
|
||||
@@ -199,6 +199,12 @@ impl DefaultObservabilityService {
|
||||
configuration.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(configuration) = &settings.xai_client_profile {
|
||||
configurations.insert(
|
||||
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
|
||||
configuration.clone(),
|
||||
);
|
||||
}
|
||||
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let total_slots = runtime_slots.as_ref().map_or_else(
|
||||
|
||||
@@ -39,11 +39,13 @@ pub trait SettingsService: Send + Sync {
|
||||
) -> Result<(), AdminError>;
|
||||
async fn client_profile_options(
|
||||
&self,
|
||||
_provider: &str,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
|
||||
}
|
||||
async fn preview_client_profile(
|
||||
&self,
|
||||
_provider: &str,
|
||||
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
|
||||
@@ -68,17 +70,26 @@ pub trait SettingsService: Send + Sync {
|
||||
pub(crate) struct DefaultSettingsService {
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
store: Arc<dyn SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultSettingsService {
|
||||
fn profile_provider(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
|
||||
let kind = gateway_core::routing::ProviderKind::new(provider)
|
||||
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
|
||||
self.providers
|
||||
.require(&kind)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
profile_provider: Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
providers: crate::ports::provider::ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn crate::ports::pricing::PricingSource>,
|
||||
) -> Self {
|
||||
@@ -87,7 +98,6 @@ impl DefaultSettingsService {
|
||||
pricing_source,
|
||||
store,
|
||||
snapshot,
|
||||
profile_provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,16 +260,18 @@ impl SettingsService for DefaultSettingsService {
|
||||
|
||||
async fn client_profile_options(
|
||||
&self,
|
||||
provider: &str,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let mut options = self
|
||||
.profile_provider
|
||||
.profile_provider(provider)?
|
||||
.client_profile_options()
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
let configuration = self
|
||||
.load()
|
||||
.await?
|
||||
.openai_client_profile
|
||||
.client_profile(provider)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
options.insert(
|
||||
"globalConfiguration".to_owned(),
|
||||
@@ -270,8 +282,10 @@ impl SettingsService for DefaultSettingsService {
|
||||
|
||||
async fn preview_client_profile(
|
||||
&self,
|
||||
provider: &str,
|
||||
configuration: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let profile_provider = self.profile_provider(provider)?;
|
||||
let global;
|
||||
let (configuration, source) = if let Some(configuration) = configuration {
|
||||
(configuration, "override")
|
||||
@@ -279,12 +293,12 @@ impl SettingsService for DefaultSettingsService {
|
||||
global = self
|
||||
.load()
|
||||
.await?
|
||||
.openai_client_profile
|
||||
.client_profile(provider)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
(&global, "global")
|
||||
};
|
||||
let mut preview = self
|
||||
.profile_provider
|
||||
let mut preview = profile_provider
|
||||
.preview_client_profile(configuration)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
@@ -305,10 +319,15 @@ impl SettingsService for DefaultSettingsService {
|
||||
command: ReplaceRuntimeSettings,
|
||||
) -> Result<RuntimeSettings, AdminError> {
|
||||
validate_settings(&command)?;
|
||||
if let Some(profile) = &command.openai_client_profile {
|
||||
self.profile_provider
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
for (provider, profile) in [
|
||||
("openai", &command.openai_client_profile),
|
||||
("xai", &command.xai_client_profile),
|
||||
] {
|
||||
if let Some(profile) = profile {
|
||||
self.profile_provider(provider)?
|
||||
.preview_client_profile(profile)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
let settings = self
|
||||
.store
|
||||
|
||||
@@ -76,6 +76,23 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
.filter_map(|window| {
|
||||
let usage = window.local_usage.as_ref()?;
|
||||
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
|
||||
let usage = QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
};
|
||||
Some(QuotaForecastSample {
|
||||
key: window.key.clone(),
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
@@ -89,23 +106,8 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
observation_count: 0,
|
||||
pending_request_count: 0,
|
||||
discontinuous: false,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
},
|
||||
cycle_usage: usage.clone(),
|
||||
usage,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -238,7 +240,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
sample.block_count = 2;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(10_000));
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
@@ -250,6 +252,7 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage.missing_token_count = 1;
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
@@ -304,6 +307,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
@@ -345,6 +349,7 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
sample.cycle_usage = sample.usage.clone();
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
@@ -380,3 +385,50 @@ fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast() {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.windows[0].used_percent = Some(92.0);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(20);
|
||||
sample.baseline_percent = 75.0;
|
||||
sample.sampled_percent = 17.0;
|
||||
sample.block_count = 3;
|
||||
sample.usage.tokens = 141_226_903;
|
||||
sample.usage.usd = 237.21007884;
|
||||
sample.cycle_usage.tokens = 1_084_786_565;
|
||||
sample.cycle_usage.usd = 1_412.57957384;
|
||||
let [cycle, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(2);
|
||||
sample.baseline_percent = 85.0;
|
||||
sample.sampled_percent = 15.0;
|
||||
sample.block_count = 3;
|
||||
sample.usage.tokens = 100;
|
||||
sample.usage.usd = 0.1;
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
|
||||
}
|
||||
|
||||
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -55,6 +55,7 @@ fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_rati
|
||||
assert_eq!(sample.baseline_percent, 10.0);
|
||||
assert_eq!(sample.sampled_percent, 17.0);
|
||||
assert_eq!(sample.usage.tokens, 700);
|
||||
assert_eq!(sample.cycle_usage.tokens, 900);
|
||||
assert_eq!(sample.block_count, 3);
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption(
|
||||
);
|
||||
assert_eq!(sample.baseline_percent, 1.0);
|
||||
assert_eq!(sample.usage.tokens, 70);
|
||||
assert_eq!(sample.cycle_usage.tokens, 70);
|
||||
assert_eq!(sample.sampled_percent, 7.0);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select(
|
||||
@@ -122,3 +124,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() {
|
||||
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
|
||||
assert_eq!(future.observation_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() {
|
||||
let sample = select(
|
||||
vec![point(1, 100.0, 10_000), point(2, 0.0, 10_000)],
|
||||
point(3, 10.0, 10_100),
|
||||
);
|
||||
assert_eq!(sample.cycle_usage.tokens, 100);
|
||||
assert_eq!(sample.usage.tokens, 100);
|
||||
assert_eq!(sample.sampled_percent, 10.0);
|
||||
assert!(!sample.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_window_restarts_cycle_totals_at_first_matching_observation() {
|
||||
let sample = select_forecast_sample(
|
||||
"week".to_owned(),
|
||||
time(0),
|
||||
point(4, 12.0, 10_120),
|
||||
vec![point(2, 0.0, 10_000), point(3, 5.0, 10_050)],
|
||||
0,
|
||||
true,
|
||||
);
|
||||
assert_eq!(sample.cycle_usage.tokens, 120);
|
||||
assert!((sample.cycle_usage.usd - 0.12).abs() < 1e-10);
|
||||
assert_eq!(sample.usage.tokens, 120);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select_forecast_sample(
|
||||
"week".to_owned(),
|
||||
time(0),
|
||||
point(4, 2.0, 10_020),
|
||||
vec![],
|
||||
0,
|
||||
true,
|
||||
);
|
||||
assert_eq!(waiting.cycle_usage.tokens, 0);
|
||||
assert!(waiting.discontinuous);
|
||||
}
|
||||
|
||||
@@ -935,6 +935,13 @@ impl AccountStore for FakeAccountStore {
|
||||
self.record("store.batch_update_accounts");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
if let Some(enabled) = command.enabled {
|
||||
for account in self.accounts.lock().expect("accounts").iter_mut() {
|
||||
if command.account_ids.contains(&account.id) {
|
||||
account.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AccountsUpdateResult {
|
||||
config_revision: revision(2),
|
||||
account_ids: command
|
||||
@@ -991,6 +998,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
@@ -1359,7 +1367,6 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
account.credential_state = CredentialState::Invalid;
|
||||
account.quota = QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
@@ -1406,6 +1413,110 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
assert_eq!(store.audit_requests(), ["recover-request"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_quota() {
|
||||
for has_error in [false, true] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
if has_error {
|
||||
account.credential_state = CredentialState::Invalid;
|
||||
account.last_error_reason =
|
||||
Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
|
||||
account.last_error_message = Some("invalid credential".to_owned());
|
||||
account.quota = QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
std::time::SystemTime::now(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let quota = ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "primary".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(92.0),
|
||||
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
};
|
||||
provider.set_quota(quota.clone());
|
||||
let store = FakeAccountStore::with_account(account.clone(), events.clone());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.recover(
|
||||
&context("enable-request"),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
.expect("enable disabled account");
|
||||
|
||||
account.enabled = true;
|
||||
assert_eq!(result.config_revision, revision(2));
|
||||
assert_eq!(result.account.account, account);
|
||||
assert_eq!(result.account.quota, quota);
|
||||
assert_eq!(
|
||||
result.account.projection.status,
|
||||
if has_error {
|
||||
gateway_admin::model::accounts::AccountStatus::Error
|
||||
} else {
|
||||
gateway_admin::model::accounts::AccountStatus::Normal
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"store.batch_update_accounts",
|
||||
"provider.account_facts_changed",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["enable-request"]);
|
||||
let requests = provider.quota_requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(!requests[0].refresh);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_recover_disabled_should_not_publish_when_enabling_fails() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let mut account = account_record("openai");
|
||||
account.enabled = false;
|
||||
let store = FakeAccountStore::with_account(account.clone(), events.clone());
|
||||
store.fail_next_commit();
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
|
||||
let result = services
|
||||
.accounts()
|
||||
.recover(
|
||||
&context("enable-failed"),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(*store.accounts.lock().expect("accounts"), [account]);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.load_account", "store.batch_update_accounts"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_update_should_commit_then_release_disabled_account_and_publish_facts() {
|
||||
let events = events();
|
||||
@@ -2039,6 +2150,29 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap()
|
||||
.contains("不连续")
|
||||
);
|
||||
// 新额度段已有足够观测后恢复预测,但总量不能带回重置前的累计用量。
|
||||
let mut first = make_point(1, 5.0, 1_750, 0);
|
||||
first.completed_at = now - TimeDelta::minutes(45);
|
||||
first.started_at = first.completed_at - TimeDelta::seconds(10);
|
||||
let mut next = make_point(1, 20.0, 1_900, 0);
|
||||
next.completed_at = now - TimeDelta::minutes(20);
|
||||
next.started_at = next.completed_at - TimeDelta::seconds(10);
|
||||
store
|
||||
.quota_forecast_history
|
||||
.lock()
|
||||
.unwrap()
|
||||
.points
|
||||
.extend([first, next]);
|
||||
let result = services
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -75,6 +75,7 @@ impl ClientKeyStore for TestClientKeyStore {
|
||||
self.plaintexts.lock().unwrap().push(key.plaintext);
|
||||
let record = ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
@@ -260,6 +261,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
|
||||
&mutation_context(),
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new("key_existing").unwrap(),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
@@ -280,6 +282,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
|
||||
fn create_command(key: Option<&str>) -> CreateClientKey {
|
||||
CreateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
|
||||
@@ -26,6 +26,7 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
|
||||
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
|
||||
@@ -921,6 +921,7 @@ pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
|
||||
dashboard_profile: None,
|
||||
calculated_billing: Some(
|
||||
gateway_admin::model::observability::CalculatedBillingBreakdown {
|
||||
long_context_billing_applied: false,
|
||||
custom_multiplier_bps: 10_000,
|
||||
image: None,
|
||||
input_amount: amount("0.8"),
|
||||
|
||||
@@ -773,6 +773,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
@@ -870,6 +871,7 @@ fn total_record(
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: id.to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
@@ -878,6 +880,7 @@ fn total_record(
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
|
||||
@@ -13,6 +13,20 @@ use gateway_admin::{
|
||||
|
||||
struct UnusedSettingsStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() {
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(std::sync::Arc::new(UnusedSettingsStore))
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.settings()
|
||||
.preview_client_profile("unknown", None)
|
||||
.await
|
||||
.expect_err("unknown provider cannot use global settings");
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for UnusedSettingsStore {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<gateway_admin::model::pricing::StoredPricing> {
|
||||
@@ -79,6 +93,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
model_mappings: Default::default(),
|
||||
|
||||
@@ -60,13 +60,13 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
operation_id: "operation-update".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
need_restart: true,
|
||||
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Ok(SystemUpdateStatus {
|
||||
need_restart: false,
|
||||
previous_version: None,
|
||||
current_version: Some("1.0.0".to_owned()),
|
||||
operation: SystemOperationState {
|
||||
|
||||
@@ -148,6 +148,7 @@ impl ClientKeySort {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
custom_key: Option<String>,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
@@ -180,6 +181,9 @@ impl CreateClientKeyRequest {
|
||||
openai_client_profile_override: self
|
||||
.openai_client_profile_override
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
xai_client_profile_override: self
|
||||
.xai_client_profile_override
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
custom_key: self
|
||||
.custom_key
|
||||
.filter(|key| !key.is_empty())
|
||||
@@ -208,6 +212,8 @@ impl CreateClientKeyRequest {
|
||||
pub struct UpdateClientKeyRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_profile_override")]
|
||||
openai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
#[serde(default, deserialize_with = "deserialize_profile_override")]
|
||||
xai_client_profile_override: Option<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
id: String,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
@@ -231,6 +237,9 @@ impl UpdateClientKeyRequest {
|
||||
openai_client_profile_override: self
|
||||
.openai_client_profile_override
|
||||
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
|
||||
xai_client_profile_override: self
|
||||
.xai_client_profile_override
|
||||
.map(|value| value.map(gateway_core::account::OpaqueProviderData::new)),
|
||||
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
@@ -317,6 +326,7 @@ impl ClientKeyMutationRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyView {
|
||||
openai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
id: String,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
@@ -358,6 +368,9 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
openai_client_profile_override: record
|
||||
.openai_client_profile_override
|
||||
.map(gateway_core::account::OpaqueProviderData::into_inner),
|
||||
xai_client_profile_override: record
|
||||
.xai_client_profile_override
|
||||
.map(gateway_core::account::OpaqueProviderData::into_inner),
|
||||
id: record.id.to_string(),
|
||||
name: record.name,
|
||||
label: record.label,
|
||||
|
||||
@@ -214,6 +214,7 @@ fn capitalize_first(value: &str) -> String {
|
||||
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
|
||||
match billing? {
|
||||
domain::UsageBilling::Total { source, total } => Some(BillingView {
|
||||
long_context_billing_applied: false,
|
||||
image: None,
|
||||
input_amount_display: "—".to_owned(),
|
||||
output_amount_display: "—".to_owned(),
|
||||
@@ -233,6 +234,7 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
|
||||
multiplier_display: "—".to_owned(),
|
||||
}),
|
||||
domain::UsageBilling::Calculated(value) => Some(BillingView {
|
||||
long_context_billing_applied: value.long_context_billing_applied,
|
||||
image: value
|
||||
.image
|
||||
.as_ref()
|
||||
@@ -277,12 +279,14 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
|
||||
.clone()
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
UsageListRecordView {
|
||||
client_api_key_name: record.client_api_key_name,
|
||||
id: record.id,
|
||||
provider: record.provider_kind,
|
||||
authentication_kind: record.provider_account_authentication_kind,
|
||||
account_id: record.provider_account_ref,
|
||||
account_email: record.provider_account_email,
|
||||
account_name: record.provider_account_name,
|
||||
account_notes: record.provider_account_notes,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
@@ -564,6 +568,7 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
.clone()
|
||||
.or_else(|| error.requested_model_id.clone());
|
||||
OpsErrorView {
|
||||
client_api_key_name: error.client_api_key_name,
|
||||
id: error.event_id,
|
||||
request_id: error.request_id,
|
||||
client_api_key_id: error.client_api_key_ref,
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct CostCoverageView {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingView {
|
||||
pub long_context_billing_applied: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<ImageBillingView>,
|
||||
pub input_amount_display: String,
|
||||
@@ -85,12 +86,14 @@ pub struct ImageBillingView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageListRecordView {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub provider: Option<String>,
|
||||
pub authentication_kind: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_notes: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
@@ -773,6 +776,7 @@ pub struct DiagnosticsView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpsErrorView {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub request_id: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
@@ -35,6 +35,7 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -68,6 +69,8 @@ pub struct RuntimeSettingsView {
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_profile_update")]
|
||||
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
#[serde(default, deserialize_with = "deserialize_profile_update")]
|
||||
pub xai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -181,6 +184,9 @@ impl UpdateRuntimeSettingsRequest {
|
||||
openai_client_profile: self
|
||||
.openai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
xai_client_profile: self
|
||||
.xai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
request_location_enabled: self.request_location_enabled,
|
||||
request_location: self
|
||||
.request_location
|
||||
@@ -225,6 +231,9 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
openai_client_profile: settings
|
||||
.openai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::into_inner),
|
||||
xai_client_profile: settings
|
||||
.xai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::into_inner),
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
@@ -361,11 +370,11 @@ where
|
||||
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
.route(
|
||||
"/api/admin/settings/client-profiles/openai",
|
||||
"/api/admin/settings/client-profiles/{provider}",
|
||||
get(client_profile_options::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/client-profiles/openai/preview",
|
||||
"/api/admin/settings/client-profiles/{provider}/preview",
|
||||
post(preview_client_profile::<S>),
|
||||
)
|
||||
.route("/api/admin/settings/update", post(update_settings::<S>))
|
||||
@@ -731,6 +740,7 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
|
||||
|
||||
async fn client_profile_options<S>(
|
||||
_auth: AdminAuth,
|
||||
Path(provider): Path<String>,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
@@ -739,7 +749,7 @@ where
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.client_profile_options()
|
||||
.client_profile_options(&provider)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
@@ -756,6 +766,7 @@ struct ClientProfilePreviewRequest {
|
||||
|
||||
async fn preview_client_profile<S>(
|
||||
_auth: AdminAuth,
|
||||
Path(provider): Path<String>,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<ClientProfilePreviewRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
@@ -768,7 +779,7 @@ where
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.preview_client_profile(configuration.as_ref())
|
||||
.preview_client_profile(&provider, configuration.as_ref())
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
|
||||
@@ -163,6 +163,7 @@ impl From<SystemOperationState> for SystemOperationStateView {
|
||||
struct SystemUpdateStatusView {
|
||||
previous_version: Option<String>,
|
||||
current_version: Option<String>,
|
||||
need_restart: bool,
|
||||
operation: SystemOperationStateView,
|
||||
}
|
||||
|
||||
@@ -171,6 +172,7 @@ impl From<SystemUpdateStatus> for SystemUpdateStatusView {
|
||||
Self {
|
||||
previous_version: status.previous_version,
|
||||
current_version: status.current_version,
|
||||
need_restart: status.need_restart,
|
||||
operation: status.operation.into(),
|
||||
}
|
||||
}
|
||||
@@ -182,7 +184,6 @@ struct UpdateAcceptedView {
|
||||
operation_id: String,
|
||||
deployment_mode: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
@@ -308,19 +309,17 @@ where
|
||||
operation_id,
|
||||
deployment_mode,
|
||||
message,
|
||||
need_restart,
|
||||
target_version,
|
||||
} = result
|
||||
else {
|
||||
return Err(AdminError::internal());
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
StatusCode::ACCEPTED,
|
||||
AdminEnvelope::ok(UpdateAcceptedView {
|
||||
operation_id,
|
||||
deployment_mode,
|
||||
message,
|
||||
need_restart,
|
||||
target_version,
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -23,6 +23,8 @@ where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/key-usage/version", get(version::<S>))
|
||||
.route("/api/key-usage/config", get(config::<S>))
|
||||
.route("/api/key-usage/overview", get(overview::<S>))
|
||||
.route("/api/key-usage/records", get(records::<S>))
|
||||
.route("/api/key-usage", any(not_found))
|
||||
@@ -31,6 +33,48 @@ where
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn version<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(_): AdminQuery<query::EmptyQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let version = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.version(session_cookie::value(&headers).as_deref())
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::version(version)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn config<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(_): AdminQuery<query::EmptyQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let secret = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.config(session_cookie::value(&headers).as_deref())
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::config(secret)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn overview<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
|
||||
//! 用量响应不含凭据;配置响应仅在显式读取时返回当前 Key 的名称与明文。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
client_keys::ClientKeySecret,
|
||||
key_usage::{KeyUsageOverview, KeyUsageRecords},
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
},
|
||||
system::SystemVersion,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -14,6 +16,36 @@ use crate::admin::observability::{
|
||||
health_timeline_view, usage_list_token_details,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct VersionView {
|
||||
version: String,
|
||||
git_sha: String,
|
||||
}
|
||||
|
||||
pub(super) fn version(version: SystemVersion) -> VersionView {
|
||||
// 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。
|
||||
VersionView {
|
||||
version: version.version,
|
||||
git_sha: version.git_sha,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ConfigView {
|
||||
name: String,
|
||||
plaintext_key: String,
|
||||
}
|
||||
|
||||
pub(super) fn config(secret: ClientKeySecret) -> ConfigView {
|
||||
let plaintext_key = secret.expose_for_response().to_owned();
|
||||
ConfigView {
|
||||
name: secret.record.name,
|
||||
plaintext_key,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct OverviewView {
|
||||
|
||||
@@ -11,6 +11,10 @@ use serde::Deserialize;
|
||||
|
||||
use crate::admin::AdminError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct EmptyQuery {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct OverviewQuery {
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod responses;
|
||||
pub mod router;
|
||||
pub mod search;
|
||||
pub(crate) mod service;
|
||||
mod usage;
|
||||
|
||||
/// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。
|
||||
fn with_model_request_id(
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::{
|
||||
models::{model_detail, models},
|
||||
responses::{responses, responses_websocket},
|
||||
search::standalone_search,
|
||||
usage,
|
||||
};
|
||||
|
||||
use crate::ApiState;
|
||||
@@ -25,6 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
|
||||
.route("/v1/models", get(models))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
.merge(usage::router())
|
||||
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
|
||||
// 上游本可接受的未来 payload。
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Bearer Key 的只读额度查询,与自助页面共用当前预算账本。
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::metering::Decimal;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{ApiState, auth::SessionState as _};
|
||||
|
||||
use super::{
|
||||
auth::{bearer_client_api_key, client_access_error_response},
|
||||
error::{missing_client_api_key_response, openai_error_response},
|
||||
};
|
||||
|
||||
pub(super) fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/v1/usage", get(usage))
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UsageQuery {}
|
||||
|
||||
async fn usage(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
uri: axum::http::Uri,
|
||||
) -> Response {
|
||||
let key = match bearer_client_api_key(&headers) {
|
||||
Ok(key) => key,
|
||||
Err(error) => return client_access_error_response(error.into()),
|
||||
};
|
||||
// 范围只能来自 Bearer 身份,不接受调用者指定 Key、账号或时间窗口。
|
||||
if Query::<UsageQuery>::try_from_uri(&uri).is_err() {
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Usage query does not accept query parameters",
|
||||
"invalid_request_error",
|
||||
"invalid_usage_query",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let budget = match state.admin_services().key_usage().budget(key).await {
|
||||
Ok(Some(budget)) => budget,
|
||||
Ok(None) => return missing_client_api_key_response().into_response(),
|
||||
Err(_) => {
|
||||
return openai_error_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Key usage is temporarily unavailable",
|
||||
"server_error",
|
||||
"usage_unavailable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
Json(json!({
|
||||
"unit": "USD",
|
||||
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at),
|
||||
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at),
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn window(total: Decimal, used: Decimal, resets_at: Option<SystemTime>) -> Value {
|
||||
let limited = total != Decimal::ZERO;
|
||||
json!({
|
||||
"total": limited.then(|| total.canonical()),
|
||||
"used": used.canonical(),
|
||||
"remaining": limited.then(|| total.saturating_sub(used).canonical()),
|
||||
"resetsAt": resets_at.map(DateTime::<Utc>::from),
|
||||
})
|
||||
}
|
||||
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -433,6 +433,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
.expect("valid time");
|
||||
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
budget: Default::default(),
|
||||
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
|
||||
name: "visible".to_owned(),
|
||||
@@ -586,3 +587,20 @@ fn profile_override_distinguishes_omission_from_explicit_inheritance() {
|
||||
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
|
||||
assert!(decode(payload).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() {
|
||||
let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0});
|
||||
let decode = |body| {
|
||||
serde_json::from_value::<UpdateClientKeyRequest>(body)
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap()
|
||||
.xai_client_profile_override
|
||||
};
|
||||
assert_eq!(decode(payload.clone()), None);
|
||||
payload["xaiClientProfileOverride"] = json!(null);
|
||||
assert_eq!(decode(payload.clone()), Some(None));
|
||||
payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"});
|
||||
assert!(decode(payload).unwrap().is_some());
|
||||
}
|
||||
|
||||
@@ -521,6 +521,7 @@ impl SettingsStore for MemorySettingsStore {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: command.request_location_enabled,
|
||||
request_location: command.request_location,
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
@@ -888,10 +889,15 @@ impl ClientKeyStore for MemoryClientKeyStore {
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeySecret>> {
|
||||
if let Some(record) = self.0.lock().expect("client key").clone() {
|
||||
return Ok((record.id == *id)
|
||||
.then(|| ClientKeySecret::new(record, format!("sk_{}", "a".repeat(43)))));
|
||||
}
|
||||
let now = Utc::now();
|
||||
Ok(Some(ClientKeySecret::new(
|
||||
ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
budget: Default::default(),
|
||||
id: id.clone(),
|
||||
name: "revealed".to_owned(),
|
||||
@@ -1435,6 +1441,7 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
]);
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
|
||||
@@ -169,6 +169,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
|
||||
#[test]
|
||||
fn billing_view_should_preserve_the_original_detail_contract() {
|
||||
let value = serde_json::to_value(BillingView {
|
||||
long_context_billing_applied: false,
|
||||
image: None,
|
||||
input_amount_display: "$0.03".to_owned(),
|
||||
output_amount_display: "$0.00".to_owned(),
|
||||
@@ -631,6 +632,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
.lock()
|
||||
.expect("ops errors")
|
||||
.push(OpsError {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
source: "model_request".to_owned(),
|
||||
event_id: "err_snapshot".to_owned(),
|
||||
request_id: Some("req_err".to_owned()),
|
||||
@@ -701,6 +703,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
.await
|
||||
.expect("ops errors body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("ops errors JSON");
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"provider": value["data"]["items"][0]["provider"],
|
||||
@@ -954,6 +957,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
.lock()
|
||||
.expect("usage records")
|
||||
.push(UsageListRecord {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: "request_endpoint".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "websocket".to_owned(),
|
||||
@@ -962,6 +966,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
provider_account_ref: Some("acct_snapshot".to_owned()),
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
provider_account_email: Some("alpha@example.invalid".to_owned()),
|
||||
provider_account_notes: Some("Team workspace".to_owned()),
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -980,6 +985,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
cost_currency: None,
|
||||
billing: Some(UsageBilling::Calculated(Box::new(
|
||||
CalculatedBillingBreakdown {
|
||||
long_context_billing_applied: true,
|
||||
custom_multiplier_bps: 10_000,
|
||||
image: None,
|
||||
input_amount: usd("0.03"),
|
||||
@@ -1051,6 +1057,16 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
.expect("usage response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
|
||||
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][1]["billing"]["longContextBillingApplied"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["inputPriceDisplay"],
|
||||
"$10 / 1M Token"
|
||||
|
||||
@@ -103,6 +103,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
|
||||
let settings = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
@@ -148,6 +149,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
value,
|
||||
json!({
|
||||
"openaiClientProfile": null,
|
||||
"xaiClientProfile": null,
|
||||
"requestLocationEnabled": false,
|
||||
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
|
||||
"modelMappings": {
|
||||
@@ -202,6 +204,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
.collect();
|
||||
let settings = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
@@ -251,6 +254,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
.collect();
|
||||
let mut expected_fields = request_fields;
|
||||
expected_fields.insert("openaiClientProfile".to_owned());
|
||||
expected_fields.insert("xaiClientProfile".to_owned());
|
||||
expected_fields.insert("updatedAt".to_owned());
|
||||
|
||||
assert_eq!(response_fields, expected_fields);
|
||||
@@ -640,14 +644,15 @@ async fn settings_reject_removed_global_fast_policy() {
|
||||
|
||||
#[test]
|
||||
fn global_profile_can_be_omitted_but_cannot_be_cleared() {
|
||||
let mut body = update_body();
|
||||
body.as_object_mut().unwrap().remove("openaiClientProfile");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
|
||||
body["openaiClientProfile"] = json!(null);
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
|
||||
body["openaiClientProfile"] =
|
||||
json!({"client":"cli", "platform":"linux", "versionMode":"latest"});
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
|
||||
for field in ["openaiClientProfile", "xaiClientProfile"] {
|
||||
let mut body = update_body();
|
||||
body.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_ok());
|
||||
body[field] = json!(null);
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body.clone()).is_err());
|
||||
body[field] = json!({"versionMode":"latest"});
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_pricing() -> Value {
|
||||
|
||||
@@ -4,14 +4,15 @@ use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, header},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures::stream;
|
||||
use gateway_admin::{
|
||||
model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
},
|
||||
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
|
||||
};
|
||||
@@ -67,6 +68,51 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_return_accepted_and_report_restart_only_through_status() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let router = app(fixture.state());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/system/update")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_system_update")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"targetVersion":"0.2.0"}"#))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
|
||||
.expect("JSON");
|
||||
assert_eq!(body["data"]["operationId"], "update-1");
|
||||
assert_eq!(body["data"]["targetVersion"], "0.2.0");
|
||||
assert!(body["data"].get("needRestart").is_none());
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/system/update/status")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_system_update")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
|
||||
.expect("JSON");
|
||||
assert_eq!(body["data"]["operation"]["status"], "succeeded");
|
||||
assert_eq!(body["data"]["needRestart"], true);
|
||||
}
|
||||
|
||||
fn app(state: AdminTestState) -> Router {
|
||||
system::router::<AdminTestState>().with_state(state)
|
||||
}
|
||||
@@ -100,13 +146,32 @@ impl SystemOperations for ProgressSystem {
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
target: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id: "update-1".to_owned(),
|
||||
deployment_mode: "docker".to_owned(),
|
||||
message: "更新已开始".to_owned(),
|
||||
target_version: target.expect("target"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
Ok(SystemUpdateStatus {
|
||||
previous_version: Some("0.1.0".to_owned()),
|
||||
current_version: Some("0.2.0".to_owned()),
|
||||
need_restart: true,
|
||||
operation: SystemOperationState {
|
||||
operation_id: Some("update-1".to_owned()),
|
||||
kind: Some(SystemOperationKind::Update),
|
||||
status: SystemOperationStatus::Succeeded,
|
||||
target_version: Some("0.2.0".to_owned()),
|
||||
message: Some("done".to_owned()),
|
||||
error: None,
|
||||
started_at: Some(Utc::now()),
|
||||
finished_at: Some(Utc::now()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
|
||||
@@ -81,6 +81,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/search/http.rs",
|
||||
"src/openai/search/mod.rs",
|
||||
"src/openai/service.rs",
|
||||
"src/openai/usage.rs",
|
||||
"src/session_cookie.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
@@ -132,6 +133,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/openai/responses/websocket/protocol.rs",
|
||||
"tests/openai/router.rs",
|
||||
"tests/openai/search/mod.rs",
|
||||
"tests/openai/usage.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
|
||||
let now = Utc::now();
|
||||
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new("key-42").unwrap(),
|
||||
name: "Development".to_owned(),
|
||||
label: Some("private-sentinel".to_owned()),
|
||||
@@ -90,6 +91,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
|
||||
|
||||
fn usage_record() -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: "req-visible".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
@@ -98,6 +100,7 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_account_ref: Some("private-sentinel".to_owned()),
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_notes: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
@@ -146,6 +149,7 @@ fn usage_record() -> UsageListRecord {
|
||||
|
||||
fn error_record() -> OpsError {
|
||||
OpsError {
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
source: "private-sentinel".to_owned(),
|
||||
event_id: "error-visible".to_owned(),
|
||||
request_id: Some("private-sentinel".to_owned()),
|
||||
|
||||
@@ -40,7 +40,11 @@ async fn login(app: &Router, mode: &str) -> String {
|
||||
}
|
||||
|
||||
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
|
||||
let path = format!("/api/key-usage/{resource}?{RANGE}{suffix}");
|
||||
let path = if matches!(resource, "config" | "version") {
|
||||
format!("/api/key-usage/{resource}{suffix}")
|
||||
} else {
|
||||
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(Method::GET, &path, cookie))
|
||||
@@ -50,6 +54,99 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_exposes_only_build_identifiers_for_key_sessions() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
let response = get(&app, "version", "", &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response_json(response).await["data"],
|
||||
json!({"version": "3.7.0", "gitSha": "internal-revision"})
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, "version", "?keyId=other", &cookie).await.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(
|
||||
Method::GET,
|
||||
"/api/admin/system/version",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_reveals_only_the_session_keys_name_and_plaintext() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
let response = get(&app, "config", "", &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let data = response_json(response).await["data"].clone();
|
||||
assert_eq!(
|
||||
data,
|
||||
json!({
|
||||
"name": "Development",
|
||||
"plaintextKey": format!("sk_{}", "a".repeat(43)),
|
||||
})
|
||||
);
|
||||
let overview = get(&app, "overview", "", &cookie).await;
|
||||
assert!(
|
||||
!response_json(overview)
|
||||
.await
|
||||
.to_string()
|
||||
.contains("plaintextKey")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_rejects_caller_selected_scope() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
for query in [
|
||||
"?keyId=other",
|
||||
"?id=other",
|
||||
"?clientApiKeyRef=other",
|
||||
"?provider=openai",
|
||||
] {
|
||||
let response = get(&app, "config", query, &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(response_json(response).await["data"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_does_not_reveal_disabled_or_other_keys() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
{
|
||||
let mut key = fixture.client_key.lock().unwrap();
|
||||
key.as_mut().unwrap().enabled = false;
|
||||
}
|
||||
assert_eq!(
|
||||
get(&app, "config", "", &cookie).await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
{
|
||||
let mut key = fixture.client_key.lock().unwrap();
|
||||
let key = key.as_mut().unwrap();
|
||||
key.enabled = true;
|
||||
key.id = gateway_core::policy::ClientApiKeyId::new("other-key").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
get(&app, "config", "", &cookie).await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_fields(value: &Value, expected: &[&str]) {
|
||||
let mut actual: Vec<_> = value
|
||||
.as_object()
|
||||
@@ -218,7 +315,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let admin = login(&app, "admin").await;
|
||||
let key = login(&app, "key").await;
|
||||
for resource in ["overview", "records"] {
|
||||
for resource in ["overview", "records", "config", "version"] {
|
||||
assert_eq!(
|
||||
get(&app, resource, "", "").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
@@ -352,6 +449,16 @@ async fn namespace_errors_remain_json_and_uncacheable() {
|
||||
"/api/key-usage/overview",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/api/key-usage/config",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/api/key-usage/version",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
|
||||
@@ -446,6 +446,27 @@ fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locally_exhausted_account_pool_returns_official_usage_limit_contract() {
|
||||
let error = EngineError::Provider(
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"All eligible accounts have exhausted their quota.",
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
)),
|
||||
);
|
||||
let response = engine_error_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap();
|
||||
assert_eq!(body["error"]["type"], "usage_limit_reached");
|
||||
assert_eq!(body["error"]["code"], "usage_limit_reached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
|
||||
let timeout = EngineError::Provider(ProviderError::new(
|
||||
|
||||
@@ -6,6 +6,7 @@ mod models;
|
||||
mod responses;
|
||||
mod router;
|
||||
mod search;
|
||||
mod usage;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
@@ -521,3 +521,20 @@ async fn initial_quota_recovery_delivers_client_projection_instead_of_upstream_s
|
||||
assert_eq!(error["headers"]["x-request-id"], "req-quota-rejected");
|
||||
assert!(error["headers"].get("retry-after").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locally_exhausted_account_pool_sends_one_official_usage_limit_error() {
|
||||
let provider = ProviderError::new(
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"All eligible accounts have exhausted their quota.",
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
));
|
||||
let error = initial_error(EngineError::Provider(provider), Vec::new()).await;
|
||||
assert_eq!(error["status"], 429);
|
||||
assert_eq!(error["error"]["type"], "usage_limit_reached");
|
||||
assert_eq!(error["error"]["code"], "usage_limit_reached");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
use axum::{
|
||||
Router,
|
||||
http::{Method, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::{
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::{
|
||||
admin::AdminTestFixture,
|
||||
support::{RAW_KEY, empty_request, key_fixture, response_json},
|
||||
};
|
||||
|
||||
const KEY: &str = RAW_KEY;
|
||||
|
||||
async fn fixture() -> (AdminTestFixture, Router) {
|
||||
let fixture = key_fixture().await;
|
||||
let id = ClientApiKeyId::new("key-42").unwrap();
|
||||
let mut key = fixture
|
||||
.services
|
||||
.client_keys()
|
||||
.reveal(&id)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
key.id = id;
|
||||
key.label = Some("private-usage-sentinel".to_owned());
|
||||
key.budget = ClientBudgetStatus {
|
||||
limits: ClientBudgetLimits {
|
||||
daily_usd: "1".parse().unwrap(),
|
||||
weekly_usd: "5".parse().unwrap(),
|
||||
},
|
||||
daily_used_usd: "0.6400000001".parse().unwrap(),
|
||||
weekly_used_usd: "2.35".parse().unwrap(),
|
||||
daily_resets_at: Some((Utc::now() + Duration::days(1)).into()),
|
||||
weekly_resets_at: Some((Utc::now() + Duration::days(7)).into()),
|
||||
};
|
||||
*fixture.client_key.lock().unwrap() = Some(key);
|
||||
let app = super::api_router_with_admin(fixture.services.clone());
|
||||
(fixture, app)
|
||||
}
|
||||
|
||||
async fn query(app: &Router, path: &str, authorization: Option<&str>) -> Response {
|
||||
let mut request = empty_request(Method::GET, path);
|
||||
if let Some(value) = authorization {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::AUTHORIZATION, value.parse().unwrap());
|
||||
}
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_returns_current_key_budget_without_exposing_private_data_or_writing_usage() {
|
||||
let (fixture, app) = fixture().await;
|
||||
let before = fixture.client_key.lock().unwrap().clone().unwrap();
|
||||
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"unit": "USD",
|
||||
"daily": {
|
||||
"total": "1", "used": "0.6400000001", "remaining": "0.3599999999",
|
||||
"resetsAt": before.budget.daily_resets_at.map(chrono::DateTime::<Utc>::from),
|
||||
},
|
||||
"weekly": {
|
||||
"total": "5", "used": "2.35", "remaining": "2.65",
|
||||
"resetsAt": before.budget.weekly_resets_at.map(chrono::DateTime::<Utc>::from),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert!(!body.to_string().contains("private-usage-sentinel"));
|
||||
assert_eq!(fixture.client_key.lock().unwrap().as_ref(), Some(&before));
|
||||
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_remains_readable_when_exhausted_and_never_returns_negative_remaining() {
|
||||
let (fixture, app) = fixture().await;
|
||||
{
|
||||
let mut key = fixture.client_key.lock().unwrap();
|
||||
let budget = &mut key.as_mut().unwrap().budget;
|
||||
budget.daily_used_usd = "1".parse().unwrap();
|
||||
budget.weekly_used_usd = "5.75".parse().unwrap();
|
||||
}
|
||||
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["daily"]["remaining"], "0");
|
||||
assert_eq!(body["weekly"]["remaining"], "0");
|
||||
assert_eq!(body["weekly"]["used"], "5.75");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_distinguishes_unlimited_and_unused_windows_from_exhausted_budgets() {
|
||||
let (fixture, app) = fixture().await;
|
||||
for (daily, weekly) in [("0", "0"), ("1", "0"), ("0", "5")] {
|
||||
fixture.client_key.lock().unwrap().as_mut().unwrap().budget = ClientBudgetStatus {
|
||||
limits: ClientBudgetLimits {
|
||||
daily_usd: daily.parse().unwrap(),
|
||||
weekly_usd: weekly.parse().unwrap(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
for (period, limit) in [("daily", daily), ("weekly", weekly)] {
|
||||
assert_eq!(
|
||||
body[period],
|
||||
json!({
|
||||
"total": (limit != "0").then_some(limit),
|
||||
"used": "0",
|
||||
"remaining": (limit != "0").then_some(limit),
|
||||
"resetsAt": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_rejects_missing_invalid_disabled_deleted_and_other_keys() {
|
||||
let (fixture, app) = fixture().await;
|
||||
for authorization in [None, Some("Basic invalid"), Some("Bearer unknown-key")] {
|
||||
let response = query(&app, "/v1/usage", authorization).await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(response_json(response).await.get("error").is_some());
|
||||
}
|
||||
let original = fixture.client_key.lock().unwrap().clone().unwrap();
|
||||
for mode in ["disabled", "deleted", "other"] {
|
||||
{
|
||||
let mut key = fixture.client_key.lock().unwrap();
|
||||
*key = Some(original.clone());
|
||||
match mode {
|
||||
"disabled" => key.as_mut().unwrap().enabled = false,
|
||||
"deleted" => *key = None,
|
||||
_ => key.as_mut().unwrap().id = ClientApiKeyId::new("other-key").unwrap(),
|
||||
}
|
||||
}
|
||||
let response = query(&app, "/v1/usage", Some(&format!("Bearer {KEY}"))).await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_does_not_accept_sessions_admin_keys_or_caller_selected_scope() {
|
||||
let (fixture, app) = fixture().await;
|
||||
fixture.auth.insert_session("valid-admin");
|
||||
for (name, value) in [
|
||||
(header::COOKIE.as_str(), "cpr_session=valid-admin"),
|
||||
("x-api-key", KEY),
|
||||
] {
|
||||
let mut request = empty_request(Method::GET, "/v1/usage");
|
||||
request.headers_mut().insert(
|
||||
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
value.parse().unwrap(),
|
||||
);
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
for suffix in [
|
||||
"?keyId=other",
|
||||
"?clientApiKeyRef=other",
|
||||
"?startTime=2026-01-01",
|
||||
"?api_key=test",
|
||||
] {
|
||||
let response = query(
|
||||
&app,
|
||||
&format!("/v1/usage{suffix}"),
|
||||
Some(&format!("Bearer {KEY}")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response_json(response).await["error"]["code"],
|
||||
"invalid_usage_query"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -881,6 +881,7 @@ where
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
@@ -897,6 +898,7 @@ where
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
) && let Some(last_failure) = self.last_retryable_failure.take()
|
||||
@@ -919,6 +921,7 @@ where
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ProviderInfrastructureUnavailable
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
@@ -1447,6 +1450,7 @@ where
|
||||
error.kind(),
|
||||
ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -39,6 +39,12 @@ impl Decimal {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// 非负金额相减,超支时返回零。
|
||||
#[must_use]
|
||||
pub const fn saturating_sub(self, other: Self) -> Self {
|
||||
Self(self.0.saturating_sub(other.0))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn checked_add(self, other: Self) -> Option<Self> {
|
||||
self.0
|
||||
@@ -304,6 +310,8 @@ pub struct CalculatedCost {
|
||||
/// 随本地费用事件保存,以免后续改价改变历史明细。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedCostBreakdown {
|
||||
// Provider 选中的价格区间事实,与服务档位和自定义倍率独立。
|
||||
long_context_billing_applied: bool,
|
||||
image: Option<ImageCostBreakdown>,
|
||||
input_amount: Money,
|
||||
output_amount: Money,
|
||||
@@ -393,6 +401,7 @@ impl CalculatedCostBreakdown {
|
||||
multiplier_percent: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
long_context_billing_applied: false,
|
||||
input_amount: amounts.input,
|
||||
image: None,
|
||||
output_amount: amounts.output,
|
||||
@@ -410,6 +419,17 @@ impl CalculatedCostBreakdown {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_long_context_billing(mut self, applied: bool) -> Self {
|
||||
self.long_context_billing_applied = applied;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn long_context_billing_applied(&self) -> bool {
|
||||
self.long_context_billing_applied
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_image(mut self, image: ImageCostBreakdown) -> Self {
|
||||
self.image = Some(image);
|
||||
|
||||
@@ -1888,67 +1888,69 @@ fn native_continuation_exact_retry_keeps_scope_and_account() {
|
||||
|
||||
#[test]
|
||||
fn unavailable_native_continuation_uses_provider_recovery_with_the_configured_account_policy() {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
let operation = Operation::Generate(generate.with_provider_session_state(
|
||||
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
|
||||
));
|
||||
let policy = AccountSelectionPolicy::new(
|
||||
RotationStrategy::RoundRobin,
|
||||
NonZeroU32::new(2).expect("account concurrency"),
|
||||
Duration::from_millis(50),
|
||||
);
|
||||
let route_plan = plan_with_policy(&operation, policy);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
)),
|
||||
Script::Stream {
|
||||
account_id: "acct_two",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let original = ProviderAccountId::new("acct_one").expect("account");
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new("previous-response"),
|
||||
PreviousResponseId::new("upstream-response"),
|
||||
ClientApiKeyId::new("key_client_1").expect("client key"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
original.clone(),
|
||||
);
|
||||
for kind in [
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
] {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
let operation = Operation::Generate(generate.with_provider_session_state(
|
||||
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
|
||||
));
|
||||
let policy = AccountSelectionPolicy::new(
|
||||
RotationStrategy::RoundRobin,
|
||||
NonZeroU32::new(2).expect("account concurrency"),
|
||||
Duration::from_millis(50),
|
||||
);
|
||||
let route_plan = plan_with_policy(&operation, policy);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Error(ProviderError::new(kind, UpstreamSendState::NotSent)),
|
||||
Script::Stream {
|
||||
account_id: "acct_two",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let original = ProviderAccountId::new("acct_one").expect("account");
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new("previous-response"),
|
||||
PreviousResponseId::new("upstream-response"),
|
||||
ClientApiKeyId::new("key_client_1").expect("client key"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
original.clone(),
|
||||
);
|
||||
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
contexts[0].continuation_attempt(),
|
||||
ContinuationAttempt::Native
|
||||
);
|
||||
assert_eq!(
|
||||
contexts[1].continuation_attempt(),
|
||||
ContinuationAttempt::ReplayAny
|
||||
);
|
||||
assert_eq!(
|
||||
contexts[1].account_selection_policy().strategy(),
|
||||
RotationStrategy::RoundRobin
|
||||
);
|
||||
assert!(contexts[1].excluded_accounts().contains(&original));
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.attempts.len(), 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
contexts[0].continuation_attempt(),
|
||||
ContinuationAttempt::Native
|
||||
);
|
||||
assert_eq!(
|
||||
contexts[1].continuation_attempt(),
|
||||
ContinuationAttempt::ReplayAny
|
||||
);
|
||||
assert_eq!(
|
||||
contexts[1].account_selection_policy().strategy(),
|
||||
RotationStrategy::RoundRobin
|
||||
);
|
||||
assert!(contexts[1].excluded_accounts().contains(&original));
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.attempts.len(), 1);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3671,6 +3673,7 @@ fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
|
||||
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
|
||||
for capacity_error in [
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
ProviderErrorKind::ConcurrencyQueueFull,
|
||||
ProviderErrorKind::ConcurrencyQueueTimeout,
|
||||
] {
|
||||
@@ -3942,60 +3945,70 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
|
||||
|
||||
#[test]
|
||||
fn no_eligible_account_before_stream_records_failure_without_fabricating_an_attempt() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
))]);
|
||||
for (kind, status, gateway_kind) in [
|
||||
(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
503,
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
),
|
||||
(
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
429,
|
||||
GatewayErrorKind::RateLimited,
|
||||
),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::Error(ProviderError::new(
|
||||
kind,
|
||||
UpstreamSendState::NotSent,
|
||||
))]);
|
||||
|
||||
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");
|
||||
let error = block_on(session.collect_uncommitted())
|
||||
.expect_err("provider failed before returning metadata");
|
||||
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");
|
||||
let error = block_on(session.collect_uncommitted())
|
||||
.expect_err("provider failed before returning metadata");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
gateway_core::engine::EngineError::Provider(ref error)
|
||||
if error.kind() == ProviderErrorKind::NoEligibleAccount
|
||||
));
|
||||
assert!(session.provider_attempt_outcomes().is_empty());
|
||||
assert!(session.is_finalized());
|
||||
block_on(session.record_client_status(503)).expect("HTTP error status");
|
||||
block_on(session.cancel_and_finalize()).expect("repeated finalization");
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.created, 1);
|
||||
assert!(state.attempts.is_empty());
|
||||
assert_eq!(state.finalizations.len(), 1);
|
||||
assert_eq!(state.recorded_statuses, [503]);
|
||||
let finalization = &state.finalizations[0];
|
||||
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
|
||||
assert_eq!(finalization.attempt_count, 0);
|
||||
assert_eq!(finalization.send_state, UpstreamSendState::NotSent);
|
||||
assert_eq!(
|
||||
finalization.error_kind,
|
||||
Some(GatewayErrorKind::NoAvailableProvider)
|
||||
);
|
||||
assert_eq!(finalization.upstream_transport, None);
|
||||
assert_eq!(finalization.total_tokens, None);
|
||||
assert_eq!(finalization.cost_ticks, None);
|
||||
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
|
||||
let trace: Value =
|
||||
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
|
||||
let events = trace["events"].as_array().unwrap();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| event["stage"] == "attempt.failed")
|
||||
);
|
||||
assert_eq!(events.last().unwrap()["stage"], "request.finished");
|
||||
assert!(matches!(
|
||||
error,
|
||||
gateway_core::engine::EngineError::Provider(ref error)
|
||||
if error.kind() == kind
|
||||
));
|
||||
assert!(session.provider_attempt_outcomes().is_empty());
|
||||
assert!(session.is_finalized());
|
||||
block_on(session.record_client_status(status)).expect("HTTP error status");
|
||||
block_on(session.cancel_and_finalize()).expect("repeated finalization");
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.created, 1);
|
||||
assert!(state.attempts.is_empty());
|
||||
assert_eq!(state.finalizations.len(), 1);
|
||||
assert_eq!(state.recorded_statuses, [status]);
|
||||
let finalization = &state.finalizations[0];
|
||||
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
|
||||
assert_eq!(finalization.attempt_count, 0);
|
||||
assert_eq!(finalization.send_state, UpstreamSendState::NotSent);
|
||||
assert_eq!(finalization.error_kind, Some(gateway_kind));
|
||||
assert_eq!(finalization.upstream_transport, None);
|
||||
assert_eq!(finalization.total_tokens, None);
|
||||
assert_eq!(finalization.cost_ticks, None);
|
||||
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
|
||||
let trace: Value =
|
||||
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
|
||||
let events = trace["events"].as_array().unwrap();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| event["stage"] == "attempt.failed")
|
||||
);
|
||||
assert_eq!(events.last().unwrap()["stage"], "request.finished");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,7 @@ mod swap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -32,11 +33,12 @@ use self::archive::extract_release;
|
||||
use self::download::{MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_file, verify_checksum};
|
||||
use self::process::{environment_value, spawn_replacement};
|
||||
use self::release::{
|
||||
ReleaseCache, confirmed_target, detail_from_release, fetch_latest, select_archive,
|
||||
versions_cross_major,
|
||||
ReleaseCache, UpdateChannel, confirmed_target, detail_from_release, fetch_latest,
|
||||
select_archive, validate_update_target, version_channel,
|
||||
};
|
||||
use self::state::{
|
||||
OperationFileLock, UpdateTempDir, finish, operation_id, read_status, set_running,
|
||||
OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status,
|
||||
recover_interrupted, set_running,
|
||||
};
|
||||
use self::swap::{replace_release_files, rollback_release};
|
||||
|
||||
@@ -57,7 +59,6 @@ pub struct SystemUpdateConfig {
|
||||
pub build_time: String,
|
||||
pub deployment_mode: String,
|
||||
pub build_type: String,
|
||||
pub update_channel: String,
|
||||
pub update_repository: Option<String>,
|
||||
pub github_api_base: String,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
@@ -98,8 +99,6 @@ impl Default for SystemUpdateConfig {
|
||||
.to_owned(),
|
||||
deployment_mode,
|
||||
build_type: option_env!("CPR_BUILD_TYPE").unwrap_or("source").to_owned(),
|
||||
update_channel: environment_value("CPR_UPDATE_CHANNEL")
|
||||
.unwrap_or_else(|| "stable".to_owned()),
|
||||
update_repository: Some(
|
||||
environment_value("CPR_UPDATE_REPOSITORY")
|
||||
.unwrap_or_else(|| DEFAULT_UPDATE_REPOSITORY.to_owned()),
|
||||
@@ -158,17 +157,18 @@ impl SystemUpdateConfig {
|
||||
"host.system_update.deployment_mode",
|
||||
));
|
||||
}
|
||||
if !matches!(self.update_channel.as_str(), "stable" | "preview") {
|
||||
return Err(ConfigError::InvalidField(
|
||||
"host.system_update.update_channel",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_support_error(&self) -> Option<String> {
|
||||
if self.build_type != "release" {
|
||||
return Some("一键更新需要正式构建包".to_owned());
|
||||
if !matches!(self.build_type.as_str(), "release" | "experimental") {
|
||||
return Some("在线更新需要官方发布构建".to_owned());
|
||||
}
|
||||
let Some(channel) = version_channel(&self.version) else {
|
||||
return Some("当前版本不符合发行命名规范,无法确定更新通道".to_owned());
|
||||
};
|
||||
if self.build_type == "experimental" && channel != UpdateChannel::Experimental {
|
||||
return Some("实验构建必须使用 exp.N 版本,无法在线更新".to_owned());
|
||||
}
|
||||
let Some(repository) = self.update_repository.as_deref() else {
|
||||
return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_owned());
|
||||
@@ -187,13 +187,12 @@ impl SystemUpdateConfig {
|
||||
|
||||
fn release_cache_key(&self) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}|{}",
|
||||
"{}|{}|{}|{}|{}",
|
||||
self.update_repository.as_deref().unwrap_or_default(),
|
||||
self.github_api_base,
|
||||
self.version,
|
||||
self.deployment_mode,
|
||||
self.build_type,
|
||||
self.update_channel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,12 +214,13 @@ impl SystemUpdateConfig {
|
||||
}
|
||||
|
||||
/// gateway-admin 消费的真实进程/文件系统操作实现。
|
||||
#[derive(Clone)]
|
||||
pub struct ProcessSystemOperations {
|
||||
cancellation: CancellationToken,
|
||||
events: UpdateEvents,
|
||||
config: SystemUpdateConfig,
|
||||
operation_lock: AsyncMutex<()>,
|
||||
release_cache: ReleaseCache,
|
||||
events: Arc<UpdateEvents>,
|
||||
config: Arc<SystemUpdateConfig>,
|
||||
operation_lock: Arc<AsyncMutex<()>>,
|
||||
release_cache: Arc<ReleaseCache>,
|
||||
}
|
||||
|
||||
impl ProcessSystemOperations {
|
||||
@@ -228,110 +228,117 @@ impl ProcessSystemOperations {
|
||||
pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self {
|
||||
Self {
|
||||
cancellation,
|
||||
events: UpdateEvents::default(),
|
||||
config,
|
||||
operation_lock: AsyncMutex::const_new(()),
|
||||
release_cache: ReleaseCache::default(),
|
||||
events: Arc::new(UpdateEvents::default()),
|
||||
config: Arc::new(config),
|
||||
operation_lock: Arc::new(AsyncMutex::new(())),
|
||||
release_cache: Arc::new(ReleaseCache::default()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_update_inner(
|
||||
fn start_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
let _operation = self
|
||||
.operation_lock
|
||||
.try_lock()
|
||||
let operation_lock = Arc::clone(&self.operation_lock)
|
||||
.try_lock_owned()
|
||||
.map_err(|_| conflict("system operation is already running"))?;
|
||||
if self.cancellation.is_cancelled() {
|
||||
return Err(conflict("服务正在关闭"));
|
||||
}
|
||||
if let Some(reason) = self.config.update_support_error() {
|
||||
self.events
|
||||
.error_terminal(None, Some("preflight"), reason.clone());
|
||||
return Err(conflict(reason));
|
||||
}
|
||||
let target = confirmed_target(target_version)?;
|
||||
if versions_cross_major(&self.config.version, &target).unwrap_or(false) {
|
||||
let reason = "跨大版本升级不提供自动更新,请按发布说明手动迁移";
|
||||
self.events.error_terminal(None, Some("preflight"), reason);
|
||||
return Err(conflict(reason));
|
||||
if let Err(error) = validate_update_target(&self.config.version, &target) {
|
||||
self.events
|
||||
.error_terminal(None, Some("preflight"), error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
|
||||
let status = read_status(&self.config.update_state_file, &self.config.version)?;
|
||||
if status.need_restart {
|
||||
return Err(conflict("更新已完成,请先重启服务"));
|
||||
}
|
||||
let operation_id = operation_id("update");
|
||||
let mut operation = UpdateOperation::start(
|
||||
&self.config.update_state_file,
|
||||
&operation_id,
|
||||
&target,
|
||||
&self.config.version,
|
||||
file_lock,
|
||||
)?;
|
||||
let accepted = SystemOperationAccepted::Update {
|
||||
operation_id: operation_id.clone(),
|
||||
deployment_mode: self.config.deployment_mode.clone(),
|
||||
message: "更新已开始".to_owned(),
|
||||
target_version: target.clone(),
|
||||
};
|
||||
let service = self.clone();
|
||||
// 任务持有互斥锁和落盘守卫,HTTP 断开不会取消更新;Host 关闭仍会收敛终态。
|
||||
drop(tokio::spawn(async move {
|
||||
let _operation_lock = operation_lock;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")),
|
||||
result = service.perform_update_inner(&target, &operation_id) => result,
|
||||
};
|
||||
if let Err(error) = operation.complete(&result) {
|
||||
tracing::warn!(error = %error, "系统更新终态落盘失败");
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(()) => service.events.success_terminal(
|
||||
Some(&operation_id),
|
||||
Some("done"),
|
||||
"更新文件已替换,等待服务重启生效",
|
||||
),
|
||||
Err(error) => service.events.error_terminal(
|
||||
Some(&operation_id),
|
||||
Some("failed"),
|
||||
error.to_string(),
|
||||
),
|
||||
}
|
||||
}));
|
||||
Ok(accepted)
|
||||
}
|
||||
|
||||
async fn perform_update_inner(
|
||||
&self,
|
||||
target: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<(), OperationError> {
|
||||
let repository = self
|
||||
.config
|
||||
.update_repository
|
||||
.as_deref()
|
||||
.ok_or_else(|| conflict("update repository is not configured"))?;
|
||||
self.events
|
||||
.info(None, Some("release"), "正在获取最新 Release 信息");
|
||||
let release = match fetch_latest(
|
||||
self.events.info(
|
||||
Some(operation_id),
|
||||
Some("release"),
|
||||
"正在获取最新 Release 信息",
|
||||
);
|
||||
let release = fetch_latest(
|
||||
&self.config.github_api_base,
|
||||
repository,
|
||||
&self.config.update_channel,
|
||||
&self.config.version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(release) => release,
|
||||
Err(error) => {
|
||||
self.events
|
||||
.error_terminal(None, Some("release"), error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
.await?
|
||||
.ok_or_else(|| conflict("当前发行通道没有可用更新"))?;
|
||||
let detail = detail_from_release(&self.config, &release);
|
||||
if detail.latest_version != target {
|
||||
self.events.warning_terminal(
|
||||
None,
|
||||
Some("release"),
|
||||
"remote latest version changed; confirm again",
|
||||
);
|
||||
return Err(conflict("remote latest version changed"));
|
||||
return Err(conflict("远端最新版本已变化,请重新确认"));
|
||||
}
|
||||
if !detail.has_update {
|
||||
self.events
|
||||
.warning_terminal(None, Some("release"), "already up to date");
|
||||
return Err(conflict("already up to date"));
|
||||
return Err(conflict("当前没有可用更新"));
|
||||
}
|
||||
|
||||
let operation_id = operation_id("update");
|
||||
let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?;
|
||||
set_running(
|
||||
&self.config.update_state_file,
|
||||
&operation_id,
|
||||
SystemOperationKind::Update,
|
||||
Some(&target),
|
||||
&self.config.version,
|
||||
)?;
|
||||
self.events.info(
|
||||
Some(&operation_id),
|
||||
Some(operation_id),
|
||||
Some("prepare"),
|
||||
format!("准备更新到 v{target}"),
|
||||
);
|
||||
let result = self.install_release(&release, &target, &operation_id).await;
|
||||
match &result {
|
||||
Ok(()) => self.events.success_terminal(
|
||||
Some(&operation_id),
|
||||
Some("done"),
|
||||
"更新文件已替换,等待服务重启生效",
|
||||
),
|
||||
Err(error) => {
|
||||
self.events
|
||||
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
|
||||
}
|
||||
}
|
||||
finish(
|
||||
&self.config.update_state_file,
|
||||
&operation_id,
|
||||
SystemOperationKind::Update,
|
||||
result.as_ref().ok().map(|()| target.clone()),
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
);
|
||||
drop(file_lock);
|
||||
result?;
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id,
|
||||
deployment_mode: self.config.deployment_mode.clone(),
|
||||
message: "更新完成,请重启服务。".to_owned(),
|
||||
need_restart: true,
|
||||
target_version: target,
|
||||
})
|
||||
self.install_release(&release, target, operation_id).await
|
||||
}
|
||||
|
||||
async fn install_release(
|
||||
@@ -428,7 +435,9 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
git_sha: self.config.git_sha.clone(),
|
||||
build_time: self.config.build_time.clone(),
|
||||
deployment_mode: self.config.deployment_mode.clone(),
|
||||
update_channel: self.config.update_channel.clone(),
|
||||
update_channel: version_channel(&self.config.version)
|
||||
.map(|channel| channel.label().to_owned())
|
||||
.unwrap_or_else(|| "unknown".to_owned()),
|
||||
latest_version: detail.latest_version,
|
||||
has_update: detail.has_update,
|
||||
update_cached: detail.cached,
|
||||
@@ -470,11 +479,17 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, OperationError> {
|
||||
self.perform_update_inner(target_version).await
|
||||
self.start_update(target_version)
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, OperationError> {
|
||||
read_status(&self.config.update_state_file)
|
||||
if let Ok(_operation) = self.operation_lock.try_lock() {
|
||||
recover_interrupted(
|
||||
&self.config.update_state_file,
|
||||
&self.config.update_lock_file,
|
||||
)?;
|
||||
}
|
||||
read_status(&self.config.update_state_file, &self.config.version)
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, OperationError> {
|
||||
@@ -495,6 +510,13 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
&self.config.version,
|
||||
)?;
|
||||
let result = rollback_release(&self.config);
|
||||
finish(
|
||||
&self.config.update_state_file,
|
||||
&operation_id,
|
||||
SystemOperationKind::Rollback,
|
||||
None,
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)?;
|
||||
match &result {
|
||||
Ok(()) => self.events.success_terminal(
|
||||
Some(&operation_id),
|
||||
@@ -506,13 +528,6 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
.error_terminal(Some(&operation_id), Some("failed"), error.to_string())
|
||||
}
|
||||
}
|
||||
finish(
|
||||
&self.config.update_state_file,
|
||||
&operation_id,
|
||||
SystemOperationKind::Rollback,
|
||||
None,
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
);
|
||||
drop(file_lock);
|
||||
result?;
|
||||
Ok(SystemOperationAccepted::Rollback {
|
||||
@@ -613,22 +628,6 @@ impl UpdateEvents {
|
||||
);
|
||||
}
|
||||
|
||||
fn warning_terminal(
|
||||
&self,
|
||||
operation_id: Option<&str>,
|
||||
step: Option<&str>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
self.emit(
|
||||
SystemUpdateEventLevel::Warning,
|
||||
operation_id,
|
||||
step,
|
||||
message,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn success_terminal(
|
||||
&self,
|
||||
operation_id: Option<&str>,
|
||||
|
||||
@@ -12,6 +12,27 @@ use super::{OperationError, SystemUpdateConfig, conflict, invalid, upstream};
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
const CACHE_TTL: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UpdateChannel {
|
||||
Stable,
|
||||
Alpha,
|
||||
Beta,
|
||||
Rc,
|
||||
Experimental,
|
||||
}
|
||||
|
||||
impl UpdateChannel {
|
||||
pub(crate) fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Beta => "beta",
|
||||
Self::Rc => "rc",
|
||||
Self::Experimental => "exp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct GitHubRelease {
|
||||
pub(crate) tag_name: String,
|
||||
@@ -49,6 +70,9 @@ impl ReleaseCache {
|
||||
config: &SystemUpdateConfig,
|
||||
refresh: bool,
|
||||
) -> Result<SystemUpdateDetail, OperationError> {
|
||||
if let Some(reason) = config.update_support_error() {
|
||||
return Ok(super::base_update_detail(config, Some(reason), None));
|
||||
}
|
||||
let repository = config
|
||||
.update_repository
|
||||
.as_deref()
|
||||
@@ -59,9 +83,12 @@ impl ReleaseCache {
|
||||
if !refresh && let Some(detail) = self.cached(&key).await {
|
||||
return Ok(detail);
|
||||
}
|
||||
match fetch_latest(&config.github_api_base, repository, &config.update_channel).await {
|
||||
match fetch_latest(&config.github_api_base, repository, &config.version).await {
|
||||
Ok(release) => {
|
||||
let detail = detail_from_release(config, &release);
|
||||
let detail = release.as_ref().map_or_else(
|
||||
|| super::base_update_detail(config, config.update_support_error(), None),
|
||||
|release| detail_from_release(config, release),
|
||||
);
|
||||
*self.entry.lock().await = Some(CachedRelease {
|
||||
key,
|
||||
detail: detail.clone(),
|
||||
@@ -69,7 +96,11 @@ impl ReleaseCache {
|
||||
});
|
||||
Ok(detail)
|
||||
}
|
||||
Err(error) => self.cached(&key).await.ok_or(error),
|
||||
Err(error) => {
|
||||
// 强制检查失败后清掉旧结果,避免后续版本查询再次显示旧的更新标记。
|
||||
*self.entry.lock().await = None;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,35 +115,66 @@ impl ReleaseCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先返回可升级的最高版本,没有候选时保留当前版本的发布信息。
|
||||
pub(crate) async fn fetch_latest(
|
||||
api_base: &str,
|
||||
repository: &str,
|
||||
update_channel: &str,
|
||||
) -> Result<GitHubRelease, OperationError> {
|
||||
current_version: &str,
|
||||
) -> Result<Option<GitHubRelease>, OperationError> {
|
||||
validate_api_base(api_base).map_err(conflict)?;
|
||||
validate_repository(repository)?;
|
||||
let base = api_base.trim_end_matches('/');
|
||||
// GitHub 的 /releases/latest 永远只返回正式版;preview 渠道需要覆盖
|
||||
// prerelease,因此改查列表端点并取最新一条非草稿 Release。
|
||||
if update_channel == "preview" {
|
||||
let releases: Vec<GitHubRelease> =
|
||||
fetch_release_json(&format!("{base}/{repository}/releases?per_page=10")).await?;
|
||||
return releases
|
||||
.into_iter()
|
||||
.find(|release| !release.draft)
|
||||
.ok_or_else(|| upstream("no published release available"));
|
||||
}
|
||||
fetch_release_json(&format!("{base}/{repository}/releases/latest")).await
|
||||
}
|
||||
|
||||
async fn fetch_release_json<T: serde::de::DeserializeOwned>(
|
||||
url: &str,
|
||||
) -> Result<T, OperationError> {
|
||||
let current = semver::Version::parse(&normalize_version(current_version))
|
||||
.map_err(|_| invalid("current version is invalid"))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| upstream(format!("failed to create release client: {error}")))?;
|
||||
|
||||
// 所有发行线先过滤再排序,避免更高大版本或其他实验遮住仍可安装的更新。
|
||||
let mut latest: Option<(semver::Version, GitHubRelease)> = None;
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let releases: Vec<GitHubRelease> = fetch_release_json(
|
||||
&client,
|
||||
&format!("{base}/{repository}/releases?per_page=100&page={page}"),
|
||||
)
|
||||
.await?;
|
||||
let last_page = releases.len() < 100;
|
||||
for release in releases {
|
||||
let Some(version) = eligible_release_version(&release, ¤t) else {
|
||||
continue;
|
||||
};
|
||||
if latest
|
||||
.as_ref()
|
||||
.is_none_or(|(previous, _)| version.cmp_precedence(previous).is_gt())
|
||||
{
|
||||
latest = Some((version, release));
|
||||
}
|
||||
}
|
||||
if last_page {
|
||||
return Ok(latest.map(|(_, release)| release));
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn eligible_release_version(
|
||||
release: &GitHubRelease,
|
||||
current: &semver::Version,
|
||||
) -> Option<semver::Version> {
|
||||
let version = semver::Version::parse(&normalize_version(&release.tag_name)).ok()?;
|
||||
(!release.draft
|
||||
&& release.prerelease != version.pre.is_empty()
|
||||
&& (version == *current || update_target_allowed(current, &version)))
|
||||
.then_some(version)
|
||||
}
|
||||
|
||||
async fn fetch_release_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<T, OperationError> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
@@ -136,24 +198,26 @@ pub(crate) fn detail_from_release(
|
||||
config: &SystemUpdateConfig,
|
||||
release: &GitHubRelease,
|
||||
) -> SystemUpdateDetail {
|
||||
let latest = normalize_version(&release.tag_name);
|
||||
let newer = (config.update_channel != "stable" || !release.prerelease)
|
||||
&& version_is_newer(&config.version, &latest).unwrap_or(false);
|
||||
let cross_major = versions_cross_major(&config.version, &latest).unwrap_or(false);
|
||||
let unsupported_reason = config.update_support_error();
|
||||
let latest_version = normalize_version(&release.tag_name);
|
||||
let has_update = validate_update_target(&config.version, &latest_version).is_ok();
|
||||
if unsupported_reason.is_some()
|
||||
|| (!has_update && latest_version != normalize_version(&config.version))
|
||||
{
|
||||
return super::base_update_detail(config, unsupported_reason, None);
|
||||
}
|
||||
SystemUpdateDetail {
|
||||
current_version: config.version.clone(),
|
||||
latest_version: latest.clone(),
|
||||
has_update: newer && !cross_major,
|
||||
latest_version,
|
||||
has_update,
|
||||
deployment_mode: config.deployment_mode.clone(),
|
||||
build_type: config.build_type.clone(),
|
||||
release_url: release.html_url.clone(),
|
||||
notes: release.body.clone().or_else(|| release.name.clone()),
|
||||
cached: false,
|
||||
update_supported: config.update_support_error().is_none(),
|
||||
unsupported_reason: config.update_support_error(),
|
||||
warning: (newer && cross_major).then(|| {
|
||||
format!("检测到新的大版本 v{latest}:跨大版本升级不提供自动更新,请按发布说明手动迁移")
|
||||
}),
|
||||
update_supported: true,
|
||||
unsupported_reason: None,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,17 +342,68 @@ fn normalize_version(version: &str) -> String {
|
||||
version.trim().trim_start_matches('v').to_owned()
|
||||
}
|
||||
|
||||
fn version_is_newer(current: &str, latest: &str) -> Option<bool> {
|
||||
let current = semver::Version::parse(&normalize_version(current)).ok()?;
|
||||
let latest = semver::Version::parse(&normalize_version(latest)).ok()?;
|
||||
Some(latest > current)
|
||||
fn channel_for_version(version: &semver::Version) -> Option<UpdateChannel> {
|
||||
if version.pre.is_empty() {
|
||||
return Some(UpdateChannel::Stable);
|
||||
}
|
||||
let (name, sequence) = version.pre.as_str().split_once('.')?;
|
||||
if sequence.is_empty() || sequence == "0" || !sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match name {
|
||||
"alpha" => Some(UpdateChannel::Alpha),
|
||||
"beta" => Some(UpdateChannel::Beta),
|
||||
"rc" => Some(UpdateChannel::Rc),
|
||||
"exp" => Some(UpdateChannel::Experimental),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动更新只在同 major 内提供;跨大版本的配置与数据迁移契约不保证兼容。
|
||||
pub(crate) fn versions_cross_major(current: &str, latest: &str) -> Option<bool> {
|
||||
let current = semver::Version::parse(&normalize_version(current)).ok()?;
|
||||
let latest = semver::Version::parse(&normalize_version(latest)).ok()?;
|
||||
Some(current.major != latest.major)
|
||||
pub(crate) fn version_channel(version: &str) -> Option<UpdateChannel> {
|
||||
let version = semver::Version::parse(&normalize_version(version)).ok()?;
|
||||
channel_for_version(&version)
|
||||
}
|
||||
|
||||
/// 检查更新与执行更新共用同一规则;构建元数据不构成更新。
|
||||
fn update_target_allowed(current: &semver::Version, target: &semver::Version) -> bool {
|
||||
use UpdateChannel::{Alpha, Beta, Experimental, Rc, Stable};
|
||||
|
||||
if current.major != target.major || !target.cmp_precedence(current).is_gt() {
|
||||
return false;
|
||||
}
|
||||
let (Some(current_channel), Some(target_channel)) =
|
||||
(channel_for_version(current), channel_for_version(target))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if current_channel == Stable {
|
||||
return target_channel == Stable;
|
||||
}
|
||||
// 预发行只承接本轮目标版本;exp 的相同基线也只代表同一轮实验。
|
||||
if (current.minor, current.patch) != (target.minor, target.patch) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
(current_channel, target_channel),
|
||||
(Alpha, Alpha | Beta | Rc | Stable)
|
||||
| (Beta, Beta | Rc | Stable)
|
||||
| (Rc, Rc | Stable)
|
||||
| (Experimental, Experimental)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_update_target(current: &str, target: &str) -> Result<(), OperationError> {
|
||||
let current = semver::Version::parse(&normalize_version(current))
|
||||
.map_err(|_| conflict("当前版本格式无效"))?;
|
||||
let target = semver::Version::parse(&normalize_version(target))
|
||||
.map_err(|_| invalid("target version is invalid"))?;
|
||||
if !update_target_allowed(¤t, &target) {
|
||||
return Err(conflict(
|
||||
"当前版本不允许更新到此目标:不支持跨通道、跨发行线、跨大版本或降级",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repository_character(character: char) -> bool {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -154,8 +155,101 @@ impl Drop for UpdateTempDir {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新任务拥有持久化终态的责任,Future 在取消或 panic 时析构也必须收尾。
|
||||
pub(crate) struct UpdateOperation {
|
||||
path: PathBuf,
|
||||
operation_id: String,
|
||||
target: String,
|
||||
completed: bool,
|
||||
_file_lock: OperationFileLock,
|
||||
}
|
||||
|
||||
impl UpdateOperation {
|
||||
pub(crate) fn start(
|
||||
path: &Path,
|
||||
operation_id: &str,
|
||||
target: &str,
|
||||
current_version: &str,
|
||||
file_lock: OperationFileLock,
|
||||
) -> Result<Self, OperationError> {
|
||||
set_running(
|
||||
path,
|
||||
operation_id,
|
||||
SystemOperationKind::Update,
|
||||
Some(target),
|
||||
current_version,
|
||||
)?;
|
||||
Ok(Self {
|
||||
path: path.to_owned(),
|
||||
operation_id: operation_id.to_owned(),
|
||||
target: target.to_owned(),
|
||||
completed: false,
|
||||
_file_lock: file_lock,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn complete(
|
||||
&mut self,
|
||||
result: &Result<(), OperationError>,
|
||||
) -> Result<(), OperationError> {
|
||||
finish(
|
||||
&self.path,
|
||||
&self.operation_id,
|
||||
SystemOperationKind::Update,
|
||||
result.as_ref().ok().map(|()| self.target.clone()),
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)?;
|
||||
self.completed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UpdateOperation {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed
|
||||
&& let Err(error) = finish(
|
||||
&self.path,
|
||||
&self.operation_id,
|
||||
SystemOperationKind::Update,
|
||||
None,
|
||||
Some("更新任务已中断,请重新发起更新".to_owned()),
|
||||
)
|
||||
{
|
||||
tracing::warn!(error = %error, "收敛中断的系统更新状态失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只在拿到进程内锁后调用,旧版本断连遗留且已无执行者的 running 不能永久保留。
|
||||
pub(crate) fn recover_interrupted(path: &Path, lock_path: &Path) -> Result<(), OperationError> {
|
||||
let state = read_persisted(path)?;
|
||||
if !matches!(state.operation.status, PersistedStatus::Running) {
|
||||
return Ok(());
|
||||
}
|
||||
let _lock = match OperationFileLock::acquire(lock_path) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) if error.kind() == super::SystemOperationErrorKind::Conflict => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if let (Some(operation_id), Some(kind)) = (state.operation.operation_id, state.operation.kind) {
|
||||
finish(
|
||||
path,
|
||||
&operation_id,
|
||||
kind.into(),
|
||||
None,
|
||||
Some("更新任务已中断,请重新发起更新".to_owned()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn operation_id(kind: &str) -> String {
|
||||
format!("sysop-{kind}-{}", Utc::now().timestamp_millis())
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
format!(
|
||||
"sysop-{kind}-{}-{}",
|
||||
Utc::now().timestamp_millis(),
|
||||
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_running(
|
||||
@@ -188,16 +282,10 @@ pub(crate) fn finish(
|
||||
kind: SystemOperationKind,
|
||||
version: Option<String>,
|
||||
error: Option<String>,
|
||||
) {
|
||||
let mut state = match read_persisted(path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "读取系统更新状态失败");
|
||||
return;
|
||||
}
|
||||
};
|
||||
) -> Result<(), OperationError> {
|
||||
let mut state = read_persisted(path)?;
|
||||
if state.operation.operation_id.as_deref() != Some(operation_id) {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(error) = error {
|
||||
state.operation.status = PersistedStatus::Failed;
|
||||
@@ -222,15 +310,20 @@ pub(crate) fn finish(
|
||||
state.operation.target_version = version;
|
||||
}
|
||||
state.operation.finished_at = Some(Utc::now().to_rfc3339());
|
||||
if let Err(error) = write_persisted(path, &state) {
|
||||
tracing::warn!(error = %error, "写入系统更新状态失败");
|
||||
}
|
||||
write_persisted(path, &state)
|
||||
}
|
||||
|
||||
pub(crate) fn read_status(path: &Path) -> Result<SystemUpdateStatus, OperationError> {
|
||||
pub(crate) fn read_status(
|
||||
path: &Path,
|
||||
running_version: &str,
|
||||
) -> Result<SystemUpdateStatus, OperationError> {
|
||||
let state = read_persisted(path)?;
|
||||
let operation = state.operation;
|
||||
Ok(SystemUpdateStatus {
|
||||
need_restart: matches!(operation.status, PersistedStatus::Succeeded)
|
||||
&& state.current_version.as_deref().is_some_and(|version| {
|
||||
version.trim_start_matches('v') != running_version.trim_start_matches('v')
|
||||
}),
|
||||
previous_version: state.previous_version,
|
||||
current_version: state.current_version,
|
||||
operation: SystemOperationState {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
let replacement = postgres::ControlPlaneReplacement {
|
||||
settings: postgres::RuntimeSettingsUpdate {
|
||||
openai_client_profile: command.openai_client_profile,
|
||||
xai_client_profile: command.xai_client_profile,
|
||||
admin_api_key: current.settings.admin_api_key,
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
@@ -232,6 +233,7 @@ pub(crate) fn admin_runtime_settings(
|
||||
.collect::<AdminStoreResult<ModelMappings>>()?;
|
||||
Ok(AdminRuntimeSettings {
|
||||
openai_client_profile: settings.openai_client_profile,
|
||||
xai_client_profile: settings.xai_client_profile,
|
||||
config_revision: admin_revision(settings.config_revision)?,
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
|
||||
@@ -112,6 +112,7 @@ impl fmt::Debug for ClientApiKeySecret {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientApiKeyRecord {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -273,6 +274,7 @@ pub struct ClientApiKeyPage {
|
||||
#[derive(Clone)]
|
||||
pub struct NewClientApiKey {
|
||||
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -306,6 +308,7 @@ impl NewClientApiKey {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientApiKeyDetails {
|
||||
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
@@ -363,6 +366,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
|
||||
let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new(
|
||||
"select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override,
|
||||
k.provider_request_profiles_json -> 'xai' as xai_client_profile_override,
|
||||
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
|
||||
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
|
||||
k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds
|
||||
@@ -423,6 +427,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
|
||||
require_nonempty(ENTITY, "id", id)?;
|
||||
let record = sqlx::query(
|
||||
"select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override,
|
||||
k.provider_request_profiles_json -> 'xai' as xai_client_profile_override,
|
||||
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
|
||||
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
|
||||
k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups,
|
||||
@@ -769,6 +774,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
.create_client_api_key(
|
||||
NewClientApiKey {
|
||||
openai_client_profile_override: command.openai_client_profile_override,
|
||||
xai_client_profile_override: command.xai_client_profile_override,
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
@@ -820,6 +826,7 @@ impl ClientKeyStore for PgAdminClientKeyStore {
|
||||
.update_client_api_key(
|
||||
UpdateClientApiKeyDetails {
|
||||
openai_client_profile_override: command.openai_client_profile_override,
|
||||
xai_client_profile_override: command.xai_client_profile_override,
|
||||
id: id.as_str().to_owned(),
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
@@ -978,6 +985,7 @@ fn admin_client_key_cursor(cursor: ClientApiKeyCursor) -> AdminStoreResult<Admin
|
||||
fn admin_client_key_record(record: ClientApiKeyRecord) -> AdminStoreResult<AdminClientKeyRecord> {
|
||||
Ok(AdminClientKeyRecord {
|
||||
openai_client_profile_override: record.openai_client_profile_override,
|
||||
xai_client_profile_override: record.xai_client_profile_override,
|
||||
id: ClientApiKeyId::new(record.id)
|
||||
.map_err(|_| admin_store_error(ENTITY, invalid("invalid client key id")))?,
|
||||
name: record.name,
|
||||
@@ -1029,7 +1037,8 @@ pub(crate) async fn insert_client_api_key_in_transaction(
|
||||
"insert into client_api_keys (
|
||||
id, name, label, key, enabled, max_concurrency, requests_per_minute,
|
||||
last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json
|
||||
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end)",
|
||||
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end
|
||||
|| case when $10::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $10::jsonb) end)",
|
||||
)
|
||||
.bind(&key.id)
|
||||
.bind(key.name.trim())
|
||||
@@ -1040,6 +1049,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
|
||||
.bind(key.budget.daily_usd.canonical())
|
||||
.bind(key.budget.weekly_usd.canonical())
|
||||
.bind(key.openai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
|
||||
.bind(key.xai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -1072,9 +1082,11 @@ pub(crate) async fn update_client_api_key_in_transaction(
|
||||
requests_per_minute = $5, updated_at = now(),
|
||||
daily_limit_usd = coalesce($6::text::numeric, daily_limit_usd),
|
||||
weekly_limit_usd = coalesce($7::text::numeric, weekly_limit_usd),
|
||||
provider_request_profiles_json = case when not $8 then provider_request_profiles_json
|
||||
when $9::jsonb is null then provider_request_profiles_json - 'openai'
|
||||
else jsonb_set(provider_request_profiles_json, '{openai}', $9) end
|
||||
provider_request_profiles_json = (provider_request_profiles_json
|
||||
- case when $8 then array['openai'] else array[]::text[] end
|
||||
- case when $10 then array['xai'] else array[]::text[] end)
|
||||
|| case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end
|
||||
|| case when $11::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $11::jsonb) end
|
||||
where id = $1",
|
||||
)
|
||||
.bind(&key.id)
|
||||
@@ -1091,6 +1103,13 @@ pub(crate) async fn update_client_api_key_in_transaction(
|
||||
.and_then(Option::as_ref)
|
||||
.map(|profile| sqlx::types::Json(profile.expose_to_provider())),
|
||||
)
|
||||
.bind(key.xai_client_profile_override.is_some())
|
||||
.bind(
|
||||
key.xai_client_profile_override
|
||||
.as_ref()
|
||||
.and_then(Option::as_ref)
|
||||
.map(|profile| sqlx::types::Json(profile.expose_to_provider())),
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("update client API key in transaction"))?;
|
||||
@@ -1246,6 +1265,12 @@ fn client_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<ClientApiK
|
||||
)
|
||||
.map_err(|_| invalid("invalid request profile"))?
|
||||
.map(|value| gateway_core::account::OpaqueProviderData::new(value.0)),
|
||||
xai_client_profile_override: row
|
||||
.try_get::<Option<sqlx::types::Json<serde_json::Map<String, serde_json::Value>>>, _>(
|
||||
"xai_client_profile_override",
|
||||
)
|
||||
.map_err(|_| invalid("invalid request profile"))?
|
||||
.map(|value| gateway_core::account::OpaqueProviderData::new(value.0)),
|
||||
id: row.try_get("id").map_err(|_| invalid("invalid id"))?,
|
||||
name: row.try_get("name").map_err(|_| invalid("invalid name"))?,
|
||||
label: row.try_get("label").map_err(|_| invalid("invalid label"))?,
|
||||
|
||||
@@ -449,6 +449,7 @@ pub(crate) fn admin_usage_list_record(
|
||||
};
|
||||
let billing = restore_billing_snapshot(billing, record.billing_snapshot_json.as_ref());
|
||||
Ok(admin_observability::UsageListRecord {
|
||||
client_api_key_name: record.client_api_key_name,
|
||||
id: record.id,
|
||||
endpoint: record.endpoint,
|
||||
client_transport: record.client_transport,
|
||||
@@ -457,6 +458,7 @@ pub(crate) fn admin_usage_list_record(
|
||||
provider_account_ref: record.provider_account_ref,
|
||||
provider_account_name: record.provider_account_name,
|
||||
provider_account_email: record.provider_account_email,
|
||||
provider_account_notes: record.provider_account_notes,
|
||||
provider_account_authentication_kind: record.provider_account_authentication_kind,
|
||||
upstream_model_id: record.upstream_model_id,
|
||||
upstream_transport: record.upstream_transport,
|
||||
@@ -720,6 +722,7 @@ pub(crate) fn admin_ops_error_page(
|
||||
|
||||
pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::OpsError {
|
||||
admin_observability::OpsError {
|
||||
client_api_key_name: error.client_api_key_name,
|
||||
source: error.source,
|
||||
event_id: error.event_id,
|
||||
request_id: error.request_id,
|
||||
@@ -777,6 +780,7 @@ pub(crate) fn usage_list_record_from_row(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> StoreResult<UsageListRecord> {
|
||||
Ok(UsageListRecord {
|
||||
client_api_key_name: get(row, "client_api_key_name")?,
|
||||
billing_snapshot_json: get(row, "billing_snapshot_json")?,
|
||||
id: get(row, "id")?,
|
||||
endpoint: get(row, "endpoint")?,
|
||||
@@ -786,6 +790,7 @@ pub(crate) fn usage_list_record_from_row(
|
||||
provider_account_ref: get(row, "provider_account_ref")?,
|
||||
provider_account_name: get(row, "provider_account_name")?,
|
||||
provider_account_email: get(row, "provider_account_email")?,
|
||||
provider_account_notes: get(row, "provider_account_notes")?,
|
||||
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
@@ -912,6 +917,7 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
|
||||
|
||||
pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<OpsErrorRecord> {
|
||||
Ok(OpsErrorRecord {
|
||||
client_api_key_name: get(row, "client_api_key_name")?,
|
||||
source: get(row, "source")?,
|
||||
event_id: get(row, "event_id")?,
|
||||
request_id: get(row, "request_id")?,
|
||||
|
||||
@@ -480,6 +480,7 @@ pub struct DashboardObservation {
|
||||
/// 使用记录列表所需的窄投影;完整执行、路由和客户端详情按 ID 单独读取。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageListRecord {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub billing_snapshot_json: Option<serde_json::Value>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
@@ -489,6 +490,7 @@ pub struct UsageListRecord {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_notes: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -693,6 +695,7 @@ pub struct DiagnosticObservation {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorRecord {
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub source: String,
|
||||
pub event_id: String,
|
||||
pub request_id: Option<String>,
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::super::*;
|
||||
const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
|
||||
mr.id as event_id, mr.id as request_id,
|
||||
nullif(mr.attempt_count, 0) as attempt_index,
|
||||
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
|
||||
mr.client_api_key_ref, client_key.name as client_api_key_name, 'model_request'::text as component, mr.operation,
|
||||
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
|
||||
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
@@ -32,11 +32,12 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
|
||||
mr.completed_at as occurred_at,
|
||||
'model_request:' || mr.id as stable_sort_id
|
||||
from model_requests mr
|
||||
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
|
||||
where true";
|
||||
|
||||
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
|
||||
oe.id as event_id, oe.model_request_id as request_id, oe.attempt_index,
|
||||
mr.client_api_key_ref, oe.component, oe.operation,
|
||||
mr.client_api_key_ref, client_key.name as client_api_key_name, oe.component, oe.operation,
|
||||
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
|
||||
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
|
||||
oe.provider_account_name_snapshot as provider_account_name,
|
||||
@@ -64,6 +65,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
|
||||
'ops_event:' || oe.id as stable_sort_id
|
||||
from ops_events oe
|
||||
left join model_requests mr on mr.id = oe.model_request_id
|
||||
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
|
||||
where true";
|
||||
|
||||
pub(crate) async fn list_ops_errors(
|
||||
|
||||
@@ -124,10 +124,11 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
"select mr.id, mr.endpoint, mr.client_transport, mr.requested_model_id,
|
||||
"select mr.id, client_key.name as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
|
||||
mr.provider_kind, mr.provider_account_ref,
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
mr.provider_account_email_snapshot as provider_account_email,
|
||||
account.notes as provider_account_notes,
|
||||
mr.provider_account_authentication_kind_snapshot
|
||||
as provider_account_authentication_kind,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
|
||||
@@ -141,7 +142,9 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
host(mr.client_ip) as client_ip, mr.user_agent,
|
||||
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
|
||||
mr.started_at
|
||||
from model_requests mr";
|
||||
from model_requests mr
|
||||
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
|
||||
left join provider_accounts account on account.id = mr.provider_account_ref";
|
||||
|
||||
pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
|
||||
"select mr.id, mr.client_api_key_ref, mr.config_revision,
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) fn encode_billing_snapshot(
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"longContextBillingApplied": b.long_context_billing_applied(),
|
||||
"image": b.image().map(|image| serde_json::json!({
|
||||
"inputTokens": image.input_tokens, "cachedTokens": image.cached_tokens,
|
||||
"input": image.input_amount.amount().canonical(),
|
||||
@@ -55,6 +56,11 @@ pub(crate) fn decode_billing_snapshot(
|
||||
})
|
||||
};
|
||||
Some(CalculatedBillingBreakdown {
|
||||
// 旧快照未记录长上下文计费事实,不根据当前价格倒推历史标识。
|
||||
long_context_billing_applied: value
|
||||
.get("longContextBillingApplied")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
image: match value.get("image").filter(|v| !v.is_null()) {
|
||||
Some(image) => {
|
||||
let amount = |key| {
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub config_revision: Revision,
|
||||
pub admin_api_key: Option<String>,
|
||||
pub refresh_margin_seconds: u64,
|
||||
@@ -110,6 +111,7 @@ impl fmt::Debug for RuntimeSettings {
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeSettingsUpdate {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub admin_api_key: Option<String>,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
@@ -373,7 +375,9 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
request_location_json = $23,
|
||||
request_location_enabled = $24,
|
||||
responses_max_decompressed_body_bytes = $25,
|
||||
provider_request_profiles_json = case when $26::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $26) end,
|
||||
provider_request_profiles_json = provider_request_profiles_json
|
||||
|| case when $26::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $26::jsonb) end
|
||||
|| case when $27::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $27::jsonb) end,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
@@ -416,6 +420,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
.map_err(|_| invalid_numeric())?,
|
||||
)
|
||||
.bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
|
||||
.bind(update.xai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
|
||||
@@ -504,6 +509,11 @@ fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult<Runtime
|
||||
.0
|
||||
.remove("openai")
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
xai_client_profile: row
|
||||
.provider_request_profiles_json
|
||||
.0
|
||||
.remove("xai")
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
config_revision: Revision::new(to_u64(row.config_revision)?)?,
|
||||
admin_api_key: row.admin_api_key,
|
||||
refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?,
|
||||
|
||||
@@ -171,6 +171,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
|
||||
.update_client_key(
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
id: client_key_id("key_group_one"),
|
||||
@@ -197,6 +198,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip
|
||||
.update_client_key(
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
id: client_key_id("key_group_one"),
|
||||
@@ -321,6 +323,7 @@ fn new_key(id: &str, group_ids: Vec<AccountGroupId>) -> NewClientKey {
|
||||
let marker = char::from(id.as_bytes().last().copied().unwrap_or(b'k'));
|
||||
NewClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
budget: Default::default(),
|
||||
id: client_key_id(id),
|
||||
name: id.to_owned(),
|
||||
|
||||
@@ -472,6 +472,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
|
||||
.unwrap();
|
||||
let update = UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new("key").unwrap(),
|
||||
name: "key".to_owned(),
|
||||
label: None,
|
||||
@@ -491,6 +492,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
|
||||
.update_client_key(
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
..update.clone()
|
||||
@@ -515,6 +517,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() {
|
||||
.update_client_key(
|
||||
UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
daily_limit_usd: Some("0".parse().unwrap()),
|
||||
weekly_limit_usd: None,
|
||||
..update
|
||||
|
||||
@@ -50,6 +50,7 @@ async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() {
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: id.clone(),
|
||||
name: format!("Migrated {index}"),
|
||||
label: None,
|
||||
@@ -105,6 +106,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
|
||||
let key = "legacy-key-case-sensitive!";
|
||||
let command = |id: &str| NewClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new(id).unwrap(),
|
||||
name: id.to_owned(),
|
||||
label: None,
|
||||
@@ -141,6 +143,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
|
||||
fn generated_client_key_format_remains_valid() {
|
||||
let key = NewClientApiKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
budget: Default::default(),
|
||||
id: "key-1".to_owned(),
|
||||
name: "default".to_owned(),
|
||||
@@ -174,6 +177,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() {
|
||||
};
|
||||
let create = |id: &str, name: &str| NewClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id: ClientApiKeyId::new(id).unwrap(),
|
||||
name: name.to_owned(),
|
||||
label: None,
|
||||
@@ -184,6 +188,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() {
|
||||
};
|
||||
let update = |id: ClientApiKeyId, name: &str| UpdateClientKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
label: None,
|
||||
@@ -639,6 +644,7 @@ fn client_key_debug_redacts_plaintext() {
|
||||
let secret = format!("sk_{}", "s".repeat(43));
|
||||
let key = NewClientApiKey {
|
||||
openai_client_profile_override: None,
|
||||
xai_client_profile_override: None,
|
||||
budget: Default::default(),
|
||||
id: "key-1".to_owned(),
|
||||
name: "default".to_owned(),
|
||||
@@ -717,12 +723,14 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
|
||||
budget: Default::default(),
|
||||
plaintext: "synthetic-profile-test-key".to_owned(),
|
||||
openai_client_profile_override: Some(profile.clone()),
|
||||
xai_client_profile_override: Some(profile.clone()),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(record.openai_client_profile_override, Some(profile.clone()));
|
||||
assert_eq!(record.xai_client_profile_override, Some(profile.clone()));
|
||||
let update = |override_value| UpdateClientKey {
|
||||
id: id.clone(),
|
||||
name: "profile".to_owned(),
|
||||
@@ -732,6 +740,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
openai_client_profile_override: override_value,
|
||||
xai_client_profile_override: None,
|
||||
};
|
||||
let (_, unchanged) = store
|
||||
.update_client_key(update(None), &context)
|
||||
@@ -741,6 +750,7 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
|
||||
unchanged.openai_client_profile_override,
|
||||
Some(profile.clone())
|
||||
);
|
||||
assert_eq!(unchanged.xai_client_profile_override, Some(profile.clone()));
|
||||
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
||||
.load_runtime_snapshot()
|
||||
.await
|
||||
@@ -749,6 +759,20 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance
|
||||
snapshot.client_api_keys[0].request_profiles.values().next(),
|
||||
Some(&profile)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.client_api_keys[0]
|
||||
.request_profiles
|
||||
.get(&gateway_core::routing::ProviderKind::new("xai").unwrap()),
|
||||
Some(&profile)
|
||||
);
|
||||
let mut clear_xai = update(None);
|
||||
clear_xai.xai_client_profile_override = Some(None);
|
||||
let (_, cleared_xai) = store.update_client_key(clear_xai, &context).await.unwrap();
|
||||
assert_eq!(cleared_xai.xai_client_profile_override, None);
|
||||
assert_eq!(
|
||||
cleared_xai.openai_client_profile_override,
|
||||
Some(profile.clone())
|
||||
);
|
||||
let (_, cleared) = store
|
||||
.update_client_key(update(Some(None)), &context)
|
||||
.await
|
||||
|
||||
@@ -465,6 +465,7 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
|
||||
Some("default".to_owned()),
|
||||
100,
|
||||
)
|
||||
.with_long_context_billing(true)
|
||||
.with_custom_multiplier(12500)
|
||||
.unwrap();
|
||||
let mut finalization = successful_core_finalization("req_billing_snapshot");
|
||||
@@ -484,6 +485,7 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
|
||||
else {
|
||||
panic!("persisted billing breakdown must be restored without provider recalculation");
|
||||
};
|
||||
assert!(saved.long_context_billing_applied);
|
||||
assert_eq!(saved.custom_multiplier_bps, 12500);
|
||||
assert_eq!(saved.total_amount.amount, "0.00075".parse().unwrap());
|
||||
sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1")
|
||||
@@ -496,6 +498,18 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(original.request.billing, after.request.billing);
|
||||
sqlx::query("update model_requests set billing_snapshot_json = billing_snapshot_json - 'longContextBillingApplied' where id = 'req_billing_snapshot'")
|
||||
.execute(&database.pool).await.unwrap();
|
||||
let legacy = observation
|
||||
.usage_record_detail("req_billing_snapshot")
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(admin_observability::UsageBilling::Calculated(legacy)) = legacy.request.billing else {
|
||||
panic!("legacy billing snapshot must remain readable");
|
||||
};
|
||||
assert!(!legacy.long_context_billing_applied);
|
||||
assert_eq!(legacy.total_amount, saved.total_amount);
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,125 @@ async fn usage_page_should_always_return_total() {
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.current_page, 1);
|
||||
assert_eq!(page.page_size, 10);
|
||||
assert_eq!(page.items[0].client_api_key_name, None);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
let Some(database) = TestDatabase::create("usage_account_notes").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_observability_facts(&database.pool, now)
|
||||
.await
|
||||
.expect("seed observability facts");
|
||||
sqlx::query(
|
||||
"insert into provider_accounts (
|
||||
id, provider_kind, name, email, notes, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind,
|
||||
provider_credentials_json, credential_revision, credential_observed_at,
|
||||
has_refresh_token, created_at, updated_at
|
||||
) select 'acct_team', provider_kind, name, email, '团队工作区', upstream_user_id,
|
||||
'team-workspace', 'team', authentication_kind,
|
||||
provider_credentials_json, credential_revision, credential_observed_at,
|
||||
has_refresh_token, created_at, updated_at
|
||||
from provider_accounts where id = 'acct_observe'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed same-email team account");
|
||||
sqlx::query(
|
||||
"update model_requests
|
||||
set provider_account_id = 'acct_team', provider_account_ref = 'acct_team',
|
||||
downstream_committed_at = completed_at
|
||||
where id = 'req_observe_uncommitted'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("assign completed request to team account");
|
||||
let range =
|
||||
admin_observability::TimeRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
|
||||
.expect("admin observability range");
|
||||
let store = admin_observability_store(&database.pool);
|
||||
|
||||
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
|
||||
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
|
||||
.bind(notes)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current account notes");
|
||||
let page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
filter: admin_observability::UsageFilter::default(),
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("usage list with current notes");
|
||||
assert_eq!(page.total, 2);
|
||||
assert_eq!(page.items.len(), 2);
|
||||
let personal = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|record| record.id == "req_observe_success")
|
||||
.expect("personal request");
|
||||
let team = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|record| record.id == "req_observe_uncommitted")
|
||||
.expect("team request");
|
||||
assert_eq!(
|
||||
personal.provider_account_ref.as_deref(),
|
||||
Some("acct_observe")
|
||||
);
|
||||
assert_eq!(personal.provider_account_email, team.provider_account_email);
|
||||
assert_eq!(personal.provider_account_notes.as_deref(), notes);
|
||||
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
|
||||
}
|
||||
|
||||
let dashboard = store
|
||||
.dashboard_summary(range, now)
|
||||
.await
|
||||
.expect("dashboard with current account notes");
|
||||
assert_eq!(
|
||||
dashboard
|
||||
.recent_requests
|
||||
.iter()
|
||||
.find(|record| record.provider_account_ref.as_deref() == Some("acct_team"))
|
||||
.expect("team dashboard request")
|
||||
.provider_account_notes
|
||||
.as_deref(),
|
||||
Some("团队工作区")
|
||||
);
|
||||
|
||||
sqlx::query("delete from provider_accounts where id = 'acct_team'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("delete team account");
|
||||
let page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
filter: admin_observability::UsageFilter {
|
||||
provider_account_ref: Some("acct_team".to_owned()),
|
||||
..admin_observability::UsageFilter::default()
|
||||
},
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("deleted account history");
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(
|
||||
page.items[0].provider_account_ref.as_deref(),
|
||||
Some("acct_team")
|
||||
);
|
||||
assert_eq!(
|
||||
page.items[0].provider_account_email.as_deref(),
|
||||
Some("account@example.invalid")
|
||||
);
|
||||
assert_eq!(page.items[0].provider_account_notes, None);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -383,6 +502,10 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
|
||||
assert_eq!(page.items.len() as u64, expected);
|
||||
if expected == 1 {
|
||||
assert_eq!(page.items[0].id, "req_observe_success");
|
||||
assert_eq!(
|
||||
page.items[0].client_api_key_name.as_deref(),
|
||||
Some("Production_%专用")
|
||||
);
|
||||
}
|
||||
let errors = repository
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
@@ -398,8 +521,55 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
|
||||
.unwrap();
|
||||
assert_eq!(errors.total, expected * 2, "error search: {search}");
|
||||
assert_eq!(errors.items.len() as u64, expected * 2);
|
||||
for error in &errors.items {
|
||||
assert_eq!(
|
||||
error.client_api_key_name.as_deref(),
|
||||
Some("Production_%专用")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in [Some("Renamed Key"), None] {
|
||||
if let Some(name) = name {
|
||||
sqlx::query("update client_api_keys set name = $1 where id = 'key_observe'")
|
||||
.bind(name)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
sqlx::query("delete from client_api_keys where id = 'key_observe'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let page = repository
|
||||
.list_usage_records(UsageRecordQuery {
|
||||
range,
|
||||
filter: UsageRecordFilter::default(),
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].client_api_key_name.as_deref(), name);
|
||||
let errors = repository
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range,
|
||||
filter: OpsErrorFilter::default(),
|
||||
current_page: 1,
|
||||
page_size: ObservabilityPageSize::new(10).unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(errors.total, 2);
|
||||
assert!(
|
||||
errors
|
||||
.items
|
||||
.iter()
|
||||
.all(|error| error.client_api_key_name.as_deref() == name)
|
||||
);
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -1519,7 +1519,7 @@ async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_recovery_resets_status_facts_without_changing_credentials_or_scheduling() {
|
||||
async fn account_enable_preserves_facts_and_explicit_recovery_clears_them() {
|
||||
const GROUP_ID: &str = "grp_00000000000000000000000000000070";
|
||||
let Some(database) = TestDatabase::create("provider_account_recovery").await else {
|
||||
return;
|
||||
@@ -1594,6 +1594,57 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
|
||||
.await
|
||||
.expect("load account before recovery");
|
||||
let store = admin_account_store(&database.pool);
|
||||
let facts_query = "select to_jsonb(a) - 'enabled' - 'updated_at'
|
||||
from provider_accounts a where id = 'acct_recovery'";
|
||||
let before_enable: serde_json::Value = sqlx::query_scalar(facts_query)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load disabled account facts");
|
||||
|
||||
let enabled = store
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
account_ids: vec!["acct_recovery".to_owned()],
|
||||
enabled: Some(true),
|
||||
concurrency_limit: None,
|
||||
weight: None,
|
||||
model_access: None,
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
&MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "request_account_enable".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("enable account scheduling");
|
||||
|
||||
assert_eq!(enabled.config_revision.get(), 2);
|
||||
assert!(
|
||||
repository
|
||||
.load_provider_account("acct_recovery")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.summary
|
||||
.enabled
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, serde_json::Value>(facts_query)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load enabled account facts"),
|
||||
before_enable
|
||||
);
|
||||
assert_eq!(
|
||||
account_group_ids(&database.pool, "acct_recovery").await,
|
||||
[GROUP_ID]
|
||||
);
|
||||
assert_eq!(
|
||||
audit_count(&database.pool, "request_account_enable").await,
|
||||
1
|
||||
);
|
||||
|
||||
let result = store
|
||||
.recover_account(
|
||||
@@ -1606,7 +1657,7 @@ async fn account_recovery_resets_status_facts_without_changing_credentials_or_sc
|
||||
.await
|
||||
.expect("recover account");
|
||||
|
||||
assert_eq!(result.config_revision.get(), 2);
|
||||
assert_eq!(result.config_revision.get(), 3);
|
||||
let current = sqlx::query_as::<_, RecoveredAccountRow>(
|
||||
"select enabled, credential_state, quota_access_state, quota_evidence,
|
||||
last_error_message, provider_quota_json, concurrency_limit, weight,
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::TestDatabase;
|
||||
fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
|
||||
RuntimeSettingsUpdate {
|
||||
openai_client_profile: None,
|
||||
xai_client_profile: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
admin_api_key: None,
|
||||
@@ -502,3 +503,53 @@ async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_i
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_profile_initialization_and_updates_preserve_other_providers() {
|
||||
use gateway_core::{
|
||||
account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort,
|
||||
routing::ProviderKind,
|
||||
};
|
||||
let Some(database) = TestDatabase::create("xai_profiles").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
let document = |version: &str| {
|
||||
OpaqueProviderData::new(
|
||||
serde_json::json!({"clientVersion":version})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
};
|
||||
let provider = ProviderKind::new("xai").unwrap();
|
||||
repository
|
||||
.initialize_request_profile(&provider, document("initial"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repository
|
||||
.initialize_request_profile(&provider, document("ignored"))
|
||||
.await
|
||||
.unwrap(),
|
||||
document("initial")
|
||||
);
|
||||
let mut update = settings_with_margin(3600);
|
||||
update.openai_client_profile = Some(document("openai"));
|
||||
update.xai_client_profile = Some(document("xai"));
|
||||
repository.update_runtime_settings(update).await.unwrap();
|
||||
let mut update = settings_with_margin(3600);
|
||||
update.xai_client_profile = Some(document("edited"));
|
||||
repository.update_runtime_settings(update).await.unwrap();
|
||||
let settings = repository.load_runtime_settings().await.unwrap();
|
||||
assert_eq!(settings.openai_client_profile, Some(document("openai")));
|
||||
assert_eq!(settings.xai_client_profile, Some(document("edited")));
|
||||
assert_eq!(
|
||||
repository
|
||||
.initialize_request_profile(&provider, document("old-yaml"))
|
||||
.await
|
||||
.unwrap(),
|
||||
document("edited")
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ hex = "0.4.3"
|
||||
# TLS 依赖按官方 Core 锁文件固定,包含用于约束传递依赖的直接声明。
|
||||
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
|
||||
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
|
||||
aws-lc-rs = "=1.16.2"
|
||||
aws-lc-rs = "=1.18.1"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = "=0.27.7"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
|
||||
@@ -38,7 +38,7 @@ 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 = { version = "=0.23.45", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12"] }
|
||||
rustls-native-certs = "=0.8.3"
|
||||
rustls-pki-types = "=1.14.0"
|
||||
secrecy = "0.10.3"
|
||||
|
||||
@@ -371,6 +371,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(CalculatedBillingBreakdown {
|
||||
// 历史总额只能核对费用拆分,不能证明当时保存过长上下文标记。
|
||||
long_context_billing_applied: false,
|
||||
image: None,
|
||||
custom_multiplier_bps: breakdown.custom_multiplier_bps(),
|
||||
input_amount: currency_cost(breakdown.input_amount())?,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! OpenAI Provider 启动配置与 Codex Desktop 请求画像校验。
|
||||
//! OpenAI Provider 启动配置;客户端身份由管理端设置持久化。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::credential::CodexQuotaRefreshPolicy;
|
||||
use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState};
|
||||
use crate::transport::profile::CodexResidency;
|
||||
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
|
||||
use crate::transport::websocket::CodexWebSocketPoolConfig;
|
||||
use crate::{
|
||||
@@ -41,7 +40,7 @@ pub struct OpenAiConfig {
|
||||
#[serde(default = "default_stream_max_retries")]
|
||||
pub stream_max_retries: u64,
|
||||
#[serde(default)]
|
||||
pub wire_profile: CodexWireProfileConfig,
|
||||
pub residency: Option<CodexResidency>,
|
||||
#[serde(skip)]
|
||||
identity_secret_path: PathBuf,
|
||||
}
|
||||
@@ -56,69 +55,10 @@ impl OpenAiConfig {
|
||||
self.ws_pool.validate()?;
|
||||
self.quota.validate()?;
|
||||
self.auth.validate()?;
|
||||
self.wire_profile.validate()?;
|
||||
self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn wire_profile_state(&self) -> CodexWireProfileState {
|
||||
// 官方发布基线独立于用户固定配置;旧 YAML 只用于首次导入管理设置。
|
||||
CodexWireProfileState::new(
|
||||
CodexWireProfileConfig {
|
||||
residency: self.wire_profile.residency,
|
||||
..CodexWireProfileConfig::default()
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 将旧部署选择转换为一次性初始化值;数据库已有选择时不覆盖。
|
||||
pub fn initial_client_profile(
|
||||
&self,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, OpenAiConfigError> {
|
||||
use crate::transport::profile::selection::{
|
||||
ClientKind, ClientPlatform, ClientProfileSelection, VersionMode,
|
||||
};
|
||||
let profile = &self.wire_profile;
|
||||
let baseline = CodexWireProfileConfig::default();
|
||||
let platform = match profile.os_type.as_str() {
|
||||
"Mac OS" | "Darwin" | "macOS" => ClientPlatform::Macos,
|
||||
"Linux" => ClientPlatform::Linux,
|
||||
"Windows" => ClientPlatform::Windows,
|
||||
_ => {
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.os_type",
|
||||
));
|
||||
}
|
||||
};
|
||||
let fixed = profile.codex_version != baseline.codex_version
|
||||
|| profile.desktop_version != baseline.desktop_version
|
||||
|| profile.desktop_build != baseline.desktop_build
|
||||
|| platform != ClientPlatform::Macos
|
||||
|| profile.arch != "arm64";
|
||||
let selection = ClientProfileSelection {
|
||||
client: ClientKind::Desktop,
|
||||
platform,
|
||||
version_mode: if fixed {
|
||||
VersionMode::Fixed
|
||||
} else {
|
||||
VersionMode::Latest
|
||||
},
|
||||
originator: (profile.originator != baseline.originator)
|
||||
.then(|| profile.originator.clone()),
|
||||
os_version: Some(profile.os_version.clone()),
|
||||
arch: Some(profile.arch.clone()),
|
||||
terminal: Some(profile.terminal.clone()),
|
||||
codex_version: fixed.then(|| profile.codex_version.clone()),
|
||||
desktop_version: fixed.then(|| profile.desktop_version.clone()),
|
||||
desktop_build: fixed.then(|| profile.desktop_build.clone()),
|
||||
};
|
||||
selection
|
||||
.document()
|
||||
.map_err(|_| OpenAiConfigError::InvalidField("openai.wire_profile"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.api.base_url
|
||||
@@ -174,7 +114,7 @@ impl Default for OpenAiConfig {
|
||||
quota: CodexQuotaSettings::default(),
|
||||
auth: CodexAuthSettings::default(),
|
||||
stream_max_retries: DEFAULT_STREAM_MAX_RETRIES,
|
||||
wire_profile: CodexWireProfileConfig::default(),
|
||||
residency: None,
|
||||
identity_secret_path: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
@@ -307,115 +247,8 @@ impl CodexAuthSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// 经审计固定的 Codex Desktop 上游请求画像。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct CodexWireProfileConfig {
|
||||
pub originator: String,
|
||||
/// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。
|
||||
pub codex_version: String,
|
||||
pub desktop_version: String,
|
||||
pub desktop_build: String,
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
pub arch: String,
|
||||
pub terminal: String,
|
||||
#[serde(default)]
|
||||
pub residency: Option<CodexResidency>,
|
||||
pub verified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for CodexWireProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.153.4".to_owned(),
|
||||
desktop_version: "26.901.51231".to_owned(),
|
||||
desktop_build: "8109".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
residency: None,
|
||||
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
|
||||
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexWireProfileConfig {
|
||||
fn validate(&self) -> Result<(), OpenAiConfigError> {
|
||||
for (field, value) in [
|
||||
("openai.wire_profile.originator", self.originator.as_str()),
|
||||
(
|
||||
"openai.wire_profile.codex_version",
|
||||
self.codex_version.as_str(),
|
||||
),
|
||||
(
|
||||
"openai.wire_profile.desktop_version",
|
||||
self.desktop_version.as_str(),
|
||||
),
|
||||
(
|
||||
"openai.wire_profile.desktop_build",
|
||||
self.desktop_build.as_str(),
|
||||
),
|
||||
("openai.wire_profile.os_type", self.os_type.as_str()),
|
||||
("openai.wire_profile.os_version", self.os_version.as_str()),
|
||||
("openai.wire_profile.arch", self.arch.as_str()),
|
||||
("openai.wire_profile.terminal", self.terminal.as_str()),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(OpenAiConfigError::InvalidField(field));
|
||||
}
|
||||
}
|
||||
if semver::Version::parse(&self.codex_version).is_err() {
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.codex_version",
|
||||
));
|
||||
}
|
||||
if !numeric_dotted_version(&self.desktop_version) {
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.desktop_version",
|
||||
));
|
||||
}
|
||||
if !self.desktop_build.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(OpenAiConfigError::InvalidField(
|
||||
"openai.wire_profile.desktop_build",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CodexWireProfileConfig> for CodexWireProfile {
|
||||
fn from(value: CodexWireProfileConfig) -> Self {
|
||||
Self {
|
||||
client_kind: crate::transport::profile::selection::ClientKind::Desktop,
|
||||
originator: value.originator,
|
||||
codex_version: value.codex_version,
|
||||
desktop_version: value.desktop_version,
|
||||
desktop_build: value.desktop_build,
|
||||
os_type: value.os_type,
|
||||
os_version: value.os_version,
|
||||
arch: value.arch,
|
||||
terminal: value.terminal,
|
||||
residency: value.residency,
|
||||
verified_at: value.verified_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OpenAiConfigError {
|
||||
#[error("OpenAI configuration field is invalid: {0}")]
|
||||
InvalidField(&'static str),
|
||||
}
|
||||
|
||||
fn numeric_dotted_version(value: &str) -> bool {
|
||||
let mut parts = value.split('.');
|
||||
let valid_parts = parts
|
||||
.by_ref()
|
||||
.filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.count();
|
||||
valid_parts >= 2 && valid_parts == value.split('.').count()
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ use snapshot::{
|
||||
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
pub(crate) const QUOTA_SCHEDULING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const QUOTA_HYDRATION_FAILURE_TTL: Duration = Duration::from_secs(5);
|
||||
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const EXHAUSTED_QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
|
||||
const PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const QUOTA_RESET_GRACE: Duration = Duration::from_secs(2 * 60);
|
||||
/// 首次 OAuth 异步观察失败时,由既有 quota worker 兜底重试的单轮上限。
|
||||
const INITIAL_QUOTA_SYNC_BATCH: usize = 100;
|
||||
// 5xx 上游拒绝的短退避重试预算;指数退避 1s/2s,吞掉瞬时抖动。
|
||||
@@ -394,15 +394,19 @@ impl CodexQuotaSchedulingProjection {
|
||||
fn reserve_periodic_refreshes(
|
||||
&self,
|
||||
accounts: Vec<ProviderAccount>,
|
||||
observed_snapshots: &BTreeMap<ProviderAccountId, CodexAccountQuotaSnapshot>,
|
||||
now: SystemTime,
|
||||
) -> Vec<ProviderAccount> {
|
||||
let candidates = accounts
|
||||
.into_iter()
|
||||
.filter_map(|account| quota_refresh_candidate(account, now))
|
||||
.filter_map(|account| {
|
||||
let snapshot = observed_snapshots.get(account.id());
|
||||
quota_refresh_candidate(account, snapshot, now)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_ids = candidates
|
||||
.iter()
|
||||
.map(|account| account.id().clone())
|
||||
.map(|(account, _)| account.id().clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let refreshed_at = Instant::now();
|
||||
let mut state = self
|
||||
@@ -413,18 +417,12 @@ impl CodexQuotaSchedulingProjection {
|
||||
.last_periodic_refresh_at
|
||||
.retain(|account_id, _| candidate_ids.contains(account_id));
|
||||
|
||||
// 正常账号只由真实请求的响应头和 `codex.rate_limits` 被动同步。
|
||||
// 已耗尽账号每 30 分钟复核,不能等待旧 reset:官方活动可能提前重置额度。
|
||||
// reset + 2 分钟可提前触发一次复核,给上游重置留出传播时间。
|
||||
// 已耗尽账号首次立即复核,之后每 30 分钟复核,以发现官方提前重置。
|
||||
// reset + 2 分钟额外触发一次复核,给上游重置留出传播时间。
|
||||
// 正常账号仅在非零用量窗口经过宽限期后参与,未更新时复用周期节流。
|
||||
let mut reserved = Vec::new();
|
||||
for account in candidates {
|
||||
if !periodic_quota_refresh_due(
|
||||
&state,
|
||||
account.id(),
|
||||
account.quota().reset_at(),
|
||||
now,
|
||||
refreshed_at,
|
||||
) {
|
||||
for (account, target_reset) in candidates {
|
||||
if !periodic_quota_refresh_due(&state, account.id(), target_reset, now, refreshed_at) {
|
||||
continue;
|
||||
}
|
||||
state.last_periodic_refresh_at.insert(
|
||||
@@ -440,9 +438,35 @@ impl CodexQuotaSchedulingProjection {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_refresh_candidate(account: ProviderAccount, now: SystemTime) -> Option<ProviderAccount> {
|
||||
(eligible_periodic_quota_refresh(&account, now) && account.quota().is_exhausted())
|
||||
.then_some(account)
|
||||
fn quota_refresh_candidate(
|
||||
account: ProviderAccount,
|
||||
snapshot: Option<&CodexAccountQuotaSnapshot>,
|
||||
now: SystemTime,
|
||||
) -> Option<(ProviderAccount, Option<SystemTime>)> {
|
||||
if !eligible_periodic_quota_refresh(&account, now) {
|
||||
return None;
|
||||
}
|
||||
if account.quota().is_exhausted() {
|
||||
let reset_at = account.quota().reset_at();
|
||||
return Some((account, reset_at));
|
||||
}
|
||||
// 正常账号首次进入周期候选也要等待宽限期,不能由“无刷新历史”绕过。
|
||||
let snapshot = snapshot?;
|
||||
let expired_window_reset = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
window.used_percent().is_some_and(|used| used > 0.0) || window.limit_reached()
|
||||
})
|
||||
.filter_map(CodexQuotaWindow::reset_at)
|
||||
.map(SystemTime::from)
|
||||
.filter(|reset| {
|
||||
reset
|
||||
.checked_add(QUOTA_RESET_GRACE)
|
||||
.is_some_and(|due_at| due_at <= now)
|
||||
})
|
||||
.min();
|
||||
expired_window_reset.map(|reset_at| (account, Some(reset_at)))
|
||||
}
|
||||
|
||||
fn periodic_quota_refresh_due(
|
||||
@@ -457,9 +481,9 @@ fn periodic_quota_refresh_due(
|
||||
.get(account_id)
|
||||
.is_none_or(|last| {
|
||||
monotonic_now.saturating_duration_since(last.monotonic_at)
|
||||
>= EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL
|
||||
>= PERIODIC_QUOTA_REFRESH_RETRY_INTERVAL
|
||||
|| reset_at
|
||||
.and_then(|reset| reset.checked_add(EXHAUSTED_QUOTA_RESET_GRACE))
|
||||
.and_then(|reset| reset.checked_add(QUOTA_RESET_GRACE))
|
||||
// 已在该边界之后复核过时回到周期重试,避免过期 reset 每轮触发。
|
||||
.is_some_and(|due_at| last.wall_at < due_at && due_at <= now)
|
||||
})
|
||||
@@ -781,8 +805,26 @@ impl CodexCredentialQuotaService {
|
||||
});
|
||||
let mut summary = CodexQuotaSyncSummary::default();
|
||||
let now = SystemTime::now();
|
||||
let initial = self.initial_quota_sync_accounts(&accounts, now).await?;
|
||||
let periodic = self.scheduling.reserve_periodic_refreshes(accounts, now);
|
||||
let account_ids = accounts
|
||||
.iter()
|
||||
.map(|account| account.id().clone())
|
||||
.collect::<Vec<_>>();
|
||||
let observed = self.store.get_quotas(&account_ids).await?;
|
||||
let observed_snapshots = observed
|
||||
.iter()
|
||||
.filter_map(|obs| {
|
||||
quota_snapshot_from_observation(obs)
|
||||
.map(|snapshot| (obs.account_id.clone(), snapshot))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let observed_ids = observed
|
||||
.into_iter()
|
||||
.map(|observation| observation.account_id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let initial = Self::initial_quota_sync_accounts(&accounts, &observed_ids, now);
|
||||
let periodic =
|
||||
self.scheduling
|
||||
.reserve_periodic_refreshes(accounts, &observed_snapshots, now);
|
||||
let accounts = initial
|
||||
.into_iter()
|
||||
.chain(periodic)
|
||||
@@ -857,28 +899,19 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
|
||||
/// `quota_observed_at` 为空代表首次异步观察尚未成功;不另建同步状态表。
|
||||
async fn initial_quota_sync_accounts(
|
||||
&self,
|
||||
fn initial_quota_sync_accounts(
|
||||
accounts: &[ProviderAccount],
|
||||
observed_ids: &BTreeSet<ProviderAccountId>,
|
||||
now: SystemTime,
|
||||
) -> Result<Vec<ProviderAccount>, CodexCredentialQuotaError> {
|
||||
let account_ids = accounts
|
||||
.iter()
|
||||
.map(|account| account.id().clone())
|
||||
.collect::<Vec<_>>();
|
||||
let observed = self.store.get_quotas(&account_ids).await?;
|
||||
let observed_ids = observed
|
||||
.into_iter()
|
||||
.map(|observation| observation.account_id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
Ok(accounts
|
||||
) -> Vec<ProviderAccount> {
|
||||
accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
!observed_ids.contains(account.id()) && eligible_initial_quota_sync(account, now)
|
||||
})
|
||||
.take(INITIAL_QUOTA_SYNC_BATCH)
|
||||
.cloned()
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
|
||||
|
||||
@@ -580,10 +580,25 @@ impl CodexCredentialSelector {
|
||||
})?;
|
||||
continue 'capacity;
|
||||
}
|
||||
// 只判断本次账号范围;空池、认证失效和租约失败不能伪装成额度耗尽。
|
||||
// 使用最初的排除集合,避免本轮临时跳过的繁忙账号丢失其可恢复语义。
|
||||
let mut statuses = candidates
|
||||
.iter()
|
||||
.filter(|candidate| !base_excluded.contains(candidate.account.id()))
|
||||
.map(|candidate| {
|
||||
candidate
|
||||
.account
|
||||
.status_projection(context.now, candidate.signals.cooldown)
|
||||
.status
|
||||
});
|
||||
let quota_exhausted = !diagnostic
|
||||
&& statuses.next() == Some(AccountStatus::QuotaExhausted)
|
||||
&& statuses.all(|status| status == AccountStatus::QuotaExhausted);
|
||||
return match shortest_retry {
|
||||
Some(retry_after) => Err(CredentialSelectionError::CapacityUnavailable {
|
||||
retry_after: Some(retry_after),
|
||||
}),
|
||||
None if quota_exhausted => Err(CredentialSelectionError::QuotaExhausted),
|
||||
None => Err(CredentialSelectionError::NoEligibleCredential),
|
||||
};
|
||||
};
|
||||
@@ -1450,6 +1465,8 @@ pub enum CredentialSelectionError {
|
||||
QueueRejected(#[from] QueueRejection),
|
||||
#[error("no eligible Codex account")]
|
||||
NoEligibleCredential,
|
||||
#[error("all eligible Codex accounts have exhausted their quota")]
|
||||
QuotaExhausted,
|
||||
#[error("Codex account capacity is unavailable")]
|
||||
CapacityUnavailable { retry_after: Option<Duration> },
|
||||
#[error("Codex account data is invalid")]
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::transport::profile::{
|
||||
};
|
||||
use crate::transport::{CodexWebSocketPool, build_reqwest_client};
|
||||
|
||||
pub use config::{CodexWireProfileConfig, OpenAiConfig, OpenAiConfigError};
|
||||
pub use config::{OpenAiConfig, OpenAiConfigError};
|
||||
pub use provider::{
|
||||
CodexProvider, CodexProviderConfigError, CodexProviderTransport, OFFICIAL_CODEX_BASE_PATH,
|
||||
OFFICIAL_CODEX_BASE_URL, openai_failure_affects_account_score,
|
||||
@@ -62,9 +62,9 @@ pub async fn initialize(
|
||||
let session_exclusions = ports.session_exclusions();
|
||||
let account_feedback = ports.account_feedback();
|
||||
let runtime_policy = ports.runtime_policy();
|
||||
let initial_profile = config
|
||||
.initial_client_profile()
|
||||
.map_err(OpenAiInitializeError::Config)?;
|
||||
let initial_profile = transport::profile::selection::ClientProfileSelection::default()
|
||||
.document()
|
||||
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
|
||||
let configured_profile = runtime_policy
|
||||
.initialize_request_profile(&provider_kind, initial_profile)
|
||||
.await
|
||||
@@ -72,7 +72,11 @@ pub async fn initialize(
|
||||
transport::profile::selection::ClientProfileSelection::parse(&configured_profile)
|
||||
.map_err(|_| OpenAiInitializeError::RuntimePolicy)?;
|
||||
let credential_state = ports.credential_state();
|
||||
let profile = config.wire_profile_state();
|
||||
let profile =
|
||||
transport::profile::CodexWireProfileState::new(transport::profile::CodexWireProfile {
|
||||
residency: config.residency,
|
||||
..Default::default()
|
||||
});
|
||||
let artifact_cache =
|
||||
CodexArtifactProfileCache::new(provider_kind.clone(), ports.artifact_profiles());
|
||||
let configured_build = profile.snapshot().desktop_build.parse::<u64>().ok();
|
||||
@@ -269,8 +273,6 @@ impl ProviderBundle {
|
||||
/// OpenAI 初始化失败的脱敏分类。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OpenAiInitializeError {
|
||||
#[error(transparent)]
|
||||
Config(OpenAiConfigError),
|
||||
#[error("OpenAI runtime policy is unavailable")]
|
||||
RuntimePolicy,
|
||||
#[error("OpenAI Provider kind is invalid")]
|
||||
|
||||
@@ -1339,12 +1339,8 @@ pub(super) fn map_upstream_failure(
|
||||
),
|
||||
error_message: failure.client_message,
|
||||
cyber_policy_failure,
|
||||
// 仅上游明确返回的容量/服务不可用计数;本地连接保护也会映射为
|
||||
// Unavailable,不能用通用 ProviderErrorKind 推断容量证据。
|
||||
upstream_capacity_failure: matches!(
|
||||
category,
|
||||
CodexFailureCategory::CapacityUnavailable | CodexFailureCategory::Unavailable
|
||||
),
|
||||
// 普通 5xx 与未知流内错误不足以证明容量拒绝,不能用于冻结账号。
|
||||
upstream_capacity_failure: capacity_unavailable,
|
||||
set_cookie_headers: failure.set_cookie_headers,
|
||||
rate_limit_headers: failure.rate_limit_headers,
|
||||
observation,
|
||||
|
||||
@@ -92,7 +92,7 @@ use crate::transport::{
|
||||
CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport,
|
||||
CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata,
|
||||
CodexResponseMetadataUpdates, CodexTransportMetrics, CodexUpstreamDiagnostics,
|
||||
CodexWebSocketPool, endpoint_url,
|
||||
CodexWebSocketPool, endpoint_url, normalize_non_codex_request_body,
|
||||
};
|
||||
|
||||
mod execution;
|
||||
@@ -549,6 +549,12 @@ impl Provider for CodexProvider {
|
||||
lease.installation_id(),
|
||||
account_scope,
|
||||
);
|
||||
if matches!(
|
||||
lease.authentication(),
|
||||
crate::credential::CodexRuntimeAuthentication::OAuth(_)
|
||||
) {
|
||||
normalize_non_codex_request_body(upstream_request.body_mut());
|
||||
}
|
||||
// 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。
|
||||
if let Some(location) = lease
|
||||
.account()
|
||||
|
||||
@@ -788,6 +788,15 @@ pub(super) fn map_selection_error(error: CredentialSelectionError) -> ProviderEr
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
),
|
||||
CredentialSelectionError::QuotaExhausted => provider_error(
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"All eligible accounts have exhausted their quota. Retry after quota resets or add an available account.",
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
Some("usage_limit_reached".to_owned()),
|
||||
)),
|
||||
CredentialSelectionError::InvalidCredential
|
||||
| CredentialSelectionError::Store
|
||||
| CredentialSelectionError::Coordinator
|
||||
|
||||
@@ -453,6 +453,11 @@ fn classify_upstream_failure(
|
||||
};
|
||||
}
|
||||
|
||||
// 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码。
|
||||
if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" {
|
||||
return CodexFailureCategory::InvalidRequest;
|
||||
}
|
||||
|
||||
if [code.as_str(), message.as_str(), body.as_str()]
|
||||
.into_iter()
|
||||
.any(is_model_unsupported)
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
//! 下游客户端模型请求正文的 Codex 协议兼容。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// 补齐 Codex 请求缺省字段并移除已确认不适用的顶层参数,不递归清洗业务正文。
|
||||
/// 非官方客户端及跨客户端历史回填的兼容入口;调用方必须已选定 Codex/OAuth 上游。
|
||||
/// 只处理已验证被拒绝的字段,不根据客户端品牌推断整份历史是否合法。
|
||||
pub(crate) fn normalize_non_codex_request_body(body: &mut Map<String, Value>) {
|
||||
let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
for item in input {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
continue;
|
||||
}
|
||||
// SDK 输出项的 status 不属于 Codex reasoning 输入合同;其他项的 status 可能合法。
|
||||
item.shift_remove("status");
|
||||
// Codex 只接受空 content 数组;仅在加密历史仍可回填时去掉冗余明文,
|
||||
// 没有加密内容的历史不自动丢弃,保留给上游明确拒绝。
|
||||
if item
|
||||
.get("encrypted_content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|content| !content.trim().is_empty())
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|content| !content.is_empty())
|
||||
{
|
||||
item.shift_remove("content");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。
|
||||
///
|
||||
/// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。
|
||||
/// 未知字段继续透传,不能因官方请求结构中没有某个字段就将其列入过滤规则。
|
||||
@@ -10,6 +41,36 @@ pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map<String,
|
||||
// 官方 Core/Desktop 显式发送 store=false;仅为缺字段的下游请求补齐,保留显式值。
|
||||
body.entry("store").or_insert(Value::Bool(false));
|
||||
|
||||
// 公开 Responses API 允许 `input` 为字符串(等价于一条 user 文本消息),
|
||||
// 而 Codex 后端只接受条目数组,否则返回 400 "Input must be a list"。
|
||||
// 这里按官方 ResponseItem::Message 的形状展开;其他非数组类型不猜测语义,交给上游判定。
|
||||
if let Some(Value::String(text)) = body.get_mut("input") {
|
||||
let text = std::mem::take(text);
|
||||
body.insert(
|
||||
"input".to_owned(),
|
||||
json!([{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
}]),
|
||||
);
|
||||
}
|
||||
|
||||
// Codex 上游拒绝显式 message 的 system role;沿用官方客户端的 developer
|
||||
// role 承载指令,只转换已确认的消息形状,保留内容与其他字段。
|
||||
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
|
||||
for item in input {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& item.get("role").and_then(Value::as_str) == Some("system")
|
||||
{
|
||||
item.insert("role".to_owned(), Value::String("developer".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for field in [
|
||||
// Pi 普通 Responses 适配将 maxTokens 映射为 max_output_tokens,
|
||||
// temperature 则原样写入;Pi 的 Codex 适配也可能发送 temperature。
|
||||
|
||||
@@ -5,4 +5,5 @@ mod body;
|
||||
mod headers;
|
||||
|
||||
pub(super) use body::normalize_codex_request_body;
|
||||
pub(crate) use body::normalize_non_codex_request_body;
|
||||
pub(super) use headers::is_non_codex_request_header;
|
||||
|
||||
@@ -19,6 +19,7 @@ mod response_meta;
|
||||
pub(crate) mod session;
|
||||
pub mod subscription;
|
||||
mod time;
|
||||
pub(crate) use downstream::normalize_non_codex_request_body;
|
||||
pub(crate) use endpoints::valid_upstream_base_url;
|
||||
pub mod tls;
|
||||
pub mod usage;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub use gateway_core::account::RequestLocation as CodexRequestLocation;
|
||||
|
||||
/// Codex Desktop 上游请求身份。
|
||||
///
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
|
||||
/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用
|
||||
/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CodexWireProfile {
|
||||
@@ -77,6 +77,25 @@ pub struct CodexWireProfile {
|
||||
pub verified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for CodexWireProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client_kind: ClientKind::Desktop,
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.153.4".to_owned(),
|
||||
desktop_version: "26.901.51231".to_owned(),
|
||||
desktop_build: "8109".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
residency: None,
|
||||
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。
|
||||
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexWireProfile {
|
||||
/// 按 bundled Core app-server 的官方格式生成最终 User-Agent。
|
||||
pub fn user_agent(&self) -> String {
|
||||
|
||||
@@ -513,8 +513,10 @@ fn openai_billing_breakdown_with_context(
|
||||
let pricing = model_pricing(model);
|
||||
let normalized_tier = normalize_service_tier(service_tier);
|
||||
let tier = pricing_tier(normalized_tier.as_deref())?;
|
||||
let standard_rates = effective_rates(pricing, custom, PricingTier::Standard, long_context)?;
|
||||
let selected_rates = effective_rates(pricing, custom, tier, long_context)?;
|
||||
let (standard_rates, _) =
|
||||
effective_rates(pricing, custom, PricingTier::Standard, long_context)?;
|
||||
let (selected_rates, long_context_billing_applied) =
|
||||
effective_rates(pricing, custom, tier, long_context)?;
|
||||
let cache_write_percent = pricing.map_or(0, |pricing| pricing.cache_write_percent);
|
||||
let mut standard = token_amounts(
|
||||
standard_rates,
|
||||
@@ -556,6 +558,7 @@ fn openai_billing_breakdown_with_context(
|
||||
Some(normalized_tier.unwrap_or_else(|| "default".to_owned())),
|
||||
multiplier_percent,
|
||||
)
|
||||
.with_long_context_billing(long_context_billing_applied)
|
||||
.with_custom_multiplier(custom.map_or(10_000, |pricing| pricing.multiplier_bps))
|
||||
}
|
||||
|
||||
@@ -564,7 +567,7 @@ fn effective_rates(
|
||||
custom: Option<&gateway_core::metering::ModelPriceOverride>,
|
||||
tier: PricingTier,
|
||||
long_context: bool,
|
||||
) -> Option<TokenRates> {
|
||||
) -> Option<(TokenRates, bool)> {
|
||||
let long_band = match tier {
|
||||
PricingTier::Standard => "long_standard",
|
||||
PricingTier::Fast => "long_fast",
|
||||
@@ -582,15 +585,22 @@ fn effective_rates(
|
||||
(PricingTier::Flex, true) => "long_flex",
|
||||
};
|
||||
if let Some(rates) = custom.and_then(|p| p.bands.get(band)) {
|
||||
return Some(TokenRates {
|
||||
input_ticks: rates.input.ticks_per_token(),
|
||||
output_ticks: rates.output.ticks_per_token(),
|
||||
cache_read_ticks: rates.cache_read.ticks_per_token(),
|
||||
cache_write_ticks: Some(rates.cache_write.ticks_per_token()),
|
||||
explicit_cache: true,
|
||||
});
|
||||
return Some((
|
||||
TokenRates {
|
||||
input_ticks: rates.input.ticks_per_token(),
|
||||
output_ticks: rates.output.ticks_per_token(),
|
||||
cache_read_ticks: rates.cache_read.ticks_per_token(),
|
||||
cache_write_ticks: Some(rates.cache_write.ticks_per_token()),
|
||||
explicit_cache: true,
|
||||
},
|
||||
uses_long,
|
||||
));
|
||||
}
|
||||
default?.rates(tier, long_context)
|
||||
let default = default?;
|
||||
Some((
|
||||
default.rates(tier, long_context)?,
|
||||
long_context && default.long_standard.is_configured(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn pricing_catalog()
|
||||
|
||||
@@ -44,7 +44,7 @@ use gateway_core::routing::{
|
||||
RuntimeSnapshot, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
|
||||
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
|
||||
use provider_openai::config::OpenAiConfig;
|
||||
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
|
||||
use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
|
||||
use secrecy::SecretString;
|
||||
@@ -321,9 +321,14 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
baseline.release.as_ref().map(|release| release.status),
|
||||
Some(DesktopReleaseStatus::Unchecked)
|
||||
);
|
||||
let selection = OpaqueProviderData::new(json!({
|
||||
"client": "desktop", "platform": "macos", "versionMode": "fixed",
|
||||
"codexVersion": "0.102.0", "desktopVersion": "1.2026.190", "desktopBuild": "19012345678",
|
||||
"osVersion": "15.5.0", "arch": "arm64", "terminal": "xterm-256color"
|
||||
}).as_object().unwrap().clone());
|
||||
let profile = admin
|
||||
.configured_wire_profile(&config.config.initial_client_profile().unwrap())
|
||||
.expect("imported fixed profile");
|
||||
.configured_wire_profile(&selection)
|
||||
.expect("managed fixed profile");
|
||||
assert_eq!(profile.version, "0.102.0");
|
||||
assert_eq!(profile.build, None);
|
||||
assert_eq!(profile.target.os_type, "Mac OS");
|
||||
@@ -380,6 +385,34 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_legacy_billing_should_not_infer_long_context_flag() {
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(config.config.clone(), provider_ports())
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
let billing = bundle
|
||||
.admin_provider()
|
||||
.calculated_billing(&ProviderBillingInput {
|
||||
upstream_model_id: "gpt-5.4".to_owned(),
|
||||
service_tier: None,
|
||||
input_tokens: Some(300_000),
|
||||
output_tokens: Some(0),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "1.5".parse().expect("stored total"),
|
||||
},
|
||||
})
|
||||
.expect("legacy billing")
|
||||
.expect("matching billing breakdown");
|
||||
|
||||
assert_eq!(billing.total_amount.amount.as_str(), "1.5");
|
||||
assert_eq!(billing.input_price_per_million.amount.as_str(), "5");
|
||||
assert!(!billing.long_context_billing_applied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_credit_success_with_invalid_body_should_remain_an_unknown_consume_result() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -1109,6 +1142,121 @@ async fn openai_admin_keeps_confirmed_exhaustion_separate_from_raw_usage_display
|
||||
assert_eq!(recovered.windows[0].used_percent, Some(86.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_admin_preserves_expired_window_usage_and_exhaustion_attribution() {
|
||||
for exhausted in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_admin_expired_window";
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
name: "admin expired window".to_owned(),
|
||||
secret: secret("admin-expired-window-access"),
|
||||
verified_account: profile("chatgpt-admin-expired-window"),
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account = store.account(account_id).expect("stored account");
|
||||
let past_reset_at = Utc::now().timestamp() - 60;
|
||||
let observed_at = SystemTime::now() - Duration::from_secs(300);
|
||||
let weekly_used = if exhausted { 100.0 } else { 74.0 };
|
||||
let state = if exhausted {
|
||||
QuotaState::exhausted(
|
||||
QuotaEvidence::ProviderDenied,
|
||||
observed_at,
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(past_reset_at as u64)),
|
||||
)
|
||||
} else {
|
||||
QuotaState::allowed(observed_at)
|
||||
};
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
plan_type: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": !exhausted,
|
||||
"limit_reached": exhausted,
|
||||
"primary_window": {
|
||||
"used_percent": 15,
|
||||
"reset_at": past_reset_at + 18_000,
|
||||
"limit_window_seconds": 18_000,
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": weekly_used,
|
||||
"reset_at": past_reset_at,
|
||||
"limit_window_seconds": 604_800,
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
),
|
||||
observed_at,
|
||||
state,
|
||||
})
|
||||
.await
|
||||
.expect("persist raw quota");
|
||||
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
let mut projected = bundle
|
||||
.admin_provider()
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account.id().clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.expect("project quota");
|
||||
assert_eq!(projected.limit_reached, exhausted);
|
||||
// 账号接口还会归一化耗尽展示;过期周窗口不能把触顶错误转移到短期窗口。
|
||||
projected.apply_limit_reached_display();
|
||||
let primary = projected
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.window_seconds == Some(18_000))
|
||||
.expect("primary");
|
||||
let weekly = projected
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.window_seconds == Some(604_800))
|
||||
.expect("weekly");
|
||||
assert_eq!(
|
||||
(primary.used_percent, primary.limit_reached),
|
||||
(Some(15.0), false)
|
||||
);
|
||||
assert_eq!(
|
||||
(weekly.used_percent, weekly.limit_reached),
|
||||
(Some(weekly_used), exhausted)
|
||||
);
|
||||
assert_eq!(
|
||||
weekly.reset_at.map(|reset| reset.timestamp()),
|
||||
Some(past_reset_at)
|
||||
);
|
||||
let raw = store
|
||||
.get_quotas(std::slice::from_ref(account.id()))
|
||||
.await
|
||||
.expect("raw quota")
|
||||
.pop()
|
||||
.expect("observation");
|
||||
assert_eq!(raw.observed_at, observed_at);
|
||||
assert_eq!(
|
||||
raw.quota.expose_to_provider()["rate_limit"]["secondary_window"]["used_percent"],
|
||||
weekly_used
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -1416,21 +1564,6 @@ struct TestOpenAiConfig {
|
||||
|
||||
fn valid_config() -> TestOpenAiConfig {
|
||||
let mut config = OpenAiConfig::default();
|
||||
config.wire_profile = CodexWireProfileConfig {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.102.0".to_owned(),
|
||||
desktop_version: "1.2026.190".to_owned(),
|
||||
desktop_build: "19012345678".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.5.0".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
residency: None,
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid test time"),
|
||||
};
|
||||
let runtime = tempfile::tempdir().expect("test runtime directory");
|
||||
config
|
||||
.resolve_and_validate(&runtime.path().join("deploy"))
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use provider_openai::config::{
|
||||
CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig,
|
||||
};
|
||||
use provider_openai::config::{DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig};
|
||||
|
||||
#[test]
|
||||
fn openai_config_imports_legacy_fixed_profile_without_changing_official_release_facts() {
|
||||
let mut config = valid_config();
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.expect("valid OpenAI config");
|
||||
let state = config.wire_profile_state();
|
||||
let selection = provider_openai::transport::profile::selection::ClientProfileSelection::parse(
|
||||
&config.initial_client_profile().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let profile = selection.resolve(&state).unwrap();
|
||||
assert_eq!(
|
||||
state.snapshot().codex_version,
|
||||
CodexWireProfileConfig::default().codex_version
|
||||
);
|
||||
fn openai_config_ignores_removed_yaml_identity_fields() {
|
||||
let config: OpenAiConfig = serde_json::from_value(serde_json::json!({
|
||||
"wire_profile": {"originator": "legacy-client", "codex_version": "invalid", "residency": "us"}
|
||||
})).unwrap();
|
||||
assert_eq!(config, OpenAiConfig::default());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
profile.user_agent(),
|
||||
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
|
||||
);
|
||||
assert_eq!(profile.desktop_build, "19012345678");
|
||||
#[test]
|
||||
fn openai_config_keeps_residency_separate_from_client_identity() {
|
||||
use provider_openai::transport::profile::CodexResidency;
|
||||
let config: OpenAiConfig = serde_json::from_str(r#"{"residency":"us"}"#).unwrap();
|
||||
assert_eq!(config.residency, Some(CodexResidency::Us));
|
||||
assert!(serde_json::from_str::<OpenAiConfig>(r#"{"residency":"invalid"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -39,33 +28,6 @@ fn openai_config_derives_identity_secret_from_runtime_data_dir() {
|
||||
assert!(format!("{config:?}").contains("/srv/gateway/runtime-data/identity_hmac_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_rejects_noncanonical_versions_and_empty_fields() {
|
||||
let mut config = valid_config();
|
||||
config.wire_profile.codex_version = "latest".to_owned();
|
||||
assert!(
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut config = valid_config();
|
||||
config.wire_profile.desktop_version = "1.preview".to_owned();
|
||||
assert!(
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut config = valid_config();
|
||||
config.wire_profile.originator.clear();
|
||||
assert!(
|
||||
config
|
||||
.resolve_and_validate(Path::new("/srv/gateway"))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_config_restricts_upstream_base_url_to_https_or_loopback_http() {
|
||||
for base_url in [
|
||||
@@ -135,7 +97,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
config.wire_profile_state().snapshot().user_agent(),
|
||||
provider_openai::transport::profile::CodexWireProfile::default().user_agent(),
|
||||
"Codex Desktop/0.153.4 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.901.51231)"
|
||||
);
|
||||
}
|
||||
@@ -152,21 +114,5 @@ fn openai_stream_retry_budget_uses_the_official_hard_cap() {
|
||||
}
|
||||
|
||||
fn valid_config() -> OpenAiConfig {
|
||||
let mut config = OpenAiConfig::default();
|
||||
config.wire_profile = CodexWireProfileConfig {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.102.0".to_owned(),
|
||||
desktop_version: "1.2026.190".to_owned(),
|
||||
desktop_build: "19012345678".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.5.0".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
residency: None,
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 19, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid test time"),
|
||||
};
|
||||
config
|
||||
OpenAiConfig::default()
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ fn pat_service(server: &MockServer) -> CodexCredentialAdminService {
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
provider_openai::OpenAiConfig::default().wire_profile_state(),
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
|
||||
)
|
||||
.expect("auth client");
|
||||
CodexCredentialAdminService::new(
|
||||
@@ -389,7 +389,7 @@ async fn pat_import_times_out_without_falling_back_to_document_identity() {
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
provider_openai::OpenAiConfig::default().wire_profile_state(),
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
|
||||
);
|
||||
let service = CodexCredentialAdminService::new(
|
||||
Arc::new(UnusedRefresher),
|
||||
|
||||
@@ -1207,10 +1207,7 @@ fn native_continuation_surfaces_the_original_accounts_quota_status_to_the_coordi
|
||||
)
|
||||
.expect_err("the selector must surface the unavailable native account");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CredentialSelectionError::NoEligibleCredential
|
||||
));
|
||||
assert!(matches!(error, CredentialSelectionError::QuotaExhausted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1279,10 +1276,7 @@ fn native_continuation_surfaces_the_original_accounts_quota_signal_to_the_coordi
|
||||
)
|
||||
.expect_err("the selector must surface the quota-limited native account");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CredentialSelectionError::NoEligibleCredential
|
||||
));
|
||||
assert!(matches!(error, CredentialSelectionError::QuotaExhausted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,6 @@ use gateway_admin::model::provider_credentials::{
|
||||
use gateway_admin::model::{MutationActor, MutationContext};
|
||||
use gateway_core::account::{AccountStatus, ProviderAccountId};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::OpenAiConfig;
|
||||
use provider_openai::credential::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
AuthorizationTokenSet,
|
||||
@@ -362,7 +361,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
|
||||
}
|
||||
|
||||
fn profile() -> CodexWireProfileState {
|
||||
OpenAiConfig::default().wire_profile_state()
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -128,3 +128,97 @@ async fn periodic_checks_continue_when_reset_is_unknown_or_far_in_the_future() {
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_account_with_expired_window_synchronizes_at_reset_grace() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_allowed_expired_window").await;
|
||||
let account = store
|
||||
.account("acct_allowed_expired_window")
|
||||
.expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
|
||||
let short_reset = Utc::now().timestamp() - 114;
|
||||
let usage = |used: u32, reset: i64| {
|
||||
json!({"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {
|
||||
"used_percent": used,
|
||||
"reset_at": reset,
|
||||
"limit_window_seconds": 18_000,
|
||||
}
|
||||
}})
|
||||
};
|
||||
|
||||
mount_usage(&server, usage(100, short_reset)).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed quota with 100% usage");
|
||||
|
||||
assert!(
|
||||
Utc::now().timestamp() < short_reset + 120,
|
||||
"fixture must precede grace deadline"
|
||||
);
|
||||
let requests = server.received_requests().await.expect("requests").len();
|
||||
service.synchronize().await.expect("initial periodic check");
|
||||
service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("repeat check before grace");
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
requests,
|
||||
"正常账号首次复核也必须等待 reset 宽限期"
|
||||
);
|
||||
|
||||
// 当到达 reset + 120s 宽限期后,账号虽然处于 allowed 状态,但包含已到期的非零用量窗口,被调度主动同步。
|
||||
mount_usage(&server, usage(0, short_reset + 18_000)).await;
|
||||
wait_for_reset_grace(short_reset).await;
|
||||
|
||||
let summary = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("synchronize expired window");
|
||||
assert_eq!(summary.updated, 1);
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(0.0));
|
||||
service.synchronize().await.expect("skip fresh window");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allowed_expired_window_refresh_is_throttled_when_observation_is_unchanged() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_allowed_expired_retry").await;
|
||||
let account = store
|
||||
.account("acct_allowed_expired_retry")
|
||||
.expect("account");
|
||||
let server = MockServer::start().await;
|
||||
let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri());
|
||||
let usage = json!({"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {
|
||||
"used_percent": 74,
|
||||
"reset_at": Utc::now().timestamp() - 180,
|
||||
"limit_window_seconds": 18_000,
|
||||
}
|
||||
}});
|
||||
mount_usage(&server, usage.clone()).await;
|
||||
service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed quota");
|
||||
mount_usage(&server, usage).await;
|
||||
|
||||
assert_eq!(service.synchronize().await.expect("first check").updated, 1);
|
||||
service.synchronize().await.expect("throttled repeat check");
|
||||
assert_eq!(server.received_requests().await.expect("requests").len(), 1);
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
provider_openai::OpenAiConfig::default().wire_profile_state(),
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
|
||||
)),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
|
||||
@@ -57,7 +57,8 @@ async fn refresh_tracks_the_online_profile_without_contaminating_code_exchange()
|
||||
.expect(4)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let profile = provider_openai::OpenAiConfig::default().wire_profile_state();
|
||||
let profile =
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default());
|
||||
let client = provider_openai::credential::token_client::openai_token_client(
|
||||
TokenClientConfig {
|
||||
client_id: "audit-client".to_owned(),
|
||||
@@ -123,7 +124,7 @@ fn client(server: &MockServer) -> OpenAiTokenClient {
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
provider_openai::OpenAiConfig::default().wire_profile_state(),
|
||||
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user