mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fbd447d94 | ||
|
|
5a5cdbdcde | ||
|
|
cca89f2f96 | ||
|
|
e25aad2231 | ||
|
|
84382761fc | ||
|
|
0a09c1572f | ||
|
|
90deabdfe3 | ||
|
|
b1c24e276b | ||
|
|
c8fdc2b5cd | ||
|
|
dfabf0f9dd | ||
|
|
817b0f2d6d | ||
|
|
a975868046 | ||
|
|
cc2dfab19a | ||
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 | ||
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e | ||
|
|
4105c1ece6 | ||
|
|
4d59687cbc | ||
|
|
8621e24e7c | ||
|
|
3bebccac5d | ||
|
|
98287195ca | ||
|
|
fd1b159f71 | ||
|
|
7b03fdc8ab | ||
|
|
851a50962d | ||
|
|
b6e17db0c6 | ||
|
|
b98c2f01a5 |
@@ -10,12 +10,14 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 文档入口 |
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
@@ -29,7 +31,8 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
|
||||
@@ -37,6 +37,11 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
|
||||
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
@@ -46,6 +51,8 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
@@ -76,8 +83,13 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
|
||||
@@ -53,8 +53,9 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
|
||||
## 执行正式发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
|
||||
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
@@ -63,7 +64,7 @@ description: 准备或执行当前仓库发版,维护中文发布说明。用
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
+17
-1
@@ -21,11 +21,27 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
|
||||
ignore:
|
||||
- dependency-name: aws-lc-rs
|
||||
- dependency-name: hyper
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: hyper-tls
|
||||
- dependency-name: hyper-util
|
||||
- dependency-name: native-tls
|
||||
- dependency-name: openssl
|
||||
- dependency-name: openssl-src
|
||||
- dependency-name: openssl-sys
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
- dependency-name: rustls-native-certs
|
||||
- dependency-name: rustls-pki-types
|
||||
- dependency-name: tokio
|
||||
- dependency-name: tokio-native-tls
|
||||
- dependency-name: tokio-rustls
|
||||
- dependency-name: tokio-tungstenite
|
||||
- dependency-name: tungstenite
|
||||
- dependency-name: zstd
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
@@ -205,3 +205,6 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
- 'release/notes.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -315,6 +315,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -349,7 +350,18 @@ jobs:
|
||||
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
release_dir.mkdir(parents=True)
|
||||
archives = []
|
||||
config_template = pathlib.Path("deploy/config.example.yaml")
|
||||
shutil.copy2(config_template, release_dir / config_template.name)
|
||||
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
||||
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
||||
if compose.count(default_image) != 1:
|
||||
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
||||
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
||||
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
@@ -367,6 +379,9 @@ jobs:
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
deploy_dir = package_root / "deploy"
|
||||
deploy_dir.mkdir()
|
||||
shutil.copy2(config_template, deploy_dir / config_template.name)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
@@ -375,12 +390,12 @@ jobs:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
with checksum_path.open("w", encoding="utf-8") as file:
|
||||
for archive in archives:
|
||||
file.write(f"{sha256(archive)} {archive.name}\n")
|
||||
for asset in assets:
|
||||
file.write(f"{sha256(asset)} {asset.name}\n")
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
@@ -655,25 +670,27 @@ jobs:
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
|
||||
|
||||
## 📥 Installation
|
||||
## 📥 安装
|
||||
|
||||
**Docker:**
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
Download the appropriate archive for your platform from the assets below.
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
## 📚 Documentation
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
- [GitHub Repository](https://github.com/${{ github.repository }})
|
||||
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
|
||||
@@ -40,7 +40,16 @@ jobs:
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
shell: bash
|
||||
run: |
|
||||
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
|
||||
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
|
||||
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
|
||||
audit_advisory_args=()
|
||||
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
|
||||
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
|
||||
fi
|
||||
cargo audit "${audit_advisory_args[@]}"
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -52,7 +61,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
+20
-3
@@ -76,15 +76,32 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
## 项目约定
|
||||
|
||||
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
@@ -113,7 +130,7 @@ pnpm --dir frontend build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`build` 已包含类型检查;按当前架构约定,不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
面向 Codex 的自托管多账号 AI 网关。
|
||||
面向 Codex 的自托管多账号 AI 网关
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
@@ -21,30 +21,31 @@
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
|
||||
### 1. 下载部署文件并配置
|
||||
|
||||
```bash
|
||||
mkdir -p codex-proxy-rs/deploy
|
||||
cd codex-proxy-rs
|
||||
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
|
||||
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
|
||||
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
|
||||
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
|
||||
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
|
||||
-o deploy/compose.yaml
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
|
||||
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
|
||||
-o deploy/config.example.yaml
|
||||
|
||||
mkdir -p .runtime/data .runtime/logs
|
||||
install -d -m 0750 .runtime/postgres .runtime/redis
|
||||
cp deploy/config.example.yaml deploy/config.yaml
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
|
||||
chmod 0770 .runtime/data .runtime/logs
|
||||
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
|
||||
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
|
||||
```
|
||||
|
||||
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
|
||||
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
|
||||
|
||||
分别生成数据库和 Redis 密码:
|
||||
|
||||
```bash
|
||||
@@ -60,8 +61,6 @@ openssl rand -hex 24
|
||||
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
|
||||
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
|
||||
|
||||
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
@@ -73,13 +72,14 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
|
||||
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
|
||||
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
|
||||
### 3. 添加账号与客户端密钥
|
||||
|
||||
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
|
||||
1. 在「账号」中添加账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
|
||||
@@ -93,7 +93,7 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
|
||||
|
||||
Generated
+215
-37
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,22 +165,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
|
||||
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -206,9 +205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.144.0"
|
||||
version = "1.146.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
|
||||
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -479,9 +478,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -749,9 +749,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -808,6 +808,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1192,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1291,6 +1301,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1383,7 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1879,6 +1904,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1896,9 +1937,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2236,6 +2279,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -2291,12 +2351,66 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2604,6 +2718,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cookie",
|
||||
"flate2",
|
||||
"futures",
|
||||
@@ -2615,6 +2730,9 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -2741,7 +2859,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2840,9 +2958,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -2919,6 +3037,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -2927,9 +3046,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2941,6 +3063,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -3011,7 +3134,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3032,21 +3155,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -3130,6 +3253,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3137,7 +3273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3205,7 +3341,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3663,9 +3799,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3692,6 +3828,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -3713,7 +3870,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,7 +3899,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3811,9 +3968,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3836,6 +3993,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -4184,9 +4351,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -4419,6 +4586,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct GatewayConfig {
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
@@ -34,6 +36,9 @@ impl LoadableConfig for GatewayConfig {
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
@@ -55,6 +60,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
@@ -75,15 +81,17 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
host.report_startup_ready("Core");
|
||||
let mut admin = gateway_admin::initialize(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
(
|
||||
core.account_probe(),
|
||||
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
),
|
||||
host.client_distribution_resolver(),
|
||||
host.system_operations(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
providers: vec![openai.admin_provider(), xai.admin_provider()],
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
@@ -70,6 +70,8 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
"identity.rs" => Some(&["validation"]),
|
||||
"upstream.rs" => Some(&["validation"]),
|
||||
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
|
||||
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
|
||||
"concurrency.rs" => Some(&["error"]),
|
||||
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
|
||||
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
@@ -176,7 +178,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
|
||||
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-api", &["admin", "openai"]),
|
||||
("crates/gateway-api", &["admin", "auth", "openai"]),
|
||||
(
|
||||
"crates/gateway-host",
|
||||
&[
|
||||
|
||||
@@ -278,6 +278,37 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
|
||||
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "unsafe$password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_zero_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
*config
|
||||
.pointer_mut("/client/session_ttl_minutes")
|
||||
.expect("example client session TTL") = serde_json::json!(0);
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_default_missing_client_section() {
|
||||
let mut config = valid_config_document();
|
||||
config
|
||||
.as_object_mut()
|
||||
.expect("example config mapping")
|
||||
.remove("client")
|
||||
.expect("example client section");
|
||||
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
config["client"]
|
||||
.as_object_mut()
|
||||
.expect("example client mapping")
|
||||
.remove("session_ttl_minutes")
|
||||
.expect("example client session TTL");
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
assert_rejected(valid_config().replace(
|
||||
@@ -310,6 +341,39 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_accept_empty_and_custom_request_locations() {
|
||||
let original = valid_config();
|
||||
let omitted = original
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("location:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_ne!(original, omitted);
|
||||
parse_config(&omitted).expect("location passthrough when omitted");
|
||||
let location_line = original
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with("location:"))
|
||||
.expect("example location");
|
||||
for empty in [" location:", " location: null", " location: ~"] {
|
||||
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
|
||||
}
|
||||
let custom = original.replace(
|
||||
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
|
||||
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
|
||||
);
|
||||
assert_ne!(original, custom);
|
||||
parse_config(&custom).expect("custom location from YAML");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_request_location_timezones() {
|
||||
let original = valid_config();
|
||||
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
|
||||
assert_ne!(original, invalid);
|
||||
assert_rejected(invalid);
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
@@ -332,6 +396,18 @@ fn valid_config() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_config_document() -> serde_json::Value {
|
||||
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
|
||||
config::Config::builder()
|
||||
.add_source(config::File::from_str(
|
||||
&valid_config(),
|
||||
config::FileFormat::Yaml,
|
||||
))
|
||||
.build()
|
||||
.and_then(config::Config::try_deserialize)
|
||||
.expect("example config document")
|
||||
}
|
||||
|
||||
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
|
||||
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
|
||||
let deploy = directory.path().join("deploy");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use gateway_core::{
|
||||
engine::execution::ClientKeyVerifier,
|
||||
engine::probe::AccountProbe,
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
@@ -21,6 +22,8 @@ pub mod model;
|
||||
pub mod ports;
|
||||
mod use_case;
|
||||
|
||||
pub use use_case::key_usage::KeyUsageService;
|
||||
|
||||
pub use use_case::{
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_distribution::ClientDistributionService,
|
||||
@@ -58,6 +61,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
];
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -98,6 +102,35 @@ pub struct AdminConfig {
|
||||
pub default_password: InitialAdminPassword,
|
||||
}
|
||||
|
||||
/// Client 登录域的通用启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// 校验 Client session TTL;当前配置不含相对路径。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话有效期为零或无法安全换算时返回错误。
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径。
|
||||
///
|
||||
@@ -151,6 +184,7 @@ pub enum AdminConfigError {
|
||||
pub struct AdminServices {
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
@@ -164,6 +198,11 @@ pub struct AdminServices {
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn key_usage(&self) -> &dyn KeyUsageService {
|
||||
self.key_usage.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
@@ -243,6 +282,17 @@ impl AdminBundle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
pub snapshot: Arc<dyn SnapshotControl>,
|
||||
pub account_probe: Arc<dyn AccountProbe>,
|
||||
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
|
||||
pub client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
pub system: Arc<dyn SystemOperations>,
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -250,17 +300,25 @@ impl AdminBundle {
|
||||
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
|
||||
pub async fn initialize(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
|
||||
client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let (probe, proxy_probe) = probes;
|
||||
let AdminRuntimePorts {
|
||||
providers,
|
||||
snapshot,
|
||||
account_probe: probe,
|
||||
proxy_probe,
|
||||
client_distribution,
|
||||
system,
|
||||
client_key_verifier,
|
||||
} = runtime;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
client_config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
|
||||
let openai = registry
|
||||
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
|
||||
@@ -272,7 +330,9 @@ pub async fn initialize(
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier,
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
@@ -296,7 +356,13 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
));
|
||||
let services = AdminServices {
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
|
||||
@@ -18,12 +18,14 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
@@ -86,6 +88,7 @@ pub struct AccountRecord {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
@@ -98,6 +101,7 @@ pub struct AccountRecord {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
@@ -217,9 +221,12 @@ pub struct AccountSummary {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
/// 缺省保留备注;空字符串清空备注。
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
@@ -231,14 +238,15 @@ pub struct AccountUpdateResult {
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 一批账号可编辑事实的一次性替换命令。
|
||||
/// 仅修改显式字段的批量账号设置命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
pub weight: Option<AccountWeight>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 管理员认证、会话和安全审计事实。
|
||||
//! 控制面认证、统一会话和管理员安全审计事实。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
@@ -40,43 +41,82 @@ impl AdminRequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员登录命令。
|
||||
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginCommand {
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
pub enum LoginCommand {
|
||||
Admin {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
},
|
||||
Key {
|
||||
api_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginCommand {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginCommand")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
match self {
|
||||
Self::Admin { username, .. } => formatter
|
||||
.debug_struct("AdminLogin")
|
||||
.field("username", username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Key { .. } => formatter
|
||||
.debug_struct("KeyLogin")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后返回的会话事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginResult {
|
||||
pub session_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub session: AuthSession,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginResult {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginResult")
|
||||
.field("session_id", &"[REDACTED]")
|
||||
.field("session", &self.session)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录状态机可被 API 精确映射的失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("invalid administrator credentials")]
|
||||
#[error("invalid login credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("administrator authentication is unavailable")]
|
||||
#[error("too many login attempts")]
|
||||
TooManyAttempts { retry_after_seconds: u64 },
|
||||
#[error("authentication is unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Redis 中可恢复的管理员会话。
|
||||
impl From<ClientAuthenticationError> for LoginError {
|
||||
fn from(error: ClientAuthenticationError) -> Self {
|
||||
match error {
|
||||
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
|
||||
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminSession {
|
||||
pub admin_user_id: String,
|
||||
pub enum SessionSubject {
|
||||
Admin { admin_user_id: String },
|
||||
Key { client_key_id: ClientApiKeyId },
|
||||
}
|
||||
|
||||
/// 两种登录方式共用的固定有效期会话。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthSession {
|
||||
pub subject: SessionSubject,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
@@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
|
||||
|
||||
use super::{
|
||||
PageSize,
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KeyUsageRecordKind {
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageRecordsQuery {
|
||||
pub usage: KeyUsageQuery,
|
||||
pub kind: KeyUsageRecordKind,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
pub struct KeyUsageOverview {
|
||||
pub key: ClientKeyRecord,
|
||||
pub overview: UsageOverview,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub health_timeline: HealthTimeline,
|
||||
}
|
||||
|
||||
pub enum KeyUsageRecords {
|
||||
Success(UsagePage),
|
||||
Error(OpsErrorPage),
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
@@ -21,6 +22,7 @@ pub mod system;
|
||||
pub enum AdminErrorKind {
|
||||
Invalid,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Conflict,
|
||||
RateLimited,
|
||||
|
||||
@@ -99,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
@@ -757,6 +758,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
|
||||
@@ -26,7 +26,6 @@ pub struct AccountQuotaForecast {
|
||||
pub low_sample: bool,
|
||||
pub incomplete_cost: bool,
|
||||
pub incomplete_tokens: bool,
|
||||
pub method: QuotaForecastMethod,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
@@ -36,30 +35,12 @@ pub struct AccountQuotaForecast {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaForecastSource {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub window_seconds: u64,
|
||||
pub used_percent: Option<f64>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub start_at: Option<DateTime<Utc>>,
|
||||
pub reset_at: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
pub tokens: Option<u64>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub known_cost_count: u64,
|
||||
pub partial_cost_count: u64,
|
||||
pub unavailable_cost_count: u64,
|
||||
pub usd: Option<f64>,
|
||||
pub sample_start_at: Option<DateTime<Utc>>,
|
||||
pub baseline_percent: f64,
|
||||
pub sampled_percent: Option<f64>,
|
||||
pub block_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub missing_token_count: u64,
|
||||
pub excluded_request_count: u64,
|
||||
pub pending_request_count: u64,
|
||||
}
|
||||
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
|
||||
@@ -88,7 +69,6 @@ pub fn account_quota_forecasts(
|
||||
low_sample: false,
|
||||
incomplete_cost: false,
|
||||
incomplete_tokens: false,
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
@@ -138,30 +118,12 @@ impl AccountQuotaForecast {
|
||||
.and_then(Duration::try_seconds)
|
||||
.and_then(|duration| reset_at.checked_sub_signed(duration));
|
||||
self.source = Some(QuotaForecastSource {
|
||||
key: window.key.clone(),
|
||||
label: window.label.clone(),
|
||||
window_seconds: seconds,
|
||||
used_percent: percent,
|
||||
observed_at,
|
||||
start_at: start,
|
||||
reset_at,
|
||||
request_count: usage.map_or(0, |usage| usage.request_count),
|
||||
tokens: usage.map(|usage| usage.tokens),
|
||||
input_tokens: usage.map(|usage| usage.input_tokens),
|
||||
output_tokens: usage.map(|usage| usage.output_tokens),
|
||||
cached_tokens: usage.map(|usage| usage.cached_tokens),
|
||||
known_cost_count: usage.map_or(0, |usage| usage.known_cost_count),
|
||||
partial_cost_count: 0,
|
||||
unavailable_cost_count: usage.map_or(0, |usage| usage.unavailable_cost_count),
|
||||
usd,
|
||||
sample_start_at: sample.map(|sample| sample.start_at),
|
||||
baseline_percent: sample.map_or(0.0, |sample| sample.baseline_percent),
|
||||
sampled_percent: sample.map(|sample| sample.sampled_percent),
|
||||
block_count: sample.map_or(0, |sample| sample.block_count),
|
||||
observation_count: sample.map_or(0, |sample| sample.observation_count),
|
||||
missing_token_count: usage.map_or(0, |usage| usage.missing_token_count),
|
||||
excluded_request_count: usage.map_or(0, |usage| usage.excluded_request_count),
|
||||
pending_request_count: sample.map_or(0, |sample| sample.pending_request_count),
|
||||
});
|
||||
self.incomplete_cost = usage.is_none_or(|usage| {
|
||||
usage.unavailable_cost_count > 0
|
||||
@@ -169,7 +131,7 @@ impl AccountQuotaForecast {
|
||||
|| usage.known_cost_count != usage.request_count
|
||||
});
|
||||
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
|
||||
self.method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
|
||||
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
|
||||
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
|
||||
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
|
||||
return;
|
||||
@@ -183,7 +145,7 @@ impl AccountQuotaForecast {
|
||||
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
|
||||
return;
|
||||
}
|
||||
if account_added_at > start && self.method == QuotaForecastMethod::Cumulative {
|
||||
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
|
||||
self.unavailable_reason = Some(
|
||||
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
|
||||
);
|
||||
@@ -210,23 +172,20 @@ impl AccountQuotaForecast {
|
||||
return;
|
||||
};
|
||||
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|
||||
|| (self.method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
|| (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.incomplete_tokens);
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd
|
||||
.filter(|_| !self.incomplete_cost)
|
||||
.and_then(|value| estimate(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
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
|
||||
.filter(|_| !self.incomplete_cost)
|
||||
.and_then(|value| estimate(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。")
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -89,16 +89,6 @@ pub enum QuotaForecastMethod {
|
||||
Incremental,
|
||||
}
|
||||
|
||||
impl QuotaForecastMethod {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cumulative => "cumulative",
|
||||
Self::Incremental => "incremental",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastSample {
|
||||
pub key: String,
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct RuntimeSettings {
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -40,6 +43,9 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
@@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -20,7 +20,7 @@ use crate::model::{
|
||||
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
StaleRevision,
|
||||
DuplicateName,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
}
|
||||
@@ -194,7 +195,7 @@ pub trait AccountRuntimeStore: Send + Sync {
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
}
|
||||
|
||||
/// 管理员密码、会话和安全审计。
|
||||
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
@@ -207,19 +208,38 @@ pub trait AuthStore: Send + Sync {
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AdminSession)
|
||||
-> AdminStoreResult<()>;
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
source_ip: IpAddr,
|
||||
source_limit: u32,
|
||||
global_limit: u32,
|
||||
window: Duration,
|
||||
) -> AdminStoreResult<Option<Duration>>;
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理写入。
|
||||
/// Client API Key 资料读取与管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>>;
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
|
||||
async fn reveal_client_key(
|
||||
|
||||
@@ -22,9 +22,9 @@ use crate::{
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
@@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountQuotaForecastReport, AdminError>;
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
@@ -570,7 +570,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
if !enabled {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
@@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn personal_info(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.profile_statistics(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider profile statistics"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
let (initial, provider) = self.provider_for_account(account_id).await?;
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
|
||||
let (profile, subscription) = futures::join!(
|
||||
provider.profile_statistics(account_id),
|
||||
provider.subscription(account_id),
|
||||
);
|
||||
let profile =
|
||||
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
|
||||
let subscription = subscription
|
||||
.map_err(|error| map_provider_error(error, "provider subscription"))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
|
||||
let current = self.load_account(account_id).await?;
|
||||
if current.account.provider_kind != initial.account.provider_kind
|
||||
|| current.account.credential_revision != initial.account.credential_revision
|
||||
|| current.account.upstream_user_id != initial.account.upstream_user_id
|
||||
|| current.account.upstream_account_id != initial.account.upstream_account_id
|
||||
{
|
||||
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
|
||||
}
|
||||
Ok(AccountPersonalInfo {
|
||||
profile,
|
||||
subscription,
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
//! 管理员认证状态机。
|
||||
//! 控制面登录、会话恢复与身份校验的唯一 owner。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::engine::execution::ClientKeyVerifier;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
AdminError, AdminErrorKind,
|
||||
auth::{
|
||||
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
|
||||
AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult,
|
||||
SessionSubject,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
@@ -22,61 +24,108 @@ use crate::{
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// API 鉴权与管理员登录消费的统一服务。
|
||||
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
|
||||
/// `Utc::now() + session_ttl` 永不越界。
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60);
|
||||
const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||
const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
|
||||
|
||||
/// 单管理员认证策略的最终实现。
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
session_ttl: Duration,
|
||||
admin_session_ttl: Duration,
|
||||
key_session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
default_admin_user_id: impl Into<String>,
|
||||
session_ttl_minutes: u64,
|
||||
admin_session_ttl_minutes: u64,
|
||||
key_session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES);
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
session_ttl: Duration::minutes(minutes),
|
||||
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
|
||||
key_session_ttl: session_ttl(key_session_ttl_minutes),
|
||||
store,
|
||||
verifier,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
|
||||
fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::AdminSession,
|
||||
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(&self.default_admin_user_id),
|
||||
actor_admin_user_id: Some(admin_user_id.to_owned()),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(admin_user_id),
|
||||
request_id: None,
|
||||
action: action.to_owned(),
|
||||
entity_kind: "admin_session".to_owned(),
|
||||
entity_ref: self.default_admin_user_id.clone(),
|
||||
entity_ref: admin_user_id.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields: Vec::new(),
|
||||
occurred_at,
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(&self, command: LoginCommand) -> Result<SessionSubject, LoginError> {
|
||||
match command {
|
||||
LoginCommand::Admin { username, password } => {
|
||||
if username.as_deref().unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Admin {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
})
|
||||
}
|
||||
LoginCommand::Key { api_key } => {
|
||||
if api_key.is_empty() || api_key.len() > 4096 {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let client_key_id = self.verifier.verify_client_key(&api_key)?;
|
||||
if !self
|
||||
.store
|
||||
.client_key_enabled(&client_key_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Key { client_key_id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,22 +140,51 @@ impl AuthService for DefaultAuthService {
|
||||
.map_err(|error| map_store_error(error, "administrator"))
|
||||
}
|
||||
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError> {
|
||||
let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(session) = self
|
||||
.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.expires_at <= Utc::now() {
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let SessionSubject::Key { client_key_id } = &session.subject
|
||||
&& !self
|
||||
.store
|
||||
.client_key_enabled(client_key_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "session key"))?
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
let Some(session_id) = session_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map(|session| {
|
||||
session
|
||||
.filter(|session| session.expires_at > Utc::now())
|
||||
.map(|session| session.admin_user_id)
|
||||
})
|
||||
.map_err(|error| map_store_error(error, "administrator session"))
|
||||
match self
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)),
|
||||
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问管理接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
|
||||
@@ -124,69 +202,77 @@ impl AuthService for DefaultAuthService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
|
||||
if command
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError> {
|
||||
if let Some(retry_after) = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let session_id = random_session_token();
|
||||
let expires_at = Utc::now() + self.session_ttl;
|
||||
self.store
|
||||
.store_session(
|
||||
&session_id,
|
||||
&AdminSession {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
expires_at,
|
||||
},
|
||||
.consume_login_attempt(
|
||||
source_ip,
|
||||
LOGIN_ATTEMPTS_PER_SOURCE,
|
||||
LOGIN_ATTEMPTS_GLOBAL,
|
||||
LOGIN_WINDOW,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: retry_after.as_secs().max(1),
|
||||
});
|
||||
}
|
||||
let subject = self.authenticate(command).await?;
|
||||
let ttl = match subject {
|
||||
SessionSubject::Admin { .. } => self.admin_session_ttl,
|
||||
SessionSubject::Key { .. } => self.key_session_ttl,
|
||||
};
|
||||
let session = AuthSession {
|
||||
subject,
|
||||
expires_at: Utc::now() + ttl,
|
||||
};
|
||||
let session_id = random_session_token();
|
||||
self.store
|
||||
.store_session(&session_id, &session)
|
||||
.await
|
||||
.is_err()
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if let SessionSubject::Admin { admin_user_id } = &session.subject
|
||||
&& self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
|
||||
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
|
||||
&& self.logout(previous).await.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
Ok(LoginResult {
|
||||
session_id,
|
||||
expires_at,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
|
||||
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
|
||||
}
|
||||
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
|
||||
let session = self
|
||||
.store
|
||||
.delete_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if let Some(session) = session {
|
||||
let mut event = self.auth_audit("admin.logout", Utc::now());
|
||||
event.actor_admin_user_id = Some(session.admin_user_id.clone());
|
||||
event.actor_ref = crate::model::auth::admin_session_actor_ref(&session.admin_user_id);
|
||||
event.entity_ref = session.admin_user_id;
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?;
|
||||
if let Some(AuthSession {
|
||||
subject: SessionSubject::Admin { admin_user_id },
|
||||
..
|
||||
}) = session
|
||||
{
|
||||
self.store
|
||||
.append_audit_event(event)
|
||||
.append_audit_event(self.auth_audit("admin.logout", &admin_user_id))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator audit"))?;
|
||||
}
|
||||
@@ -194,6 +280,14 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_ttl(minutes: u64) -> Duration {
|
||||
Duration::minutes(
|
||||
i64::try_from(minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES),
|
||||
)
|
||||
}
|
||||
|
||||
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes())
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::ClientKeyStore,
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
@@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
|
||||
let plaintext = if let Some(key) = command.custom_key {
|
||||
key.expose_for_auth().to_owned()
|
||||
} else {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
};
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.create_client_key(
|
||||
@@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(CreatedClientKey {
|
||||
config_revision,
|
||||
@@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.update_client_key(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
@@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
|
||||
match error.kind() {
|
||||
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
|
||||
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
|
||||
_ => map_store_error(error, "client API key"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
let Some(cursor) = &query.cursor else {
|
||||
return Ok(());
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
key_usage::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
|
||||
use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError>;
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
keys,
|
||||
observations,
|
||||
}
|
||||
}
|
||||
|
||||
async fn key_id(&self, session_id: Option<&str>) -> Result<Option<ClientApiKeyId>, AdminError> {
|
||||
match self
|
||||
.auth
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Key { client_key_id }) => Ok(Some(client_key_id)),
|
||||
Some(SessionSubject::Admin { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问密钥用量接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
UsageFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model,
|
||||
..UsageFilter::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(key) = self
|
||||
.keys
|
||||
.get_client_key(&id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage profile"))?
|
||||
.filter(|key| key.enabled)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = usage_filter(&id, query.model);
|
||||
let now = Utc::now();
|
||||
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
|
||||
let today = TimeRange {
|
||||
start: china_day_start(now),
|
||||
end: now,
|
||||
};
|
||||
let (overview, trend, health_points) = futures::try_join!(
|
||||
self.observations.usage_summary(query.range, filter.clone()),
|
||||
self.observations.usage_trend(query.range, filter),
|
||||
self.observations
|
||||
.usage_trend(today, usage_filter(&id, None)),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "key usage overview"))?;
|
||||
Ok(Some(KeyUsageOverview {
|
||||
key,
|
||||
overview,
|
||||
trend,
|
||||
health_timeline: health_timeline_at(&health_points, now),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let result = match query.kind {
|
||||
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
|
||||
self.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage records"))?,
|
||||
),
|
||||
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
|
||||
self.observations
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range: query.usage.range,
|
||||
filter: OpsErrorFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model: query.usage.model,
|
||||
..OpsErrorFilter::default()
|
||||
},
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage errors"))?,
|
||||
),
|
||||
};
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod proxies;
|
||||
@@ -42,9 +43,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
|
||||
|
||||
@@ -852,7 +852,10 @@ impl DefaultObservabilityService {
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
|
||||
pub(super) fn health_timeline_at(
|
||||
records: &[RequestMetricPoint],
|
||||
now: DateTime<Utc>,
|
||||
) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
|
||||
@@ -117,6 +117,9 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
let valid = command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_concurrent_per_account > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
|
||||
@@ -633,22 +633,39 @@ impl AuthStore for FakeAuthStore {
|
||||
async fn load_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn store_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_session: &gateway_admin::model::auth::AdminSession,
|
||||
_session: &gateway_admin::model::auth::AuthSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audit.lock().expect("audit").push(event);
|
||||
Ok(())
|
||||
|
||||
@@ -23,6 +23,14 @@ mod backup;
|
||||
mod model;
|
||||
mod use_case;
|
||||
|
||||
#[test]
|
||||
fn client_config_should_default_to_a_one_day_session() {
|
||||
assert_eq!(
|
||||
gateway_admin::ClientConfig::default().session_ttl_minutes,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_should_reject_zero() {
|
||||
assert!(Revision::new(0).is_err());
|
||||
@@ -88,7 +96,7 @@ fn admin_api_key_debug_should_redact_plaintext() {
|
||||
|
||||
#[test]
|
||||
fn login_command_debug_should_redact_password() {
|
||||
let command = LoginCommand {
|
||||
let command = LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "secret-password".to_owned(),
|
||||
};
|
||||
|
||||
@@ -129,8 +129,8 @@ fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
#[test]
|
||||
fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
|
||||
let [week, month] = forecast("a(vec![window("month", 31), window("week", 7)]));
|
||||
assert_eq!(week.source.unwrap().key, "week");
|
||||
assert_eq!(month.source.unwrap().key, "month");
|
||||
assert_eq!(week.source.unwrap().label, "week");
|
||||
assert_eq!(month.source.unwrap().label, "month");
|
||||
assert!(!month.extrapolated);
|
||||
assert_eq!(month.target_seconds, 31 * 86_400);
|
||||
assert_eq!(month.estimated_tokens, Some(5_000));
|
||||
@@ -241,21 +241,22 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
assert_eq!(week.estimated_tokens, Some(10_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert_eq!(week.method, QuotaForecastMethod::Incremental);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() {
|
||||
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;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().missing_token_count, 1);
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -269,7 +270,7 @@ fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_or_unknown_costs_suppress_only_money_estimates() {
|
||||
fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
for coverage in [
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
@@ -281,19 +282,77 @@ fn partial_or_unknown_costs_suppress_only_money_estimates() {
|
||||
unavailable_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
CostCoverage::default(),
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(week.estimated_usd.is_none());
|
||||
assert!(week.remaining_usd.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
sample.usage.request_count += 2;
|
||||
sample.usage.missing_token_count = 2;
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
let mut sample = window("week", 7);
|
||||
let usage = sample.local_usage.as_mut().unwrap();
|
||||
usage.cost_coverage = CostCoverage {
|
||||
unavailable_count: usage.request_count,
|
||||
..Default::default()
|
||||
};
|
||||
usage.costs.clear();
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage = QuotaForecastUsage {
|
||||
request_count: 2,
|
||||
missing_token_count: 2,
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
|
||||
let mut sample = window("week", 7);
|
||||
|
||||
@@ -38,8 +38,10 @@ use gateway_admin::{
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
|
||||
@@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
|
||||
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
|
||||
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -97,6 +102,9 @@ impl FakeProviderAdmin {
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
profile_result: Mutex::new(Ok(empty_profile_statistics())),
|
||||
subscription_result: Mutex::new(Ok(None)),
|
||||
personal_info_barrier: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +112,14 @@ impl FakeProviderAdmin {
|
||||
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
|
||||
}
|
||||
|
||||
async fn wait_personal_info_queries(&self) {
|
||||
let barrier = self.personal_info_barrier.lock().unwrap().clone();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
|
||||
*self.failure.lock().expect("provider failure") =
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
@@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
|
||||
self.record("provider.profile_statistics");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.profile_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.record("provider.subscription");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.subscription_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
}
|
||||
@@ -880,6 +922,9 @@ impl SettingsStore for StaticSettingsStore {
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
@@ -1288,6 +1333,8 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
|
||||
.update(
|
||||
&context("update-request"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1325,6 +1372,8 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
|
||||
.update(
|
||||
&context("update-failure"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1367,12 +1416,13 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(Vec::new()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1825,9 +1875,7 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
|
||||
use gateway_admin::model::quota_forecast_sampling::{
|
||||
QuotaForecastHistoryPoint, QuotaForecastMethod,
|
||||
};
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
|
||||
let now = Utc::now();
|
||||
let observed = now - TimeDelta::minutes(1);
|
||||
let reset = now + TimeDelta::days(1);
|
||||
@@ -1884,7 +1932,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.forecasts[0].method, QuotaForecastMethod::Incremental);
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
@@ -2360,6 +2408,8 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: "acct_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
@@ -2402,6 +2452,7 @@ fn prepared_create_with_id(
|
||||
) -> PreparedCredentialCreate {
|
||||
let now = Utc::now();
|
||||
PreparedCredentialCreate {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
|
||||
provider_kind,
|
||||
@@ -2431,6 +2482,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_profile_statistics() -> ProviderProfileStatistics {
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Preview user".to_owned()),
|
||||
username: None,
|
||||
image_url: None,
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(123),
|
||||
peak_tokens: None,
|
||||
longest_task_duration_ms: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage: None,
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_effort_percent: None,
|
||||
skills_explored: None,
|
||||
total_skills_used: None,
|
||||
total_threads: None,
|
||||
invocations: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_info_subscription() -> ProviderSubscription {
|
||||
ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: Utc::now(),
|
||||
will_renew: Some(true),
|
||||
billing_period: Some("monthly".to_owned()),
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let info = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
services.accounts().personal_info(&account_id),
|
||||
)
|
||||
.await
|
||||
.expect("两项查询应并发执行")
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, Some(subscription.clone()));
|
||||
}
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
|
||||
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.subscription_result.lock().unwrap() = subscription;
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_subscription_when_profile_fails() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
info.profile.unwrap_err().kind(),
|
||||
gateway_admin::model::AdminErrorKind::BadGateway
|
||||
);
|
||||
assert_eq!(info.subscription, Some(subscription));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_reject_missing_account_before_querying_provider() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.set_accounts(Vec::new());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let error = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
|
||||
assert_eq!(recorded(&events), ["store.load_account"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_discard_results_when_account_changes_during_query() {
|
||||
for change in 0..5 {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(3));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let mutate = async {
|
||||
barrier.wait().await;
|
||||
let mut accounts = store.accounts.lock().unwrap().clone();
|
||||
match change {
|
||||
0 => accounts[0].credential_revision = revision(99),
|
||||
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
|
||||
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
|
||||
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
|
||||
_ => accounts.clear(),
|
||||
}
|
||||
store.set_accounts(accounts);
|
||||
barrier.wait().await;
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::join!(services.accounts().personal_info(&account_id), mutate)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if change == 4 {
|
||||
gateway_admin::model::AdminErrorKind::NotFound
|
||||
} else {
|
||||
gateway_admin::model::AdminErrorKind::Conflict
|
||||
};
|
||||
assert_eq!(result.unwrap_err().kind(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
async fn accounts_service(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
@@ -2532,6 +2744,8 @@ fn unsupported() -> ProviderAdminError {
|
||||
|
||||
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
|
||||
gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: Some("团队备用".to_owned()),
|
||||
model_access: Default::default(),
|
||||
enabled: false,
|
||||
concurrency_limit: Some(
|
||||
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
|
||||
|
||||
@@ -5,7 +5,7 @@ use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AdminSession, LoginCommand},
|
||||
auth::{AdminAuditEvent, AuthSession, LoginCommand},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
@@ -13,8 +13,11 @@ use gateway_admin::{
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryAuthStore {
|
||||
retry_after: Mutex<Option<std::time::Duration>>,
|
||||
unavailable: std::sync::atomic::AtomicBool,
|
||||
reject_delete: Mutex<Option<String>>,
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
sessions: Mutex<BTreeMap<String, AuthSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
}
|
||||
|
||||
@@ -41,7 +44,10 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.unavailable.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(super::unavailable("session"));
|
||||
}
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
@@ -50,11 +56,7 @@ impl AuthStore for MemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
@@ -62,10 +64,30 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.reject_delete.lock().unwrap().as_deref() == Some(session_id) {
|
||||
return Err(super::unavailable("delete session"));
|
||||
}
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(*self.retry_after.lock().unwrap())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audits.lock().expect("audits").push(event);
|
||||
Ok(())
|
||||
@@ -79,19 +101,24 @@ async fn successful_login_should_create_expiring_session_and_audit() {
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login");
|
||||
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(store.audits.lock().expect("audits").len(), 1);
|
||||
}
|
||||
@@ -113,10 +140,14 @@ async fn repeated_default_initialization_should_not_replace_password() {
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
@@ -134,20 +165,134 @@ async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking()
|
||||
let before = Utc::now();
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login with huge session TTL");
|
||||
|
||||
assert!(result.expires_at > before);
|
||||
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(result.session.expires_at > before);
|
||||
assert!(result.session.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() {
|
||||
use gateway_admin::model::{AdminErrorKind, auth::SessionSubject};
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
store.sessions.lock().unwrap().insert(
|
||||
"expired".to_owned(),
|
||||
AuthSession {
|
||||
subject: SessionSubject::Admin {
|
||||
admin_user_id: "admin".to_owned(),
|
||||
},
|
||||
expires_at: Utc::now() - TimeDelta::seconds(1),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("expired"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
store
|
||||
.unavailable
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("existing"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_login_types_share_the_login_limit_before_credentials_are_checked() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(37));
|
||||
let services = super::AdminHarness::new().auth(store).build().await;
|
||||
for command in [
|
||||
LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "wrong-password".to_owned(),
|
||||
},
|
||||
LoginCommand::Key {
|
||||
api_key: "unknown-key".to_owned(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(command, std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_retryable() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
let command = || LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "strong-test-password".to_owned(),
|
||||
};
|
||||
let original = services
|
||||
.auth()
|
||||
.login(command(), std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!format!("{original:?}").contains(&original.session_id));
|
||||
*store.reject_delete.lock().unwrap() = Some(original.session_id.clone());
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(
|
||||
command(),
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
Some(&original.session_id)
|
||||
)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::Unavailable
|
||||
);
|
||||
assert_eq!(store.sessions.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some(&original.session_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(services.auth().logout(&original.session_id).await.is_err());
|
||||
*store.reject_delete.lock().unwrap() = None;
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::{
|
||||
model::auth::{AdminAuditEvent, AuthSession, LoginCommand, LoginError, SessionSubject},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn client_login_command_debug_should_redact_api_key() {
|
||||
let command = LoginCommand::Key {
|
||||
api_key: "cpr_should_never_be_logged".to_owned(),
|
||||
};
|
||||
|
||||
let rendered = format!("{command:?}");
|
||||
|
||||
assert!(rendered.contains("[REDACTED]"));
|
||||
assert!(!rendered.contains("cpr_should_never_be_logged"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_should_create_key_bound_session_with_fixed_ttl() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(key.clone()));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.client_session_ttl_minutes(30)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
|
||||
assert!(login.session_id.starts_with("session_"));
|
||||
assert!(!login.session_id.contains("valid-client-key"));
|
||||
assert_eq!(verifier.calls(), 1);
|
||||
assert_eq!(
|
||||
store.session(&login.session_id).map(|value| value.subject),
|
||||
Some(SessionSubject::Key {
|
||||
client_key_id: key.clone()
|
||||
})
|
||||
);
|
||||
let remaining = login.session.expires_at - Utc::now();
|
||||
assert!(remaining > Duration::minutes(29));
|
||||
assert!(remaining <= Duration::minutes(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_and_removed_keys_should_share_the_same_login_error() {
|
||||
let rejected_store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
let rejected = super::AdminHarness::new()
|
||||
.auth(rejected_store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::rejecting()))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("unknown-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let removed = super::AdminHarness::new()
|
||||
.auth(Arc::new(MemoryKeyAuthStore::new(None)))
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("removed-key").expect("key id"),
|
||||
)))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("disabled-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(rejected, Err(LoginError::InvalidCredentials));
|
||||
assert_eq!(removed, Err(LoginError::InvalidCredentials));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_should_reject_before_verifying_the_candidate_key() {
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
store.set_retry_after(Some(StdDuration::from_secs(37)));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("key-42").expect("key id"),
|
||||
));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37,
|
||||
})
|
||||
);
|
||||
assert_eq!(verifier.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_session_should_be_revoked_when_its_key_is_no_longer_active() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(key.clone())))
|
||||
.build()
|
||||
.await;
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
store.replace_key(None);
|
||||
|
||||
let session = services
|
||||
.auth()
|
||||
.session(Some(&login.session_id))
|
||||
.await
|
||||
.expect("session status");
|
||||
|
||||
assert!(session.is_none());
|
||||
assert!(store.session(&login.session_id).is_none());
|
||||
}
|
||||
|
||||
fn login_command(api_key: &str) -> LoginCommand {
|
||||
LoginCommand::Key {
|
||||
api_key: api_key.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_key(id: &str) -> ClientApiKeyId {
|
||||
ClientApiKeyId::new(id).expect("client key id")
|
||||
}
|
||||
|
||||
struct FixtureVerifier {
|
||||
result: Result<ClientApiKeyId, ClientAuthenticationError>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FixtureVerifier {
|
||||
fn accepting(id: ClientApiKeyId) -> Self {
|
||||
Self {
|
||||
result: Ok(id),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejecting() -> Self {
|
||||
Self {
|
||||
result: Err(ClientAuthenticationError::InvalidKey),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyVerifier for FixtureVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryKeyAuthStore {
|
||||
key: Mutex<Option<ClientApiKeyId>>,
|
||||
sessions: Mutex<HashMap<String, AuthSession>>,
|
||||
retry_after: Mutex<Option<StdDuration>>,
|
||||
}
|
||||
|
||||
impl MemoryKeyAuthStore {
|
||||
fn new(key: Option<ClientApiKeyId>) -> Self {
|
||||
Self {
|
||||
key: Mutex::new(key),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
retry_after: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_key(&self, key: Option<ClientApiKeyId>) {
|
||||
*self.key.lock().expect("client key") = key;
|
||||
}
|
||||
|
||||
fn set_retry_after(&self, retry_after: Option<StdDuration>) {
|
||||
*self.retry_after.lock().expect("retry after") = retry_after;
|
||||
}
|
||||
|
||||
fn session(&self, session_id: &str) -> Option<AuthSession> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for MemoryKeyAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn load_admin_api_key(
|
||||
&self,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn client_key_enabled(&self, id: &ClientApiKeyId) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.key
|
||||
.lock()
|
||||
.expect("client key")
|
||||
.as_ref()
|
||||
.is_some_and(|key| key == id))
|
||||
}
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self.session(session_id))
|
||||
}
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.insert(session_id.to_owned(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.remove(session_id))
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: StdDuration,
|
||||
) -> AdminStoreResult<Option<StdDuration>> {
|
||||
Ok(*self.retry_after.lock().expect("retry after"))
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use chrono::Utc;
|
||||
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
|
||||
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
|
||||
UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
|
||||
};
|
||||
|
||||
struct UnusedClientKeyStore;
|
||||
#[derive(Default)]
|
||||
struct TestClientKeyStore {
|
||||
plaintexts: Mutex<Vec<String>>,
|
||||
create_error: Option<AdminStoreErrorKind>,
|
||||
update_error: Option<AdminStoreErrorKind>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnusedClientKeyStore {
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
assert_eq!(query.page_size.get(), u16::MAX);
|
||||
Ok(ClientKeyPage {
|
||||
@@ -36,10 +52,32 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
key: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
if let Some(kind) = self.create_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test create failure",
|
||||
));
|
||||
}
|
||||
self.plaintexts.lock().unwrap().push(key.plaintext);
|
||||
let record = ClientKeyRecord {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
groups: Vec::new(),
|
||||
provider_kinds: Vec::new(),
|
||||
prefix: String::new(),
|
||||
enabled: true,
|
||||
limits: key.limits,
|
||||
budget: Default::default(),
|
||||
last_used_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
Ok((Revision::new(2).unwrap(), record))
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
@@ -47,6 +85,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
if let Some(kind) = self.update_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test update failure",
|
||||
));
|
||||
}
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
@@ -70,7 +115,7 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
#[tokio::test]
|
||||
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let sort = ClientKeySort {
|
||||
@@ -98,7 +143,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
#[tokio::test]
|
||||
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
@@ -118,6 +163,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
|
||||
let store = Arc::new(TestClientKeyStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(value.as_deref()))
|
||||
.await
|
||||
.unwrap();
|
||||
let plaintext = created.secret.expose_for_response();
|
||||
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
|
||||
if let Some(value) = value {
|
||||
assert_eq!(plaintext, value);
|
||||
} else {
|
||||
assert!(plaintext.starts_with("sk_"));
|
||||
assert_eq!(plaintext.len(), 46);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::Conflict),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let key = "legacy-duplicate-must-stay-private";
|
||||
let error = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(Some(key)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
|
||||
assert!(!format!("{error:?}").contains(key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
update_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let updated = services
|
||||
.client_keys()
|
||||
.update(
|
||||
&mutation_context(),
|
||||
UpdateClientKey {
|
||||
id: ClientApiKeyId::new("key_existing").unwrap(),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
for error in [created, updated] {
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_command(key: Option<&str>) -> CreateClientKey {
|
||||
CreateClientKey {
|
||||
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
budget: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mutation_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "custom-key-test".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account_groups;
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod auth_key;
|
||||
mod backup;
|
||||
mod client_keys;
|
||||
mod observability;
|
||||
@@ -19,7 +20,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
@@ -31,7 +32,7 @@ use gateway_admin::{
|
||||
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_distribution::CodexDesktopWindowsDownloads,
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
@@ -71,7 +72,10 @@ use gateway_admin::{
|
||||
};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
engine::{
|
||||
execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::ClientApiKeyId,
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
@@ -81,6 +85,7 @@ use gateway_core::{
|
||||
pub(super) struct AdminHarness {
|
||||
default_password: String,
|
||||
session_ttl_minutes: u64,
|
||||
client_session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
@@ -93,6 +98,7 @@ pub(super) struct AdminHarness {
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl AdminHarness {
|
||||
@@ -101,6 +107,7 @@ impl AdminHarness {
|
||||
Self {
|
||||
default_password: "strong-test-password".to_owned(),
|
||||
session_ttl_minutes: 60,
|
||||
client_session_ttl_minutes: 1_440,
|
||||
accounts: unavailable.clone(),
|
||||
proxies: Arc::new(proxies::TestProxies::default()),
|
||||
account_runtime: unavailable.clone(),
|
||||
@@ -116,6 +123,7 @@ impl AdminHarness {
|
||||
],
|
||||
probe: Arc::new(UnavailableProbe),
|
||||
system: Arc::new(UnavailableSystem),
|
||||
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +162,16 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn client_session_ttl_minutes(mut self, minutes: u64) -> Self {
|
||||
self.client_session_ttl_minutes = minutes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn client_key_verifier(mut self, verifier: Arc<dyn ClientKeyVerifier>) -> Self {
|
||||
self.client_key_verifier = verifier;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
|
||||
self.observability = store;
|
||||
self
|
||||
@@ -201,6 +219,9 @@ impl AdminHarness {
|
||||
default_username: "admin".to_owned(),
|
||||
default_password: InitialAdminPassword::new(self.default_password),
|
||||
},
|
||||
ClientConfig {
|
||||
session_ttl_minutes: self.client_session_ttl_minutes,
|
||||
},
|
||||
AdminStorePorts::new(
|
||||
AdminAccountStorePorts::new(
|
||||
self.accounts,
|
||||
@@ -214,11 +235,15 @@ impl AdminHarness {
|
||||
self.settings,
|
||||
self.backup,
|
||||
),
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
(self.probe, Arc::new(proxies::TestProxies::default())),
|
||||
Arc::new(NoopClientDistribution),
|
||||
self.system,
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
providers: self.providers,
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
account_probe: self.probe,
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
client_distribution: Arc::new(NoopClientDistribution),
|
||||
system: self.system,
|
||||
client_key_verifier: self.client_key_verifier,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("initialize admin test harness")
|
||||
@@ -268,18 +293,35 @@ impl AuthStore for BootstrapAuthStore {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn store_session(&self, _: &str, _: &AdminSession) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, _: &str, _: &AuthSession) -> AdminStoreResult<()> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -287,6 +329,14 @@ impl AuthStore for BootstrapAuthStore {
|
||||
|
||||
struct UnavailableStore;
|
||||
|
||||
struct UnavailableClientKeyVerifier;
|
||||
|
||||
impl ClientKeyVerifier for UnavailableClientKeyVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
Err(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableAccountGroupStore;
|
||||
|
||||
#[async_trait]
|
||||
@@ -480,6 +530,13 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Err(unavailable("client key"))
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
Err(unavailable("client key list"))
|
||||
}
|
||||
|
||||
@@ -759,6 +759,9 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -66,6 +66,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
refresh_concurrency: 1,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Account group HTTP wire and fixed routes.
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
@@ -22,8 +24,8 @@ use gateway_core::routing::AccountGroupId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta, WireValidationError, wire::map_admin_service_error,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
@@ -221,7 +223,7 @@ impl From<AccountGroupMutation> for AccountGroupMutationData {
|
||||
/// Construct all fixed account-group management routes.
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/account-groups", get(list::<S>))
|
||||
@@ -238,7 +240,7 @@ async fn list<S>(
|
||||
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -258,7 +260,7 @@ async fn create<S>(
|
||||
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
@@ -284,7 +286,7 @@ async fn update<S>(
|
||||
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
@@ -311,7 +313,7 @@ async fn enable<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, true).await
|
||||
}
|
||||
@@ -322,7 +324,7 @@ async fn disable<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, false).await
|
||||
}
|
||||
@@ -334,7 +336,7 @@ async fn set_enabled<S>(
|
||||
enabled: bool,
|
||||
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
@@ -358,7 +360,7 @@ async fn delete<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError> {
|
||||
if notes.is_some_and(|notes| {
|
||||
notes.chars().count() > 500
|
||||
|| notes
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
|
||||
}) {
|
||||
return Err(WireValidationError::new("notes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AccountProvider {
|
||||
OpenAi,
|
||||
@@ -18,19 +30,22 @@ impl AccountProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入统一设置,复用编辑账号的调度和分组约束。
|
||||
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportSettingsRequest {
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountImportSettingsRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
validate_account_notes(self.notes.as_deref())?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
@@ -41,9 +56,11 @@ impl AccountImportSettingsRequest {
|
||||
self,
|
||||
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
|
||||
Ok(gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: self.notes,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
@@ -200,16 +217,19 @@ pub struct UpdateAccountRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl UpdateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
validate_account_notes(self.notes.as_deref())?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
@@ -224,9 +244,11 @@ impl UpdateAccountRequest {
|
||||
self.outbound_proxy_url,
|
||||
)?,
|
||||
account_id: self.account_id,
|
||||
notes: self.notes,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! 账号管理路由与 HTTP handler 编排。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 构造统一账号管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/accounts", get(list_accounts::<S>))
|
||||
@@ -23,12 +25,12 @@ where
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
"/api/admin/accounts/personal-info",
|
||||
get(account_personal_info::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-statistics",
|
||||
get(account_profile_statistics::<S>),
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-avatar",
|
||||
@@ -67,7 +69,7 @@ async fn batch_update_accounts<S>(
|
||||
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -88,7 +90,7 @@ async fn list_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = query.validate().map_err(map_wire_error)?;
|
||||
let page = command.page;
|
||||
@@ -109,7 +111,7 @@ async fn account_detail<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -130,7 +132,7 @@ async fn export_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<AccountExportQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let ids = query.into_ids().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -153,7 +155,7 @@ async fn import_accounts<S>(
|
||||
AdminJson(request): AdminJson<AccountImportRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -181,7 +183,7 @@ async fn start_account_authorization<S>(
|
||||
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -215,7 +217,7 @@ async fn complete_account_authorization<S>(
|
||||
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -249,7 +251,7 @@ async fn rotate_account<S>(
|
||||
AdminJson(request): AdminJson<RotateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -272,7 +274,7 @@ async fn update_account<S>(
|
||||
AdminJson(request): AdminJson<UpdateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -293,7 +295,7 @@ async fn delete_accounts<S>(
|
||||
AdminJson(request): AdminJson<AccountDeletionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -315,7 +317,7 @@ async fn refresh_account<S>(
|
||||
AdminJson(request): AdminJson<AccountRefreshRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -334,7 +336,7 @@ async fn recover_account<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -353,7 +355,7 @@ async fn account_quota<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -374,7 +376,7 @@ async fn account_quota_forecast<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -389,24 +391,24 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_profile_statistics<S>(
|
||||
async fn account_personal_info<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_statistics(&account_id)
|
||||
.personal_info(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -416,7 +418,7 @@ async fn account_profile_avatar<S>(
|
||||
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let avatar = state
|
||||
@@ -479,7 +481,7 @@ async fn refresh_account_quota<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -500,7 +502,7 @@ async fn account_reset_credits<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -521,7 +523,7 @@ async fn consume_account_reset_credit<S>(
|
||||
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -542,7 +544,7 @@ async fn account_models<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -561,7 +563,7 @@ async fn refresh_account_models<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -580,7 +582,7 @@ async fn test_account_connection<S>(
|
||||
AdminQuery(query): AdminQuery<AccountTestQuery>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
|
||||
let stream = state
|
||||
|
||||
@@ -25,14 +25,15 @@ use gateway_admin::model::{
|
||||
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
|
||||
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
|
||||
StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
@@ -45,8 +46,8 @@ use uuid::{Uuid, Version};
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta, WireValidationError,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
WireValidationError,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
@@ -74,8 +75,8 @@ pub(super) use presenter::quota_window_view;
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
|
||||
provider_document_value, require_account_id, validate_wire_group_ids,
|
||||
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
|
||||
require_account_id, validate_wire_group_ids,
|
||||
};
|
||||
use presenter::*;
|
||||
use wire::BatchUpdatedAccountsData;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport};
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastMethod;
|
||||
|
||||
pub(super) fn account_page_data(
|
||||
result: AccountDirectoryPage,
|
||||
@@ -76,6 +75,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
AccountView {
|
||||
id: account.id.clone(),
|
||||
name: account.name,
|
||||
notes: account.notes,
|
||||
provider: account.provider_kind.to_string(),
|
||||
groups: account
|
||||
.groups
|
||||
@@ -104,6 +104,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
@@ -129,7 +130,6 @@ impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
|
||||
Self {
|
||||
account_id: report.account_id,
|
||||
generated_at: china_rfc3339(&report.generated_at),
|
||||
generated_at_display: china_datetime(&report.generated_at),
|
||||
forecasts: report
|
||||
.forecasts
|
||||
.into_iter()
|
||||
@@ -148,9 +148,7 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
target_days: forecast.target_seconds as f64 / 86_400.0,
|
||||
extrapolated: forecast.extrapolated,
|
||||
source: forecast.source.map(|source| QuotaForecastSourceView {
|
||||
key: source.key,
|
||||
label: source.label,
|
||||
window_days: source.window_seconds as f64 / 86_400.0,
|
||||
used_percent: source.used_percent,
|
||||
used_percent_display: source
|
||||
.used_percent
|
||||
@@ -159,48 +157,14 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
observed_at_display: source
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
start_at: source.start_at.map(|value| china_rfc3339(&value)),
|
||||
start_at_display: source
|
||||
.start_at
|
||||
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
reset_at: china_rfc3339(&source.reset_at),
|
||||
reset_at_display: china_datetime(&source.reset_at),
|
||||
request_count_display: format_number(source.request_count),
|
||||
tokens_display: display_optional_tokens(source.tokens),
|
||||
input_tokens_display: display_optional_tokens(source.input_tokens),
|
||||
output_tokens_display: display_optional_tokens(source.output_tokens),
|
||||
cached_tokens_display: display_optional_tokens(source.cached_tokens),
|
||||
known_cost_count: source.known_cost_count,
|
||||
known_cost_count_display: format_number(source.known_cost_count),
|
||||
partial_cost_count: source.partial_cost_count,
|
||||
partial_cost_count_display: format_number(source.partial_cost_count),
|
||||
unavailable_cost_count: source.unavailable_cost_count,
|
||||
unavailable_cost_count_display: format_number(source.unavailable_cost_count),
|
||||
usd_display: forecast_usd_display(source.usd),
|
||||
sample_start_at: source.sample_start_at.map(|value| china_rfc3339(&value)),
|
||||
sample_start_at_display: source
|
||||
.sample_start_at
|
||||
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
baseline_percent: source.baseline_percent,
|
||||
sampled_percent: source.sampled_percent,
|
||||
sampled_percent_display: source
|
||||
.sampled_percent
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1} 个百分点")),
|
||||
block_count: source.block_count,
|
||||
observation_count: source.observation_count,
|
||||
missing_token_count: source.missing_token_count,
|
||||
excluded_request_count: source.excluded_request_count,
|
||||
pending_request_count: source.pending_request_count,
|
||||
}),
|
||||
unavailable_reason: forecast.unavailable_reason,
|
||||
low_sample: forecast.low_sample,
|
||||
incomplete_cost: forecast.incomplete_cost,
|
||||
incomplete_tokens: forecast.incomplete_tokens,
|
||||
method: forecast.method.as_str(),
|
||||
method_display: match forecast.method {
|
||||
QuotaForecastMethod::Cumulative => "窗口累计估算",
|
||||
QuotaForecastMethod::Incremental => "近期分段估算",
|
||||
},
|
||||
estimated_tokens: forecast.estimated_tokens,
|
||||
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
|
||||
estimated_usd: forecast.estimated_usd,
|
||||
|
||||
@@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_nullable_limit")]
|
||||
pub concurrency_limit: Option<Option<u64>>,
|
||||
pub weight: Option<u64>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -91,6 +92,12 @@ impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<u64>>, D::Error> {
|
||||
Option::<u64>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
impl BatchUpdateAccountsRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.account_ids.is_empty()
|
||||
@@ -103,9 +110,23 @@ impl BatchUpdateAccountsRequest {
|
||||
{
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
if let Some(group_ids) = &self.group_ids {
|
||||
validate_wire_group_ids(group_ids)?;
|
||||
}
|
||||
self.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
&& self.group_ids.is_none()
|
||||
&& self.model_access.is_none()
|
||||
&& self.outbound_proxy_id.is_none()
|
||||
&& self.outbound_proxy_url.is_none()
|
||||
{
|
||||
return Err(WireValidationError::new("accountSettings"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,9 +136,17 @@ impl BatchUpdateAccountsRequest {
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
concurrency_limit: self
|
||||
.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?,
|
||||
weight: self.weight.map(parse_account_weight).transpose()?,
|
||||
model_access: self.model_access,
|
||||
group_ids: self
|
||||
.group_ids
|
||||
.as_deref()
|
||||
.map(validate_wire_group_ids)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,6 +269,7 @@ pub struct AccountView {
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub provider: String,
|
||||
pub groups: Vec<AccountGroupRefView>,
|
||||
pub resource_ref: String,
|
||||
@@ -260,6 +290,7 @@ pub struct AccountView {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<u32>,
|
||||
pub weight: u16,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub access_token_expires_at: Option<String>,
|
||||
pub access_token_expires_at_display: Option<String>,
|
||||
pub refresh_token_expires_at: Option<String>,
|
||||
@@ -278,7 +309,6 @@ pub struct AccountView {
|
||||
pub struct AccountQuotaForecastData {
|
||||
pub account_id: String,
|
||||
pub generated_at: String,
|
||||
pub generated_at_display: String,
|
||||
pub forecasts: Vec<AccountQuotaForecastView>,
|
||||
}
|
||||
|
||||
@@ -293,8 +323,6 @@ pub struct AccountQuotaForecastView {
|
||||
pub low_sample: bool,
|
||||
pub incomplete_cost: bool,
|
||||
pub incomplete_tokens: bool,
|
||||
pub method: &'static str,
|
||||
pub method_display: &'static str,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_tokens_display: String,
|
||||
pub estimated_usd: Option<f64>,
|
||||
@@ -308,39 +336,14 @@ pub struct AccountQuotaForecastView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuotaForecastSourceView {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub window_days: f64,
|
||||
pub used_percent: Option<f64>,
|
||||
pub used_percent_display: String,
|
||||
pub observed_at: Option<String>,
|
||||
pub observed_at_display: String,
|
||||
pub start_at: Option<String>,
|
||||
pub start_at_display: String,
|
||||
pub reset_at: String,
|
||||
pub reset_at_display: String,
|
||||
pub request_count_display: String,
|
||||
pub tokens_display: String,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens_display: String,
|
||||
pub known_cost_count: u64,
|
||||
pub known_cost_count_display: String,
|
||||
pub partial_cost_count: u64,
|
||||
pub partial_cost_count_display: String,
|
||||
pub unavailable_cost_count: u64,
|
||||
pub unavailable_cost_count_display: String,
|
||||
pub usd_display: String,
|
||||
pub sample_start_at: Option<String>,
|
||||
pub sample_start_at_display: String,
|
||||
pub baseline_percent: f64,
|
||||
pub sampled_percent: Option<f64>,
|
||||
pub sampled_percent_display: String,
|
||||
pub block_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub missing_token_count: u64,
|
||||
pub excluded_request_count: u64,
|
||||
pub pending_request_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -681,6 +684,54 @@ pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
/// 个人资料、累计统计与订阅的统一响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPersonalInfoData {
|
||||
pub profile: Option<AccountProfileStatisticsData>,
|
||||
pub profile_error: Option<String>,
|
||||
pub subscription: Option<AccountSubscriptionData>,
|
||||
}
|
||||
|
||||
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
fn from(info: AccountPersonalInfo) -> Self {
|
||||
let (profile, profile_error) = match info.profile {
|
||||
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
|
||||
Err(error) => (None, Some(error.message().to_owned())),
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
profile_error,
|
||||
subscription: info.subscription.map(AccountSubscriptionData::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需读取的订阅安全字段,不暴露原始上游响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSubscriptionData {
|
||||
pub starts_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
fn from(subscription: ProviderSubscription) -> Self {
|
||||
Self {
|
||||
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
|
||||
expires_at: subscription.expires_at.to_rfc3339(),
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料统计响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
//! 管理员登录、会话与管理请求鉴权接线。
|
||||
|
||||
use std::fmt;
|
||||
//! 管理端权限校验与请求审计上下文。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{FromRequestParts, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
extract::FromRequestParts,
|
||||
http::{HeaderMap, request::Parts},
|
||||
};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use gateway_admin::model::auth::{AdminPrincipal, AdminRequestContext};
|
||||
use tower_http::request_id::RequestId;
|
||||
|
||||
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
|
||||
use crate::{auth::SessionState, session_cookie};
|
||||
|
||||
use super::{AdminError, wire::map_admin_service_error};
|
||||
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
|
||||
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
|
||||
|
||||
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
|
||||
pub trait AdminSessionState {
|
||||
fn admin_services(&self) -> &AdminServices;
|
||||
}
|
||||
|
||||
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
|
||||
pub struct AdminAuth {
|
||||
@@ -41,7 +27,7 @@ impl AdminAuth {
|
||||
|
||||
impl<S> FromRequestParts<S> for AdminAuth
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
type Rejection = AdminError;
|
||||
|
||||
@@ -74,23 +60,23 @@ fn admin_request_id(parts: &Parts) -> Option<String> {
|
||||
|
||||
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
match state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
|
||||
.resolve_admin_user_id(session_cookie::value(headers).as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(admin_user_id)) => Ok(admin_user_id),
|
||||
Ok(None) => Err(AdminError::admin_session_required()),
|
||||
Err(_) => Err(AdminError::internal()),
|
||||
Ok(None) => Err(AdminError::session_required()),
|
||||
Err(error) => Err(map_admin_service_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
if let Some(api_key) = admin_api_key_header(headers) {
|
||||
return match state
|
||||
@@ -101,7 +87,7 @@ where
|
||||
{
|
||||
Ok(true) => Ok(AdminPrincipal::ApiKey),
|
||||
Ok(false) => Err(AdminError::invalid_admin_api_key()),
|
||||
Err(_) => Err(AdminError::internal()),
|
||||
Err(error) => Err(map_admin_service_error(error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,171 +100,3 @@ fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
|
||||
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
/// 管理员登录请求;密码不得进入 Debug。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminLoginRequest {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl AdminLoginRequest {
|
||||
/// 取出认证用例所需字段。
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Option<String>, String) {
|
||||
(self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AdminLoginRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminLoginData {
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl AdminLoginData {
|
||||
#[must_use]
|
||||
pub fn new(expires_at: String) -> Self {
|
||||
Self { expires_at }
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员会话状态响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSessionStatusData {
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
impl AdminSessionStatusData {
|
||||
#[must_use]
|
||||
pub const fn new(authenticated: bool) -> Self {
|
||||
Self { authenticated }
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出成功响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct AdminLogoutData {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
impl AdminLogoutData {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
message: "Logged out successfully",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdminLogoutData {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 管理员认证路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/auth/login", post(login::<S>))
|
||||
.route("/api/admin/auth/status", get(session_status::<S>))
|
||||
.route("/api/admin/auth/logout", post(logout::<S>))
|
||||
}
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
AdminJson(payload): AdminJson<AdminLoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (username, password) = payload.into_parts();
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(LoginCommand { username, password })
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
|
||||
)
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
|
||||
session.session_id
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn session_status<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let authenticated = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.validate_session(admin_session_cookie(&headers).as_deref())
|
||||
.await
|
||||
.map_err(|_| AdminError::internal())?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
if let Some(session_id) = admin_session_cookie(&headers) {
|
||||
let _ = state.admin_services().auth().logout(&session_id).await;
|
||||
}
|
||||
let mut response =
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
|
||||
.into_response();
|
||||
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
let cookie = headers.get("cookie")?.to_str().ok()?;
|
||||
cookie.split(';').find_map(|part| {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
|
||||
LoginError::Unavailable => AdminError::internal(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! 备份设置、计划、手动创建、记录、下载与删除路由。
|
||||
|
||||
//!
|
||||
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
@@ -19,7 +22,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
|
||||
AdminSessionState, PageMeta, wire::map_admin_service_error,
|
||||
PageMeta, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
|
||||
@@ -200,7 +203,7 @@ impl From<ConnectionTestResult> for ConnectionTestView {
|
||||
/// 构造固定 GET/POST 备份路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings/backups", get(backup_settings::<S>))
|
||||
@@ -239,7 +242,7 @@ async fn backup_settings<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let settings = state
|
||||
.admin_services()
|
||||
@@ -259,7 +262,7 @@ async fn update_backup_storage<S>(
|
||||
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupStorageCommand {
|
||||
endpoint: request.endpoint,
|
||||
@@ -287,7 +290,7 @@ async fn test_backup_storage<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -307,7 +310,7 @@ async fn update_backup_schedule<S>(
|
||||
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupScheduleCommand {
|
||||
schedule_enabled: request.schedule_enabled,
|
||||
@@ -334,7 +337,7 @@ async fn backup_records<S>(
|
||||
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
|
||||
@@ -389,7 +392,7 @@ async fn create_backup<S>(
|
||||
body: Option<AdminJson<CreateBackupRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let expires_at = body
|
||||
.and_then(|body| body.0.expires_in_days)
|
||||
@@ -420,7 +423,7 @@ async fn download_backup_url<S>(
|
||||
AdminJson(request): AdminJson<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -440,7 +443,7 @@ async fn delete_backup<S>(
|
||||
AdminJson(request): AdminJson<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let record = state
|
||||
.admin_services()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Client API Key 管理 wire contract。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -13,7 +15,7 @@ use gateway_admin::model::client_keys::{
|
||||
use gateway_core::{
|
||||
engine::budget::ClientBudgetLimits,
|
||||
metering::Decimal,
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,7 +29,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -70,9 +72,7 @@ impl ListClientKeysQuery {
|
||||
}
|
||||
let search = self.search.map(|search| search.trim().to_owned());
|
||||
if search.as_deref().is_some_and(|search| {
|
||||
search.len() > MAX_SEARCH_BYTES
|
||||
|| search.chars().any(char::is_control)
|
||||
|| contains_client_key_material(search)
|
||||
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(WireValidationError::new("search"));
|
||||
}
|
||||
@@ -143,9 +143,10 @@ impl ClientKeySort {
|
||||
}
|
||||
|
||||
/// 创建 Client Key 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[derive(Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
custom_key: Option<String>,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
group_ids: Vec<String>,
|
||||
@@ -155,6 +156,16 @@ pub struct CreateClientKeyRequest {
|
||||
weekly_limit_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreateClientKeyRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreateClientKeyRequest")
|
||||
.field("name", &self.name)
|
||||
.field("custom_key", &"[REDACTED]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
@@ -164,6 +175,12 @@ impl CreateClientKeyRequest {
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
custom_key: self
|
||||
.custom_key
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(PlaintextClientApiKey::new)
|
||||
.transpose()
|
||||
.map_err(|_| WireValidationError::new("customKey"))?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
group_ids,
|
||||
@@ -661,19 +678,10 @@ fn validate_optional_text(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contains_client_key_material(value: &str) -> bool {
|
||||
value.as_bytes().windows(46).any(|window| {
|
||||
&window[..3] == b"sk_"
|
||||
&& window[3..]
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/client-keys", get(list_client_keys::<S>))
|
||||
@@ -706,7 +714,7 @@ async fn list_client_keys<S>(
|
||||
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -724,7 +732,7 @@ async fn create_client_key<S>(
|
||||
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -745,7 +753,7 @@ async fn reveal_client_key<S>(
|
||||
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = query.into_domain_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -771,7 +779,7 @@ async fn update_client_key<S>(
|
||||
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -789,7 +797,7 @@ async fn disable_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -810,7 +818,7 @@ async fn enable_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -831,7 +839,7 @@ async fn delete_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 管理端 HTTP adapter、wire contract 与固定路由。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderValue, header},
|
||||
@@ -21,7 +23,7 @@ pub mod settings;
|
||||
pub mod system;
|
||||
pub mod wire;
|
||||
|
||||
pub use auth::{AdminAuth, AdminSessionState};
|
||||
pub use auth::AdminAuth;
|
||||
pub use extract::{AdminJson, AdminQuery};
|
||||
pub use wire::{
|
||||
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
|
||||
@@ -31,13 +33,12 @@ pub use wire::{
|
||||
/// 构造完整且固定的 `/api/admin` 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.merge(account_groups::router::<S>())
|
||||
.merge(proxies::router::<S>())
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(backups::router::<S>())
|
||||
.merge(client_keys::router::<S>())
|
||||
.merge(observability::router::<S>())
|
||||
|
||||
@@ -10,8 +10,8 @@ use serde_json::Value;
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, WireValidationError,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
mod presenter;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! 固定 `/api/admin` 观测路由与 handler。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/dashboard/summary", get(dashboard_summary::<S>))
|
||||
@@ -35,7 +37,7 @@ pub(crate) async fn dashboard_summary<S>(
|
||||
AdminQuery(query): AdminQuery<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
|
||||
@@ -59,7 +61,7 @@ pub(crate) async fn dashboard_trend<S>(
|
||||
AdminQuery(query): AdminQuery<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
@@ -82,7 +84,7 @@ pub(crate) async fn usage_records<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = usage_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -101,7 +103,7 @@ pub(crate) async fn usage_record_detail<S>(
|
||||
AdminQuery(query): AdminQuery<DetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
query.validate().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -122,7 +124,7 @@ pub(crate) async fn usage_records_summary<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
@@ -145,7 +147,7 @@ pub(crate) async fn usage_insights_overview<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
@@ -168,7 +170,7 @@ pub(crate) async fn usage_insights_diagnostics<S>(
|
||||
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let dimension = query.dimension().map_err(map_wire_error)?;
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
@@ -198,7 +200,7 @@ pub(crate) async fn ops_errors<S>(
|
||||
AdminQuery(query): AdminQuery<OpsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = ops_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
@@ -15,8 +17,7 @@ use gateway_admin::model::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
accounts::{AccountGroupRefView, AccountProxyUpdate},
|
||||
};
|
||||
|
||||
@@ -170,7 +171,7 @@ impl From<ProxyMutation> for MutationView {
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/proxies", get(list::<S>))
|
||||
@@ -203,7 +204,7 @@ async fn list<S>(
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -237,7 +238,7 @@ async fn list_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -295,7 +296,7 @@ async fn create<S>(
|
||||
AdminJson(request): AdminJson<CreateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -325,7 +326,7 @@ async fn remove_account<S>(
|
||||
AdminJson(request): AdminJson<RemoveAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let revision = state
|
||||
.admin_services()
|
||||
@@ -349,7 +350,7 @@ async fn update<S>(
|
||||
AdminJson(request): AdminJson<UpdateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -385,7 +386,7 @@ async fn delete<S>(
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -409,7 +410,7 @@ async fn probe<S>(
|
||||
AdminJson(request): AdminJson<ProbeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -433,7 +434,7 @@ async fn test<S>(
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use axum::{
|
||||
@@ -21,7 +23,7 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -37,6 +39,9 @@ pub struct RuntimeSettingsView {
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -55,6 +60,9 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -67,6 +75,17 @@ impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.max_waiting_per_key, "maxWaitingPerKey"),
|
||||
(self.max_waiting_per_account, "maxWaitingPerAccount"),
|
||||
] {
|
||||
if value > 1_000 {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
}
|
||||
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
|
||||
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
|
||||
}
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
(self.refresh_concurrency, "refreshConcurrency"),
|
||||
@@ -104,6 +123,9 @@ impl UpdateRuntimeSettingsRequest {
|
||||
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
|
||||
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
|
||||
request_interval_ms: self.request_interval_ms,
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
min_codex_desktop_version: self.min_codex_desktop_version,
|
||||
@@ -126,6 +148,9 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
refresh_concurrency: u64::from(settings.refresh_concurrency),
|
||||
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
@@ -228,7 +253,7 @@ impl Default for DeletedAdminApiKey {
|
||||
/// 构造固定 GET/POST 设置路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
@@ -257,7 +282,7 @@ async fn codex_desktop_windows_downloads<S>(
|
||||
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let downloads = state
|
||||
.admin_services()
|
||||
@@ -275,7 +300,7 @@ async fn settings<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -295,7 +320,7 @@ async fn update_settings<S>(
|
||||
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -315,7 +340,7 @@ async fn admin_api_key_status<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let exists = state
|
||||
.admin_services()
|
||||
@@ -334,7 +359,7 @@ async fn regenerate_admin_api_key<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -355,7 +380,7 @@ async fn delete_admin_api_key<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
state
|
||||
.admin_services()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! 系统管理接口的查询与请求 wire contract。
|
||||
|
||||
//!
|
||||
//! 这里不依赖更新服务或进程控制;应用层通过窄端口提供系统操作事实。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
@@ -23,7 +26,7 @@ use gateway_admin::model::system::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -214,7 +217,7 @@ struct SystemUpdateEventView {
|
||||
/// 构造固定 GET/POST 系统管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/system/version", get(version::<S>))
|
||||
@@ -234,7 +237,7 @@ async fn version<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let version = state
|
||||
.admin_services()
|
||||
@@ -254,7 +257,7 @@ async fn update_detail<S>(
|
||||
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let detail = state
|
||||
.admin_services()
|
||||
@@ -273,7 +276,7 @@ async fn update_event_stream<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let stream = state
|
||||
.admin_services()
|
||||
@@ -293,7 +296,7 @@ async fn perform_update<S>(
|
||||
payload: Option<AdminJson<UpdateRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -328,7 +331,7 @@ async fn update_status<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let status = state
|
||||
.admin_services()
|
||||
@@ -347,7 +350,7 @@ async fn rollback<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -378,7 +381,7 @@ async fn restart<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
|
||||
@@ -96,17 +96,19 @@ impl AdminErrorCode {
|
||||
pub const INVALID_TIME_RANGE: Self = Self(40002);
|
||||
/// 模型来源不合法。
|
||||
pub const INVALID_MODEL_SOURCE: Self = Self(40003);
|
||||
/// 缺少管理员会话。
|
||||
/// 缺少登录会话。
|
||||
pub const SESSION_REQUIRED: Self = Self(40101);
|
||||
/// 管理员登录凭据错误。
|
||||
/// 登录凭据错误。
|
||||
pub const INVALID_CREDENTIALS: Self = Self(40102);
|
||||
/// 管理 API Key 错误。
|
||||
pub const INVALID_API_KEY: Self = Self(40103);
|
||||
/// 当前会话无权访问。
|
||||
pub const FORBIDDEN: Self = Self(40301);
|
||||
/// 资源不存在。
|
||||
pub const NOT_FOUND: Self = Self(40401);
|
||||
/// 配置 revision 或资源状态冲突。
|
||||
pub const CONFLICT: Self = Self(40901);
|
||||
/// 管理员登录尝试过多。
|
||||
/// 登录尝试过多。
|
||||
pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901);
|
||||
/// 设置持久化失败。
|
||||
pub const SETTINGS_PERSIST: Self = Self(50000);
|
||||
@@ -165,6 +167,7 @@ impl AdminErrorBody {
|
||||
pub struct AdminError {
|
||||
status: StatusCode,
|
||||
body: AdminErrorBody,
|
||||
retry_after_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -202,12 +205,12 @@ const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new(
|
||||
const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::SESSION_REQUIRED,
|
||||
"需要管理员登录",
|
||||
"需要登录",
|
||||
);
|
||||
const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::INVALID_CREDENTIALS,
|
||||
"管理员用户名或密码错误",
|
||||
"登录凭据错误",
|
||||
);
|
||||
const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -255,6 +258,7 @@ impl AdminError {
|
||||
Self {
|
||||
status,
|
||||
body: AdminErrorBody::new(code, message),
|
||||
retry_after_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +290,11 @@ impl AdminError {
|
||||
Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message)
|
||||
}
|
||||
|
||||
pub fn admin_session_required() -> Self {
|
||||
pub fn session_required() -> Self {
|
||||
Self::from_spec(SESSION_REQUIRED)
|
||||
}
|
||||
|
||||
pub fn invalid_admin_credentials() -> Self {
|
||||
pub fn invalid_credentials() -> Self {
|
||||
Self::from_spec(INVALID_CREDENTIALS)
|
||||
}
|
||||
|
||||
@@ -302,6 +306,10 @@ impl AdminError {
|
||||
Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS)
|
||||
}
|
||||
|
||||
pub fn forbidden() -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, AdminErrorCode::FORBIDDEN, "无权访问")
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, AdminErrorCode::CONFLICT, message)
|
||||
}
|
||||
@@ -325,6 +333,12 @@ impl AdminError {
|
||||
pub fn service_unavailable() -> Self {
|
||||
Self::from_spec(SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_after(mut self, seconds: u64) -> Self {
|
||||
self.retry_after_seconds = Some(seconds.max(1));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
|
||||
@@ -333,7 +347,8 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
|
||||
|
||||
let mut response = match error.kind() {
|
||||
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
|
||||
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
|
||||
AdminErrorKind::Forbidden => AdminError::forbidden(),
|
||||
AdminErrorKind::Unauthorized => AdminError::session_required(),
|
||||
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
|
||||
AdminErrorKind::Conflict => AdminError::conflict(error.message()),
|
||||
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
|
||||
@@ -358,7 +373,15 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
|
||||
|
||||
impl IntoResponse for AdminError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.body)).into_response()
|
||||
let mut response = (self.status, Json(self.body)).into_response();
|
||||
if let Some(seconds) = self.retry_after_seconds
|
||||
&& let Ok(value) = axum::http::HeaderValue::from_str(&seconds.to_string())
|
||||
{
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 控制面统一登录、会话恢复与退出;身份由认证用例返回。
|
||||
|
||||
use std::{fmt, net::SocketAddr};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get, post},
|
||||
};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
admin::{AdminEnvelope, AdminError, AdminJson, AdminResponse, wire::map_admin_service_error},
|
||||
session_cookie,
|
||||
};
|
||||
|
||||
/// 控制面 HTTP adapter 消费同一组用例;权限由各入口服务端校验。
|
||||
pub trait SessionState {
|
||||
fn admin_services(&self) -> &AdminServices;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub enum LoginRequest {
|
||||
Admin {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
},
|
||||
Key {
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for LoginRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Admin { username, .. } => formatter
|
||||
.debug_struct("AdminLogin")
|
||||
.field("username", username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Key { .. } => formatter
|
||||
.debug_struct("KeyLogin")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginRequest> for LoginCommand {
|
||||
fn from(request: LoginRequest) -> Self {
|
||||
match request {
|
||||
LoginRequest::Admin { username, password } => Self::Admin { username, password },
|
||||
LoginRequest::Key { api_key } => Self::Key { api_key },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionData {
|
||||
role: &'static str,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl From<&AuthSession> for SessionData {
|
||||
fn from(session: &AuthSession) -> Self {
|
||||
Self {
|
||||
role: match session.subject {
|
||||
SessionSubject::Admin { .. } => "admin",
|
||||
SessionSubject::Key { .. } => "key",
|
||||
},
|
||||
expires_at: session.expires_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionStatusData {
|
||||
authenticated: bool,
|
||||
session: Option<SessionData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LogoutData {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn router<S>() -> Router<S>
|
||||
where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/auth/login", post(login::<S>))
|
||||
.route("/api/auth/status", get(session_status::<S>))
|
||||
.route("/api/auth/logout", post(logout::<S>))
|
||||
.route("/api/auth", any(not_found))
|
||||
.route("/api/auth/{*path}", any(not_found))
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
AdminJson(payload): AdminJson<LoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(
|
||||
payload.into(),
|
||||
peer.ip(),
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let max_age = result
|
||||
.session
|
||||
.expires_at
|
||||
.signed_duration_since(chrono::Utc::now())
|
||||
.num_seconds()
|
||||
.max(1);
|
||||
let expires = result
|
||||
.session
|
||||
.expires_at
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT");
|
||||
let cookie = format!(
|
||||
"{}={}; {}; Max-Age={max_age}; Expires={expires}",
|
||||
session_cookie::NAME,
|
||||
result.session_id,
|
||||
session_cookie::attributes(&headers)
|
||||
);
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SessionData::from(&result.session)),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn session_status<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.session(session_cookie::value(&headers).as_deref())
|
||||
.await
|
||||
.map_err(map_admin_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SessionStatusData {
|
||||
authenticated: session.is_some(),
|
||||
session: session.as_ref().map(SessionData::from),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
if let Some(session_id) = session_cookie::value(&headers) {
|
||||
state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.logout(&session_id)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?;
|
||||
}
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogoutData {
|
||||
message: "Logged out successfully",
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{}=; {}; Max-Age=0",
|
||||
session_cookie::NAME,
|
||||
session_cookie::attributes(&headers)
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_credentials(),
|
||||
LoginError::TooManyAttempts {
|
||||
retry_after_seconds,
|
||||
} => AdminError::too_many_login_attempts().with_retry_after(retry_after_seconds),
|
||||
LoginError::Unavailable => AdminError::service_unavailable(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn method_not_allowed() -> AdminError {
|
||||
AdminError::method_not_allowed()
|
||||
}
|
||||
async fn not_found() -> AdminError {
|
||||
AdminError::not_found("认证接口不存在")
|
||||
}
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 当前 Key 的单页用量 API;只暴露页面需要的只读字段。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
admin::{AdminEnvelope, AdminError, AdminQuery, AdminResponse, wire::map_admin_service_error},
|
||||
auth::SessionState,
|
||||
session_cookie,
|
||||
};
|
||||
|
||||
mod presenter;
|
||||
mod query;
|
||||
|
||||
pub(crate) fn router<S>() -> Router<S>
|
||||
where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/key-usage/overview", get(overview::<S>))
|
||||
.route("/api/key-usage/records", get(records::<S>))
|
||||
.route("/api/key-usage", any(not_found))
|
||||
.route("/api/key-usage/{*path}", any(not_found))
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn overview<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(query): AdminQuery<query::OverviewQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let overview = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.overview(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::overview(overview)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn records<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(query): AdminQuery<query::RecordsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let records = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.records(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::records(records)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn not_found() -> AdminError {
|
||||
AdminError::not_found("密钥用量接口不存在")
|
||||
}
|
||||
async fn method_not_allowed() -> AdminError {
|
||||
AdminError::method_not_allowed()
|
||||
}
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
key_usage::{KeyUsageOverview, KeyUsageRecords},
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::admin::observability::{
|
||||
BillingView, HealthTimelineView, PageData, TokenDetailsView, billing_view,
|
||||
health_timeline_view, usage_list_token_details,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct OverviewView {
|
||||
as_of: DateTime<Utc>,
|
||||
start_time: DateTime<Utc>,
|
||||
end_time: DateTime<Utc>,
|
||||
key: KeyView,
|
||||
summary: MetricsView,
|
||||
trend: Vec<TrendPointView>,
|
||||
health_timeline: HealthTimelineView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyView {
|
||||
name: String,
|
||||
prefix: String,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
daily_limit_usd: String,
|
||||
daily_used_usd: String,
|
||||
daily_resets_at: Option<DateTime<Utc>>,
|
||||
weekly_limit_usd: String,
|
||||
weekly_used_usd: String,
|
||||
weekly_resets_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MetricsView {
|
||||
requests: u64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
total_tokens: u64,
|
||||
cost_usd: Option<String>,
|
||||
cost_incomplete: bool,
|
||||
}
|
||||
|
||||
fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCoverage) -> MetricsView {
|
||||
MetricsView {
|
||||
requests: value.request_count,
|
||||
input_tokens: value.input_tokens,
|
||||
output_tokens: value.output_tokens,
|
||||
cached_tokens: value.cached_tokens,
|
||||
cache_write_tokens: value.cache_write_tokens,
|
||||
reasoning_tokens: value.reasoning_tokens,
|
||||
total_tokens: value.total_tokens,
|
||||
cost_usd: costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
|
||||
.map(|cost| cost.amount.as_str().to_owned())
|
||||
.or_else(|| (value.request_count == 0).then(|| "0".to_owned())),
|
||||
cost_incomplete: coverage.partial_count > 0 || coverage.unavailable_count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TrendPointView {
|
||||
time: DateTime<Utc>,
|
||||
bucket_seconds: u32,
|
||||
#[serde(flatten)]
|
||||
metrics: MetricsView,
|
||||
}
|
||||
|
||||
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
let key = value.key;
|
||||
OverviewView {
|
||||
as_of: Utc::now(),
|
||||
start_time: value.overview.range.start,
|
||||
end_time: value.overview.range.end,
|
||||
key: KeyView {
|
||||
name: key.name,
|
||||
prefix: key.prefix,
|
||||
max_concurrency: key.limits.max_concurrency,
|
||||
requests_per_minute: key.limits.requests_per_minute,
|
||||
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
|
||||
daily_used_usd: key.budget.daily_used_usd.canonical(),
|
||||
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
|
||||
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
|
||||
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
|
||||
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
|
||||
},
|
||||
summary: metrics(
|
||||
&value.overview.requests,
|
||||
&value.overview.attempts.costs,
|
||||
&value.overview.attempts.cost_coverage,
|
||||
),
|
||||
trend: value
|
||||
.trend
|
||||
.into_iter()
|
||||
.map(|point| TrendPointView {
|
||||
time: point.bucket_start,
|
||||
bucket_seconds: match point.granularity {
|
||||
Granularity::FifteenMinutes => 900,
|
||||
Granularity::Hour => 3600,
|
||||
Granularity::Day => 86400,
|
||||
},
|
||||
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
|
||||
})
|
||||
.collect(),
|
||||
health_timeline: health_timeline_view(value.health_timeline),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RecordView {
|
||||
id: String,
|
||||
created_at: DateTime<Utc>,
|
||||
model: Option<String>,
|
||||
route: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
client_transport: Option<String>,
|
||||
upstream_transport: Option<String>,
|
||||
token_details: Option<TokenDetailsView>,
|
||||
billing: Option<BillingView>,
|
||||
latency_ms: Option<u64>,
|
||||
first_token_latency_ms: Option<u64>,
|
||||
latency_details: OutputTimingView,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
status: &'static str,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
// Key 只查看自身请求的输出时间,不包含账号容量、调度等待等管理侧观测。
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_text_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn success_record(value: UsageListRecord) -> RecordView {
|
||||
let token_details = Some(usage_list_token_details(&value));
|
||||
let billing = billing_view(value.billing.as_ref());
|
||||
RecordView {
|
||||
id: value.id,
|
||||
created_at: value.started_at,
|
||||
model: value.requested_model_id,
|
||||
route: Some(value.endpoint),
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: Some(value.client_transport),
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details,
|
||||
billing,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: value.first_token_ms,
|
||||
latency_details: OutputTimingView {
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
},
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "success",
|
||||
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实。
|
||||
status_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record(value: OpsError) -> RecordView {
|
||||
RecordView {
|
||||
id: value.event_id,
|
||||
created_at: value.occurred_at,
|
||||
model: value.requested_model_id,
|
||||
route: value.endpoint,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: value.client_transport,
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details: None,
|
||||
billing: None,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: None,
|
||||
latency_details: OutputTimingView::default(),
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "error",
|
||||
status_code: value.client_status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
|
||||
match value {
|
||||
KeyUsageRecords::Success(page) => PageData {
|
||||
items: page.items.into_iter().map(success_record).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
KeyUsageRecords::Error(page) => PageData {
|
||||
items: page.items.into_iter().map(error_record).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, Duration};
|
||||
use gateway_admin::model::{
|
||||
PageSize,
|
||||
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
|
||||
observability::TimeRange,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::admin::AdminError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct OverviewQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl OverviewQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
|
||||
let parse = |value: &str| {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.to_utc())
|
||||
.map_err(|_| {
|
||||
AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"时间必须使用 RFC3339 格式",
|
||||
)
|
||||
})
|
||||
};
|
||||
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
|
||||
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
|
||||
if range.end - range.start > Duration::days(31) {
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"一次最多查询 31 天的用量",
|
||||
));
|
||||
}
|
||||
let model = self
|
||||
.model
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty());
|
||||
if model
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"模型筛选内容不合法",
|
||||
));
|
||||
}
|
||||
Ok(KeyUsageQuery { range, model })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum RecordKind {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct RecordsQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
model: Option<String>,
|
||||
current_page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
#[serde(default)]
|
||||
kind: RecordKind,
|
||||
}
|
||||
|
||||
impl RecordsQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
let page_size = self.page_size.unwrap_or(20);
|
||||
if current_page == 0 || !(1..=100).contains(&page_size) {
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"页码必须大于 0,每页数量为 1–100",
|
||||
));
|
||||
}
|
||||
Ok(KeyUsageRecordsQuery {
|
||||
usage: OverviewQuery {
|
||||
start_time: self.start_time,
|
||||
end_time: self.end_time,
|
||||
model: self.model,
|
||||
}
|
||||
.into_domain()?,
|
||||
kind: match self.kind {
|
||||
RecordKind::Success => KeyUsageRecordKind::Success,
|
||||
RecordKind::Error => KeyUsageRecordKind::Error,
|
||||
},
|
||||
current_page,
|
||||
page_size: PageSize::new(page_size).map_err(|_| {
|
||||
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,11 @@ use crate::health::HealthStatus;
|
||||
use crate::openai::service::OpenAiService;
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
mod health;
|
||||
mod key_usage;
|
||||
pub mod openai;
|
||||
mod session_cookie;
|
||||
|
||||
/// API-owned HTTP 与静态资源配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
@@ -136,6 +139,8 @@ pub fn initialize(
|
||||
.route("/healthz", get(health::healthz))
|
||||
.merge(openai::router::router())
|
||||
.merge(admin::router::<ApiState>())
|
||||
.merge(auth::router::<ApiState>())
|
||||
.merge(key_usage::router::<ApiState>())
|
||||
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
|
||||
if !config.cors_allowed_origins.is_empty() {
|
||||
let origins = config
|
||||
@@ -225,7 +230,7 @@ impl ApiState {
|
||||
}
|
||||
}
|
||||
|
||||
impl admin::AdminSessionState for ApiState {
|
||||
impl auth::SessionState for ApiState {
|
||||
fn admin_services(&self) -> &AdminServices {
|
||||
&self.admin
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
|
||||
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
|
||||
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
|
||||
let raw = headers
|
||||
.get(AUTHORIZATION)
|
||||
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
|
||||
if token.is_empty() {
|
||||
return Err(ClientApiKeyAuthError::MalformedAuthorization);
|
||||
}
|
||||
if !token.starts_with("sk_") {
|
||||
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
|
||||
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
|
||||
}
|
||||
Ok(token)
|
||||
|
||||
@@ -247,6 +247,16 @@ pub const fn gateway_error_contract(
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
),
|
||||
GatewayErrorKind::ConcurrencyQueueFull => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"concurrency_queue_full",
|
||||
),
|
||||
GatewayErrorKind::ConcurrencyQueueTimeout => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"concurrency_queue_timeout",
|
||||
),
|
||||
GatewayErrorKind::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::ApiState;
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::model_not_found_response,
|
||||
error::{model_not_found_response, openai_error_response},
|
||||
};
|
||||
|
||||
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
|
||||
@@ -37,19 +37,53 @@ pub(crate) async fn models(
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
if query
|
||||
if let Some(version) = query
|
||||
.client_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| !version.trim().is_empty())
|
||||
.filter(|version| !version.trim().is_empty())
|
||||
{
|
||||
let models = service
|
||||
.public_model_profiles(&client)
|
||||
if version.len() > 64
|
||||
|| !version
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
|
||||
{
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid client_version",
|
||||
"invalid_request_error",
|
||||
"invalid_client_version",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let catalog = match service.client_model_catalog(&client, version).await {
|
||||
Ok(catalog) => catalog,
|
||||
Err(_) => return catalog_unavailable_response(),
|
||||
};
|
||||
let models = catalog
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
.map(|(index, entry)| match entry {
|
||||
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
|
||||
PublicModelDescriptor::Native { model, payload } => {
|
||||
if payload.protocol() != "codex" {
|
||||
return Err(());
|
||||
}
|
||||
let mut value: Value =
|
||||
serde_json::from_slice(payload.body()).map_err(|_| ())?;
|
||||
let object = value.as_object_mut().ok_or(())?;
|
||||
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
|
||||
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
|
||||
Ok(value)
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>, ()>>();
|
||||
let Ok(models) = models else {
|
||||
return catalog_unavailable_response();
|
||||
};
|
||||
return (
|
||||
StatusCode::OK,
|
||||
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
|
||||
[("cache-control", "private, no-store")],
|
||||
Json(json!({
|
||||
"models": models,
|
||||
})),
|
||||
@@ -72,6 +106,16 @@ pub(crate) async fn models(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn catalog_unavailable_response() -> Response {
|
||||
openai_error_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Model catalog is temporarily unavailable",
|
||||
"server_error",
|
||||
"model_catalog_unavailable",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/{model_id}`。
|
||||
pub(crate) async fn model_detail(
|
||||
State(state): State<ApiState>,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
|
||||
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
|
||||
use gateway_protocol::openai::sse::{
|
||||
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::ResponseEncodeError;
|
||||
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAiResponsesEncoder {
|
||||
response_id: Option<String>,
|
||||
response_snapshot: Option<Value>,
|
||||
wire_terminal: Option<Value>,
|
||||
wire_failure: bool,
|
||||
}
|
||||
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
response_id: None,
|
||||
response_snapshot: None,
|
||||
wire_terminal: None,
|
||||
wire_failure: false,
|
||||
}
|
||||
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
|
||||
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
|
||||
if wire.event_type() == Some("error")
|
||||
&& let Some(data) = response_failed_sse_data_from_error_event(
|
||||
self.response_snapshot.as_ref(),
|
||||
self.response_id.as_deref(),
|
||||
wire.data(),
|
||||
)
|
||||
{
|
||||
return vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
"response.failed",
|
||||
&data.to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))];
|
||||
}
|
||||
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
|
||||
return vec![raw_sse_frame.clone()];
|
||||
}
|
||||
@@ -57,6 +77,21 @@ impl OpenAiResponsesEncoder {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
|
||||
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
|
||||
// 端只能空等到 idle 超时。投影成 `response.failed`(消息 JSON 自带
|
||||
// type 字段),codex 将其映射为可重试错误并立即重试。可消费形状
|
||||
// (带非 2xx status 或特殊错误码)原样透传,保持业务语义。
|
||||
if wire.event_type() == Some("error")
|
||||
&& !ws_client_consumable_error(wire.data())
|
||||
&& let Some(data) = response_failed_sse_data_from_error_event(
|
||||
self.response_snapshot.as_ref(),
|
||||
self.response_id.as_deref(),
|
||||
wire.data(),
|
||||
)
|
||||
{
|
||||
return vec![data.to_string()];
|
||||
}
|
||||
vec![wire.data().to_string()]
|
||||
}
|
||||
|
||||
@@ -110,6 +145,16 @@ impl OpenAiResponsesEncoder {
|
||||
let effective_type = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.created" | "response.in_progress" | "response.queued")
|
||||
) && let Some(response) = wire
|
||||
.data()
|
||||
.get("response")
|
||||
.filter(|value| value.is_object())
|
||||
{
|
||||
self.response_snapshot = Some(response.clone());
|
||||
}
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.completed" | "response.incomplete")
|
||||
@@ -126,3 +171,29 @@ fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
|
||||
}
|
||||
|
||||
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状。
|
||||
///
|
||||
/// codex 的 WS 端点只消费两类 `error` 帧:带非 2xx HTTP status 的包装错误,
|
||||
/// 以及连接数上限 / previous_response_not_found 这类内置可重试错误码;其余
|
||||
/// 帧(包括 status 为 2xx 的矛盾形状)会被静默忽略。可消费的帧原样透传,
|
||||
/// 不可消费的才在 WS 边界投影成 `response.failed`。
|
||||
fn ws_client_consumable_error(data: &Value) -> bool {
|
||||
if data
|
||||
.get("error")
|
||||
.and_then(|error| error.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|code| {
|
||||
matches!(
|
||||
code,
|
||||
"websocket_connection_limit_reached" | "previous_response_not_found"
|
||||
)
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
data.get("status")
|
||||
.or_else(|| data.get("status_code"))
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|status| !(200..300).contains(&status))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use gateway_core::engine::execution::{
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::auth::ClientApiKeyAuthError;
|
||||
@@ -52,11 +52,14 @@ impl OpenAiService {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn public_model_profiles(
|
||||
pub(crate) async fn client_model_catalog(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
) -> Vec<PublicModelProfile> {
|
||||
self.execution.public_model_profiles(client)
|
||||
client_version: &str,
|
||||
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
|
||||
self.execution
|
||||
.client_model_catalog(client, "codex", client_version)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn contains_public_model(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! 控制面会话 Cookie 的同源 HTTP / HTTPS 属性与解析。
|
||||
|
||||
use axum::http::{HeaderMap, header::ORIGIN};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) const NAME: &str = "cpr_session";
|
||||
|
||||
pub(crate) fn attributes(headers: &HeaderMap) -> &'static str {
|
||||
// HTTPS 反代回源 HTTP 不改变浏览器 Origin。只有单个、严格合法的 HTTP Origin
|
||||
// 才允许省略 Secure;缺失、opaque 或非法来源继续 fail closed。
|
||||
let http_origin = headers.get_all(ORIGIN).iter().count() == 1
|
||||
&& headers
|
||||
.get(ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|origin| {
|
||||
Url::parse(origin).is_ok_and(|url| {
|
||||
url.scheme() == "http" && url.origin().ascii_serialization() == origin
|
||||
})
|
||||
});
|
||||
if http_origin {
|
||||
"Path=/; HttpOnly; SameSite=Lax"
|
||||
} else {
|
||||
"Path=/; Secure; HttpOnly; SameSite=Lax"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn value(headers: &HeaderMap) -> Option<String> {
|
||||
let cookie = headers.get("cookie")?.to_str().ok()?;
|
||||
cookie.split(';').find_map(|part| {
|
||||
let (candidate, value) = part.trim().split_once('=')?;
|
||||
(candidate == NAME).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState, PRIMARY_GROUP_ID};
|
||||
|
||||
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
|
||||
const SESSION_COOKIE: &str = "cpr_session=valid-session";
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_route_should_keep_camel_case_group_and_page_wire() {
|
||||
|
||||
@@ -7,6 +7,42 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_requires_admin_and_a_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (query, authenticated, expected) in [
|
||||
("?accountId=acct_test", false, StatusCode::UNAUTHORIZED),
|
||||
("", true, StatusCode::BAD_REQUEST),
|
||||
("?accountId=bad", true, StatusCode::BAD_REQUEST),
|
||||
(
|
||||
"?accountId=acct_test&refresh=true",
|
||||
true,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
// 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。
|
||||
(
|
||||
"?accountId=acct_test",
|
||||
true,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
.uri(format!("/api/admin/accounts/personal-info{query}"))
|
||||
.header("x-request-id", "req_personal_info");
|
||||
if authenticated {
|
||||
request = request.header(header::COOKIE, "cpr_session=valid-session");
|
||||
}
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "{query}");
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_requires_admin_and_valid_account_query() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
@@ -37,7 +73,7 @@ async fn quota_forecast_requires_admin_and_valid_account_query() {
|
||||
.uri(uri)
|
||||
.header("x-request-id", "req_forecast");
|
||||
if authenticated {
|
||||
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
|
||||
request = request.header(header::COOKIE, "cpr_session=valid-session");
|
||||
}
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
mod handlers;
|
||||
mod presenter;
|
||||
|
||||
mod personal_info {
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
provider_credentials::{AccountPersonalInfo, ProviderSubscription},
|
||||
};
|
||||
use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountSubscriptionData::from(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: observed,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
|
||||
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
|
||||
"observedAt": "2026-09-14T00:00:00+00:00"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_subscription_when_profile_fails() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
|
||||
subscription: Some(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: None,
|
||||
observed_at: observed,
|
||||
}),
|
||||
});
|
||||
let value = serde_json::to_value(response).unwrap();
|
||||
assert!(value["profile"].is_null());
|
||||
assert_eq!(value["profileError"], "上游服务请求失败");
|
||||
assert_eq!(
|
||||
value["subscription"]["expiresAt"],
|
||||
"2026-09-14T00:00:00+00:00"
|
||||
);
|
||||
assert_eq!(value.as_object().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
|
||||
subscription: None,
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"profile": null,
|
||||
"profileError": "Provider 服务暂不可用",
|
||||
"subscription": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod query {
|
||||
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
|
||||
use gateway_api::admin::accounts::ListQuery;
|
||||
@@ -248,31 +319,29 @@ mod batch_update {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_require_enabled_and_reject_unknown_fields() {
|
||||
fn batch_update_model_access_preserves_omitted_settings_and_rejects_unknown_fields() {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"], "modelAccess": {"mode":"allowlist","models":["test-luna"]}
|
||||
}))
|
||||
.expect("policy-only update");
|
||||
request.validate().expect("valid update");
|
||||
assert!(request.enabled.is_none());
|
||||
assert!(request.weight.is_none());
|
||||
assert!(request.group_ids.is_none());
|
||||
assert!(request.concurrency_limit.is_none());
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": [],
|
||||
"legacy": true
|
||||
"accountIds": ["acct_test"], "enabled": true, "legacy": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let empty: BatchUpdateAccountsRequest =
|
||||
serde_json::from_value(json!({"accountIds":["acct_test"]})).expect("empty mutation");
|
||||
assert!(empty.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_reject_invalid_scheduling_bounds_and_missing_fields() {
|
||||
fn batch_update_should_reject_invalid_scheduling_bounds_and_distinguish_clear_from_preserve() {
|
||||
for (concurrency_limit, weight, field) in [
|
||||
(json!(0), json!(1), "concurrencyLimit"),
|
||||
(json!(4294967296_u64), json!(1), "concurrencyLimit"),
|
||||
@@ -280,36 +349,20 @@ mod batch_update {
|
||||
(json!(null), json!(101), "weight"),
|
||||
] {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": concurrency_limit,
|
||||
"weight": weight,
|
||||
"groupIds": []
|
||||
}))
|
||||
.expect("deserialize invalid scheduling");
|
||||
"accountIds": ["acct_test"], "concurrencyLimit": concurrency_limit, "weight": weight,
|
||||
})).expect("deserialize bounds");
|
||||
assert_eq!(
|
||||
request.validate().expect_err("reject scheduling").field(),
|
||||
request.validate().expect_err("reject bounds").field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let cleared: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"], "concurrencyLimit": null
|
||||
}))
|
||||
.expect("clear concurrency override");
|
||||
cleared.validate().expect("valid clear");
|
||||
assert_eq!(cleared.concurrency_limit, Some(None));
|
||||
assert!(cleared.weight.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -336,6 +389,43 @@ mod batch_update {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_update_should_accept_optional_unicode_and_multiline_notes() {
|
||||
use gateway_api::admin::accounts::UpdateAccountRequest;
|
||||
use serde_json::json;
|
||||
for notes in [
|
||||
json!(null),
|
||||
json!(""),
|
||||
json!("团队备用\n第二行\t说明"),
|
||||
json!("备".repeat(500)),
|
||||
] {
|
||||
let request: UpdateAccountRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
|
||||
"weight": 1, "groupIds": [], "notes": notes
|
||||
}))
|
||||
.unwrap();
|
||||
request.validate().expect("accept bounded notes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_update_should_reject_oversized_notes_and_control_characters() {
|
||||
use gateway_api::admin::accounts::UpdateAccountRequest;
|
||||
use serde_json::json;
|
||||
for notes in [
|
||||
"备".repeat(501),
|
||||
"备注\0".to_owned(),
|
||||
"备注\u{001b}".to_owned(),
|
||||
] {
|
||||
let request: UpdateAccountRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_notes", "enabled": true, "concurrencyLimit": null,
|
||||
"weight": 1, "groupIds": [], "notes": notes
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(request.validate().unwrap_err().field(), "notes");
|
||||
}
|
||||
}
|
||||
|
||||
mod response {
|
||||
use gateway_api::admin::accounts::AccountUsageView;
|
||||
use gateway_api::admin::presenter::format_decimal_currency;
|
||||
@@ -774,6 +864,9 @@ mod import_settings {
|
||||
("weight", json!(0)),
|
||||
("weight", json!(101)),
|
||||
("groupIds", json!(["invalid-group"])),
|
||||
("notes", json!("备".repeat(501))),
|
||||
("notes", json!("备注\u{0000}")),
|
||||
("notes", json!("备注\u{001b}")),
|
||||
] {
|
||||
let mut settings =
|
||||
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
|
||||
@@ -794,6 +887,25 @@ mod import_settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_and_oauth_accept_optional_unicode_and_multiline_notes() {
|
||||
for notes in [
|
||||
json!(null),
|
||||
json!(""),
|
||||
json!("团队备用\n下月续费\t"),
|
||||
json!("备".repeat(500)),
|
||||
] {
|
||||
let settings = json!({"enabled": true, "concurrencyLimit": null, "weight": 1, "groupIds": [], "notes": notes});
|
||||
let import: AccountImportRequest = serde_json::from_value(
|
||||
json!({"provider": "openai", "data": {}, "settings": settings}),
|
||||
)
|
||||
.unwrap();
|
||||
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).unwrap();
|
||||
import.validate().expect("import notes");
|
||||
oauth.validate().expect("OAuth notes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_settings_require_a_complete_explicit_configuration() {
|
||||
let settings =
|
||||
|
||||
@@ -1,48 +1,28 @@
|
||||
use gateway_admin::model::{
|
||||
provider_credentials::AccountUsagePeriod,
|
||||
quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource},
|
||||
quota_forecast_sampling::QuotaForecastMethod,
|
||||
};
|
||||
use gateway_api::admin::accounts::AccountQuotaForecastData;
|
||||
|
||||
#[test]
|
||||
fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
|
||||
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
let now = "2026-09-12T00:00:00Z".parse().unwrap();
|
||||
let forecast = AccountQuotaForecast {
|
||||
period: AccountUsagePeriod::Weekly,
|
||||
target_seconds: 7 * 86_400,
|
||||
extrapolated: false,
|
||||
source: Some(QuotaForecastSource {
|
||||
key: "week".to_owned(),
|
||||
label: "周额度".to_owned(),
|
||||
window_seconds: 7 * 86_400,
|
||||
used_percent: Some(100.0),
|
||||
observed_at: Some(now),
|
||||
start_at: Some(now),
|
||||
reset_at: now,
|
||||
request_count: 1_024,
|
||||
tokens: Some(1_000_000),
|
||||
input_tokens: Some(900_000),
|
||||
output_tokens: Some(100_000),
|
||||
cached_tokens: Some(800_000),
|
||||
known_cost_count: 1_000,
|
||||
partial_cost_count: 24,
|
||||
unavailable_cost_count: 0,
|
||||
usd: Some(0.1234),
|
||||
sample_start_at: Some(now),
|
||||
baseline_percent: 85.0,
|
||||
sampled_percent: Some(15.0),
|
||||
block_count: 3,
|
||||
observation_count: 96,
|
||||
missing_token_count: 0,
|
||||
excluded_request_count: 4,
|
||||
pending_request_count: 2,
|
||||
}),
|
||||
unavailable_reason: None,
|
||||
low_sample: false,
|
||||
incomplete_cost: true,
|
||||
incomplete_tokens: false,
|
||||
method: QuotaForecastMethod::Incremental,
|
||||
estimated_tokens: Some(1_000_000),
|
||||
estimated_usd: None,
|
||||
remaining_tokens: Some(0),
|
||||
@@ -66,13 +46,22 @@ fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() {
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
assert_eq!(week["remainingTokensDisplay"], "0");
|
||||
assert_eq!(week["source"]["requestCountDisplay"], "1,024");
|
||||
assert_eq!(week["source"]["usdDisplay"], "$0.1234");
|
||||
assert_eq!(week["source"]["partialCostCount"], 24);
|
||||
assert_eq!(week["method"], "incremental");
|
||||
assert_eq!(week["source"]["sampledPercentDisplay"], "15.0 个百分点");
|
||||
assert_eq!(week["source"]["pendingRequestCount"], 2);
|
||||
assert!(week["source"].get("providerData").is_none());
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
"label": "周额度",
|
||||
"usedPercent": 100.0,
|
||||
"usedPercentDisplay": "100.0%",
|
||||
"observedAt": "2026-09-12T08:00:00+08:00",
|
||||
"observedAtDisplay": "2026-09-12 08:00:00",
|
||||
"resetAt": "2026-09-12T08:00:00+08:00",
|
||||
"tokensDisplay": "1M",
|
||||
"usdDisplay": "$0.1234"
|
||||
})
|
||||
);
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use gateway_admin::model::auth::{LoginCommand, LoginError};
|
||||
use gateway_api::admin::auth::{
|
||||
AdminLoginData, AdminLoginRequest, AdminLogoutData, AdminSessionStatusData,
|
||||
};
|
||||
use gateway_api::auth::LoginRequest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::AdminTestFixture;
|
||||
@@ -9,18 +7,26 @@ use super::AdminTestFixture;
|
||||
#[test]
|
||||
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
|
||||
let password = "admin-password-must-not-leak";
|
||||
let request = serde_json::from_value::<AdminLoginRequest>(json!({
|
||||
let request = serde_json::from_value::<LoginRequest>(json!({
|
||||
"mode": "admin",
|
||||
"username": "admin@example.invalid",
|
||||
"password": password
|
||||
}))
|
||||
.expect("deserialize login request");
|
||||
|
||||
assert!(!format!("{request:?}").contains(password));
|
||||
let (username, parsed_password) = request.into_parts();
|
||||
let LoginCommand::Admin {
|
||||
username,
|
||||
password: parsed_password,
|
||||
} = request.into()
|
||||
else {
|
||||
panic!("admin login expected")
|
||||
};
|
||||
assert_eq!(username.as_deref(), Some("admin@example.invalid"));
|
||||
assert_eq!(parsed_password, password);
|
||||
assert!(
|
||||
serde_json::from_value::<AdminLoginRequest>(json!({
|
||||
serde_json::from_value::<LoginRequest>(json!({
|
||||
"mode": "admin",
|
||||
"password": password,
|
||||
"rememberMe": true
|
||||
}))
|
||||
@@ -28,39 +34,27 @@ fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_responses_should_keep_stable_wire_shapes() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminLoginData::new("2026-07-18T08:00:00+08:00".to_owned()))
|
||||
.expect("serialize login"),
|
||||
json!({ "expiresAt": "2026-07-18T08:00:00+08:00" })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminSessionStatusData::new(true)).expect("serialize status"),
|
||||
json!({ "authenticated": true })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminLogoutData::new()).expect("serialize logout"),
|
||||
json!({ "message": "Logged out successfully" })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_auth_service_should_initialize_login_validate_and_logout() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let service = fixture.services.auth();
|
||||
let session = service
|
||||
.login(LoginCommand {
|
||||
username: Some("admin_1".to_owned()),
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin_1".to_owned()),
|
||||
password: "strong-admin-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login succeeds");
|
||||
assert!(
|
||||
service
|
||||
.validate_session(Some(&session.session_id))
|
||||
.session(Some(&session.session_id))
|
||||
.await
|
||||
.expect("validate session")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
@@ -76,9 +70,10 @@ async fn default_auth_service_should_initialize_login_validate_and_logout() {
|
||||
.expect("logout session");
|
||||
assert!(
|
||||
!service
|
||||
.validate_session(Some(&session.session_id))
|
||||
.session(Some(&session.session_id))
|
||||
.await
|
||||
.expect("validate logged-out session")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(fixture.auth.audit_count(), 2);
|
||||
}
|
||||
@@ -124,10 +119,14 @@ async fn audit_failure_should_revoke_new_session_before_returning_it() {
|
||||
fixture
|
||||
.services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: None,
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "strong-admin-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.expect_err("audit failure rejects login"),
|
||||
LoginError::Unavailable
|
||||
|
||||
@@ -10,6 +10,71 @@ use serde_json::json;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[test]
|
||||
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
|
||||
for value in [
|
||||
"q".to_owned(),
|
||||
"sk-old!@/key+=value".to_owned(),
|
||||
"x".repeat(8192),
|
||||
] {
|
||||
let request: CreateClientKeyRequest = serde_json::from_value(json!({
|
||||
"name": "migration", "customKey": value, "groupIds": [],
|
||||
"maxConcurrency": 2, "requestsPerMinute": 0
|
||||
}))
|
||||
.unwrap();
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
let command = request.into_command().unwrap();
|
||||
assert_eq!(command.custom_key.unwrap().expose_for_auth(), value);
|
||||
}
|
||||
let secret = "legacy-key-should-not-leak";
|
||||
let request: CreateClientKeyRequest = serde_json::from_value(json!({
|
||||
"name": "migration", "customKey": secret, "groupIds": [],
|
||||
"maxConcurrency": 0, "requestsPerMinute": 0
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!format!("{request:?}").contains(secret));
|
||||
assert!(!format!("{:?}", request.into_command().unwrap()).contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_client_key_is_optional_and_only_rejects_untransportable_values() {
|
||||
let payload = json!({"name": "migration", "groupIds": [], "maxConcurrency": 0,
|
||||
"requestsPerMinute": 0});
|
||||
for value in [None, Some(json!(null)), Some(json!(""))] {
|
||||
let mut payload = payload.clone();
|
||||
if let Some(value) = value {
|
||||
payload["customKey"] = value;
|
||||
}
|
||||
let command = serde_json::from_value::<CreateClientKeyRequest>(payload)
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap();
|
||||
assert!(command.custom_key.is_none());
|
||||
}
|
||||
for value in [
|
||||
" key",
|
||||
"key ",
|
||||
"key with spaces",
|
||||
"key\n",
|
||||
"key\tvalue",
|
||||
"密钥",
|
||||
] {
|
||||
let mut payload = payload.clone();
|
||||
payload["customKey"] = json!(value);
|
||||
let error = serde_json::from_value::<CreateClientKeyRequest>(payload)
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap_err();
|
||||
assert_eq!(error.field(), "customKey");
|
||||
assert!(!format!("{error:?}").contains(value));
|
||||
}
|
||||
let mut update = payload;
|
||||
update["id"] = json!("key_existing");
|
||||
update["customKey"] = json!("replacement-credential");
|
||||
assert!(serde_json::from_value::<UpdateClientKeyRequest>(update).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
|
||||
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
|
||||
@@ -63,10 +128,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
"search": "a".repeat(257)
|
||||
}))
|
||||
.expect("deserialize oversized search");
|
||||
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"search": format!("sk_{}", "a".repeat(43))
|
||||
}))
|
||||
.expect("deserialize secret search");
|
||||
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"sortBy": "plaintextKey",
|
||||
"sortDirection": "asc"
|
||||
@@ -92,13 +153,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
.field(),
|
||||
"search"
|
||||
);
|
||||
assert_eq!(
|
||||
secret_search
|
||||
.into_command()
|
||||
.expect_err("reject full key in search")
|
||||
.field(),
|
||||
"search"
|
||||
);
|
||||
assert_eq!(
|
||||
invalid_sort
|
||||
.into_command()
|
||||
@@ -140,6 +194,16 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_name_search_does_not_interpret_names_as_credentials() {
|
||||
let name = format!("sk_{}", "a".repeat(43));
|
||||
let command = serde_json::from_value::<ListClientKeysQuery>(json!({ "search": name }))
|
||||
.unwrap()
|
||||
.into_command()
|
||||
.unwrap();
|
||||
assert_eq!(command.search.as_deref(), Some(name.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
@@ -331,7 +395,7 @@ async fn reveal_route_should_use_query_id_and_no_store_response() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/client-keys/reveal?id=key_1")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_reveal")
|
||||
.body(Body::empty())
|
||||
.expect("reveal request"),
|
||||
@@ -372,7 +436,7 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/client-keys?limit=65535")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_client_keys_max_page")
|
||||
.body(Body::empty())
|
||||
.expect("client key list request"),
|
||||
|
||||
@@ -3,16 +3,18 @@ use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use gateway_api::admin;
|
||||
use gateway_api::auth::SessionState;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
|
||||
const SESSION_COOKIE: &str = "cpr_session=valid-session";
|
||||
|
||||
fn app(state: AdminTestState) -> Router {
|
||||
admin::router::<AdminTestState>().with_state(state)
|
||||
crate::openai::api_router_with_admin(state.admin_services().clone()).layer(axum::Extension(
|
||||
axum::extract::ConnectInfo(std::net::SocketAddr::from(([127, 0, 0, 1], 41000))),
|
||||
))
|
||||
}
|
||||
|
||||
fn request(method: Method, uri: &str, body: Body) -> Request<Body> {
|
||||
@@ -44,7 +46,7 @@ async fn malformed_login_json_should_use_the_admin_error_envelope() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let mut request = request(
|
||||
Method::POST,
|
||||
"/api/admin/auth/login",
|
||||
"/api/auth/login",
|
||||
Body::from(r#"{"password":"secret""#),
|
||||
);
|
||||
request
|
||||
@@ -77,8 +79,10 @@ async fn invalid_login_json_data_should_not_echo_the_submitted_value() {
|
||||
let submitted = "password-must-not-leak";
|
||||
let mut request = request(
|
||||
Method::POST,
|
||||
"/api/admin/auth/login",
|
||||
Body::from(json!({ "password": submitted, "rememberMe": true }).to_string()),
|
||||
"/api/auth/login",
|
||||
Body::from(
|
||||
json!({ "mode": "admin", "password": submitted, "rememberMe": true }).to_string(),
|
||||
),
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
@@ -111,8 +115,8 @@ async fn login_without_json_content_type_should_keep_415_with_an_admin_envelope(
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/auth/login",
|
||||
Body::from(json!({ "password": "secret" }).to_string()),
|
||||
"/api/auth/login",
|
||||
Body::from(json!({ "mode": "admin", "password": "secret" }).to_string()),
|
||||
))
|
||||
.await
|
||||
.expect("missing JSON content type response");
|
||||
@@ -229,11 +233,7 @@ async fn unknown_admin_path_should_not_fall_through_to_the_spa() {
|
||||
async fn unsupported_admin_method_should_keep_allow_and_return_an_envelope() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/auth/status",
|
||||
Body::empty(),
|
||||
))
|
||||
.oneshot(request(Method::POST, "/api/auth/status", Body::empty()))
|
||||
.await
|
||||
.expect("unsupported admin method response");
|
||||
let allow = response
|
||||
@@ -280,8 +280,8 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() {
|
||||
.expect("invalid API key response");
|
||||
let mut invalid_login = request(
|
||||
Method::POST,
|
||||
"/api/admin/auth/login",
|
||||
Body::from(json!({ "password": "wrong-password" }).to_string()),
|
||||
"/api/auth/login",
|
||||
Body::from(json!({ "mode": "admin", "password": "wrong-password" }).to_string()),
|
||||
);
|
||||
invalid_login
|
||||
.headers_mut()
|
||||
@@ -300,15 +300,11 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() {
|
||||
assert_eq!(
|
||||
actual.map(|(status, _, body)| (status, body["code"].clone(), body["message"].clone())),
|
||||
[
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
json!(40101),
|
||||
json!("需要管理员登录")
|
||||
),
|
||||
(StatusCode::UNAUTHORIZED, json!(40101), json!("需要登录")),
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
json!(40102),
|
||||
json!("管理员用户名或密码错误")
|
||||
json!("登录凭据错误")
|
||||
),
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -473,6 +469,8 @@ mod provider {
|
||||
};
|
||||
AccountPageItem {
|
||||
account: AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
id: "acct_error_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(provider).unwrap(),
|
||||
groups: Vec::new(),
|
||||
|
||||
@@ -10,7 +10,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
@@ -24,7 +24,7 @@ use gateway_admin::{
|
||||
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_distribution::{
|
||||
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
|
||||
CodexDesktopWindowsDownloads,
|
||||
@@ -66,10 +66,13 @@ use gateway_admin::{
|
||||
},
|
||||
},
|
||||
};
|
||||
use gateway_api::admin::AdminSessionState;
|
||||
use gateway_api::auth::SessionState;
|
||||
use gateway_core::{
|
||||
account::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
|
||||
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
engine::{
|
||||
execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId},
|
||||
runtime::SnapshotControl,
|
||||
@@ -88,6 +91,8 @@ mod wire;
|
||||
|
||||
pub(super) struct AdminTestFixture {
|
||||
pub services: AdminServices,
|
||||
pub client_key: Arc<Mutex<Option<ClientKeyRecord>>>,
|
||||
pub observations: Arc<Mutex<MemoryObservations>>,
|
||||
pub auth: Arc<MemoryAuthStore>,
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
@@ -106,10 +111,26 @@ impl AdminTestFixture {
|
||||
}
|
||||
|
||||
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
|
||||
Self::with_dependencies(system, None).await
|
||||
}
|
||||
|
||||
pub async fn with_key_verifier(
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Self {
|
||||
Self::with_dependencies(system, Some(verifier)).await
|
||||
}
|
||||
|
||||
async fn with_dependencies(
|
||||
system: Arc<dyn SystemOperations>,
|
||||
verifier: Option<Arc<dyn ClientKeyVerifier>>,
|
||||
) -> Self {
|
||||
let api_key = Arc::new(Mutex::new(None));
|
||||
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
|
||||
let settings = Arc::new(MemorySettingsStore::new(api_key));
|
||||
let client_keys = Arc::new(MemoryClientKeyStore);
|
||||
let client_key = Arc::new(Mutex::new(None));
|
||||
let client_keys = Arc::new(MemoryClientKeyStore(client_key.clone()));
|
||||
let observations = Arc::new(Mutex::new(MemoryObservations::default()));
|
||||
let account_groups = Arc::new(MemoryAccountGroupStore::new());
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let usage_detail = Arc::new(Mutex::new(None));
|
||||
@@ -120,6 +141,7 @@ impl AdminTestFixture {
|
||||
let provider_error = Arc::new(Mutex::new(None));
|
||||
let account = Arc::new(Mutex::new(None));
|
||||
let unused = Arc::new(UnusedStore {
|
||||
observations: observations.clone(),
|
||||
usage_records: Arc::clone(&usage_records),
|
||||
usage_detail: Arc::clone(&usage_detail),
|
||||
diagnostics: Arc::clone(&diagnostics),
|
||||
@@ -151,17 +173,24 @@ impl AdminTestFixture {
|
||||
default_username: "admin_1".to_owned(),
|
||||
default_password: InitialAdminPassword::new("strong-admin-password"),
|
||||
},
|
||||
ClientConfig::default(),
|
||||
stores,
|
||||
providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
|
||||
Arc::new(StaticClientDistribution),
|
||||
system,
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
providers,
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
account_probe: Arc::new(NoopProbe),
|
||||
proxy_probe: Arc::new(proxies::SuccessfulProbe),
|
||||
client_distribution: Arc::new(StaticClientDistribution),
|
||||
system,
|
||||
client_key_verifier: verifier.unwrap_or_else(|| Arc::new(UnusedClientKeyVerifier)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("initialize test admin services");
|
||||
Self {
|
||||
services: bundle.services(),
|
||||
client_key,
|
||||
observations,
|
||||
auth,
|
||||
settings,
|
||||
usage_records,
|
||||
@@ -207,15 +236,17 @@ impl ClientDistributionResolver for StaticClientDistribution {
|
||||
#[derive(Clone)]
|
||||
pub(super) struct AdminTestState(AdminServices);
|
||||
|
||||
impl AdminSessionState for AdminTestState {
|
||||
impl SessionState for AdminTestState {
|
||||
fn admin_services(&self) -> &AdminServices {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MemoryAuthStore {
|
||||
pub(super) enabled: AtomicBool,
|
||||
pub(super) unavailable: AtomicBool,
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
sessions: Mutex<BTreeMap<String, AuthSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
api_key: Arc<Mutex<Option<AdminApiKey>>>,
|
||||
fail_audit: AtomicBool,
|
||||
@@ -224,6 +255,8 @@ pub(super) struct MemoryAuthStore {
|
||||
impl MemoryAuthStore {
|
||||
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
|
||||
Self {
|
||||
enabled: AtomicBool::new(false),
|
||||
unavailable: AtomicBool::new(false),
|
||||
password_hash: Mutex::new(None),
|
||||
sessions: Mutex::new(BTreeMap::new()),
|
||||
audits: Mutex::new(Vec::new()),
|
||||
@@ -235,8 +268,10 @@ impl MemoryAuthStore {
|
||||
pub fn insert_session(&self, session_id: &str) {
|
||||
self.sessions.lock().expect("sessions").insert(
|
||||
session_id.to_owned(),
|
||||
AdminSession {
|
||||
admin_user_id: "admin_1".to_owned(),
|
||||
AuthSession {
|
||||
subject: gateway_admin::model::auth::SessionSubject::Admin {
|
||||
admin_user_id: "admin_1".to_owned(),
|
||||
},
|
||||
expires_at: Utc::now() + Duration::hours(1),
|
||||
},
|
||||
);
|
||||
@@ -282,7 +317,7 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(self.api_key.lock().expect("API key").clone())
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
@@ -291,11 +326,7 @@ impl AuthStore for MemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
@@ -303,10 +334,30 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
if self.unavailable.load(Ordering::SeqCst) {
|
||||
return Err(unavailable("key status"));
|
||||
}
|
||||
Ok(id.as_str() == "key-42" && self.enabled.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
if self.fail_audit.load(Ordering::SeqCst) {
|
||||
return Err(unavailable("auth audit"));
|
||||
@@ -357,6 +408,9 @@ impl SettingsStore for MemorySettingsStore {
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
max_concurrent_per_account: command.max_concurrent_per_account,
|
||||
request_interval_ms: command.request_interval_ms,
|
||||
max_waiting_per_key: command.max_waiting_per_key,
|
||||
max_waiting_per_account: command.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
|
||||
rotation_strategy: command.rotation_strategy,
|
||||
min_codex_desktop_version: command.min_codex_desktop_version,
|
||||
min_codex_cli_version: command.min_codex_cli_version,
|
||||
@@ -395,7 +449,17 @@ impl SettingsStore for MemorySettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MemoryClientKeyStore;
|
||||
pub(super) struct MemoryClientKeyStore(Arc<Mutex<Option<ClientKeyRecord>>>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MemoryObservations {
|
||||
pub summary: Option<UsageOverview>,
|
||||
pub trend: Option<Vec<RequestMetricPoint>>,
|
||||
pub summaries: Vec<(TimeRange, UsageFilter)>,
|
||||
pub trends: Vec<(TimeRange, UsageFilter)>,
|
||||
pub records: Vec<UsageQuery>,
|
||||
pub errors: Vec<OpsErrorQuery>,
|
||||
}
|
||||
|
||||
pub(super) const PRIMARY_GROUP_ID: &str = "grp_11111111111111111111111111111111";
|
||||
pub(super) const SECONDARY_GROUP_ID: &str = "grp_22222222222222222222222222222222";
|
||||
@@ -636,6 +700,18 @@ fn mutation(
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for MemoryClientKeyStore {
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.expect("client key")
|
||||
.clone()
|
||||
.filter(|key| &key.id == id))
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
Ok(ClientKeyPage {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
@@ -703,6 +779,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
|
||||
}
|
||||
|
||||
struct UnusedStore {
|
||||
observations: Arc<Mutex<MemoryObservations>>,
|
||||
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
@@ -712,6 +789,14 @@ struct UnusedStore {
|
||||
account: Arc<Mutex<Option<AccountPageItem>>>,
|
||||
}
|
||||
|
||||
struct UnusedClientKeyVerifier;
|
||||
|
||||
impl ClientKeyVerifier for UnusedClientKeyVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
Err(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for UnusedStore {
|
||||
async fn list_accounts(
|
||||
@@ -885,10 +970,12 @@ impl ObservabilityStore for UnusedStore {
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Err(unavailable("usage trend"))
|
||||
let mut data = self.observations.lock().expect("observations");
|
||||
data.trends.push((range, filter));
|
||||
data.trend.clone().ok_or_else(|| unavailable("usage trend"))
|
||||
}
|
||||
|
||||
fn usage_calculated_billing_facts(
|
||||
@@ -902,6 +989,11 @@ impl ObservabilityStore for UnusedStore {
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
self.observations
|
||||
.lock()
|
||||
.expect("observations")
|
||||
.records
|
||||
.push(query.clone());
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
Ok(UsagePage {
|
||||
current_page: query.current_page,
|
||||
@@ -919,8 +1011,17 @@ impl ObservabilityStore for UnusedStore {
|
||||
.ok_or_else(|| unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
Err(unavailable("usage summary"))
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview> {
|
||||
let mut data = self.observations.lock().expect("observations");
|
||||
data.summaries.push((range, filter));
|
||||
data.summary
|
||||
.clone()
|
||||
.map(|summary| UsageOverview { range, ..summary })
|
||||
.ok_or_else(|| unavailable("usage summary"))
|
||||
}
|
||||
|
||||
async fn usage_diagnostics(
|
||||
@@ -933,6 +1034,11 @@ impl ObservabilityStore for UnusedStore {
|
||||
}
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
self.observations
|
||||
.lock()
|
||||
.expect("observations")
|
||||
.errors
|
||||
.push(query.clone());
|
||||
let items = self.ops_errors.lock().expect("ops errors").clone();
|
||||
Ok(OpsErrorPage {
|
||||
current_page: query.current_page,
|
||||
@@ -1130,6 +1236,9 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
|
||||
@@ -245,7 +245,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_account_summary")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
@@ -326,7 +326,7 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_lifetime_totals")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
@@ -391,7 +391,7 @@ async fn dashboard_summary_should_default_to_current_china_day() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_today_range")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
@@ -480,7 +480,7 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records/detail?id=req_detail")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_usage_detail_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("usage detail request"),
|
||||
@@ -575,7 +575,7 @@ async fn zero_attempt_failure_detail_keeps_missing_upstream_facts_and_preparatio
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records/detail?id=req_early")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_zero_attempt_detail")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
@@ -688,7 +688,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/operations/errors")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_ops_errors_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("ops errors request"),
|
||||
@@ -781,7 +781,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/insights/diagnostics?dimension=account")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_diagnostics_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("diagnostics request"),
|
||||
@@ -913,7 +913,7 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records?outcome=provider_future_state")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_usage_other_outcome")
|
||||
.body(Body::empty())
|
||||
.expect("usage request"),
|
||||
@@ -1033,7 +1033,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_usage_endpoint")
|
||||
.body(Body::empty())
|
||||
.expect("usage request"),
|
||||
|
||||
@@ -184,7 +184,7 @@ async fn request(
|
||||
.uri(path)
|
||||
.method(if body.is_some() { "POST" } else { "GET" });
|
||||
if authenticated {
|
||||
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
|
||||
builder = builder.header(header::COOKIE, "cpr_session=valid-session");
|
||||
}
|
||||
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
|
||||
let response = gateway_api::admin::proxies::router::<AdminTestState>()
|
||||
|
||||
@@ -17,7 +17,7 @@ fn request(method: Method, path: &str, body: Option<Value>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_admin_settings");
|
||||
let body = if let Some(value) = body {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
@@ -45,6 +45,9 @@ fn update_body() -> Value {
|
||||
"refreshConcurrency": 4,
|
||||
"maxConcurrentPerAccount": 5,
|
||||
"requestIntervalMs": 25,
|
||||
"maxWaitingPerKey": 0,
|
||||
"maxWaitingPerAccount": 0,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"rotationStrategy": "round_robin",
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
@@ -104,6 +107,9 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
refresh_concurrency: 4,
|
||||
max_concurrent_per_account: 5,
|
||||
request_interval_ms: 25,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::RoundRobin,
|
||||
min_codex_desktop_version: Some("26.825.6671".to_owned()),
|
||||
min_codex_cli_version: Some("0.40.0".to_owned()),
|
||||
@@ -128,6 +134,9 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"refreshConcurrency": 4,
|
||||
"maxConcurrentPerAccount": 5,
|
||||
"requestIntervalMs": 25,
|
||||
"maxWaitingPerKey": 0,
|
||||
"maxWaitingPerAccount": 0,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"rotationStrategy": "round_robin",
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
@@ -176,6 +185,9 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
refresh_concurrency: u32::try_from(request.refresh_concurrency).expect("u32"),
|
||||
max_concurrent_per_account: u32::try_from(request.max_concurrent_per_account).expect("u32"),
|
||||
request_interval_ms: request.request_interval_ms,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
.expect("fixture rotation strategy"),
|
||||
min_codex_desktop_version: request.min_codex_desktop_version,
|
||||
@@ -379,7 +391,7 @@ async fn admin_auth_should_accept_a_configured_request_id_header_name() {
|
||||
let unlabelled = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/admin/settings")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.body(Body::empty())
|
||||
.expect("build settings request");
|
||||
|
||||
@@ -387,3 +399,26 @@ async fn admin_auth_should_accept_a_configured_request_id_header_name() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrency_queue_fields_validate_bounds_and_accept_disabled_queues() {
|
||||
for (field, invalid) in [
|
||||
("maxWaitingPerKey", 1001),
|
||||
("maxWaitingPerAccount", 1001),
|
||||
("concurrencyWaitTimeoutSeconds", 0),
|
||||
("concurrencyWaitTimeoutSeconds", 121),
|
||||
] {
|
||||
let mut body = update_body();
|
||||
body[field] = json!(invalid);
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(request.validate().unwrap_err().field(), field);
|
||||
}
|
||||
let mut body = update_body();
|
||||
body["maxWaitingPerKey"] = json!(0);
|
||||
body["maxWaitingPerAccount"] = json!(1000);
|
||||
body["concurrencyWaitTimeoutSeconds"] = json!(120);
|
||||
serde_json::from_value::<UpdateRuntimeSettingsRequest>(body)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/system/update/events")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_system_update_events")
|
||||
.body(Body::empty())
|
||||
.expect("update event request"),
|
||||
|
||||
@@ -175,12 +175,12 @@ mod response {
|
||||
40001,
|
||||
),
|
||||
(
|
||||
AdminError::admin_session_required(),
|
||||
AdminError::session_required(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
40101,
|
||||
),
|
||||
(
|
||||
AdminError::invalid_admin_credentials(),
|
||||
AdminError::invalid_credentials(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
40102,
|
||||
),
|
||||
|
||||
@@ -54,7 +54,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/admin/settings.rs",
|
||||
"src/admin/system.rs",
|
||||
"src/admin/wire.rs",
|
||||
"src/auth.rs",
|
||||
"src/health.rs",
|
||||
"src/key_usage/mod.rs",
|
||||
"src/key_usage/query.rs",
|
||||
"src/key_usage/presenter.rs",
|
||||
"src/lib.rs",
|
||||
"src/openai/auth.rs",
|
||||
"src/openai/error.rs",
|
||||
@@ -76,6 +80,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/search/http.rs",
|
||||
"src/openai/search/mod.rs",
|
||||
"src/openai/service.rs",
|
||||
"src/session_cookie.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
@@ -104,7 +109,11 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/admin/system.rs",
|
||||
"tests/admin/wire.rs",
|
||||
"tests/architecture.rs",
|
||||
"tests/auth.rs",
|
||||
"tests/support.rs",
|
||||
"tests/health.rs",
|
||||
"tests/key_usage/mod.rs",
|
||||
"tests/key_usage/fixtures.rs",
|
||||
"tests/main.rs",
|
||||
"tests/openai/auth.rs",
|
||||
"tests/openai/error.rs",
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::support::{
|
||||
RAW_KEY, auth_app, cookie_request, empty_request, json_request, response_json,
|
||||
};
|
||||
|
||||
fn session_cookie(response: &axum::response::Response) -> String {
|
||||
response.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.expect("cookie")
|
||||
.split(';')
|
||||
.next()
|
||||
.expect("cookie pair")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn login(
|
||||
app: &axum::Router,
|
||||
body: Value,
|
||||
previous: Option<&str>,
|
||||
) -> axum::response::Response {
|
||||
let mut request = json_request(Method::POST, "/api/auth/login", body);
|
||||
if let Some(cookie) = previous {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::COOKIE, cookie.parse().expect("cookie"));
|
||||
}
|
||||
app.clone().oneshot(request).await.expect("login response")
|
||||
}
|
||||
|
||||
async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Response {
|
||||
app.clone()
|
||||
.oneshot(cookie_request(Method::GET, path, cookie))
|
||||
.await
|
||||
.expect("GET response")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_login_returns_server_identity_and_rotates_the_previous_session() {
|
||||
let (app, _) = auth_app().await;
|
||||
let key = login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await;
|
||||
assert_eq!(key.status(), StatusCode::OK);
|
||||
let key_cookie = session_cookie(&key);
|
||||
let key_data = response_json(key).await["data"].clone();
|
||||
assert_eq!(key_data["role"], "key");
|
||||
assert!(key_data["expiresAt"].is_string());
|
||||
assert_eq!(key_data.as_object().expect("session object").len(), 2);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
|
||||
json!({"authenticated": true, "session": key_data})
|
||||
);
|
||||
|
||||
let admin = login(
|
||||
&app,
|
||||
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"}),
|
||||
Some(&key_cookie),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(admin.status(), StatusCode::OK);
|
||||
let admin_cookie = session_cookie(&admin);
|
||||
let admin_data = response_json(admin).await["data"].clone();
|
||||
assert_eq!(admin_data["role"], "admin");
|
||||
assert!(admin_data["expiresAt"].is_string());
|
||||
assert_eq!(admin_data.as_object().expect("session object").len(), 2);
|
||||
assert_ne!(key_cookie, admin_cookie);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &admin_cookie).await).await["data"],
|
||||
json!({"authenticated": true, "session": admin_data})
|
||||
);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
|
||||
json!({"authenticated": false, "session": null})
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, "/api/admin/system/version", &admin_cookie)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let key = login(
|
||||
&app,
|
||||
json!({"mode": "key", "apiKey": RAW_KEY}),
|
||||
Some(&admin_cookie),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(key.status(), StatusCode::OK);
|
||||
let renewed = session_cookie(&key);
|
||||
assert_ne!(renewed, key_cookie);
|
||||
assert_eq!(
|
||||
get(&app, "/api/admin/system/version", &admin_cookie)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &renewed).await).await["data"]["authenticated"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_legacy_type_without_replacing_the_current_session() {
|
||||
let (app, _) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for body in [
|
||||
json!({"type": "key", "apiKey": RAW_KEY}),
|
||||
json!({"type": "admin", "password": "strong-admin-password"}),
|
||||
json!({"mode": "key", "type": "key", "apiKey": RAW_KEY}),
|
||||
json!({"mode": "admin", "type": "admin", "password": "strong-admin-password"}),
|
||||
] {
|
||||
let response = login(&app, body, Some(&cookie)).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(!response.headers().contains_key(header::SET_COOKIE));
|
||||
let error = response_json(response).await.to_string();
|
||||
assert!(!error.contains(RAW_KEY));
|
||||
assert!(!error.contains("strong-admin-password"));
|
||||
}
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
|
||||
"key"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_key_session_cannot_read_or_mutate_admin_resources() {
|
||||
let (app, _) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for path in [
|
||||
"/api/admin/system/version",
|
||||
"/api/admin/accounts",
|
||||
"/api/admin/client-keys",
|
||||
"/api/admin/settings",
|
||||
] {
|
||||
let response = get(&app, path, &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
|
||||
assert_eq!(response_json(response).await["code"], 40301);
|
||||
}
|
||||
let mut request = json_request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/admin-api-key/regenerate",
|
||||
json!({}),
|
||||
);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::COOKIE, cookie.parse().unwrap());
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn declared_roles_and_foreign_key_ids_cannot_override_server_authority() {
|
||||
let (app, _) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for body in [
|
||||
json!({"mode": "admin", "apiKey": RAW_KEY}),
|
||||
json!({"mode": "key", "apiKey": RAW_KEY, "role": "admin"}),
|
||||
json!({"mode": "admin", "password": RAW_KEY, "role": "key"}),
|
||||
json!({"role": "key", "apiKey": RAW_KEY}),
|
||||
json!({"mode": "key", "apiKey": RAW_KEY, "adminUserId": "admin_1"}),
|
||||
json!({"mode": "key", "apiKey": RAW_KEY, "clientKeyId": "other-key"}),
|
||||
json!({"mode": "superadmin", "apiKey": RAW_KEY}),
|
||||
json!({"apiKey": RAW_KEY}),
|
||||
] {
|
||||
let response = login(&app, body, Some(&cookie)).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(!response_json(response).await.to_string().contains(RAW_KEY));
|
||||
}
|
||||
let invalid = login(
|
||||
&app,
|
||||
json!({"mode": "admin", "password": RAW_KEY}),
|
||||
Some(&cookie),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!invalid.headers().contains_key(header::SET_COOKIE));
|
||||
assert_eq!(response_json(invalid).await["code"], 40102);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
|
||||
"key"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_plane_keys_and_browser_sessions_are_not_interchangeable() {
|
||||
let (app, _) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
assert_eq!(
|
||||
get(&app, "/v1/models", &cookie).await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
for (name, value) in [
|
||||
(header::AUTHORIZATION.as_str(), format!("Bearer {RAW_KEY}")),
|
||||
("x-api-key", RAW_KEY.to_owned()),
|
||||
] {
|
||||
let request = Request::get("/api/auth/status")
|
||||
.header(name, value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(
|
||||
response_json(response).await["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &format!("cpr_session={RAW_KEY}")).await).await
|
||||
["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_key_revokes_its_session_and_reenabling_does_not_restore_it() {
|
||||
let (app, store) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
store.enabled.store(false, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"],
|
||||
json!({"authenticated": false, "session": null})
|
||||
);
|
||||
assert_eq!(
|
||||
login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
store.enabled.store(true, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_store_outage_is_unavailable_not_expired_or_authenticated() {
|
||||
let (app, store) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
store.unavailable.store(true, Ordering::SeqCst);
|
||||
for path in ["/api/auth/status", "/api/admin/system/version"] {
|
||||
let response = get(&app, path, &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
|
||||
assert!(!response.headers().contains_key(header::SET_COOKIE));
|
||||
}
|
||||
store.unavailable.store(false, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_auth_routes_do_not_fall_back_to_spa_or_accept_old_cookies() {
|
||||
let (app, _) = auth_app().await;
|
||||
for path in ["/api/admin/auth/login", "/api/auth/unknown"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, path))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(response_json(response).await["code"], 40401);
|
||||
}
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for old_name in ["cpr_admin_session", "cpr_client_session"] {
|
||||
let old_cookie = cookie.replace("cpr_session", old_name);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &old_cookie).await).await["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_wire_never_prints_either_credential_in_debug() {
|
||||
for body in [
|
||||
json!({"mode": "key", "apiKey": RAW_KEY}),
|
||||
json!({"mode": "admin", "password": RAW_KEY}),
|
||||
] {
|
||||
let request: gateway_api::auth::LoginRequest = serde_json::from_value(body).unwrap();
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains(RAW_KEY));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_auth_should_issue_restore_and_clear_a_unified_cookie() {
|
||||
let (app, _) = auth_app().await;
|
||||
let login = app
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
Method::POST,
|
||||
"/api/auth/login",
|
||||
json!({ "mode": "key", "apiKey": RAW_KEY }),
|
||||
))
|
||||
.await
|
||||
.expect("client login response");
|
||||
|
||||
assert_eq!(login.status(), StatusCode::OK);
|
||||
assert_eq!(login.headers()[header::CACHE_CONTROL], "no-store");
|
||||
let set_cookie = login.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.expect("client session cookie")
|
||||
.to_owned();
|
||||
assert!(set_cookie.starts_with("cpr_session=session_"));
|
||||
assert!(set_cookie.contains("Path=/;"));
|
||||
assert!(set_cookie.contains("; Secure; HttpOnly; SameSite=Lax"));
|
||||
assert!(set_cookie.contains("; Max-Age="));
|
||||
assert!(set_cookie.contains("; Expires="));
|
||||
assert!(!set_cookie.contains(RAW_KEY));
|
||||
let cookie = set_cookie
|
||||
.split(';')
|
||||
.next()
|
||||
.expect("cookie pair")
|
||||
.to_owned();
|
||||
let login_body = response_json(login).await;
|
||||
assert_eq!(login_body["data"]["role"], "key");
|
||||
assert!(login_body["data"].get("key").is_none());
|
||||
assert!(!login_body.to_string().contains(RAW_KEY));
|
||||
|
||||
let status = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
|
||||
.await
|
||||
.expect("client status response");
|
||||
assert_eq!(status.status(), StatusCode::OK);
|
||||
let status_body = response_json(status).await;
|
||||
assert_eq!(status_body["data"]["authenticated"], true);
|
||||
assert_eq!(status_body["data"]["session"]["role"], "key");
|
||||
|
||||
let logout = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(Method::POST, "/api/auth/logout", &cookie))
|
||||
.await
|
||||
.expect("client logout response");
|
||||
assert_eq!(logout.status(), StatusCode::OK);
|
||||
let cleared = logout.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.expect("cleared cookie");
|
||||
assert!(cleared.starts_with("cpr_session=;"));
|
||||
assert!(cleared.contains("Path=/;"));
|
||||
assert!(cleared.contains("Max-Age=0"));
|
||||
let status = app
|
||||
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
|
||||
.await
|
||||
.expect("logged out");
|
||||
assert_eq!(response_json(status).await["data"]["authenticated"], false);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use crate::{admin::AdminTestFixture, support::key_fixture};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::{
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
|
||||
UsageOverview,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
};
|
||||
|
||||
pub(super) async fn fixture() -> AdminTestFixture {
|
||||
let fixture = key_fixture().await;
|
||||
let now = Utc::now();
|
||||
*fixture.client_key.lock().unwrap() = Some(ClientKeyRecord {
|
||||
id: ClientApiKeyId::new("key-42").unwrap(),
|
||||
name: "Development".to_owned(),
|
||||
label: Some("private-sentinel".to_owned()),
|
||||
prefix: "cpr_live".to_owned(),
|
||||
groups: Vec::new(),
|
||||
provider_kinds: Vec::new(),
|
||||
enabled: true,
|
||||
limits: RateLimits::unlimited(),
|
||||
budget: ClientBudgetStatus {
|
||||
limits: ClientBudgetLimits {
|
||||
daily_usd: "1".parse().unwrap(),
|
||||
weekly_usd: "5".parse().unwrap(),
|
||||
},
|
||||
daily_used_usd: "0.640001".parse().unwrap(),
|
||||
weekly_used_usd: "2.35".parse().unwrap(),
|
||||
daily_resets_at: Some((now + Duration::days(1)).into()),
|
||||
weekly_resets_at: Some((now + Duration::days(7)).into()),
|
||||
},
|
||||
last_used_at: Some(now),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
let metrics = RequestMetrics {
|
||||
request_count: 2,
|
||||
success_count: 1,
|
||||
failure_count: 1,
|
||||
input_tokens: 1000,
|
||||
output_tokens: 100,
|
||||
cached_tokens: 900,
|
||||
reasoning_tokens: 40,
|
||||
total_tokens: 1100,
|
||||
..Default::default()
|
||||
};
|
||||
let coverage = CostCoverage {
|
||||
calculated_count: 1,
|
||||
unavailable_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let costs = vec![CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "0.123456".parse::<DecimalAmount>().unwrap(),
|
||||
}];
|
||||
{
|
||||
let mut observations = fixture.observations.lock().unwrap();
|
||||
observations.summary = Some(UsageOverview {
|
||||
range: TimeRange {
|
||||
start: now - Duration::hours(1),
|
||||
end: now,
|
||||
},
|
||||
requests: metrics.clone(),
|
||||
attempts: AttemptMetrics {
|
||||
costs: costs.clone(),
|
||||
cost_coverage: coverage.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
providers: Vec::new(),
|
||||
});
|
||||
observations.trend = Some(vec![RequestMetricPoint {
|
||||
bucket_start: now,
|
||||
granularity: Granularity::FifteenMinutes,
|
||||
metrics,
|
||||
costs,
|
||||
cost_coverage: coverage,
|
||||
}]);
|
||||
}
|
||||
fixture.usage_records.lock().unwrap().push(usage_record());
|
||||
fixture.ops_errors.lock().unwrap().push(error_record());
|
||||
fixture
|
||||
}
|
||||
|
||||
fn usage_record() -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
id: "req-visible".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
provider_kind: Some("private-sentinel".to_owned()),
|
||||
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_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
service_tier: Some("private-sentinel".to_owned()),
|
||||
input_tokens: Some(1000),
|
||||
output_tokens: Some(100),
|
||||
cached_tokens: Some(900),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(40),
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: Some(1100),
|
||||
cost_source: "calculated".to_owned(),
|
||||
cost_amount: Some("0.123456".parse::<DecimalAmount>().unwrap()),
|
||||
cost_currency: Some("USD".to_owned()),
|
||||
billing: Some(UsageBilling::Total {
|
||||
source: "provider_reported".to_owned(),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "0.123456".parse().unwrap(),
|
||||
},
|
||||
}),
|
||||
transport_decision_wait_ms: None,
|
||||
connect_ms: None,
|
||||
headers_ms: None,
|
||||
first_event_ms: Some(100),
|
||||
first_reasoning_ms: Some(210),
|
||||
first_text_ms: Some(600),
|
||||
first_token_ms: Some(210),
|
||||
provider_processing_ms: None,
|
||||
latency_ms: Some(1250),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
capacity_used_slots: Some(17),
|
||||
capacity_total_slots: Some(50),
|
||||
client_ip: Some("192.0.2.42".to_owned()),
|
||||
user_agent: Some("key-usage-test/1.0".to_owned()),
|
||||
reasoning_effort: Some("xhigh".to_owned()),
|
||||
reasoning_preset: Some("private-sentinel".to_owned()),
|
||||
subagent_kind: Some("private-sentinel".to_owned()),
|
||||
compact: false,
|
||||
started_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record() -> OpsError {
|
||||
OpsError {
|
||||
source: "private-sentinel".to_owned(),
|
||||
event_id: "error-visible".to_owned(),
|
||||
request_id: Some("private-sentinel".to_owned()),
|
||||
attempt_index: None,
|
||||
client_api_key_ref: Some("private-sentinel".to_owned()),
|
||||
component: "private-sentinel".to_owned(),
|
||||
operation: "private-sentinel".to_owned(),
|
||||
protocol: Some("private-sentinel".to_owned()),
|
||||
client_transport: Some("http_sse".to_owned()),
|
||||
requested_model_id: Some("coding".to_owned()),
|
||||
service_tier: Some("private-sentinel".to_owned()),
|
||||
endpoint: Some("/v1/responses".to_owned()),
|
||||
provider_kind: Some("private-sentinel".to_owned()),
|
||||
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_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
failure_kind: "private-sentinel".to_owned(),
|
||||
upstream_send_state: Some("private-sentinel".to_owned()),
|
||||
client_status_code: Some(502),
|
||||
upstream_status_code: Some(503),
|
||||
provider_error_code: Some("private-sentinel".to_owned()),
|
||||
client_response_id: Some("private-sentinel".to_owned()),
|
||||
upstream_request_id: Some("private-sentinel".to_owned()),
|
||||
latency_ms: Some(420),
|
||||
message: "private-sentinel".to_owned(),
|
||||
raw_upstream_error: Some("private-sentinel".to_owned()),
|
||||
client_ip: Some("192.0.2.42".to_owned()),
|
||||
user_agent: Some("key-usage-test/1.0".to_owned()),
|
||||
reasoning_effort: Some("xhigh".to_owned()),
|
||||
reasoning_preset: Some("private-sentinel".to_owned()),
|
||||
request_kind: Some("private-sentinel".to_owned()),
|
||||
subagent_kind: Some("private-sentinel".to_owned()),
|
||||
compact: None,
|
||||
continuation_affinity_hash: Some("private-sentinel".to_owned()),
|
||||
continuation_previous_response_id_hash: Some("private-sentinel".to_owned()),
|
||||
continuation_unavailable_reason: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_id: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_exit_reason: Some("private-sentinel".to_owned()),
|
||||
upstream_connection_age_ms: None,
|
||||
upstream_connection_idle_ms: None,
|
||||
recovery_request_id: Some("private-sentinel".to_owned()),
|
||||
recovered_at: None,
|
||||
recovery_attempt_count: 0,
|
||||
recovery_retry_delay_ms: None,
|
||||
recovery_total_latency_ms: None,
|
||||
occurred_at: Utc::now(),
|
||||
stable_sort_id: "private-sentinel".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! 通过真实路由验证会话隔离、范围收敛和对外字段白名单。
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{Method, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{RequestMetrics, china_day_start};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::support::{RAW_KEY, cookie_request, empty_request, json_request, response_json};
|
||||
|
||||
mod fixtures;
|
||||
|
||||
const RANGE: &str = "startTime=2026-09-01T00:00:00Z&endTime=2026-09-02T00:00:00Z";
|
||||
|
||||
async fn login(app: &Router, mode: &str) -> String {
|
||||
let body = if mode == "key" {
|
||||
json!({"mode": "key", "apiKey": RAW_KEY})
|
||||
} else {
|
||||
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"})
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(Method::POST, "/api/auth/login", body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
|
||||
let path = format!("/api/key-usage/{resource}?{RANGE}{suffix}");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(Method::GET, &path, cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
response
|
||||
}
|
||||
|
||||
fn assert_fields(value: &Value, expected: &[&str]) {
|
||||
let mut actual: Vec<_> = value
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
actual.sort_unstable();
|
||||
let mut expected = expected.to_vec();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
|
||||
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, "overview", "&model=%20coding%20", &cookie).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let data = response_json(response).await["data"].clone();
|
||||
assert_fields(
|
||||
&data,
|
||||
&[
|
||||
"asOf",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"key",
|
||||
"summary",
|
||||
"trend",
|
||||
"healthTimeline",
|
||||
],
|
||||
);
|
||||
assert_fields(
|
||||
&data["key"],
|
||||
&[
|
||||
"name",
|
||||
"prefix",
|
||||
"maxConcurrency",
|
||||
"requestsPerMinute",
|
||||
"dailyLimitUsd",
|
||||
"dailyUsedUsd",
|
||||
"dailyResetsAt",
|
||||
"weeklyLimitUsd",
|
||||
"weeklyUsedUsd",
|
||||
"weeklyResetsAt",
|
||||
],
|
||||
);
|
||||
assert_eq!(data["key"]["dailyUsedUsd"], "0.640001");
|
||||
assert_eq!(data["key"]["maxConcurrency"], 0);
|
||||
assert_eq!(data["summary"]["totalTokens"], 1100);
|
||||
assert_eq!(data["summary"]["reasoningTokens"], 40);
|
||||
assert_eq!(data["trend"][0]["reasoningTokens"], 40);
|
||||
assert_eq!(data["summary"]["costUsd"], "0.123456");
|
||||
assert_eq!(data["summary"]["costIncomplete"], true);
|
||||
assert_eq!(data["trend"][0]["bucketSeconds"], 900);
|
||||
assert_eq!(
|
||||
data["healthTimeline"]["points"].as_array().unwrap().len(),
|
||||
96
|
||||
);
|
||||
assert!(!data.to_string().contains("private-sentinel"));
|
||||
let observations = fixture.observations.lock().unwrap();
|
||||
assert_eq!(observations.summaries.len(), 1);
|
||||
assert_eq!(observations.trends.len(), 2);
|
||||
for (_, filter) in observations
|
||||
.summaries
|
||||
.iter()
|
||||
.chain(observations.trends.iter())
|
||||
{
|
||||
assert_eq!(filter.client_api_key_ref.as_deref(), Some("key-42"));
|
||||
assert!(filter.provider_account_ref.is_none());
|
||||
assert!(filter.provider_kind.is_none());
|
||||
}
|
||||
assert_eq!(observations.summaries[0].1.model.as_deref(), Some("coding"));
|
||||
let health = observations
|
||||
.trends
|
||||
.iter()
|
||||
.find(|(_, filter)| filter.model.is_none())
|
||||
.unwrap();
|
||||
assert_eq!(health.0.start, china_day_start(health.0.end));
|
||||
assert!((Utc::now() - health.0.end) < Duration::seconds(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
for kind in ["success", "error"] {
|
||||
let response = get(
|
||||
&app,
|
||||
"records",
|
||||
&format!("&kind={kind}&model=coding¤tPage=3&pageSize=10"),
|
||||
&cookie,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let data = response_json(response).await["data"].clone();
|
||||
assert_eq!(data["currentPage"], 3);
|
||||
assert_eq!(data["pageSize"], 10);
|
||||
assert_eq!(data["total"], 1);
|
||||
let record = &data["items"][0];
|
||||
assert_fields(
|
||||
record,
|
||||
&[
|
||||
"id",
|
||||
"createdAt",
|
||||
"model",
|
||||
"route",
|
||||
"reasoningEffort",
|
||||
"clientTransport",
|
||||
"upstreamTransport",
|
||||
"tokenDetails",
|
||||
"billing",
|
||||
"latencyMs",
|
||||
"firstTokenLatencyMs",
|
||||
"latencyDetails",
|
||||
"clientIp",
|
||||
"userAgent",
|
||||
"status",
|
||||
"statusCode",
|
||||
],
|
||||
);
|
||||
assert_eq!(record["status"], kind);
|
||||
assert_eq!(record["model"], "coding");
|
||||
assert_eq!(record["reasoningEffort"], "xhigh");
|
||||
assert_eq!(record["clientTransport"], "http_sse");
|
||||
assert_eq!(record["upstreamTransport"], "websocket");
|
||||
assert_eq!(record["clientIp"], "192.0.2.42");
|
||||
assert_eq!(record["userAgent"], "key-usage-test/1.0");
|
||||
assert!(!data.to_string().contains("private-sentinel"));
|
||||
if kind == "success" {
|
||||
assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235");
|
||||
assert_eq!(record["tokenDetails"]["reasoningTokens"], 40);
|
||||
assert_eq!(record["tokenDetails"]["totalTokens"], 1100);
|
||||
assert_eq!(record["firstTokenLatencyMs"], 210);
|
||||
assert_fields(
|
||||
&record["latencyDetails"],
|
||||
&["firstEventMs", "firstReasoningMs", "firstTextMs"],
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["firstEventMs"], 100);
|
||||
assert!(record["statusCode"].is_null());
|
||||
} else {
|
||||
assert_eq!(record["statusCode"], 502);
|
||||
assert!(record["billing"].is_null());
|
||||
assert!(record["tokenDetails"].is_null());
|
||||
assert_eq!(record["latencyDetails"], json!({}));
|
||||
}
|
||||
}
|
||||
let data = fixture.observations.lock().unwrap();
|
||||
assert_eq!(
|
||||
data.records[0].filter.client_api_key_ref.as_deref(),
|
||||
Some("key-42")
|
||||
);
|
||||
assert_eq!(
|
||||
data.errors[0].filter.client_api_key_ref.as_deref(),
|
||||
Some("key-42")
|
||||
);
|
||||
assert_eq!(data.records[0].filter.model.as_deref(), Some("coding"));
|
||||
assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
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"] {
|
||||
assert_eq!(
|
||||
get(&app, resource, "", "").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, resource, "", &admin).await.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.auth.enabled.store(false, Ordering::SeqCst);
|
||||
let response = get(&app, resource, "", &key).await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response_json(response).await["code"], 40101);
|
||||
fixture.auth.enabled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
assert!(fixture.observations.lock().unwrap().records.is_empty());
|
||||
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
for resource in ["overview", "records"] {
|
||||
for extra in [
|
||||
"&clientApiKeyRef=other",
|
||||
"&keyId=other",
|
||||
"&account=other",
|
||||
"&provider=openai",
|
||||
"&model=%00",
|
||||
"&startTime=duplicate",
|
||||
] {
|
||||
assert!(
|
||||
get(&app, resource, extra, &cookie)
|
||||
.await
|
||||
.status()
|
||||
.is_client_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
for extra in [
|
||||
"¤tPage=0",
|
||||
"&pageSize=0",
|
||||
"&pageSize=101",
|
||||
"&kind=all",
|
||||
"¤tPage=-1",
|
||||
] {
|
||||
assert!(
|
||||
get(&app, "records", extra, &cookie)
|
||||
.await
|
||||
.status()
|
||||
.is_client_error()
|
||||
);
|
||||
}
|
||||
for range in [
|
||||
"startTime=invalid&endTime=invalid",
|
||||
"startTime=2026-01-01T00:00:00Z&endTime=2026-09-02T00:00:00Z",
|
||||
"startTime=2026-09-02T00:00:00Z&endTime=2026-09-01T00:00:00Z",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cookie_request(
|
||||
Method::GET,
|
||||
&format!("/api/key-usage/overview?{range}"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
|
||||
assert!(fixture.observations.lock().unwrap().records.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_usage_has_zero_cost_but_unknown_pricing_stays_unknown() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
for requests in [0, 2] {
|
||||
{
|
||||
let mut data = fixture.observations.lock().unwrap();
|
||||
let summary = data.summary.as_mut().unwrap();
|
||||
summary.requests = RequestMetrics {
|
||||
request_count: requests,
|
||||
..Default::default()
|
||||
};
|
||||
summary.attempts.costs.clear();
|
||||
summary.attempts.cost_coverage = Default::default();
|
||||
summary.attempts.cost_coverage.unavailable_count = requests;
|
||||
data.trend = Some(Vec::new());
|
||||
}
|
||||
let data = response_json(get(&app, "overview", "", &cookie).await).await["data"].clone();
|
||||
assert_eq!(
|
||||
data["summary"]["costUsd"],
|
||||
if requests == 0 {
|
||||
json!("0")
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
);
|
||||
assert_eq!(data["summary"]["costIncomplete"], requests > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_does_not_mask_missing_keys_or_unavailable_observations() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services.clone());
|
||||
let cookie = login(&app, "key").await;
|
||||
fixture.observations.lock().unwrap().summary = None;
|
||||
assert_eq!(
|
||||
get(&app, "overview", "", &cookie).await.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
*fixture.client_key.lock().unwrap() = None;
|
||||
assert_eq!(
|
||||
get(&app, "overview", "", &cookie).await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_errors_remain_json_and_uncacheable() {
|
||||
let fixture = fixtures::fixture().await;
|
||||
let app = crate::openai::api_router_with_admin(fixture.services);
|
||||
for (method, path, status) in [
|
||||
(Method::GET, "/api/key-usage/missing", StatusCode::NOT_FOUND),
|
||||
(
|
||||
Method::POST,
|
||||
"/api/key-usage/overview",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(method, path))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
assert!(response_json(response).await["code"].is_number());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
mod admin;
|
||||
mod architecture;
|
||||
mod auth;
|
||||
mod health;
|
||||
mod key_usage;
|
||||
mod openai;
|
||||
mod support;
|
||||
|
||||
@@ -76,10 +76,28 @@ fn bearer_client_api_key_should_reject_empty_bearer_token() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
|
||||
fn bearer_client_api_key_should_accept_migrated_formats_without_rewriting() {
|
||||
for key in [
|
||||
"q".to_owned(),
|
||||
"sk-old/key+value=:!@".to_owned(),
|
||||
"x".repeat(8192),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {key}")).unwrap(),
|
||||
);
|
||||
assert_eq!(bearer_client_api_key(&headers), Ok(key.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_whitespace_inside_tokens() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer key with spaces"),
|
||||
);
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::InvalidKeyFormat)
|
||||
|
||||
@@ -110,6 +110,25 @@ fn rate_limited_error_should_map_to_openai_retryable_status() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_rejections_keep_distinct_retryable_http_codes() {
|
||||
for (kind, code) in [
|
||||
(
|
||||
GatewayErrorKind::ConcurrencyQueueFull,
|
||||
"concurrency_queue_full",
|
||||
),
|
||||
(
|
||||
GatewayErrorKind::ConcurrencyQueueTimeout,
|
||||
"concurrency_queue_timeout",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
gateway_error_contract(kind),
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limit_error", code),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_unavailable_error_should_map_to_bad_gateway() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -49,6 +49,32 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
|
||||
api_router_with_origins(execution, Vec::new()).await
|
||||
}
|
||||
|
||||
pub(super) fn api_router_with_admin(admin: gateway_admin::AdminServices) -> axum::Router {
|
||||
gateway_api::initialize(
|
||||
gateway_api::ApiConfig {
|
||||
asset_directory: std::env::temp_dir(),
|
||||
cors_allowed_origins: Vec::new(),
|
||||
request_timeout_seconds: None,
|
||||
request_id_header: "x-request-id".to_owned(),
|
||||
},
|
||||
Arc::new(DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")),
|
||||
Arc::new(UnusedExecutionStore),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedCircuits),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(IgnoredClientApiKeyUsage),
|
||||
)),
|
||||
admin,
|
||||
Vec::new(),
|
||||
Arc::new(EmptyWorkerHealth),
|
||||
Arc::new(TestLifecycle::default()),
|
||||
)
|
||||
.expect("API bundle")
|
||||
.router()
|
||||
}
|
||||
|
||||
pub(super) async fn api_router_with_worker_health(
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
worker_health: Arc<dyn WorkerHealthSource>,
|
||||
@@ -266,6 +292,14 @@ impl ExecutionStore for UnusedExecutionStore {
|
||||
struct UnusedAdmissions;
|
||||
|
||||
impl ClientAdmissionPort for UnusedAdmissions {
|
||||
fn abandon(
|
||||
&self,
|
||||
key: &gateway_core::policy::ClientApiKeyId,
|
||||
request: &gateway_core::engine::ModelRequestId,
|
||||
) {
|
||||
let _ = futures::FutureExt::now_or_never(self.release(key, request));
|
||||
}
|
||||
|
||||
fn admit(
|
||||
&self,
|
||||
_: ClientAdmissionRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
@@ -11,7 +11,8 @@ use gateway_core::engine::execution::{
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::routing::{
|
||||
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
|
||||
ModelPresentation, ModelServiceTier, ProviderCatalogUnavailable, PublicModelDescriptor,
|
||||
PublicModelId, PublicModelProfile,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -20,6 +21,8 @@ use super::{api_router, authenticated_client, authenticated_client_with_min_vers
|
||||
pub(super) struct ModelsExecution {
|
||||
client: AuthenticatedClient,
|
||||
presentation: Option<ModelPresentation>,
|
||||
native: Option<Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>>,
|
||||
requested_versions: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ModelsExecution {
|
||||
@@ -27,6 +30,8 @@ impl ModelsExecution {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
presentation: None,
|
||||
native: None,
|
||||
requested_versions: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,6 +66,8 @@ impl ModelsExecution {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
presentation: Some(presentation),
|
||||
native: None,
|
||||
requested_versions: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +75,8 @@ impl ModelsExecution {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
|
||||
presentation: None,
|
||||
native: None,
|
||||
requested_versions: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,11 +88,48 @@ impl ModelsExecution {
|
||||
None,
|
||||
),
|
||||
presentation: None,
|
||||
native: None,
|
||||
requested_versions: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn profiles(&self) -> Vec<PublicModelProfile> {
|
||||
self.presentation
|
||||
.as_ref()
|
||||
.map(|presentation| {
|
||||
PublicModelProfile::new(
|
||||
PublicModelId::new("grok-4.5").expect("model"),
|
||||
presentation.clone(),
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionService for ModelsExecution {
|
||||
fn client_model_catalog<'a>(
|
||||
&'a self,
|
||||
_client: &'a AuthenticatedClient,
|
||||
protocol: &'a str,
|
||||
version: &'a str,
|
||||
) -> BoxFuture<'a, Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable>> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(protocol, "codex");
|
||||
self.requested_versions
|
||||
.lock()
|
||||
.expect("versions")
|
||||
.push(version.to_owned());
|
||||
self.native.clone().unwrap_or_else(|| {
|
||||
Ok(self
|
||||
.profiles()
|
||||
.into_iter()
|
||||
.map(PublicModelDescriptor::Adapted)
|
||||
.collect())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
@@ -102,16 +148,6 @@ impl ExecutionService for ModelsExecution {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
|
||||
let Some(presentation) = &self.presentation else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![PublicModelProfile::new(
|
||||
PublicModelId::new("grok-4.5").expect("model"),
|
||||
presentation.clone(),
|
||||
)]
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
matches!(model.as_str(), "model-a" | "model-b")
|
||||
}
|
||||
@@ -145,6 +181,102 @@ fn authorized_request(path: &str) -> Request<Body> {
|
||||
.expect("build models request")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() {
|
||||
let original = serde_json::json!({
|
||||
"slug": "gpt-native", "display_name": "Official name", "priority": 47,
|
||||
"base_instructions": "Official instructions\nDo not replace.",
|
||||
"model_messages": {"token_budget": "upstream template", "future_section": [null, {"a": true}]},
|
||||
"service_tiers": [{"id": "priority", "name": "Fast", "description": "Official speed tier"}],
|
||||
"tool_mode": "code_mode_only", "use_responses_lite": true,
|
||||
"future_field": {"nested": [1, null, "value"]}, "explicit_null": null
|
||||
});
|
||||
let entry = |model: &str| PublicModelDescriptor::Native {
|
||||
model: PublicModelId::new(model).expect("model"),
|
||||
payload: gateway_core::operation::RawJsonPayload::new(
|
||||
"codex",
|
||||
serde_json::to_vec(&original).expect("JSON").into(),
|
||||
)
|
||||
.expect("payload"),
|
||||
};
|
||||
let execution = Arc::new(ModelsExecution {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
presentation: None,
|
||||
native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])),
|
||||
requested_versions: Mutex::default(),
|
||||
});
|
||||
let response = api_router(execution.clone())
|
||||
.await
|
||||
.oneshot(authorized_request(
|
||||
"/v1/models?client_version=0.154.0-alpha.3",
|
||||
))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["cache-control"], "private, no-store");
|
||||
assert!(response.headers().get("etag").is_none());
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
|
||||
assert_eq!(value["models"][0], original);
|
||||
let mut alias = original;
|
||||
alias["slug"] = serde_json::json!("my-alias");
|
||||
assert_eq!(value["models"][1], alias);
|
||||
assert_eq!(
|
||||
*execution.requested_versions.lock().expect("versions"),
|
||||
["0.154.0-alpha.3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() {
|
||||
let execution = Arc::new(ModelsExecution {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
presentation: None,
|
||||
native: Some(Err(ProviderCatalogUnavailable)),
|
||||
requested_versions: Mutex::default(),
|
||||
});
|
||||
let response = api_router(execution)
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models?client_version=0.154.0"))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
|
||||
assert_eq!(value["error"]["code"], "model_catalog_unavailable");
|
||||
assert!(value.get("models").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_rejects_unbounded_or_control_character_versions_before_fetching() {
|
||||
for version in [
|
||||
"x".repeat(65),
|
||||
"0.154.0%0A".to_owned(),
|
||||
"0.154.0%20".to_owned(),
|
||||
] {
|
||||
let execution = ModelsExecution::new();
|
||||
let response = api_router(execution.clone())
|
||||
.await
|
||||
.oneshot(authorized_request(&format!(
|
||||
"/v1/models?client_version={version}"
|
||||
)))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
execution
|
||||
.requested_versions
|
||||
.lock()
|
||||
.expect("versions")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_encode_the_service_visible_catalog() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
|
||||
@@ -34,6 +34,7 @@ use gateway_core::event::{
|
||||
use gateway_core::operation::{Operation, OperationKind};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
|
||||
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
|
||||
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error_data = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
|
||||
let error_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("error".to_owned()),
|
||||
error_data,
|
||||
projected_error_frame,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream error wire"),
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
started_wire,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
error_wire,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
let events = parse_sse_events(&body).expect("translated SSE should parse");
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event.as_deref() == Some("response.failed"))
|
||||
.expect("translated response.failed event");
|
||||
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
failed,
|
||||
body.matches("event: response.failed").count(),
|
||||
body.contains("event: error\n"),
|
||||
body.ends_with("data: [DONE]\n\n"),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
}),
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
|
||||
@@ -666,6 +666,87 @@ fn transparent_encoder_should_use_identical_json_for_sse_and_websocket() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websocket() {
|
||||
// codex 的 WS 端点只消费带 status 的包装错误帧;上游缺少 status 的裸
|
||||
// `error` 帧会被静默忽略,客户端只能空等到 idle 超时。WS 边界与 SSE
|
||||
// 边界一致投影成 `response.failed`,codex 将其映射为可重试错误并立即
|
||||
// 重试,而不是把失败原因拖到流 EOF。
|
||||
let response_id = "resp_ws_error";
|
||||
let started = openai_wire_event(
|
||||
Vec::new(),
|
||||
"response.created",
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": response_id, "status": "in_progress"}
|
||||
}),
|
||||
);
|
||||
let error = openai_wire_event(
|
||||
Vec::new(),
|
||||
"error",
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later."
|
||||
},
|
||||
"sequence_number": 2
|
||||
}),
|
||||
);
|
||||
|
||||
let mut websocket_encoder = OpenAiResponsesEncoder::new();
|
||||
websocket_encoder.push_websocket(&started);
|
||||
let messages = websocket_encoder.push_websocket(&error);
|
||||
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(websocket_encoder.has_wire_failure());
|
||||
let payload: Value = serde_json::from_str(&messages[0]).expect("response.failed is valid JSON");
|
||||
assert_eq!(payload["type"], "response.failed");
|
||||
assert_eq!(payload["response"]["id"], response_id);
|
||||
assert_eq!(payload["response"]["status"], "failed");
|
||||
assert_eq!(payload["response"]["error"]["code"], "server_is_overloaded");
|
||||
|
||||
// 与 SSE 边界投影到同一份 data 负载,两条客户端通道行为一致。
|
||||
let mut sse_encoder = OpenAiResponsesEncoder::new();
|
||||
sse_encoder.push_sse(&started);
|
||||
let frames = sse_encoder.push_sse(&error);
|
||||
let sse_text = String::from_utf8(
|
||||
frames
|
||||
.iter()
|
||||
.flat_map(|frame| frame.as_ref().iter().copied())
|
||||
.collect(),
|
||||
)
|
||||
.expect("SSE is UTF-8");
|
||||
let sse_data = parse_sse_events(&sse_text)
|
||||
.expect("SSE parses")
|
||||
.into_iter()
|
||||
.map(|event| event.data)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(sse_data, messages);
|
||||
|
||||
// 带非 2xx status 的包装错误帧是 codex 可直接消费的形状,必须原样透传。
|
||||
let mut passthrough_encoder = OpenAiResponsesEncoder::new();
|
||||
passthrough_encoder.push_websocket(&started);
|
||||
let wrapped = openai_wire_event(
|
||||
Vec::new(),
|
||||
"error",
|
||||
json!({
|
||||
"type": "error",
|
||||
"status": 429,
|
||||
"headers": {"x-request-id": "req_raw"},
|
||||
"error": {"type": "custom_type", "code": "custom_code", "message": "raw marker"},
|
||||
"future": {"keep": true}
|
||||
}),
|
||||
);
|
||||
let wrapped_messages = passthrough_encoder.push_websocket(&wrapped);
|
||||
assert_eq!(
|
||||
wrapped_messages,
|
||||
vec![wrapped.wire_event().expect("wire").data().to_string()]
|
||||
);
|
||||
assert!(passthrough_encoder.has_wire_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
|
||||
@@ -914,6 +914,14 @@ impl ExecutionStore for SettlementPorts {
|
||||
}
|
||||
|
||||
impl ClientAdmissionPort for SettlementPorts {
|
||||
fn abandon(
|
||||
&self,
|
||||
key: &gateway_core::policy::ClientApiKeyId,
|
||||
request: &gateway_core::engine::ModelRequestId,
|
||||
) {
|
||||
let _ = futures::FutureExt::now_or_never(self.release(key, request));
|
||||
}
|
||||
|
||||
fn admit(
|
||||
&self,
|
||||
_: ClientAdmissionRequest,
|
||||
|
||||
@@ -9,6 +9,110 @@ use super::models::ModelsExecution;
|
||||
|
||||
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_origin_controls_http_admin_sessions_without_configuration() {
|
||||
use axum::body::to_bytes;
|
||||
use serde_json::{Value, json};
|
||||
for (origins, secure) in [
|
||||
(vec!["http://admin.example.test"], false),
|
||||
(vec!["http://192.0.2.1:8080"], false),
|
||||
(vec!["http://[::1]:8080"], false),
|
||||
(vec!["https://admin.example.test"], true),
|
||||
(vec![], true),
|
||||
(vec!["null"], true),
|
||||
(vec!["invalid-origin"], true),
|
||||
(vec!["http://admin.example.test/path"], true),
|
||||
(vec!["http://admin.example.test?https"], true),
|
||||
(vec!["http://user@admin.example.test"], true),
|
||||
(
|
||||
vec!["http://admin.example.test", "https://admin.example.test"],
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let app = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.layer(axum::Extension(axum::extract::ConnectInfo(
|
||||
std::net::SocketAddr::from(([127, 0, 0, 1], 41000)),
|
||||
)));
|
||||
let request = |path: &str| {
|
||||
let mut builder = Request::post(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-proto", "http")
|
||||
.header("forwarded", "proto=http");
|
||||
for origin in &origins {
|
||||
builder = builder.header("origin", *origin);
|
||||
}
|
||||
builder
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/auth/login")
|
||||
.body(Body::from(
|
||||
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cookie = response.headers()["set-cookie"].to_str().unwrap();
|
||||
let session = cookie.split(';').next().unwrap().to_owned();
|
||||
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
|
||||
assert_eq!(attrs.contains(&"Secure"), secure);
|
||||
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax"] {
|
||||
assert!(attrs.contains(&attribute));
|
||||
}
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::get("/api/auth/status")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
|
||||
assert_eq!(body["data"]["authenticated"], true);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/auth/logout")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cookie = response.headers()["set-cookie"].to_str().unwrap();
|
||||
assert!(cookie.starts_with("cpr_session=;"));
|
||||
let attrs: Vec<_> = cookie.split(';').map(str::trim).collect();
|
||||
assert_eq!(attrs.contains(&"Secure"), secure);
|
||||
for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"] {
|
||||
assert!(attrs.contains(&attribute));
|
||||
}
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::get("/api/auth/status")
|
||||
.header("cookie", &session)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
|
||||
assert_eq!(body["data"]["authenticated"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::{Arc, atomic::Ordering},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
extract::ConnectInfo,
|
||||
http::{Method, Request, header},
|
||||
};
|
||||
use gateway_admin::{
|
||||
model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const RAW_KEY: &str = "cpr_live_candidate_must_not_leak";
|
||||
|
||||
pub(crate) async fn auth_app() -> (axum::Router, Arc<crate::admin::MemoryAuthStore>) {
|
||||
let fixture = key_fixture().await;
|
||||
(
|
||||
crate::openai::api_router_with_admin(fixture.services),
|
||||
fixture.auth,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn key_fixture() -> crate::admin::AdminTestFixture {
|
||||
let fixture = crate::admin::AdminTestFixture::with_key_verifier(
|
||||
Arc::new(AcceptingVerifier {
|
||||
key_id: ClientApiKeyId::new("key-42").expect("key ID"),
|
||||
}),
|
||||
Arc::new(VersionSystem),
|
||||
)
|
||||
.await;
|
||||
fixture.auth.enabled.store(true, Ordering::SeqCst);
|
||||
fixture
|
||||
}
|
||||
|
||||
pub(crate) fn json_request(method: Method, uri: &str, body: Value) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ORIGIN, "https://console.example.test")
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("client JSON request");
|
||||
request.extensions_mut().insert(ConnectInfo(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)),
|
||||
41_000,
|
||||
)));
|
||||
request
|
||||
}
|
||||
|
||||
pub(crate) fn empty_request(method: Method, uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("client empty request")
|
||||
}
|
||||
|
||||
pub(crate) fn cookie_request(method: Method, uri: &str, cookie: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.expect("client cookie request")
|
||||
}
|
||||
|
||||
pub(crate) async fn response_json(response: axum::response::Response) -> Value {
|
||||
let body = to_bytes(response.into_body(), 64 * 1024)
|
||||
.await
|
||||
.expect("read client response body");
|
||||
serde_json::from_slice(&body).expect("parse client response JSON")
|
||||
}
|
||||
|
||||
struct VersionSystem;
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for VersionSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Ok(SystemVersion {
|
||||
version: "3.7.0".to_owned(),
|
||||
git_sha: "internal-revision".to_owned(),
|
||||
build_time: "internal-build-time".to_owned(),
|
||||
deployment_mode: "binary".to_owned(),
|
||||
update_channel: "release".to_owned(),
|
||||
latest_version: "3.8.0".to_owned(),
|
||||
has_update: true,
|
||||
update_cached: true,
|
||||
update_warning: Some("internal-update-warning".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!("client route must not request update details")
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
unreachable!("client route must not subscribe to updates")
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not perform updates")
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
unreachable!("client route must not request update status")
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not roll back")
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
unreachable!("client route must not restart")
|
||||
}
|
||||
}
|
||||
|
||||
struct AcceptingVerifier {
|
||||
key_id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
impl ClientKeyVerifier for AcceptingVerifier {
|
||||
fn verify_client_key(
|
||||
&self,
|
||||
candidate: &str,
|
||||
) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
if candidate == RAW_KEY {
|
||||
Ok(self.key_id.clone())
|
||||
} else {
|
||||
Err(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
mod error;
|
||||
mod model;
|
||||
mod model_access;
|
||||
pub use model_access::{
|
||||
AccountModelAccess, AccountModelAccessMode, InvalidAccountModelAccess,
|
||||
MAX_ACCOUNT_ACCESS_MODELS,
|
||||
};
|
||||
mod proxy;
|
||||
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
|
||||
pub mod scope;
|
||||
|
||||
@@ -669,6 +669,7 @@ pub struct ProviderAccount {
|
||||
enabled: bool,
|
||||
concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
weight: AccountWeight,
|
||||
model_access: super::AccountModelAccess,
|
||||
credential_state: CredentialState,
|
||||
quota: QuotaState,
|
||||
last_error_reason: Option<AccountErrorReason>,
|
||||
@@ -704,6 +705,7 @@ impl ProviderAccount {
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
model_access: super::AccountModelAccess::all(),
|
||||
credential_state: CredentialState::Unknown,
|
||||
quota: QuotaState::unknown(),
|
||||
last_error_reason: None,
|
||||
@@ -728,6 +730,17 @@ impl ProviderAccount {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_model_access(mut self, model_access: super::AccountModelAccess) -> Self {
|
||||
self.model_access = model_access;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_access(&self) -> &super::AccountModelAccess {
|
||||
&self.model_access
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
|
||||
self.outbound_proxy = proxy;
|
||||
@@ -1007,6 +1020,8 @@ impl ProviderRefreshQuery {
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct NewProviderAccount {
|
||||
pub account: ProviderAccount,
|
||||
/// 导入时显式提供的政策;省略时保留已有账号设置。
|
||||
pub model_access: Option<super::AccountModelAccess>,
|
||||
pub credential: PlaintextCredential,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 管理员配置的账号模型政策,与上游模型权限分开保存和判断。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::validation::validate_text;
|
||||
|
||||
/// 一个账号最多配置的精确模型 ID 数量。
|
||||
pub const MAX_ACCOUNT_ACCESS_MODELS: usize = 256;
|
||||
|
||||
/// 账号模型政策的匹配方式。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountModelAccessMode {
|
||||
#[default]
|
||||
All,
|
||||
Allowlist,
|
||||
Denylist,
|
||||
}
|
||||
|
||||
/// 已校验的账号模型政策;私有字段保证所有边界使用相同的匹配规则。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AccountModelAccess {
|
||||
mode: AccountModelAccessMode,
|
||||
models: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// 不携带原始输入,避免在错误边界回显配置正文。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error(
|
||||
"modelAccess requires all with no models, or allowlist/denylist with 1–256 exact model IDs (at most 256 bytes each)"
|
||||
)]
|
||||
pub struct InvalidAccountModelAccess;
|
||||
|
||||
impl Default for AccountModelAccess {
|
||||
fn default() -> Self {
|
||||
Self::all()
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountModelAccess {
|
||||
#[must_use]
|
||||
pub const fn all() -> Self {
|
||||
Self {
|
||||
mode: AccountModelAccessMode::All,
|
||||
models: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
mode: AccountModelAccessMode,
|
||||
models: Vec<String>,
|
||||
) -> Result<Self, InvalidAccountModelAccess> {
|
||||
if models.len() > MAX_ACCOUNT_ACCESS_MODELS
|
||||
|| (mode == AccountModelAccessMode::All) != models.is_empty()
|
||||
|| models.iter().any(|model| {
|
||||
model.trim() != model
|
||||
|| model.trim().is_empty()
|
||||
|| model.contains('*')
|
||||
|| validate_text(model, 256, true, None).is_err()
|
||||
})
|
||||
{
|
||||
return Err(InvalidAccountModelAccess);
|
||||
}
|
||||
Ok(Self {
|
||||
mode,
|
||||
models: models.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn mode(&self) -> AccountModelAccessMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn models(&self) -> &BTreeSet<String> {
|
||||
&self.models
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allows(&self, upstream_model: &str) -> bool {
|
||||
match self.mode {
|
||||
AccountModelAccessMode::All => true,
|
||||
AccountModelAccessMode::Allowlist => self.models.contains(upstream_model),
|
||||
AccountModelAccessMode::Denylist => !self.models.contains(upstream_model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ModelAccessDocument {
|
||||
mode: AccountModelAccessMode,
|
||||
models: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AccountModelAccess {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let document = ModelAccessDocument::deserialize(deserializer)?;
|
||||
Self::new(document.mode, document.models).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user