Compare commits

...
20 Commits
Author SHA1 Message Date
ZyyandCodex 3fbd447d94 chore(release): prepare v3.8.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 03:03:43 +08:00
ZyyandCodex 5a5cdbdcde fix(ui): refine account settings and key displays
Show all batch account settings by default while updating only edited fields. Add ellipsis and full-text titles to API key names and labels, and adjust the token usage summary icon size and alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:54:23 +08:00
ZyyandCodex cca89f2f96 feat(accounts): add per-account model restrictions (#105)
Add per-account model allowlists and denylists to frozen routing, account settings, and CPR import/export. Preserve existing scheduling and account state boundaries.

Closes #97

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:29:56 +08:00
e25aad2231 feat(gateway): add bounded concurrency queues for keys and accounts (#104)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

Co-authored-by: Codex <noreply@openai.com>

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

Co-authored-by: Codex <noreply@openai.com>

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

Co-authored-by: Codex <noreply@openai.com>

* feat(gateway): queue requests at key and account concurrency limits

Share one wait deadline across client admission and account selection.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:00:26 +08:00
84382761fc feat(auth): add focused API key usage page (#102)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

Co-authored-by: Codex <noreply@openai.com>

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

Co-authored-by: Codex <noreply@openai.com>

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:49:37 +08:00
0a09c1572f feat(accounts): add notes and refine account interactions (#100)
Persist optional account notes across creation, editing, imports, and credential refreshes. Show notes in compact account rows with overflow-only popovers.

Support vertically resizable textareas, bounded select menus with custom scrollbars, and quota updates without table loading.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:36:34 +08:00
ZyyandCodex 90deabdfe3 docs(skills): ground upstream compatibility reviews in evidence
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 16:36:59 +08:00
2han9wen71an b1c24e276b fix(gateway-api): project bare WebSocket errors to response.failed (#99)
codex 的 WS 客户端(codex-api/src/endpoint/responses_websocket.rs)只消费
两类 error 帧:带非 2xx HTTP status 的包装错误,以及
websocket_connection_limit_reached / previous_response_not_found 内置可重
试错误码。OpenAI 上游的中流错误帧(如 server_is_overloaded)没有 status
字段,map_wrapped_websocket_error_event 返回 None,落回
process_responses_event 也没有 error 分支——整帧被静默忽略。

客户端收不到可识别的终态,只能空等到自身 300s stream_idle_timeout 超时
再重连,5 次重连累计 20-30 分钟"正在思考",最后才把失败呈现出来。CPA
等实现之所以秒级报错,正是因为它把错误负载重写成 codex 可消费的终态。

#90 已在 SSE 边界做了同样的投影,但客户端 WS 边界(push_websocket)仍原
样转发;使用 WS transport 的 codex 桌面端不受 #90 保护。本提交在该边界
对不可消费的错误帧投影成 response.failed(消息 JSON 自带 type 字段,经
process_responses_event 的 response.failed 分支映射为可重试错误并立即重
试);带非 2xx status 或特殊错误码的可消费形状原样透传,保持
deliverable_business_failures_are_not_rewritten... 既有契约。
2026-09-15 16:31:07 +08:00
ZyyandCodex c8fdc2b5cd chore(release): prepare v3.7.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:55:09 +08:00
2han9wen71an dfabf0f9dd fix(openai): recover quota after same-window usage drops (#95)
OpenAI 周窗口的 used_percent 可以在同一窗口内回落(滚动计量),而
reset_at 固定到窗口边界才变化。恢复规则要求「reset 前进 + 用量低于
10%」,两个条件在滑动周窗口上都无法成立:耗尽账号被钉在旧 reset
日期(最长一周)无法参与调度,尽管 /usage 已显式报告 allowed=true /
limit_reached=false 且 5h 窗口全新。

新增第二条解除路径:同一窗口(reset 未滚动)内连续两次观测都未触顶
即解除。候选记录持久化在 _quota_recovery,跨 worker 周期与重启累积;
连续两次的要求保持对「耗尽后立刻拉到的旧快照」竞态的防护(单次旧
快照撑不过两次观测)。原有「reset 前进 + 用量<10%」快路径不变。
2026-09-15 15:38:46 +08:00
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00
ZyyandCodex a975868046 fix(release): publish version-matched deployment assets
Publish the configuration template and Compose file with release checksums, pin the packaged Compose image to the release version, and include the template in platform archives.

Download deployment assets from one release tag, link versioned deployment documentation, and translate the release installation notes into Chinese.

Validated Actionlint, stable and prerelease packaging for all platforms, SHA-256 checksums, and resolved Docker Compose image versions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:49 +08:00
ZyyandCodex cc2dfab19a fix(openai): preserve client location when unset
Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior.

Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:02:36 +08:00
ZyyandCodex 040a1e82a6 chore: bump version to 3.7.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:29 +08:00
ZyyandCodex 4f39bfc5a8 docs(release): prepare v3.7.1 notes
Document configurable request location, WebSocket SSE error delivery, relaxed Compose resource limits, and the required image/config version alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:32:20 +08:00
ZyyandCodex 3e3616863d fix(deploy): remove fixed container resource limits
Allow the default Compose deployment to run on hosts with fewer than two CPUs and leave CPU and memory policy to the deployment environment.

Validated the rendered Docker Compose configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 11:01:21 +08:00
1bbae67040 fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed

WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded),
SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按
未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到
无原因的流 EOF("stream closed before response.completed"),丢失了上游的
完整错误负载(message/code/type),也无法据此分类重试。

现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`:
错误负载(type/code/message/param)原样保留,response id 沿用已交付的
response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。
原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。

* fix(gateway-api): preserve upstream response failures

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex fc07ac0803 fix(ci): scope rustls security waiver
Temporarily waive RUSTSEC-2026-0285 until 2026-09-21 UTC to preserve the pinned Codex TLS fingerprint. Keep all other audit failures blocking.

Validated cargo audit and waiver expiry behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex c7823a7821 feat(openai): make request location configurable
Group country, region, city, and timezone under openai.wire_profile.location while retaining the existing defaults. Share the configured location across web search and environment context, preserve epoch timestamps and release updates, and add validation, regression tests, and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:29 +08:00
ZyyandCodex 61d1bcb405 refactor(api): standardize request parameters and trim unused options
Use data for POST bodies and params: data for GET queries. Remove request options that have no callers while retaining cancellation, timeout, and silent polling support. Update affected backup and client-download call sites and document the convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 10:49:25 +08:00
257 changed files with 10713 additions and 2364 deletions
+3
View File
@@ -83,6 +83,9 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
+4 -3
View File
@@ -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 之后的本地文件不会改变该次构建。
## 恢复与验收
+3
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- 'release/version.yaml'
- 'release/notes.md'
pull_request:
branches:
- main
+31 -14
View File
@@ -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
+10 -1
View File
@@ -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
+10 -3
View File
@@ -21,7 +21,7 @@
## 快速开始
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
@@ -30,9 +30,12 @@
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
@@ -40,6 +43,9 @@ sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
分别生成数据库和 Redis 密码:
```bash
@@ -66,6 +72,7 @@ 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#公网访问)。
+1
View File
@@ -708,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
"serde",
]
[[package]]
+16 -8
View File
@@ -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");
+3 -1
View File
@@ -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",
&[
+76
View File
@@ -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");
+72 -6
View File
@@ -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>,
}
+57 -17
View File
@@ -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>,
}
@@ -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,
@@ -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>,
@@ -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,
@@ -195,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>>;
@@ -208,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(
@@ -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;
}
+171 -77
View File
@@ -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())
@@ -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;
@@ -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(())
+9 -1
View File
@@ -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(),
};
@@ -922,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,
@@ -1330,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,
@@ -1367,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,
@@ -1409,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
@@ -2400,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"),
@@ -2442,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,
@@ -2733,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"))
}
}
@@ -26,6 +26,13 @@ struct TestClientKeyStore {
#[async_trait]
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 {
@@ -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>))
@@ -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
@@ -395,7 +397,7 @@ async fn account_personal_info<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
@@ -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
@@ -46,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;
@@ -75,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;
@@ -75,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
@@ -103,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()
@@ -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>,
+14 -224
View File
@@ -1,54 +1,17 @@
//! 管理员登录、会话与管理请求鉴权接线。
use std::fmt;
//! 管理端权限校验与请求审计上下文。
use axum::{
Router,
extract::{FromRequestParts, State},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{ORIGIN, 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 url::Url;
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";
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
pub trait AdminSessionState {
fn admin_services(&self) -> &AdminServices;
}
fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str {
// 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。
// 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。
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"
}
}
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
pub struct AdminAuth {
@@ -64,7 +27,7 @@ impl AdminAuth {
impl<S> FromRequestParts<S> for AdminAuth
where
S: AdminSessionState + Send + Sync,
S: SessionState + Send + Sync,
{
type Rejection = AdminError;
@@ -97,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
@@ -124,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)),
};
}
@@ -137,176 +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>,
headers: HeaderMap,
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}={}; {}",
session.session_id,
admin_session_cookie_attrs(&headers)
);
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}=; {}; Max-Age=0",
admin_session_cookie_attrs(&headers)
);
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(),
}
}
+13 -10
View File
@@ -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};
@@ -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,
};
@@ -679,7 +681,7 @@ fn validate_optional_text(
/// 构造固定 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>))
@@ -712,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()
@@ -730,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
@@ -751,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
@@ -777,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(
@@ -795,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(
@@ -816,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(
@@ -837,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(
+4 -3
View File
@@ -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
+12 -11
View File
@@ -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()
+12 -9
View File
@@ -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()
+32 -9
View File
@@ -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
}
}
+232
View File
@@ -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, "每页数量不合法")
})?,
})
}
}
+6 -1
View File
@@ -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
}
@@ -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",
@@ -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))
}
@@ -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() {
@@ -31,7 +31,7 @@ async fn personal_info_requires_admin_and_a_valid_account_query() {
.uri(format!("/api/admin/accounts/personal-info{query}"))
.header("x-request-id", "req_personal_info");
if authenticated {
request = request.header(header::COOKIE, "cpr_admin_session=valid-session");
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
@@ -73,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())
@@ -319,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"),
@@ -351,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]
@@ -407,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;
@@ -845,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": []});
@@ -865,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 =
+32 -33
View File
@@ -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
@@ -395,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"),
@@ -436,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(),
+136 -27
View File
@@ -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",
+368
View File
@@ -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&currentPage=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 [
"&currentPage=0",
"&pageSize=0",
"&pageSize=101",
"&kind=all",
"&currentPage=-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());
}
}
+3
View File
@@ -1,4 +1,7 @@
mod admin;
mod architecture;
mod auth;
mod health;
mod key_usage;
mod openai;
mod support;
@@ -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,
@@ -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,
@@ -29,7 +29,11 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
true,
),
] {
let app = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
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")
@@ -43,9 +47,9 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
let response = app
.clone()
.oneshot(
request("/api/admin/auth/login")
request("/api/auth/login")
.body(Body::from(
json!({"username": "admin_1", "password": "strong-admin-password"})
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"})
.to_string(),
))
.unwrap(),
@@ -64,7 +68,7 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
let response = app
.clone()
.oneshot(
Request::get("/api/admin/auth/status")
Request::get("/api/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
@@ -78,7 +82,7 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
let response = app
.clone()
.oneshot(
request("/api/admin/auth/logout")
request("/api/auth/logout")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
@@ -87,7 +91,7 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response.headers()["set-cookie"].to_str().unwrap();
assert!(cookie.starts_with("cpr_admin_session=;"));
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"] {
@@ -96,7 +100,7 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
let response = app
.oneshot(
Request::get("/api/admin/auth/status")
Request::get("/api/auth/status")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
+146
View File
@@ -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)
}
}
@@ -47,6 +47,7 @@ impl fmt::Display for AccountGroupId {
pub struct RuntimeAccount {
provider_kind: ProviderKind,
group_ids: Arc<BTreeSet<AccountGroupId>>,
model_access: super::AccountModelAccess,
}
impl RuntimeAccount {
@@ -55,9 +56,21 @@ impl RuntimeAccount {
Self {
provider_kind,
group_ids: Arc::new(group_ids),
model_access: super::AccountModelAccess::all(),
}
}
#[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 const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
@@ -266,6 +279,23 @@ impl FrozenAccountScope {
}
}
#[must_use]
pub fn allows_model(&self, account_id: &ProviderAccountId, upstream_model: &str) -> bool {
self.allows(account_id)
&& self
.directory
.account(account_id)
.is_some_and(|account| account.model_access.allows(upstream_model))
}
/// 目录按整个授权账号池过滤,不能只看用于获取元数据的账号政策。
#[must_use]
pub fn allows_provider_model(&self, provider: &ProviderKind, upstream_model: &str) -> bool {
self.directory.accounts.iter().any(|(id, account)| {
account.provider_kind() == provider && self.allows_model(id, upstream_model)
})
}
#[must_use]
pub fn provider_kinds(&self) -> &BTreeSet<ProviderKind> {
match &self.client_scope {
@@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use crate::concurrency::ConcurrencyQueuePolicy;
use crate::identity::ProviderKind;
use super::{AccountStatus, ProviderAccount, ProviderAccountId};
@@ -49,6 +50,7 @@ pub struct AccountSelectionPolicy {
strategy: RotationStrategy,
max_concurrent_per_account: NonZeroU32,
request_interval: Duration,
queue_policy: ConcurrencyQueuePolicy,
}
impl AccountSelectionPolicy {
@@ -62,9 +64,24 @@ impl AccountSelectionPolicy {
strategy,
max_concurrent_per_account,
request_interval,
queue_policy: ConcurrencyQueuePolicy {
max_waiting: 0,
timeout: Duration::ZERO,
},
}
}
#[must_use]
pub const fn with_queue(mut self, policy: ConcurrencyQueuePolicy) -> Self {
self.queue_policy = policy;
self
}
#[must_use]
pub const fn queue_policy(self) -> ConcurrencyQueuePolicy {
self.queue_policy
}
#[must_use]
pub const fn strategy(self) -> RotationStrategy {
self.strategy
@@ -590,6 +607,38 @@ impl AccountSelector {
})
}
/// 只有本地并发/调度间隔可等待;账号权限、失效、额度与上游冷却仍立即排除。
#[must_use]
pub fn wait_candidates(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
) -> Vec<ProviderAccountId> {
let mut candidates = candidates
.iter()
.filter(|candidate| {
matches!(
self.scheduling_blocker(candidate, context),
None | Some(
AccountSchedulingBlocker::ConcurrencyLimit
| AccountSchedulingBlocker::RequestInterval
)
)
})
.collect::<Vec<_>>();
candidates.sort_by_key(|candidate| {
(
context.preferred_account.as_ref() != Some(candidate.account.id()),
Reverse(candidate.account.weight()),
candidate.account.id().clone(),
)
});
candidates
.into_iter()
.map(|candidate| candidate.account.id().clone())
.collect()
}
pub(crate) fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
@@ -0,0 +1,285 @@
//! 随请求存活的有界等待位置;执行容量仍由原子准入/租约端口裁决。
use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::sync::{Arc, Mutex, OnceLock};
use std::task::Poll;
use std::time::{Duration, Instant, SystemTime};
use futures::{FutureExt, future::poll_fn, pin_mut, select_biased, task::AtomicWaker};
use futures_timer::Delay;
const MAX_TOTAL_WAITING: usize = 1_024;
const CAPACITY_RECHECK_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ConcurrencyQueuePolicy {
pub max_waiting: u32,
pub timeout: Duration,
}
/// 从首次入队开始计时,密钥、账号与后续重试共享同一个截止时刻。
#[derive(Debug, Clone, Default)]
pub struct ConcurrencyWaitBudget {
deadline: Arc<OnceLock<Instant>>,
}
impl ConcurrencyWaitBudget {
fn deadline(&self, timeout: Duration, request_deadline: SystemTime) -> Instant {
let now = Instant::now();
let remaining = request_deadline
.duration_since(SystemTime::now())
.unwrap_or_default();
let deadline = *self.deadline.get_or_init(|| now + timeout);
deadline.min(now + remaining)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum QueueRejection {
#[error("concurrency wait queue is full")]
Full,
#[error("concurrency wait deadline elapsed")]
Timeout,
}
struct QueueState<K> {
queues: HashMap<K, VecDeque<Arc<AtomicWaker>>>,
total: usize,
}
/// 只保存等待者与唤醒器,既不持有请求正文,也不复制运行中并发计数。
pub struct ConcurrencyWaitQueue<K> {
state: Arc<Mutex<QueueState<K>>>,
}
impl<K> Default for ConcurrencyWaitQueue<K> {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(QueueState {
queues: HashMap::new(),
total: 0,
})),
}
}
}
impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
#[must_use]
pub fn has_waiters(&self, key: &K) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queues
.get(key)
.is_some_and(|queue| !queue.is_empty())
}
pub fn enqueue(
&self,
keys: &[K],
max_waiting: u32,
deadline: Instant,
) -> Result<WaitTicket<K>, QueueRejection> {
if Instant::now() >= deadline {
return Err(QueueRejection::Timeout);
}
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.total >= MAX_TOTAL_WAITING {
return Err(QueueRejection::Full);
}
let key = keys
.iter()
.filter_map(|key| {
let len = state.queues.get(key).map_or(0, VecDeque::len);
(len < max_waiting as usize).then_some((key, len))
})
.min_by_key(|(_, len)| *len)
.map(|(key, _)| key.clone())
.ok_or(QueueRejection::Full)?;
let waker = Arc::new(AtomicWaker::new());
state
.queues
.entry(key.clone())
.or_default()
.push_back(Arc::clone(&waker));
state.total += 1;
Ok(WaitTicket {
state: Arc::clone(&self.state),
key,
waker,
deadline,
})
}
}
/// Drop 同步回收等待位置,包括尚未轮到队首的取消请求。
pub struct WaitTicket<K: Eq + Hash> {
state: Arc<Mutex<QueueState<K>>>,
key: K,
waker: Arc<AtomicWaker>,
deadline: Instant,
}
impl<K: Eq + Hash> WaitTicket<K> {
#[must_use]
pub const fn key(&self) -> &K {
&self.key
}
pub async fn turn(&self) -> Result<(), QueueRejection> {
let ready = poll_fn(|cx| {
self.waker.register(cx.waker());
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.queues
.get(&self.key)
.and_then(VecDeque::front)
.is_some_and(|head| Arc::ptr_eq(head, &self.waker))
{
Poll::Ready(())
} else {
Poll::Pending
}
})
.fuse();
let timeout = Delay::new(self.deadline.saturating_duration_since(Instant::now())).fuse();
pin_mut!(ready, timeout);
select_biased! {
_ = timeout => Err(QueueRejection::Timeout),
() = ready => if Instant::now() < self.deadline { Ok(()) } else { Err(QueueRejection::Timeout) },
}
}
pub async fn retry(&self) -> Result<(), QueueRejection> {
// 租约释放可能经过后台队列;有界重查同时覆盖释放通知之前及 TTL 到期的空位。
Delay::new(
CAPACITY_RECHECK_INTERVAL.min(self.deadline.saturating_duration_since(Instant::now())),
)
.await;
self.turn().await
}
}
impl<K: Eq + Hash> Drop for WaitTicket<K> {
fn drop(&mut self) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(queue) = state.queues.get_mut(&self.key) else {
return;
};
let Some(index) = queue.iter().position(|item| Arc::ptr_eq(item, &self.waker)) else {
return;
};
queue.remove(index);
let next = queue.front().cloned();
if queue.is_empty() {
state.queues.remove(&self.key);
}
state.total -= 1;
drop(state);
if index == 0
&& let Some(next) = next
{
next.wake();
}
}
}
/// 单层等待位置与观测;时间预算由请求共享,重选账号不会重新开始计时。
pub struct CapacityWait<'a, K: Eq + Hash> {
queue: &'a ConcurrencyWaitQueue<K>,
policy: ConcurrencyQueuePolicy,
request_deadline: SystemTime,
budget: &'a ConcurrencyWaitBudget,
started_at: Option<Instant>,
ticket: Option<WaitTicket<K>>,
}
impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
#[must_use]
pub const fn new(
queue: &'a ConcurrencyWaitQueue<K>,
policy: ConcurrencyQueuePolicy,
request_deadline: SystemTime,
budget: &'a ConcurrencyWaitBudget,
) -> Self {
Self {
queue,
policy,
request_deadline,
budget,
started_at: None,
ticket: None,
}
}
#[must_use]
pub fn can_try(&self, key: &K) -> bool {
self.ticket
.as_ref()
.is_some_and(|ticket| ticket.key() == key)
|| !self.queue.has_waiters(key)
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.started_at
.map_or(Duration::ZERO, |start| start.elapsed())
}
pub async fn wait(&mut self, keys: &[K]) -> Result<(), QueueRejection> {
self.started_at.get_or_insert_with(Instant::now);
let deadline = self
.budget
.deadline(self.policy.timeout, self.request_deadline);
if self
.ticket
.as_ref()
.is_some_and(|ticket| !keys.contains(ticket.key()))
{
self.ticket = None;
}
if self.ticket.is_none() {
self.ticket = Some(
self.queue
.enqueue(keys, self.policy.max_waiting, deadline)?,
);
}
if let Some(ticket) = &self.ticket {
ticket.retry().await?;
}
Ok(())
}
}
impl QueueRejection {
#[must_use]
pub const fn provider_kind(self) -> crate::error::ProviderErrorKind {
match self {
Self::Full => crate::error::ProviderErrorKind::ConcurrencyQueueFull,
Self::Timeout => crate::error::ProviderErrorKind::ConcurrencyQueueTimeout,
}
}
#[must_use]
pub fn gateway_error(self) -> crate::error::GatewayError {
match self {
Self::Full => crate::error::GatewayError::new(
crate::error::GatewayErrorKind::ConcurrencyQueueFull,
"concurrency wait queue is full",
),
Self::Timeout => crate::error::GatewayError::new(
crate::error::GatewayErrorKind::ConcurrencyQueueTimeout,
"concurrency wait deadline elapsed",
),
}
}
}
@@ -13,6 +13,8 @@ pub struct ClientAdmissionRequest {
pub model_request_id: ModelRequestId,
pub client_api_key_id: ClientApiKeyId,
pub lease_ttl: Duration,
/// 有等待者时只允许队首取得槽位,RPM 仍由原子准入检查。
pub allow_concurrency_acquire: bool,
pub limits: RateLimits,
}
@@ -58,6 +60,9 @@ pub struct ClientAdmissionRestoreResult {
pub struct ClientAdmissionError;
pub trait ClientAdmissionPort: Send + Sync {
/// 请求 future 被取消时移交幂等释放,具体实现拥有异步清理执行器。
fn abandon(&self, client_api_key_id: &ClientApiKeyId, model_request_id: &ModelRequestId);
fn admit(
&self,
request: ClientAdmissionRequest,
@@ -1,5 +1,6 @@
//! 唯一的账号重试、发送与下游 commit barrier owner。
use crate::concurrency::ConcurrencyWaitBudget;
use crate::diagnostics::TraceContext;
use serde_json::json;
@@ -166,6 +167,7 @@ where
engine: Arc::clone(&self.engine),
request_id,
client_api_key_ref,
concurrency_wait_budget: ConcurrencyWaitBudget::default(),
observation: ResponseObservation::new(timing_started_at),
budget_prior_attempts_usd: Decimal::ZERO,
budget_attempt_already_counted: false,
@@ -270,6 +272,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
engine: Arc<GatewayEngine<S>>,
request_id: ModelRequestId,
client_api_key_ref: crate::policy::ClientApiKeyId,
concurrency_wait_budget: ConcurrencyWaitBudget,
observation: ResponseObservation,
budget_prior_attempts_usd: Decimal,
budget_attempt_already_counted: bool,
@@ -334,6 +337,12 @@ impl<S: ?Sized> ResponseExecutionSession<S>
where
S: ExecutionStore + 'static,
{
/// 首次驱动前接入密钥准入使用的预算,所有账号尝试随后共享它。
pub(crate) fn with_concurrency_wait_budget(mut self, budget: ConcurrencyWaitBudget) -> Self {
self.concurrency_wait_budget = budget;
self
}
/// 当前请求共享的诊断上下文。
pub fn trace(&self) -> TraceContext {
self.trace.clone()
@@ -779,6 +788,7 @@ where
}));
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
.with_timing_started_at(self.observation.timing_started_at)
.with_trace(self.trace.clone()),
next_attempt,
@@ -848,6 +858,8 @@ where
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && error.send_state() == UpstreamSendState::NotSent
&& matches!(
self.continuation_attempt,
@@ -861,6 +873,8 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && let Some(last_failure) = self.last_retryable_failure.take()
{
let send_state = self.current_send_state();
@@ -882,6 +896,8 @@ where
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
) && error.send_state() == UpstreamSendState::NotSent)
{
self.record_provider_failure(candidate.provider().clone(), error.kind());
@@ -11,6 +11,7 @@ use futures::{FutureExt, future::BoxFuture, pin_mut, select_biased};
use futures_timer::Delay;
use uuid::Uuid;
use crate::concurrency::{CapacityWait, ConcurrencyWaitBudget, ConcurrencyWaitQueue};
use crate::engine::admission::{
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
};
@@ -207,6 +208,16 @@ pub trait ExecutionService: Send + Sync {
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>>;
}
/// 只验证 Client API Key 并返回稳定 Key ID,不产生请求使用事实。
///
/// 管理侧登录等非数据面场景必须使用该端口,避免把认证本身误记为一次 Key 使用。
pub trait ClientKeyVerifier: Send + Sync {
fn verify_client_key(
&self,
plaintext: &str,
) -> Result<ClientApiKeyId, ClientAuthenticationError>;
}
/// 成功认证后的 API Key 使用事实接收器。
///
/// 认证仍是同步快照读取;实现必须自行异步、去重地持久化,不得阻塞客户端请求。
@@ -263,6 +274,7 @@ pub struct DefaultExecutionService {
observations: Arc<dyn ExecutionStore>,
providers: ProviderRegistry,
admissions: Arc<dyn ClientAdmissionPort>,
admission_waiting: ConcurrencyWaitQueue<ClientApiKeyId>,
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
@@ -291,6 +303,7 @@ impl DefaultExecutionService {
observations,
providers,
admissions,
admission_waiting: ConcurrencyWaitQueue::default(),
circuits,
continuation,
client_api_key_usage,
@@ -304,6 +317,25 @@ impl DefaultExecutionService {
self
}
fn authenticate_without_usage(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
let snapshot = self
.snapshots
.acquire()
.map_err(|_| ClientAuthenticationError::SnapshotUnavailable)?;
let policy = snapshot
.client_policies()
.filter(|policy| {
constant_time_equal(plaintext, policy.plaintext_key().expose_for_auth())
})
.find(|policy| policy.authorize().is_ok())
.cloned()
.ok_or(ClientAuthenticationError::InvalidKey)?;
Ok(AuthenticatedClient { snapshot, policy })
}
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
let StartExecution {
client,
@@ -497,40 +529,12 @@ impl DefaultExecutionService {
operation,
metadata,
} = request;
let admission_request = ClientAdmissionRequest {
model_request_id: request_id.clone(),
client_api_key_id: client.policy.key_id().clone(),
lease_ttl: MODEL_REQUEST_DEADLINE,
limits: client.policy.limits(),
};
let admission_started_at = Instant::now();
match self
.admissions
.admit(admission_request)
.await
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"request admission is temporarily unavailable",
)
})? {
ClientAdmissionDecision::Granted => {}
ClientAdmissionDecision::Rejected(
ClientAdmissionRejection::RateLimited
| ClientAdmissionRejection::ConcurrencyLimited,
) => {
return Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"request exceeds client API key limits",
));
}
}
let concurrency_wait_budget = ConcurrencyWaitBudget::default();
let admission = self
.acquire_client_admission(&client, &request_id, deadline_at, &concurrency_wait_budget)
.await?;
let admission_decision_ms = duration_ms(admission_started_at.elapsed());
let admission = AdmissionLease {
port: Arc::clone(&self.admissions),
client_api_key_id: client.policy.key_id().clone(),
model_request_id: request_id.clone(),
};
if let Some(budget) = &self.budget
&& let Err(error) = budget.admit(client.policy.key_id().clone()).await
{
@@ -583,7 +587,7 @@ impl DefaultExecutionService {
)
.await
{
Ok(core) => core,
Ok(core) => core.with_concurrency_wait_budget(concurrency_wait_budget),
Err(error) => {
if let Some(budget) = &self.budget {
settle_budget(
@@ -615,6 +619,85 @@ impl DefaultExecutionService {
})
}
async fn acquire_client_admission(
&self,
client: &AuthenticatedClient,
request_id: &ModelRequestId,
deadline_at: SystemTime,
budget: &ConcurrencyWaitBudget,
) -> Result<AdmissionLease, GatewayError> {
let policy = client.snapshot.client_queue_policy();
let limits = client.policy.limits();
let key = client.policy.key_id();
let mut waiting = CapacityWait::new(&self.admission_waiting, policy, deadline_at, budget);
let mut admission = AdmissionLease {
port: Arc::clone(&self.admissions),
client_api_key_id: key.clone(),
model_request_id: request_id.clone(),
armed: false,
};
loop {
let remaining = deadline_at
.duration_since(SystemTime::now())
.unwrap_or_default();
if remaining.is_zero() {
return Err(GatewayError::new(
GatewayErrorKind::Timeout,
"request deadline elapsed",
));
}
// 在发送原子准入之前接管取消清理,覆盖 Redis 已取得租约但返回尚未被观察的窗口。
admission.armed = true;
let acquire = self
.admissions
.admit(ClientAdmissionRequest {
model_request_id: request_id.clone(),
client_api_key_id: key.clone(),
lease_ttl: remaining,
allow_concurrency_acquire: limits.max_concurrency == 0 || waiting.can_try(key),
limits,
})
.fuse();
let timeout = Delay::new(remaining).fuse();
pin_mut!(acquire, timeout);
let decision = select_biased! {
result = acquire => result.map_err(|_| GatewayError::new(GatewayErrorKind::NoAvailableProvider, "request admission is temporarily unavailable"))?,
_ = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "request deadline elapsed")),
};
match decision {
ClientAdmissionDecision::Granted => {
if !waiting.elapsed().is_zero() {
tracing::info!(
request_id = request_id.as_str(),
queue_layer = "client_key",
queue_wait_ms = duration_ms(waiting.elapsed()),
"排队请求已取得 Key 并发槽位"
);
}
return Ok(admission);
}
ClientAdmissionDecision::Rejected(reason) => {
admission.armed = false;
if reason == ClientAdmissionRejection::RateLimited || policy.max_waiting == 0 {
return Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"request exceeds client API key limits",
));
}
if waiting.elapsed().is_zero()
&& let Some(budget) = &self.budget
{
budget.admit(key.clone()).await?;
}
waiting.wait(std::slice::from_ref(key)).await.map_err(|error| {
tracing::info!(request_id = request_id.as_str(), queue_layer = "client_key", queue_wait_ms = duration_ms(waiting.elapsed()), reason = %error, "Key 排队请求被拒绝");
error.gateway_error()
})?;
}
}
}
}
async fn route_context(
&self,
provider_kinds: &BTreeSet<ProviderKind>,
@@ -904,20 +987,10 @@ impl ExecutionService for DefaultExecutionService {
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
let snapshot = self
.snapshots
.acquire()
.map_err(|_| ClientAuthenticationError::SnapshotUnavailable)?;
let policy = snapshot
.client_policies()
.filter(|policy| {
constant_time_equal(plaintext, policy.plaintext_key().expose_for_auth())
})
.find(|policy| policy.authorize().is_ok())
.cloned()
.ok_or(ClientAuthenticationError::InvalidKey)?;
self.client_api_key_usage.record_used(policy.key_id());
Ok(AuthenticatedClient { snapshot, policy })
let client = self.authenticate_without_usage(plaintext)?;
self.client_api_key_usage
.record_used(client.policy().key_id());
Ok(client)
}
fn public_models(&self, client: &AuthenticatedClient) -> Vec<PublicModelId> {
@@ -943,6 +1016,12 @@ impl ExecutionService for DefaultExecutionService {
.await?
else {
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
if !scope.allows_provider_model(
kind,
&client.snapshot.mapped_model(profile.model().as_str()),
) {
continue;
}
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
}
@@ -956,6 +1035,9 @@ impl ExecutionService for DefaultExecutionService {
.collect::<std::collections::BTreeMap<_, _>>();
for entry in &models {
let target = client.snapshot.mapped_model(entry.model.as_str());
if !scope.allows_provider_model(kind, &target) {
continue;
}
let Some(source) = by_id.get(target.as_str()) else {
continue;
};
@@ -977,6 +1059,9 @@ impl ExecutionService for DefaultExecutionService {
}
for model in client.snapshot.public_models_for_provider(kind) {
let target = client.snapshot.mapped_model(model.as_str());
if !scope.allows_provider_model(kind, &target) {
continue;
}
if target == model.as_str() || seen.contains(&model) {
continue;
}
@@ -1020,6 +1105,16 @@ impl ExecutionService for DefaultExecutionService {
}
}
impl ClientKeyVerifier for DefaultExecutionService {
fn verify_client_key(
&self,
plaintext: &str,
) -> Result<ClientApiKeyId, ClientAuthenticationError> {
self.authenticate_without_usage(plaintext)
.map(|client| client.policy().key_id().clone())
}
}
impl AccountProbe for DefaultExecutionService {
fn probe(
&self,
@@ -1030,6 +1125,7 @@ impl AccountProbe for DefaultExecutionService {
}
struct AdmissionLease {
armed: bool,
port: Arc<dyn ClientAdmissionPort>,
client_api_key_id: ClientApiKeyId,
model_request_id: ModelRequestId,
@@ -1042,7 +1138,7 @@ async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge)
}
impl AdmissionLease {
async fn release(self) {
async fn release(mut self) {
if let Err(error) = self
.port
.release(&self.client_api_key_id, &self.model_request_id)
@@ -1050,6 +1146,16 @@ impl AdmissionLease {
{
tracing::warn!(%error, "Client admission 释放失败,依赖租约 TTL 收敛");
}
self.armed = false;
}
}
impl Drop for AdmissionLease {
fn drop(&mut self) {
if self.armed {
self.port
.abandon(&self.client_api_key_id, &self.model_request_id);
}
}
}
@@ -318,6 +318,7 @@ pub struct RequestAttemptContext {
client_api_key_ref: ClientApiKeyId,
timing_started_at: Instant,
trace: crate::diagnostics::TraceContext,
concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget,
}
impl RequestAttemptContext {
@@ -328,6 +329,7 @@ impl RequestAttemptContext {
client_api_key_ref,
timing_started_at: Instant::now(),
trace: crate::diagnostics::TraceContext::default(),
concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget::default(),
}
}
@@ -337,6 +339,16 @@ impl RequestAttemptContext {
self
}
/// 传递本次请求已经消耗的排队预算,不能在新 attempt 中重置。
#[must_use]
pub fn with_concurrency_wait_budget(
mut self,
budget: crate::concurrency::ConcurrencyWaitBudget,
) -> Self {
self.concurrency_wait_budget = budget;
self
}
/// 覆盖本次请求的单调计时原点。
#[must_use]
pub fn with_timing_started_at(mut self, timing_started_at: Instant) -> Self {
@@ -454,6 +466,12 @@ impl AttemptContext {
self.deadline
}
/// 返回密钥准入、账号选择与重试共用的排队预算。
#[must_use]
pub const fn concurrency_wait_budget(&self) -> &crate::concurrency::ConcurrencyWaitBudget {
&self.request.concurrency_wait_budget
}
#[must_use]
pub const fn account_selection_policy(&self) -> AccountSelectionPolicy {
self.account_selection_policy
+16
View File
@@ -34,6 +34,8 @@ pub enum ProviderErrorKind {
QuotaExhausted,
/// 仍有符合条件的账号,但它们暂时没有可调度容量。
AccountCapacityUnavailable,
ConcurrencyQueueFull,
ConcurrencyQueueTimeout,
/// Provider 已确认当前请求无法选出可用账号。
NoEligibleAccount,
/// Provider 的账号存储、租约协调或本地凭据数据不可用。
@@ -67,6 +69,8 @@ impl ProviderErrorKind {
Self::RateLimited => "rate_limited",
Self::QuotaExhausted => "quota_exhausted",
Self::AccountCapacityUnavailable => "account_capacity_unavailable",
Self::ConcurrencyQueueFull => "concurrency_queue_full",
Self::ConcurrencyQueueTimeout => "concurrency_queue_timeout",
Self::NoEligibleAccount => "no_eligible_account",
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
Self::Timeout => "timeout",
@@ -960,6 +964,8 @@ pub enum GatewayErrorKind {
NoAvailableProvider,
/// 符合条件的上游账号暂时没有调度容量。
AccountCapacityUnavailable,
ConcurrencyQueueFull,
ConcurrencyQueueTimeout,
/// Provider 的本地账号基础设施不可用。
ProviderInfrastructureUnavailable,
/// 上游限流。
@@ -986,6 +992,8 @@ impl GatewayErrorKind {
Self::ModelNotFound => "model_not_found",
Self::NoAvailableProvider => "no_available_provider",
Self::AccountCapacityUnavailable => "account_capacity_unavailable",
Self::ConcurrencyQueueFull => "concurrency_queue_full",
Self::ConcurrencyQueueTimeout => "concurrency_queue_timeout",
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
Self::RateLimited => "rate_limited",
Self::UpstreamUnavailable => "upstream_unavailable",
@@ -1072,6 +1080,14 @@ impl GatewayError {
GatewayErrorKind::RateLimited,
"upstream capacity is temporarily unavailable",
),
ProviderErrorKind::ConcurrencyQueueFull => Self::new(
GatewayErrorKind::ConcurrencyQueueFull,
"concurrency wait queue is full",
),
ProviderErrorKind::ConcurrencyQueueTimeout => Self::new(
GatewayErrorKind::ConcurrencyQueueTimeout,
"concurrency wait deadline elapsed",
),
ProviderErrorKind::AccountCapacityUnavailable => Self::new(
GatewayErrorKind::AccountCapacityUnavailable,
"all eligible upstream accounts are temporarily busy",
+11 -1
View File
@@ -4,6 +4,7 @@
//! 具体客户端协议和具体 Provider 都通过外层 adapter 接入。
pub mod account;
pub mod concurrency;
pub mod diagnostics;
pub mod engine;
pub mod error;
@@ -30,7 +31,8 @@ use engine::admission::{
};
use engine::continuation::NativeContinuationPort;
use engine::execution::{
ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService, ProviderCircuitPort,
ClientApiKeyUsageSink, ClientKeyVerifier, DefaultExecutionService, ExecutionService,
ProviderCircuitPort,
};
use engine::probe::AccountProbe;
use engine::provider::ProviderRegistry;
@@ -93,6 +95,7 @@ impl CoreStorePorts {
pub struct CoreBundle {
execution: Arc<dyn ExecutionService>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
snapshot_control: Arc<dyn SnapshotControl>,
account_probe: Arc<dyn AccountProbe>,
health_probes: Vec<Arc<dyn HealthProbe>>,
@@ -105,6 +108,11 @@ impl CoreBundle {
Arc::clone(&self.execution)
}
#[must_use]
pub fn client_key_verifier(&self) -> Arc<dyn ClientKeyVerifier> {
Arc::clone(&self.client_key_verifier)
}
#[must_use]
pub fn snapshot_control(&self) -> Arc<dyn SnapshotControl> {
Arc::clone(&self.snapshot_control)
@@ -167,11 +175,13 @@ pub async fn initialize(
}
let service = Arc::new(service);
let execution: Arc<dyn ExecutionService> = service.clone();
let client_key_verifier: Arc<dyn ClientKeyVerifier> = service.clone();
let account_probe: Arc<dyn AccountProbe> = service;
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
let health_probes: Vec<Arc<dyn HealthProbe>> = vec![Arc::new(snapshots)];
Ok(CoreBundle {
execution,
client_key_verifier,
snapshot_control,
account_probe,
health_probes,
@@ -8,6 +8,7 @@ use std::time::Duration;
use futures::future::BoxFuture;
use crate::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use crate::concurrency::ConcurrencyQueuePolicy;
use crate::operation::Operation;
use crate::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
@@ -28,6 +29,9 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotSettingsFacts {
max_concurrent_per_account: u32,
max_waiting_per_key: u32,
max_waiting_per_account: u32,
concurrency_wait_timeout_seconds: u32,
request_interval_ms: u64,
rotation_strategy: String,
model_mappings: BTreeMap<String, String>,
@@ -36,6 +40,19 @@ pub struct SnapshotSettingsFacts {
}
impl SnapshotSettingsFacts {
#[must_use]
pub const fn with_concurrency_queues(
mut self,
max_waiting_per_key: u32,
max_waiting_per_account: u32,
timeout_seconds: u32,
) -> Self {
self.max_waiting_per_key = max_waiting_per_key;
self.max_waiting_per_account = max_waiting_per_account;
self.concurrency_wait_timeout_seconds = timeout_seconds;
self
}
#[must_use]
pub fn new(
max_concurrent_per_account: u32,
@@ -47,6 +64,9 @@ impl SnapshotSettingsFacts {
) -> Self {
Self {
max_concurrent_per_account,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
request_interval_ms,
rotation_strategy: rotation_strategy.into(),
model_mappings,
@@ -102,6 +122,7 @@ impl SnapshotAccountGroupFacts {
pub struct SnapshotProviderAccountFacts {
account_id: ProviderAccountId,
provider_kind: String,
model_access: crate::account::AccountModelAccess,
}
impl SnapshotProviderAccountFacts {
@@ -110,10 +131,19 @@ impl SnapshotProviderAccountFacts {
Self {
account_id,
provider_kind: provider_kind.into(),
model_access: crate::account::AccountModelAccess::all(),
}
}
}
impl SnapshotProviderAccountFacts {
#[must_use]
pub fn with_model_access(mut self, model_access: crate::account::AccountModelAccess) -> Self {
self.model_access = model_access;
self
}
}
/// Store 读取到的一条分组成员关系。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotAccountGroupMemberFacts {
@@ -307,7 +337,8 @@ async fn compile_runtime_snapshot(
|| accounts
.insert(
account.account_id.clone(),
RuntimeAccount::new(provider_kind, BTreeSet::new()),
RuntimeAccount::new(provider_kind, BTreeSet::new())
.with_model_access(account.model_access),
)
.is_some()
{
@@ -332,10 +363,23 @@ async fn compile_runtime_snapshot(
let account = accounts
.get_mut(&account_id)
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
*account = RuntimeAccount::new(account.provider_kind().clone(), group_ids);
*account = RuntimeAccount::new(account.provider_kind().clone(), group_ids)
.with_model_access(account.model_access().clone());
}
let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts));
if facts.settings.max_waiting_per_key > 1_000
|| facts.settings.max_waiting_per_account > 1_000
|| !(1..=120).contains(&facts.settings.concurrency_wait_timeout_seconds)
{
return Err(RuntimeSnapshotCompileError::InvalidData);
}
let queue_timeout =
Duration::from_secs(u64::from(facts.settings.concurrency_wait_timeout_seconds));
let client_queue_policy = ConcurrencyQueuePolicy {
max_waiting: facts.settings.max_waiting_per_key,
timeout: queue_timeout,
};
let model_mappings = facts.settings.model_mappings;
let min_client_versions = CodexClientMinVersions::new(
facts
@@ -360,7 +404,11 @@ async fn compile_runtime_snapshot(
NonZeroU32::new(facts.settings.max_concurrent_per_account)
.ok_or(RuntimeSnapshotCompileError::InvalidData)?,
Duration::from_millis(facts.settings.request_interval_ms),
);
)
.with_queue(ConcurrencyQueuePolicy {
max_waiting: facts.settings.max_waiting_per_account,
timeout: queue_timeout,
});
let mut client_policies = Vec::with_capacity(facts.client_policies.len());
for policy in facts.client_policies {
let account_scope = if policy.group_ids.is_empty() {
@@ -414,6 +462,7 @@ async fn compile_runtime_snapshot(
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)
.map(|snapshot| {
snapshot
.with_client_queue_policy(client_queue_policy)
.with_model_mappings(model_mappings)
.with_account_directory(account_directory)
.with_known_provider_catalogs(known_provider_catalogs)
@@ -425,6 +474,7 @@ async fn compile_runtime_snapshot(
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
revision: ConfigRevision,
client_queue_policy: ConcurrencyQueuePolicy,
account_selection_policy: AccountSelectionPolicy,
providers: Arc<BTreeSet<ProviderKind>>,
provider_models: Arc<BTreeMap<ProviderKind, BTreeMap<UpstreamModelId, ModelCapabilities>>>,
@@ -439,6 +489,17 @@ pub struct RuntimeSnapshot {
}
impl RuntimeSnapshot {
#[must_use]
pub const fn client_queue_policy(&self) -> ConcurrencyQueuePolicy {
self.client_queue_policy
}
#[must_use]
pub const fn with_client_queue_policy(mut self, policy: ConcurrencyQueuePolicy) -> Self {
self.client_queue_policy = policy;
self
}
/// 校验 Provider、实时模型目录和 Client API Key,并构建快照。
pub fn new(
revision: ConfigRevision,
@@ -509,6 +570,7 @@ impl RuntimeSnapshot {
Ok(Self {
revision,
account_selection_policy,
client_queue_policy: ConcurrencyQueuePolicy::default(),
providers: Arc::new(provider_set),
provider_models: Arc::new(model_map),
provider_model_presentations: Arc::new(presentation_map),
@@ -641,7 +703,13 @@ impl RuntimeSnapshot {
scope
.provider_kinds()
.iter()
.flat_map(|provider| self.public_models_for_provider(provider))
.flat_map(|provider| {
self.public_models_for_provider(provider)
.into_iter()
.filter(|model| {
scope.allows_provider_model(provider, &self.mapped_model(model.as_str()))
})
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
@@ -656,6 +724,11 @@ impl RuntimeSnapshot {
let mut profiles = BTreeMap::new();
for provider in scope.provider_kinds() {
for profile in self.public_model_profiles_for_provider(provider) {
if !scope
.allows_provider_model(provider, &self.mapped_model(profile.model().as_str()))
{
continue;
}
profiles
.entry(profile.model().clone())
.or_insert_with(|| profile.presentation().clone());
@@ -690,10 +763,10 @@ impl RuntimeSnapshot {
public_model: &PublicModelId,
scope: &FrozenAccountScope,
) -> bool {
scope
.provider_kinds()
.iter()
.any(|provider| self.contains_public_model_for_provider(public_model, provider))
scope.provider_kinds().iter().any(|provider| {
self.contains_public_model_for_provider(public_model, provider)
&& scope.allows_provider_model(provider, &self.mapped_model(public_model.as_str()))
})
}
#[must_use]
@@ -1,3 +1,4 @@
mod model_access;
mod proxy;
mod selection;
mod selection_diagnostics;
@@ -0,0 +1,36 @@
use gateway_core::account::AccountModelAccess;
use serde_json::json;
#[test]
fn account_model_access_document_rejects_invalid_or_unbounded_settings() {
for invalid in [
json!({"mode":"invalid","models":[]}),
json!({"mode":"all","models":["test-model"]}),
json!({"mode":"allowlist","models":[]}),
json!({"mode":"denylist","models":[]}),
json!({"mode":"allowlist","models":["gpt-*"]}),
json!({"mode":"allowlist","models":[" test-model"]}),
json!({"mode":"allowlist","models":["\n"]}),
json!({"mode":"allowlist","models":["__internal"]}),
json!({"mode":"allowlist","models":["x".repeat(257)]}),
json!({"mode":"allowlist","models":vec!["test-model";257]}),
json!({"mode":"all","models":[],"unknown":true}),
json!({"mode":"all"}),
] {
assert!(serde_json::from_value::<AccountModelAccess>(invalid).is_err());
}
}
#[test]
fn account_model_access_document_deduplicates_without_changing_exact_ids() {
let policy: AccountModelAccess = serde_json::from_value(json!({
"mode":"allowlist","models":["test-luna","test-luna","Test-Luna"]
}))
.expect("policy");
assert_eq!(
serde_json::to_value(policy).expect("serialize"),
json!({
"mode":"allowlist","models":["Test-Luna","test-luna"]
})
);
}
@@ -0,0 +1,161 @@
use std::time::{Duration, Instant, SystemTime};
use futures::{FutureExt, executor::block_on};
use gateway_core::concurrency::{
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
QueueRejection,
};
#[test]
fn waiting_is_bounded_per_owner_and_cancelled_head_wakes_the_next_request() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let first = queue.enqueue(&["key_a"], 2, deadline).unwrap();
let second = queue.enqueue(&["key_a"], 2, deadline).unwrap();
assert!(matches!(
queue.enqueue(&["key_a"], 2, deadline),
Err(QueueRejection::Full)
));
let independent = queue.enqueue(&["key_b"], 2, deadline).unwrap();
assert!(second.turn().now_or_never().is_none());
independent.turn().await.unwrap();
first.turn().await.unwrap();
drop(first);
second.turn().await.unwrap();
drop(second);
assert!(!queue.has_waiters(&"key_a"));
});
}
#[test]
fn cancelling_a_middle_waiter_preserves_fifo_and_reclaims_capacity() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let head = queue.enqueue(&["account"], 3, deadline).unwrap();
let cancelled = queue.enqueue(&["account"], 3, deadline).unwrap();
let next = queue.enqueue(&["account"], 3, deadline).unwrap();
drop(cancelled);
let tail = queue.enqueue(&["account"], 3, deadline).unwrap();
drop(head);
next.turn().await.unwrap();
assert!(tail.turn().now_or_never().is_none());
drop(next);
tail.turn().await.unwrap();
});
}
#[test]
fn wait_deadline_applies_before_and_after_becoming_head() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_millis(20);
let head = queue.enqueue(&["account"], 2, deadline).unwrap();
let tail = queue.enqueue(&["account"], 2, deadline).unwrap();
assert_eq!(tail.turn().await, Err(QueueRejection::Timeout));
assert_eq!(head.turn().await, Err(QueueRejection::Timeout));
drop((head, tail));
assert!(!queue.has_waiters(&"account"));
});
}
#[test]
fn a_full_account_queue_does_not_prevent_waiting_on_another_eligible_account() {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let _first = queue.enqueue(&["a"], 1, deadline).unwrap();
let second = queue.enqueue(&["a", "b"], 1, deadline).unwrap();
assert_eq!(*second.key(), "b");
assert!(matches!(
queue.enqueue(&["a", "b"], 1, deadline),
Err(QueueRejection::Full)
));
}
#[test]
fn request_deadline_bounds_waiting_and_dropped_future_releases_its_ticket() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 1,
timeout: Duration::from_secs(30),
};
let budget = ConcurrencyWaitBudget::default();
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_millis(20),
&budget,
);
assert_eq!(waiting.wait(&["a"]).await, Err(QueueRejection::Timeout));
drop(waiting);
assert!(!queue.has_waiters(&"a"));
let budget = ConcurrencyWaitBudget::default();
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
);
assert!(waiting.wait(&["a"]).now_or_never().is_none());
assert!(queue.has_waiters(&"a"));
drop(waiting);
assert!(!queue.has_waiters(&"a"));
});
}
#[test]
fn new_layers_and_retries_cannot_restart_a_requests_wait_budget() {
block_on(async {
let keys = ConcurrencyWaitQueue::default();
let accounts = ConcurrencyWaitQueue::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 1,
timeout: Duration::from_millis(300),
};
let request_deadline = SystemTime::now() + Duration::from_secs(5);
let budget = ConcurrencyWaitBudget::default();
let mut key_wait = CapacityWait::new(&keys, policy, request_deadline, &budget);
key_wait.wait(&["key"]).await.unwrap();
drop(key_wait);
// 第一层已取得重试机会,后续准备跨过总时限后不能在账号层重新排队。
futures_timer::Delay::new(policy.timeout).await;
let next_attempt_budget = budget.clone();
let mut account_wait =
CapacityWait::new(&accounts, policy, request_deadline, &next_attempt_budget);
assert_eq!(
account_wait.wait(&["account"]).now_or_never(),
Some(Err(QueueRejection::Timeout))
);
assert!(!accounts.has_waiters(&"account"));
// 另一个请求仍有完整预算,不能受上一请求超时影响。
let independent_budget = ConcurrencyWaitBudget::default();
let mut independent =
CapacityWait::new(&accounts, policy, request_deadline, &independent_budget);
independent.wait(&["account"]).await.unwrap();
});
}
#[test]
fn creating_a_waiter_does_not_start_the_budget_before_queueing() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 1,
timeout: Duration::from_millis(300),
};
let budget = ConcurrencyWaitBudget::default();
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(5),
&budget,
);
futures_timer::Delay::new(policy.timeout).await;
waiting.wait(&["account"]).await.unwrap();
});
}
@@ -183,6 +183,14 @@ struct RecordingAdmissions {
}
impl ClientAdmissionPort for RecordingAdmissions {
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,
@@ -3598,69 +3598,75 @@ fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
#[test]
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
let operation = generate_operation();
let route_plan = plan(&operation);
let raw_body = Bytes::from_static(b"{\"error\":\"last upstream body\"}\x00");
let upstream_error =
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_retry()
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"message too big",
Some("1009".to_owned()),
Some("websocket_close_error".to_owned()),
))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
502,
Some(b"application/json".to_vec()),
raw_body.clone(),
));
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(upstream_error)],
},
Script::Error(ProviderError::new(
ProviderErrorKind::AccountCapacityUnavailable,
UpstreamSendState::NotSent,
)),
]);
for capacity_error in [
ProviderErrorKind::AccountCapacityUnavailable,
ProviderErrorKind::ConcurrencyQueueFull,
ProviderErrorKind::ConcurrencyQueueTimeout,
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let raw_body = Bytes::from_static(b"{\"error\":\"last upstream body\"}\x00");
let upstream_error =
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_retry()
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"message too big",
Some("1009".to_owned()),
Some("websocket_close_error".to_owned()),
))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
502,
Some(b"application/json".to_vec()),
raw_body.clone(),
));
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(upstream_error)],
},
Script::Error(ProviderError::new(
capacity_error,
UpstreamSendState::NotSent,
)),
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("empty selection must return the retained upstream failure");
let EngineError::Provider(error) = error else {
panic!("expected retained provider error");
};
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("empty selection must return the retained upstream failure");
let EngineError::Provider(error) = error else {
panic!("expected retained provider error");
};
assert_eq!(error.kind(), ProviderErrorKind::Transport);
let detail = error
.client_visible_upstream_error()
.expect("structured upstream detail");
assert_eq!(detail.message(), "message too big");
assert_eq!(detail.code(), Some("1009"));
assert_eq!(detail.error_type(), Some("websocket_close_error"));
let response = error
.client_visible_upstream_response()
.expect("original upstream response");
assert_eq!(response.status(), 502);
assert_eq!(response.body(), &raw_body);
assert_eq!(error.kind(), ProviderErrorKind::Transport);
let detail = error
.client_visible_upstream_error()
.expect("structured upstream detail");
assert_eq!(detail.message(), "message too big");
assert_eq!(detail.code(), Some("1009"));
assert_eq!(detail.error_type(), Some("websocket_close_error"));
let response = error
.client_visible_upstream_response()
.expect("original upstream response");
assert_eq!(response.status(), 502);
assert_eq!(response.body(), &raw_body);
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(
state.finalizations[0].send_state,
UpstreamSendState::NotSent
);
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(
state.finalizations[0].send_state,
UpstreamSendState::NotSent
);
}
}
#[test]

Some files were not shown because too many files have changed in this diff Show More