Compare commits

...
62 Commits
Author SHA1 Message Date
ZyyandCodex dd17332c31 chore: bump version to 3.3.25
Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:48:14 +08:00
ZyyandCodex 47d08786ad fix(scheduling): retain valid quota signals across idle periods
Keep OpenAI quota windows eligible for scheduling until their reset time,
independently of the projection cache TTL. Expire undated observations and
reset windows separately, and clear old signals when a fresh observation
contains no active window.

Record bounded candidate diagnostics with quota, concurrency, smart scores
and affinity results. Cover idle 74%/96% accounts with one concurrent title
request, independent window expiry, cache replacement and trace retention.

Validation: workspace tests (1852 passed), rustfmt, and strict workspace
Clippy with all targets and features. PostgreSQL/Redis integration services
were not configured.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:47:30 +08:00
ZyyandCodex 3434ad5e72 fix(ui): polish diagnostics and account usage displays
Use the shared scrollbar for request diagnostics, improve timeline spacing
and disclosure styling, and show export loading feedback with duplicate
click protection and a minimum visible duration.

Hide reset times in metric account usage windows while retaining them in
the detail variant.

Validation: frontend typecheck and build, targeted ESLint for both files,
and browser checks for diagnostic scrolling, expansion, mobile layout,
loading feedback, and exported content.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:25:16 +08:00
ZyyandCodex e91ec88a1f chore: bump version to 3.3.24
Co-authored-by: Codex <noreply@openai.com>
2026-09-07 03:40:53 +08:00
ZyyandCodex 6f7a50b1b6 fix(scheduling): rotate smart selections among similarly scored accounts
Score load, remaining quota and first-output latency on fixed scales so
small differences do not monopolize unbound requests. Rotate accounts
within a narrow score tolerance in stable ID order using the existing
cursor, preserving session affinity and availability checks.

Cover small signal differences, changing score order, material advantages
and quota invalidation after credential rotation.

Validation: workspace tests (1844 passed, none ignored), rustfmt, and strict
workspace Clippy with all targets and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 03:40:34 +08:00
ZyyandCodex db0c294e83 fix(openai): prioritize session affinity and isolate child failover
Prefer client-scoped session bindings over ordinary account weights while
keeping existing availability checks and failover behavior. Let child
threads inherit the root account on first selection and update only their
own binding when they migrate.

Share session and thread identity extraction across Responses, Search and
image endpoints. Renew existing Redis bindings for 24 hours and preserve
account-bound state isolation without adding a table or scheduler.

Validation: workspace tests (1838 passed, none ignored), rustfmt, and strict
workspace Clippy with all targets and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 03:16:45 +08:00
ZyyandCodex d8181e8e91 docs(readme): install published images with Docker Compose
Download Compose and configuration files directly for installation, and
wait for healthy services before the first health check. Align deployment
paths and image upgrade instructions with the installation directory,
while documenting source checkout requirements for development and builds.

Validation: checked 16 shell examples and 17 local documentation links;
downloaded the deployment files into a temporary directory and verified
Compose configuration and a startup dry run without a source checkout.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 03:10:55 +08:00
ZyyandCodex af134cf488 fix(docker): apply system updates to the runtime base image
Upgrade installed Debian packages before adding runtime dependencies so
digest-pinned base images receive available fixes, including the PCRE2
security update.

Validation: rebuilt the amd64 runtime-base image without cache; Trivy
reported zero fixable OS vulnerabilities across all severities; curl,
pg_dump, and pg_restore started successfully as the cpr user.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 03:05:59 +08:00
Zyy d918149c2f fix(openai): recover exhausted quota windows independently
Persist per-window recovery baselines separately from current usage. Identify windows by upstream limit ID and actual duration, using protocol slots to distinguish equal or unspecified durations. Apply the same reset advancement and usage-below-10% rule to all periods without requiring allowed=true, retaining partial recovery across refreshes and restarts.

Use the exhausted window for reset tracking, preserve access during failure follow-ups, and return the updated account status after an admin quota refresh. Remove the ignored live Desktop artifact download test.

Validation: workspace tests (1828 passed), final OpenAI suite (516 passed, none ignored), fmt, clippy, and build. Real-account checks cover quota refresh and monthly recovery; seven controlled replay scenarios cover independent and partial recovery through the backend and PostgreSQL.
2026-09-07 00:59:48 +08:00
Zyy aa6bf6497f refactor(openai): unify WebSocket recovery and session fallback
Consolidate retry decisions in the provider and share the failure budget with session HTTP fallback. Preserve WebSocket priority and the 800ms cold connection budget.

Remove transport-local retries, fresh recovery probes, and metadata-based replay exceptions; use a 300s stream idle timeout.

Validation: backend tests (1816 passed, 1 ignored), fmt, clippy, and real Codex CLI 10-turn WebSocket continuation run.
2026-09-07 00:45:08 +08:00
ZyyandCodex 074c07b5ca refactor(store): tighten persisted facts and trim redundant observability
Remove unused event occurrence counts across storage, API and UI; count event
rows directly and keep provider metadata focused on facts absent from typed
observations. Preserve actual upstream-reported model and handshake status.

Narrow four live-reference indexes, drop eight low-value indexes, enforce
complete grouped facts and normalize administrator audit identities. Make
backup storage changes invalidate verification and pause schedules atomically,
while unchanged saves preserve state and revision.

Validation: 1,816 backend tests passed with isolated PostgreSQL and Redis,
including the official artifact download test. Strict Clippy, rustfmt, migration
freeze checks, frontend lint/typecheck/build and actionlint passed. Synthetic
10,000-row query plans verified live-reference and historical pagination indexes.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 17:27:29 +08:00
ZyyandCodex f70f46e87e fix(observability): exclude tokenless endpoints from usage
Define completed usage from delivery plus model, image, token, or cost evidence so standalone search remains operationally visible without appearing in usage records.

Exclude model-less provider endpoints from model diagnostics and keep attempt cost coverage on the shared usage fact.

Validation: 169 gateway-store tests passed against PostgreSQL; Clippy and rustfmt passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 17:02:41 +08:00
ZyyandCodex c21633655e fix(api): keep SSE responses alive during upstream silence
Send SSE keepalive comments after 15 seconds without output while preserving
the pending execution future. Disable response buffering and transformation
so long compaction requests can maintain the downstream connection.

Cover four minutes of upstream silence, cancellation, first-event commitment,
and conflicting upstream headers. Document transport errors and the separate
Codex event idle timeout.

Validation: 259 gateway-api tests and Clippy passed with serde/rc enabled;
cargo fmt and git diff --check passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 16:49:35 +08:00
Zyy 1bafbb3864 fix(openai): support Auth0 profile avatars 2026-09-06 16:38:21 +08:00
Zyy 353dccedc4 feat: add request diagnostics and align Codex transport contracts 2026-09-06 16:24:14 +08:00
ZyyandCodex 697f1870a0 docs: refresh user-facing setup and existing guides
Refocus the README on installation, client setup and common questions. Consolidate native image generation, auth.json compatibility and obsolete Codex settings in the existing deployment guide.

Correct account import formats, API references, backup recovery and password rotation guidance. Synchronize architecture, theme, migration and repository skill documentation without adding new documents or changing temporary investigation notes.

Validated 53 local links, 22 shell examples, generated configuration parity and TOML syntax, Compose configuration, skill metadata and whitespace checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 15:28:32 +08:00
ZyyandCodex a404c2e09e feat(codex): enable native image generation with managed auth
Generate server-managed authentication settings for native image generation while preserving API-key auth.json and CCSwitch compatibility. Remove obsolete client settings and retain provider-level WebSocket control.

Keep gateway bearer authentication mandatory, strip client actor authorization headers before forwarding upstream, and cover the boundary with backend regression tests. Document setup and simplify the configuration dialog guidance.

Validated with backend tests, live Codex CLI image generation, WebSocket and legacy-auth checks, frontend build, and strict CLI configuration loading.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 15:09:05 +08:00
ZyyandCodex a6cfd124b0 fix(theme): balance popover header surfaces
Derive a dedicated popover header component token so light themes keep clear separation from striped table rows while dark themes retain the softer tertiary fill.

Apply the token consistently to structured popover headers and the health timeline arrow, and expose it through the theme editor.

Validated across all preset light and dark palettes, token overrides, frontend lint, typecheck and production build.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 14:18:04 +08:00
ZyyandCodex 4441b577c3 fix(theme): strengthen popover header separation
Promote structured popover headers from the tertiary fill to the secondary fill so they remain visually distinct from striped and hovered table rows in both light and dark themes.

Keep the health timeline arrow matched to its header surface when the popover placement flips.

Validated across all preset light and dark palettes, frontend lint, typecheck and production build.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 14:14:53 +08:00
ZyyandCodex a704fbc3f3 fix(websocket): serialize queued response requests
Queue incoming response.create frames until the active response finishes instead of closing the connection, aligning execution with the Codex client serial request flow.

Separate pump exit notifications from the bounded business queue so disconnects and shutdown remain prompt. Preserve expiry handling with a full queue, retain protocol validation, and document the serial execution contract.

Validated with 253 API tests, 12 architecture tests, strict workspace Clippy and Rust formatting.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 13:55:14 +08:00
ZyyandCodex dab221cdb9 refactor(theme): standardize container color roles
Rename primary, semantic and preset background roles to container and their paired foreground roles to on-container across derivation, CSS variables, utilities, consumers and documentation.

Preserve existing palette values and contrast relationships while making text on neutral surfaces distinct from content inside semantic containers.

Validated with token equivalence across presets and custom seeds, frontend typecheck, lint and production build.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 12:48:19 +08:00
ZyyandCodex 4f434ec525 fix(theme): restore semantic color roles
Separate semantic text on neutral surfaces from high-contrast text on tinted backgrounds, derive each role against its actual surfaces, and migrate badges and alerts to explicit on-background tokens.

Restore vivid usage-table accents and align input hover, active, and error outlines across light and dark themes.

Validated with semantic contrast checks across presets and custom seeds, frontend typecheck, lint and production build.

Co-authored-by: Codex <noreply@openai.com>
2026-09-06 12:08:34 +08:00
ZyyandCodex 2585d4cd7d refactor(architecture): consolidate usage and provider contracts
Move Dashboard quota projection to the backend, share pagination request state, and fix independent usage loads while keeping trend switches free of loading overlays.

Stream billing facts within the query budget, share pending authorization and usage validation, and separate Core value ownership and response observations. Remove the unused theme helper and strengthen dependency checks.

Validated with workspace integration tests against isolated PostgreSQL and Redis, strict Clippy, Rust formatting, and frontend typecheck, lint and build.

Co-authored-by: Codex <noreply@openai.com>
2026-09-05 05:29:12 +08:00
ZyyandCodex 93a34b0f56 fix(openai): add gpt-6-astra billing
Add standard, Flex, Fast, long-context and cache-write rates, and classify Astra as a reasoning model for preview web-search pricing. Cover tier aliases, the context threshold and cost components with backend regression tests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-05 05:27:32 +08:00
ZyyandCodex aa3a07209a fix(theme): improve token contrast and color derivation
Guard text and primary button contrast across surfaces and interaction states, derive dark semantic text from its seed, and reuse focus tokens. Document the palette and component rules.

Co-authored-by: Codex <noreply@openai.com>
2026-09-05 05:11:17 +08:00
ZyyandCodex c2332bc8d0 chore: bump version to 3.3.23
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 19:16:46 +08:00
ZyyandCodex 8f242e2875 feat(logging): add opt-in request dumps
Capture complete OpenAI Responses headers and payloads in a dedicated rotated log, keep the target out of normal sinks, and default the sensitive switch to off.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 19:15:28 +08:00
ZyyandCodex 51e9981bee fix(openai): resume metadata-close websocket requests
Treat metadata turn state as a same-request checkpoint, retry on the original account over WebSocket, and keep ambiguous requests without a checkpoint fail-closed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 18:48:36 +08:00
ZyyandCodex 65fbbcb3ff chore: bump version to 3.3.22
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:52:27 +08:00
ZyyandCodex bd086a7e3c fix(observability): show reasoning token usage
Expose aggregate reasoning tokens on account responses and use them in the token structure panel while retaining cache-write fields for API compatibility.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:51:18 +08:00
ZyyandCodex 3554ba13db fix(openai): preserve websocket turn state on failure
Bridge both metadata event names, project sticky state per response.create, and return captured state on pre-terminal failures without replaying ambiguous requests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 16:51:01 +08:00
ZyyandCodex bcccebc9fa chore: bump version to 3.3.21
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:52:25 +08:00
ZyyandCodex d67b46a9e2 fix(frontend): default Codex websocket config off
Keep inline switch text legible in the off state and omit WebSocket settings from generated Codex and CCSwitch configuration unless explicitly enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:47 +08:00
ZyyandCodex 20c6d33cf4 fix(observability): retain recovered transport failures
Link successful same-session HTTP fallbacks to pending WebSocket failures, keep recovered records visible in Ops diagnostics, and label their recovery state.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:41 +08:00
ZyyandCodex cf0e84c548 fix(openai): isolate websocket continuation lanes
Include the downstream connection identity in pooled upstream WebSocket keys so concurrent clients cannot contend for connection-local response state.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 11:50:34 +08:00
ZyyandCodex 2332af9a08 chore: bump version to 3.3.20
Co-authored-by: Codex <noreply@openai.com>
2026-09-04 00:47:35 +08:00
ZyyandCodex 024fc9fcf0 fix(openai): fall back after websocket fast-path misses
Treat the 800 ms fast-path budget as a routing decision instead of a provider error, while preserving background preconnect for the next request.

Co-authored-by: Codex <noreply@openai.com>
2026-09-04 00:36:50 +08:00
ZyyandCodex 4220f4e095 fix(openai): recover websocket sessions after ambiguous failures
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 23:33:26 +08:00
ZyyandCodex a709acb149 chore: bump version to 3.3.19
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:56:10 +08:00
ZyyandCodex 466842acd8 fix(observability): search usage by API key
Resolve sk_ searches through client key references while only sending the visible key prefix from the frontend, and clarify the request detail search hint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 17:38:29 +08:00
ZyyandCodex b91f28f95b fix(frontend): align configuration and usage copy
Generate the displayed Codex files and CCSwitch import payload from one source, carry the full settings through deep links, and clarify the quota percentage label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 16:37:53 +08:00
ZyyandCodex 87164db3da chore: bump version to 3.3.18
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:41:50 +08:00
ZyyandCodex ad16dec3db fix(openai): align official client routes
Remove unsupported review and model extension routes, preserve review semantics on standard Responses requests, and refine usage observability for review and empty performance data.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 15:37:14 +08:00
ZyyandCodex a0da0f4713 feat(openai): proxy standalone search
Add /v1/alpha/search as a transparent raw JSON endpoint, route requests through the OpenAI credential pipeline, and preserve account-scoped turn metadata and upstream response headers.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 14:27:15 +08:00
ZyyandCodex 65e5cde60d chore: bump version to 3.3.17
Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:44:42 +08:00
ZyyandCodex 0f0b464f4e fix(frontend): refine account usage presentation
Select quota summaries by the most recently used model, streamline dashboard account identities, and align diagnostic client metadata with successful records.

Co-authored-by: Codex <noreply@openai.com>
2026-09-03 12:21:15 +08:00
ZyyandCodex bd223f6540 chore: bump version to 3.3.16
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 22:45:09 +08:00
ZyyandCodex 168be0ca68 fix(openai): add continuation recovery observability
Persist stable continuation and WebSocket lifecycle facts, correlate successful client replays, and hide recovered failures from default business metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 21:38:48 +08:00
ZyyandCodex f2e91bbdaa fix(openai): preserve websocket continuation routing
Limit session-sticky HTTP fallback to new chains, keep continuation requests on their safe transport, and classify rebuild-required failures separately from account health.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 18:21:30 +08:00
ZyyandCodex d0c9248478 fix(openai): refine smart scheduler failure scoring
Limit account feedback to an explicit upstream reason allowlist, aggregate same-account retries, and decay failure rates over time.

Co-authored-by: Codex <noreply@openai.com>
2026-09-02 15:08:31 +08:00
ZyyandCodex 22c697bbff fix(usage): refine record details and error filters
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 14:43:44 +08:00
ZyyandCodex 08d518324a fix(frontend): refine account quota layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 12:14:03 +08:00
ZyyandCodex dfacb20e43 fix(frontend): tighten usage attempt table layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-01 19:46:49 +08:00
ZyyandCodex 55c9291c35 chore: bump version to 3.3.15
Co-authored-by: Codex <noreply@openai.com>
2026-09-01 18:14:49 +08:00
ZyyandCodex 6e8e787b82 fix(openai): harden websocket failure recovery
Prevent hidden retries after ambiguous WebSocket sends, enforce connection-local continuation ownership, and persist raw upstream failure details for operations diagnostics.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 18:12:48 +08:00
ZyyandCodex 2a0a50adc5 refactor(backend): complete module boundary cleanup
Extract runtime snapshot lifecycle into its semantic owner, split oversized provider and admin modules, and enforce mirrored src/tests module layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 17:17:57 +08:00
ZyyandCodex 5e13b6af6a refactor(backend): align module ownership boundaries
Normalize Rust module layout, assign shared core contracts to semantic owners, rename metering concepts, and add workspace architecture guards and documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 16:25:59 +08:00
ZyyandCodex bd2454a5b6 feat(settings): enforce minimum client versions
Add configurable minimum versions for Codex Desktop and CLI, return 426 for unsupported recognized clients, and provide install and Windows offline download guidance.

Co-authored-by: Codex <noreply@openai.com>
2026-09-01 13:37:04 +08:00
ZyyandCodex 2640a03219 chore: bump version to 3.3.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:23:58 +08:00
ZyyandCodex 7d0e9a0a20 fix(frontend): refine dashboard account metadata
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:23:10 +08:00
ZyyandCodex 09982d7221 fix(openai): align websocket retry fallback
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:14:18 +08:00
ZyyandCodex c4db1bb244 fix(observability): prefer per-frame Codex metadata
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 19:08:16 +08:00
484 changed files with 34816 additions and 18634 deletions
+2
View File
@@ -33,6 +33,8 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
services:
+92 -126
View File
@@ -5,195 +5,161 @@
# Codex Proxy RS
**基于 Rust 的多 Provider 透明 AI 网关**
集中管理 OpenAI、xAI 账号,为 Codex 和兼容客户端提供统一的访问地址与密钥。
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运行维护](#运行维护) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [常见问题](#常见问题) · [部署说明](deploy/README.md)
</div>
> [!NOTE]
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
## 功能
| 领域 | 当前能力 |
| --- | --- |
| 数据面 | Responses JSON、SSE、WebSocket、review 子代理、Images 生成/编辑与模型目录 |
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
- 在网页中添加账号,查看额度、请求记录和费用,刷新或重新授权过期账号。
- 用账号分组控制不同密钥可使用的账号,设置并发和请求频率。
- 接入 Codex CLI、桌面端和支持 Responses API 的客户端。
- 支持流式对话、WebSocket、图片生成与编辑、Codex 独立搜索。
- 支持 S3/R2 数据库备份、同大版本在线更新和回滚。
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
支持 OpenAI、xAI 账号,但图片与独立搜索目前仅通过 OpenAI 提供。模型和功能是否可用取决于上游账号权限。
**不支持 `/v1/chat/completions`**;仅支持该接口的客户端不能直接接入。
## 快速开始
需要 Docker Engine 与 Docker Compose Plugin。
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
### 1. 准备配置和目录
### 1. 下载部署文件并配置
```bash
git clone https://github.com/zyycn/codex-proxy-rs.git
mkdir -p codex-proxy-rs/deploy
cd codex-proxy-rs
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
-o deploy/compose.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
-o deploy/config.example.yaml
mkdir -p .runtime/data .runtime/logs
install -d -m 0750 .runtime/postgres .runtime/redis
cp deploy/config.example.yaml deploy/config.yaml
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
```
分别生成 PostgreSQL 与 Redis 密码:
```bash
openssl rand -hex 24
openssl rand -hex 24
```
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
| 配置 | 约束 |
| --- | --- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
```bash
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
chmod 0770 .runtime/data .runtime/logs
```
### 2. 启动
分别生成数据库和 Redis 密码:
```bash
openssl rand -hex 24
openssl rand -hex 24
```
编辑 `deploy/config.yaml`,填好以下三项:
| 配置项 | 填写内容 |
| --- | --- |
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
### 2. 启动服务
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml pull
docker compose -f deploy/compose.yaml up -d --no-build
docker compose -f deploy/compose.yaml up -d --no-build --wait
curl -i http://127.0.0.1:8080/healthz
```
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
`http://127.0.0.1:8080`。
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
### 3. 初始化网关
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
### 3. 添加账号和密钥
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
额度上游暂时失败不会回滚已提交的账号。
1. 在「账号」中添加 OpenAI 或 xAI 账号。OpenAI 支持 OAuth 授权、AT/RT 和账号 JSON;xAI 支持 OAuth 授权和账号 JSON。
2. 按需创建账号分组,并把账号加入分组。
3. 创建客户端密钥,选择可用分组。**不选分组表示可使用全部账号**。
4. 点击该密钥的「使用密钥」,复制客户端配置。
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
只导入 AT 的账号不能自动续期。xAI 暂不支持用 API Key 导入上游账号。
## 客户端接入
### Codex CLI 和桌面端
在「使用密钥」中按系统复制 `config.toml` 和 `auth.json`,也可以通过 CCSwitch 导入。
已有文件先备份,再合并配置并重启 Codex。客户端使用代理密钥,OpenAI 账号登录状态由服务端管理。
新配置已启用原生生图。可以直接提出需求,例如:
> 帮我做一个咖啡店首页,生成一张咖啡主题配图并用到页面中。
是否调用生图由 Codex 根据任务决定,也可以明确要求生成图片。需要支持生图的客户端、模型和 OpenAI 账号;
普通对话可用不代表账号一定有生图额度。
完整配置和旧版配置迁移见 [客户端配置](deploy/README.md#客户端配置)。
### 其他客户端
选择支持 **Responses API** 的客户端,填写:
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
| Base URL | `http://127.0.0.1:8080/v1`,远程使用时换成服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 密钥 |
可先检查密钥是否能读取模型列表:
```bash
curl http://127.0.0.1:8080/v1/models \
-H 'Authorization: Bearer <client-api-key>'
curl http://127.0.0.1:8080/v1/responses \
-H 'Authorization: Bearer <client-api-key>' \
-H 'Content-Type: application/json' \
-d '{
"model": "<model-id>",
"input": "Reply with pong.",
"stream": false
}'
```
<details>
<summary>公开数据面路由</summary>
接口列表与请求示例见 [接口文档](docs/api.md)。
| 路由 | 用途 |
| --- | --- |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
## 常见问题
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
文本模型映射。
### 仍然要求登录,或无法生成图片
</details>
重新从管理端复制配置,确认修改的是客户端实际读取的用户配置目录,然后完全退出并重启客户端。
保留仅含代理密钥的 `auth.json` 不会使新配置失效。详细检查步骤见
[登录与生图排查](deploy/README.md#登录与生图排查)。
完整的客户端与管理端路由、鉴权、请求字段和 mutation 语义见 [接口文档](docs/api.md)。
### 没有可用账号或模型
## 运行维护
检查账号是否启用、凭据是否过期、额度是否耗尽,以及密钥绑定的分组是否包含可用账号。
「恢复账号」只清除本地错误状态,不能恢复上游权限或增加额度。
### 如何更新或查看日志
同一大版本内拉取发布镜像并重建应用容器:
```bash
# 同一大版本内升级发布镜像
docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build
# 查看日志
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
# 从当前源码构建
docker compose -f deploy/compose.yaml build codex-proxy-rs
docker compose -f deploy/compose.yaml up -d
docker compose -f deploy/compose.yaml up -d --no-build codex-proxy-rs
docker compose -f deploy/compose.yaml logs --tail=100 -f codex-proxy-rs
```
> [!IMPORTANT]
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
> 本地运行状态。
> [!WARNING]
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
> Provider 账号与 Client Key。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket upgrade 与真实客户端
IP;不要暴露 PostgreSQL 或 Redis。当前运行拓扑为单副本,不能通过复制应用容器扩容。
## 维护与发布
仓库版本只能从干净、已同步上游的发布分支发布:
```bash
release/publish <version>
```
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
独立状态;脚本不会登录服务器或改变任何运行实例。
> 升级前先备份。跨大版本需使用新的数据目录重新部署,不支持直接沿用旧库在线升级。
> `.runtime/` 包含数据库和运行数据,请勿删除;账号文件、密钥和日志也不要公开分享。
## 文档
- [部署与运维](deploy/README.md)
- [客户端与管理端接口](docs/api.md)
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](docs/api.md)
- [系统架构](docs/architecture.md)
- [数据库迁移规则](backend/migrations/README.md)
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
+309 -47
View File
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,15 +165,14 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -195,7 +194,7 @@ dependencies = [
"bytes-utils",
"fastrand",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"http-body 0.4.6",
"http-body 1.0.1",
"percent-encoding",
@@ -231,7 +230,7 @@ dependencies = [
"hex",
"hmac",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"lru",
"percent-encoding",
@@ -257,7 +256,7 @@ dependencies = [
"hex",
"hmac",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"percent-encoding",
"sha2 0.11.0",
"time",
@@ -286,7 +285,7 @@ dependencies = [
"bytes",
"crc-fast",
"hex",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"md-5",
@@ -320,7 +319,7 @@ dependencies = [
"bytes-utils",
"futures-core",
"futures-util",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"percent-encoding",
@@ -339,7 +338,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2",
"http 1.4.2",
"http 1.4.0",
"hyper",
"hyper-rustls",
"hyper-util",
@@ -389,7 +388,7 @@ dependencies = [
"bytes",
"fastrand",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"http-body 0.4.6",
"http-body 1.0.1",
"http-body-util",
@@ -410,7 +409,7 @@ dependencies = [
"aws-smithy-types",
"bytes",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"pin-project-lite",
"tokio",
"tracing",
@@ -436,7 +435,7 @@ checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"http 1.4.2",
"http 1.4.0",
]
[[package]]
@@ -450,7 +449,7 @@ dependencies = [
"bytes-utils",
"futures-core",
"http 0.2.12",
"http 1.4.2",
"http 1.4.0",
"http-body 0.4.6",
"http-body 1.0.1",
"http-body-util",
@@ -503,7 +502,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper",
@@ -535,7 +534,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
@@ -954,6 +953,29 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1005,6 +1027,27 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1053,6 +1096,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1074,6 +1132,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ego-tree"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
[[package]]
name = "either"
version = "1.16.0"
@@ -1413,11 +1477,14 @@ name = "gateway-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"futures",
"futures-timer",
"semver",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tracing",
"uuid",
@@ -1438,6 +1505,7 @@ dependencies = [
"gateway-core",
"hex",
"reqwest",
"scraper",
"semver",
"serde",
"serde_json",
@@ -1466,6 +1534,7 @@ dependencies = [
name = "gateway-store"
version = "0.1.0"
dependencies = [
"async-stream",
"async-trait",
"aws-credential-types",
"aws-sdk-s3",
@@ -1501,6 +1570,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1562,7 +1640,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http 1.4.2",
"http 1.4.0",
"indexmap",
"slab",
"tokio",
@@ -1610,7 +1688,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http 1.4.2",
"http 1.4.0",
"httpdate",
"mime",
"sha1 0.10.7",
@@ -1622,7 +1700,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http 1.4.2",
"http 1.4.0",
]
[[package]]
@@ -1661,6 +1739,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -1674,9 +1762,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.2"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
@@ -1700,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.4.2",
"http 1.4.0",
]
[[package]]
@@ -1711,7 +1799,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"pin-project-lite",
]
@@ -1747,21 +1835,22 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1773,7 +1862,7 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.4.2",
"http 1.4.0",
"hyper",
"hyper-util",
"log",
@@ -1795,7 +1884,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"hyper",
"ipnet",
@@ -2064,6 +2153,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2132,6 +2232,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2283,7 +2389,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
@@ -2296,6 +2402,27 @@ dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -2306,19 +2433,42 @@ dependencies = [
"rand 0.8.7",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -2337,6 +2487,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2389,6 +2548,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primefield"
version = "0.14.0"
@@ -2431,6 +2596,7 @@ version = "0.0.0"
dependencies = [
"async-stream",
"async-trait",
"aws-lc-rs",
"base64 0.23.1",
"bytes",
"chrono",
@@ -2442,6 +2608,7 @@ dependencies = [
"gateway-protocol",
"getrandom 0.4.3",
"hex",
"hyper",
"hyper-rustls",
"reqwest",
"roxmltree",
@@ -2457,6 +2624,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",
"tokio-tungstenite 0.28.0",
"tokio-util",
"tracing",
@@ -2748,7 +2916,7 @@ dependencies = [
"futures-core",
"futures-util",
"h2",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper",
@@ -2858,9 +3026,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -2870,9 +3038,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
@@ -2917,6 +3085,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "sec1"
version = "0.8.1"
@@ -2964,6 +3147,25 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -3047,6 +3249,15 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
@@ -3386,6 +3597,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -3469,12 +3704,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -3561,9 +3805,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@@ -3673,7 +3917,7 @@ dependencies = [
"bitflags",
"bytes",
"futures-util",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"pin-project-lite",
"tower",
@@ -3692,7 +3936,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 1.4.2",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"http-range-header",
@@ -3824,7 +4068,7 @@ dependencies = [
"data-encoding",
"flate2",
"headers",
"http 1.4.2",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.4",
@@ -3843,7 +4087,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http 1.4.2",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.4",
@@ -3890,6 +4134,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4074,10 +4324,22 @@ dependencies = [
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf 0.13.1",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
@@ -4279,7 +4541,7 @@ dependencies = [
"base64 0.22.1",
"deadpool",
"futures",
"http 1.4.2",
"http 1.4.0",
"http-body-util",
"hyper",
"hyper-util",
+1 -1
View File
@@ -26,7 +26,7 @@ tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-th
[dev-dependencies]
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
serde_json = "1"
syn = { version = "3.0.3", features = ["full"] }
syn = { version = "3.0.3", features = ["full", "visit"] }
tempfile = "3.27.0"
[lints]
+1
View File
@@ -79,6 +79,7 @@ pub async fn run() -> Result<(), BootstrapError> {
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
host.client_distribution_resolver(),
host.system_operations(),
)
.await?;
+1 -1
View File
@@ -1,6 +1,6 @@
use std::{env, error::Error, io};
use provider_openai::transport::tls::ensure_rustls_provider;
use provider_openai::ensure_rustls_provider;
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
+366 -20
View File
@@ -1,12 +1,15 @@
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use syn::{Item, visit::Visit};
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
const WORKSPACE_MEMBERS: &[&str] = &[
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
"apps/gateway",
"crates/gateway-admin",
"crates/gateway-api",
@@ -58,6 +61,82 @@ fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
);
}
#[test]
fn core_value_owners_do_not_depend_on_execution_or_routing() {
let root = backend_root().join("crates/gateway-core/src");
for relative in super::rust_files(&root) {
let allowed: Option<&[&str]> = match relative.to_str().expect("source path") {
"validation.rs" => Some(&[]),
"identity.rs" => Some(&["validation"]),
"upstream.rs" => Some(&["validation"]),
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
"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"]),
_ => None,
};
let Some(allowed) = allowed else { continue };
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
let syntax = syn::parse_file(&source).expect("parse core source");
let mut references = CrateReferences::default();
references.visit_file(&syntax);
for dependency in references.0 {
assert!(
allowed.contains(&dependency.as_str()),
"{} must not depend on crate::{dependency}",
relative.display()
);
}
}
}
#[derive(Default)]
struct CrateReferences(BTreeSet<String>);
impl<'ast> Visit<'ast> for CrateReferences {
fn visit_path(&mut self, path: &'ast syn::Path) {
let mut segments = path.segments.iter();
if segments
.next()
.is_some_and(|segment| segment.ident == "crate")
&& let Some(owner) = segments.next()
{
self.0.insert(owner.ident.to_string());
}
syn::visit::visit_path(self, path);
}
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
self.use_tree(&item.tree, false);
}
}
impl CrateReferences {
fn use_tree(&mut self, tree: &syn::UseTree, crate_root: bool) {
match tree {
syn::UseTree::Path(path) if crate_root => {
self.0.insert(path.ident.to_string());
}
syn::UseTree::Name(name) if crate_root => {
self.0.insert(name.ident.to_string());
}
syn::UseTree::Rename(rename) if crate_root => {
self.0.insert(rename.ident.to_string());
}
syn::UseTree::Path(path) => self.use_tree(&path.tree, path.ident == "crate"),
syn::UseTree::Group(group) => {
for item in &group.items {
self.use_tree(item, crate_root);
}
}
syn::UseTree::Glob(_) if crate_root => {
self.0.insert("*".to_owned());
}
_ => {}
}
}
}
#[test]
fn gateway_protocol_has_no_workspace_dependencies() {
for name in dependency_names("crates/gateway-protocol") {
@@ -95,6 +174,36 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("provider-xai", "crates/providers/xai"),
];
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
("crates/gateway-api", &["admin", "openai"]),
(
"crates/gateway-host",
&[
"client_distribution",
"config",
"serve",
"system_update",
"workers",
],
),
("crates/gateway-store", &["backup", "postgres", "redis"]),
(
"crates/providers/openai",
&["config", "credential", "transport"],
),
(
"crates/providers/xai",
&["config", "credential", "transport"],
),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
("apps/gateway", &["architecture"]),
("crates/gateway-api", &["architecture"]),
];
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("codex-proxy-rs", "gateway-admin"),
@@ -123,6 +232,46 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
#[test]
fn workspace_internal_dependency_edges_match_frozen_dag() {
let metadata = cargo_metadata_json();
let actual = workspace_runtime_dependency_edges(&metadata);
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
.iter()
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
.collect();
expected.sort();
assert_eq!(
actual, expected,
"workspace internal dependency edges diverged from the frozen DAG"
);
}
#[test]
fn runtime_dependency_edges_exclude_dev_build_and_external_dependencies() {
let metadata = serde_json::json!({
"packages": [
{
"name": "gateway-api",
"dependencies": [
{ "name": "gateway-core", "kind": null },
{ "name": "gateway-store", "kind": "dev" },
{ "name": "gateway-host", "kind": "build" },
{ "name": "serde", "kind": null }
]
},
{
"name": "external-package",
"dependencies": [{ "name": "gateway-core", "kind": null }]
}
]
});
assert_eq!(
workspace_runtime_dependency_edges(&metadata),
vec![("gateway-api".to_owned(), "gateway-core".to_owned())]
);
}
fn workspace_runtime_dependency_edges(metadata: &serde_json::Value) -> Vec<(String, String)> {
let packages = metadata["packages"]
.as_array()
.expect("cargo metadata packages");
@@ -140,31 +289,43 @@ fn workspace_internal_dependency_edges_match_frozen_dag() {
if !internal_names.contains(dep_name) {
continue;
}
let is_runtime_edge = dependency["dep_kinds"]
.as_array()
.map(|kinds| {
kinds
.iter()
.any(|kind| kind["kind"].as_str() == Some("normal"))
})
.unwrap_or(true);
if is_runtime_edge {
if dependency.get("kind").expect("dependency kind").is_null() {
actual.push((name.to_owned(), dep_name.to_owned()));
}
}
}
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
.iter()
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
.collect();
actual.sort();
expected.sort();
actual
}
assert_eq!(
actual, expected,
"workspace internal dependency edges diverged from the frozen DAG"
);
#[test]
fn adapter_public_module_surfaces_match_allowlist() {
for (member, allowed) in ADAPTER_PUBLIC_MODULES {
let root = backend_root().join(member).join("src/lib.rs");
let source = fs::read_to_string(&root).expect("read adapter crate root");
let syntax = syn::parse_file(&source).expect("parse adapter crate root");
let actual = syntax
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) if matches!(module.vis, syn::Visibility::Public(_)) => {
Some(module.ident.to_string())
}
_ => None,
})
.collect::<BTreeSet<_>>();
let expected = allowed
.iter()
.map(|module| (*module).to_owned())
.collect::<BTreeSet<_>>();
assert_eq!(
actual,
expected,
"{} public module surface diverged from its allowlist",
root.display()
);
}
}
#[test]
@@ -185,7 +346,192 @@ fn production_sources_do_not_host_tests() {
}
}
fn backend_root() -> PathBuf {
#[test]
fn workspace_modules_follow_conventional_file_layout() {
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
let tests = member_root.join("tests");
if tests.is_dir() {
assert_module_tree(&tests, &["main.rs"]);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleLayout {
File,
Directory,
}
#[test]
fn integration_tests_mirror_production_module_tree() {
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
let tests_root = member_root.join("tests");
if !tests_root.is_dir() {
continue;
}
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
let tests = module_layouts(&tests_root, &["main.rs"]);
for (module, test_layout) in tests {
if module == Path::new("support") {
continue;
}
if let Some(production_layout) = production.get(&module) {
assert_eq!(
test_layout,
*production_layout,
"{} must mirror the production module layout for {}",
tests_root.join(module.with_extension("rs")).display(),
member_root.join("src").join(&module).display(),
);
continue;
}
let has_production_owner = module
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|ancestor| !ancestor.as_os_str().is_empty())
.any(|ancestor| production.contains_key(ancestor));
assert!(
has_production_owner || root_test_scenario_allowed(member, &module),
"{} has no mirrored production module owner",
tests_root.join(module.with_extension("rs")).display(),
);
}
}
}
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
super::rust_files(root)
.into_iter()
.filter(|relative| {
!crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
})
.map(|relative| {
if relative.file_name().and_then(|value| value.to_str()) == Some("mod.rs") {
(
relative.parent().expect("mod.rs parent").to_path_buf(),
ModuleLayout::Directory,
)
} else {
(relative.with_extension(""), ModuleLayout::File)
}
})
.collect()
}
fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
let Some(module) = module.to_str() else {
return false;
};
ROOT_TEST_SCENARIOS
.iter()
.any(|(candidate, scenarios)| *candidate == member && scenarios.contains(&module))
}
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
for relative in &files {
if crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
{
continue;
}
let file_name = relative
.file_name()
.and_then(|value| value.to_str())
.expect("Rust source file name");
let parent = relative.parent().expect("Rust source parent");
let module_name = if file_name == "mod.rs" {
parent
.file_name()
.and_then(|value| value.to_str())
.expect("mod.rs module name")
} else {
relative
.file_stem()
.and_then(|value| value.to_str())
.expect("Rust source module name")
};
if file_name != "mod.rs" {
assert_ne!(
parent.file_name().and_then(|value| value.to_str()),
Some(module_name),
"{} repeats its parent module name",
root.join(relative).display()
);
let child_directory = relative.with_extension("");
assert!(
!files
.iter()
.any(|candidate| candidate.starts_with(&child_directory)),
"{} mixes a leaf module with a same-name module directory",
root.join(relative).display()
);
}
let declaration_parents = if parent.as_os_str().is_empty()
|| (file_name == "mod.rs"
&& parent
.parent()
.is_some_and(|ancestor| ancestor.as_os_str().is_empty()))
{
crate_roots
.iter()
.map(|candidate| root.join(candidate))
.collect::<Vec<_>>()
} else {
let declaration_directory = if file_name == "mod.rs" {
parent.parent().expect("nested mod.rs parent")
} else {
parent
};
vec![root.join(declaration_directory).join("mod.rs")]
};
let declaration_count = declaration_parents
.iter()
.map(|path| external_module_declaration_count(path, module_name))
.sum::<usize>();
assert_eq!(
declaration_count,
1,
"{} must be declared exactly once by its parent module",
root.join(relative).display()
);
}
}
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
if !path.is_file() {
return 0;
}
let source = fs::read_to_string(path).expect("read parent module source");
let syntax = syn::parse_file(&source).expect("parse parent module source");
syntax
.items
.iter()
.filter(|item| {
matches!(
item,
Item::Mod(module)
if module.content.is_none() && module.ident == module_name
)
})
.count()
}
pub(super) fn backend_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
+2 -5
View File
@@ -143,10 +143,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace(
"codex_version: '0.151.0-alpha.7.2'",
"codex_version: 'latest'",
));
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
}
#[test]
@@ -165,7 +162,7 @@ fn config_loader_should_reject_zero_server_port() {
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '7377'", "desktop_build: 'build'"));
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
fn assert_rejected(config: String) {
+27 -25
View File
@@ -43,31 +43,33 @@ fn cargo_library_root_is_conventional_lib() {
}
#[test]
fn production_files_have_no_hidden_modules_or_test_hooks() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
for relative in ["bootstrap.rs", "lib.rs", "main.rs"] {
let path = root.join(relative);
let source = fs::read_to_string(&path).expect("read production source");
assert!(
!source.contains("include!("),
"{} uses include!",
path.display()
);
let syntax = syn::parse_file(&source).expect("parse production source");
for item in &syntax.items {
if let Item::Mod(module) = item {
assert!(
module.content.is_none(),
"{} has an inline module",
path.display()
);
}
for attribute in item_attrs(item) {
assert!(
!is_path_or_test_cfg(attribute),
"{} has a production test/path hook",
path.display(),
);
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
for member in architecture::WORKSPACE_MEMBERS {
let root = architecture::backend_root().join(member).join("src");
for relative in rust_files(&root) {
let path = root.join(relative);
let source = fs::read_to_string(&path).expect("read production source");
assert!(
!source.contains("include!("),
"{} uses include!",
path.display()
);
let syntax = syn::parse_file(&source).expect("parse production source");
for item in &syntax.items {
if let Item::Mod(module) = item {
assert!(
module.content.is_none(),
"{} has an inline module",
path.display()
);
}
for attribute in item_attrs(item) {
assert!(
!is_path_or_test_cfg(attribute),
"{} has a production test/path hook",
path.display(),
);
}
}
}
}
+1 -1
View File
@@ -22,6 +22,7 @@ gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
secrecy = { version = "0.10.3", features = ["serde"] }
subtle = "2.6.1"
thiserror = "2.0.18"
@@ -32,7 +33,6 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11"
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
@@ -9,7 +9,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::{
engine::CancellationToken,
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
};
use tracing::{info, warn};
+17 -4
View File
@@ -6,7 +6,8 @@ use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::probe::AccountProbe,
routing::{ProviderKind, snapshot::SnapshotControl},
routing::ProviderKind,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
WorkerRunnable,
@@ -22,19 +23,22 @@ mod use_case;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService,
openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
use ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
store::AdminStorePorts,
system::SystemOperations,
};
use use_case::{
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
};
@@ -149,6 +153,7 @@ pub struct AdminServices {
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
client_distribution: Arc<dyn ClientDistributionService>,
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
@@ -178,6 +183,11 @@ impl AdminServices {
self.client_keys.as_ref()
}
#[must_use]
pub fn client_distribution(&self) -> &dyn ClientDistributionService {
self.client_distribution.as_ref()
}
#[must_use]
pub fn observability(&self) -> &dyn ObservabilityService {
self.observability.as_ref()
@@ -238,6 +248,7 @@ pub async fn initialize(
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
config
@@ -290,8 +301,10 @@ pub async fn initialize(
store.client_keys(),
snapshot.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.accounts(),
store.settings(),
registry,
)),
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use gateway_core::{engine::credential::AccountStatusFacts, routing::AccountGroupId};
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
use super::{PageSize, Revision, observability::DecimalAmount};
@@ -6,14 +6,13 @@ use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::{
engine::{UpstreamSendState, probe::AccountProbeErrorSource},
error::GatewayErrorKind,
routing::ProviderKind,
engine::probe::AccountProbeErrorSource, error::GatewayErrorKind, routing::ProviderKind,
upstream::UpstreamSendState,
};
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
pub use gateway_core::engine::credential::{
pub use gateway_core::account::{
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
QuotaState, resolve_account_status,
@@ -218,7 +217,7 @@ pub struct UpdateAccount {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountUpdateResult {
pub config_revision: Revision,
pub account_id: gateway_core::engine::credential::ProviderAccountId,
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
@@ -235,7 +234,7 @@ pub struct BatchUpdateAccounts {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsUpdateResult {
pub config_revision: Revision,
pub account_ids: Vec<gateway_core::engine::credential::ProviderAccountId>,
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
}
/// 账号批量删除命令。
@@ -4,6 +4,12 @@ use chrono::{DateTime, Utc};
use super::{MutationActor, MutationContext, Revision};
/// 会话审计使用的稳定管理员标识。
#[must_use]
pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
format!("admin:{admin_user_id}")
}
/// 已认证的管理主体。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminPrincipal {
@@ -0,0 +1,34 @@
//! 备份领域的稳定错误码。
/// 配置或参数非法。
pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
/// 记录状态与期望不符,无法迁移。
pub const STATE_CONFLICT: &str = "backup.state_conflict";
/// 已有记录时禁止切换存储目标。
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败。
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 暂存磁盘空间不足。
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败。
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
/// S3 权限拒绝。
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
/// S3 上传失败。
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
/// 远端对象校验失败。
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
/// 任务被取消。
pub const CANCELLED: &str = "backup.cancelled";
/// 仓储或对象存储暂不可用。
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
/// 上游 S3 服务返回无效或失败响应。
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
@@ -447,40 +447,7 @@ impl BackupError {
}
/// 构造稳定错误码常量。
pub mod code {
/// 配置或参数非法。
pub const INVALID_CONFIG: &str = "backup.invalid_config";
/// Cron 表达式非法。
pub const INVALID_CRON: &str = "backup.invalid_cron";
/// IANA 时区非法。
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
/// 记录不存在。
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
/// 已存在活跃任务,拒绝创建。
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
/// 记录状态与期望不符,无法迁移。
pub const STATE_CONFLICT: &str = "backup.state_conflict";
/// 已有记录时禁止切换存储目标。
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败。
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 暂存磁盘空间不足。
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败。
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
/// S3 权限拒绝。
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
/// S3 上传失败。
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
/// 远端对象校验失败。
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
/// 任务被取消。
pub const CANCELLED: &str = "backup.cancelled";
/// 仓储或对象存储暂不可用。
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
/// 上游 S3 服务返回无效或失败响应。
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
}
pub mod code;
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
///
@@ -0,0 +1,58 @@
//! 管理设置页使用的 Codex 客户端下载信息。
use chrono::{DateTime, Utc};
/// Windows 安装包架构。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ClientArchitecture {
X64,
Arm64,
}
impl ClientArchitecture {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::X64 => "x64",
Self::Arm64 => "arm64",
}
}
}
/// 下载地址的可信来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientDownloadSource {
MicrosoftStore,
OfficialOpenAi,
}
impl ClientDownloadSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MicrosoftStore => "microsoft_store",
Self::OfficialOpenAi => "official_openai",
}
}
}
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientDownloadPackage {
pub architecture: ClientArchitecture,
pub source: ClientDownloadSource,
pub version: Option<String>,
pub file_name: String,
pub size_bytes: Option<u64>,
pub download_url: String,
pub expires_at: Option<DateTime<Utc>>,
}
/// Codex Desktop Windows 下载解析结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexDesktopWindowsDownloads {
pub resolved_at: DateTime<Utc>,
pub cached: bool,
pub warning: Option<String>,
pub packages: Vec<ClientDownloadPackage>,
}
@@ -6,6 +6,7 @@ pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
@@ -135,13 +135,11 @@ pub struct OpsErrorFilter {
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -189,11 +187,11 @@ impl DecimalAmount {
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
}
fn to_decimal(&self) -> Option<gateway_core::accounting::Decimal> {
fn to_decimal(&self) -> Option<gateway_core::metering::Decimal> {
self.0.parse().ok()
}
fn from_decimal(value: gateway_core::accounting::Decimal) -> Option<Self> {
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
Some(Self(value.canonical()))
}
}
@@ -493,6 +491,8 @@ pub struct DashboardAccountUsage {
///
/// `None` 表示上游未提供可比较的百分比,不应伪造为零。
pub quota_used_percent: Option<f64>,
/// Provider 返回的代表窗口;缺失时保留未知语义。
pub quota_window: Option<super::provider_credentials::ProviderQuotaWindow>,
pub models: Vec<DashboardAccountModelUsage>,
}
@@ -704,6 +704,8 @@ pub struct UsageAttempt {
/// 一条请求及其全部尝试。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageDetail {
pub trace: Option<serde_json::Value>,
pub related_requests: Vec<serde_json::Value>,
pub request: UsageRecord,
pub attempts: Vec<UsageAttempt>,
}
@@ -776,6 +778,7 @@ pub struct OpsError {
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub failure_kind: String,
pub upstream_send_state: Option<String>,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub provider_error_code: Option<String>,
@@ -783,7 +786,7 @@ pub struct OpsError {
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub message: String,
pub occurrence_count: u32,
pub raw_upstream_error: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -791,6 +794,18 @@ pub struct OpsError {
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -7,9 +7,11 @@ use bytes::Bytes;
use chrono::{DateTime, NaiveDate, Utc};
use futures::Stream;
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
};
use serde::Deserialize;
use serde_json::{Map, Value};
use uuid::Uuid;
use super::{
@@ -279,6 +281,101 @@ impl PendingAuthorizationMutation {
}
}
impl PendingAuthorizationMutation {
/// 编码 v1 中立事务字段;版本号的位置和 Provider 私有外层文档由 adapter 保持。
#[must_use]
pub fn to_storage_v1(&self) -> Map<String, Value> {
let target = match &self.target {
AuthorizationMutationTarget::Create { name } => {
serde_json::json!({"kind": "create", "name": name})
}
AuthorizationMutationTarget::Reauthorize { account_id } => {
serde_json::json!({"kind": "reauthorize", "account_id": account_id.to_string()})
}
};
let owner = match self.owner_binding.owner() {
AuthorizationOwner::AdminSession { admin_user_id } => {
serde_json::json!({"kind": "admin_session", "admin_user_id": admin_user_id})
}
AuthorizationOwner::AdminApiKey => serde_json::json!({"kind": "admin_api_key"}),
AuthorizationOwner::System => serde_json::json!({"kind": "system"}),
};
Map::from_iter([
(
"provider_kind".to_owned(),
Value::String(self.provider_kind.as_str().to_owned()),
),
("target".to_owned(), target),
("owner".to_owned(), owner),
(
"started_request_id".to_owned(),
Value::String(self.owner_binding.started_request_id().to_owned()),
),
])
}
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
///
/// # Errors
///
/// 文档字段、Provider kind 或账号身份非法时返回错误,不携带原始 pending 内容。
pub fn from_storage_v1(value: Value) -> Result<Self, AdminError> {
let invalid = || AdminError::invalid("invalid pending authorization mutation");
let document: StoredAuthorizationMutationV1 =
serde_json::from_value(value).map_err(|_| invalid())?;
let provider_kind = ProviderKind::new(document.provider_kind).map_err(|_| invalid())?;
let target = match document.target {
StoredAuthorizationTargetV1::Create { name } => {
AuthorizationMutationTarget::Create { name }
}
StoredAuthorizationTargetV1::Reauthorize { account_id } => {
AuthorizationMutationTarget::Reauthorize {
account_id: ProviderAccountId::new(account_id).map_err(|_| invalid())?,
}
}
};
let owner = match document.owner {
StoredAuthorizationOwnerV1::AdminSession { admin_user_id } => {
AuthorizationOwner::AdminSession { admin_user_id }
}
StoredAuthorizationOwnerV1::AdminApiKey => AuthorizationOwner::AdminApiKey,
StoredAuthorizationOwnerV1::System => AuthorizationOwner::System,
};
Ok(Self::new(
provider_kind,
target,
AuthorizationOwnerBinding {
owner,
started_request_id: document.started_request_id,
},
))
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredAuthorizationMutationV1 {
provider_kind: String,
target: StoredAuthorizationTargetV1,
owner: StoredAuthorizationOwnerV1,
started_request_id: String,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum StoredAuthorizationTargetV1 {
Create { name: String },
Reauthorize { account_id: String },
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum StoredAuthorizationOwnerV1 {
AdminSession { admin_user_id: String },
AdminApiKey,
System,
}
/// 启动 Provider OAuth Authorization Code 流程。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartAuthorization {
@@ -776,6 +873,20 @@ impl ProviderQuota {
.map(|(_, used_percent)| used_percent)
}
/// 选择 Dashboard 展示的真实窗口;没有百分比时仍保留 Provider 的滚动窗口语义。
#[must_use]
pub fn representative_window(&self) -> Option<&ProviderQuotaWindow> {
self.representative_used_window()
.map(|(index, _)| &self.windows[index])
.or_else(|| {
self.windows
.iter()
.enumerate()
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.map(|(_, window)| window)
})
}
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
///
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
@@ -908,7 +1019,7 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub projection: gateway_core::engine::credential::AccountStatusProjection,
pub projection: gateway_core::account::AccountStatusProjection,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
}
@@ -12,7 +12,7 @@ use super::Revision;
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
pub use gateway_core::engine::credential::RotationStrategy;
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -24,6 +24,8 @@ pub struct RuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -39,6 +41,8 @@ pub struct ReplaceRuntimeSettings {
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
@@ -145,7 +145,7 @@ pub trait DatabaseDumpPort: Send + Sync {
#[derive(Debug, Clone)]
pub struct DumpRequest {
pub backup_id: String,
pub cancellation: gateway_core::engine::CancellationToken,
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 已完成并落盘的归档事实。
@@ -212,7 +212,7 @@ pub struct UploadObjectRequest {
pub object_key: String,
pub source: PathBuf,
pub metadata: BackupObjectMetadata,
pub cancellation: gateway_core::engine::CancellationToken,
pub cancellation: gateway_core::lifecycle::CancellationToken,
}
/// 备份控制面所需的全部 Store 能力集合。
@@ -0,0 +1,10 @@
//! 客户端安装包解析的外部能力端口。
use async_trait::async_trait;
use crate::model::client_distribution::CodexDesktopWindowsDownloads;
#[async_trait]
pub trait ClientDistributionResolver: Send + Sync {
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
@@ -1,6 +1,7 @@
//! 管理用例依赖的外部能力端口。
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod store;
pub mod system;
@@ -4,7 +4,7 @@ use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use gateway_core::{
engine::credential::ProviderAccountId,
account::ProviderAccountId,
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
@@ -6,6 +6,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::stream::BoxStream;
use super::backup::BackupStorePorts;
use crate::model::{
@@ -118,13 +119,13 @@ pub trait AccountStore: Send + Sync {
async fn credential_details(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_id: &gateway_core::engine::credential::ProviderAccountId,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
async fn load_credentials_for_export(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
account_ids: &[gateway_core::account::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
async fn commit_credential_import(
@@ -159,7 +160,7 @@ pub trait AccountStore: Send + Sync {
async fn recover_account(
&self,
account_id: &gateway_core::engine::credential::ProviderAccountId,
account_id: &gateway_core::account::ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
@@ -177,7 +178,7 @@ pub trait AccountStore: Send + Sync {
async fn record_credential_export(
&self,
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
account_ids: &[gateway_core::account::ProviderAccountId],
context: &MutationContext,
) -> AdminStoreResult<()>;
}
@@ -289,6 +290,10 @@ pub trait AccountGroupStore: Send + Sync {
) -> AdminStoreResult<AccountGroupMutation>;
}
/// 逐条读取的已计算费用事实;消费结束或丢弃时释放查询资源。
pub type UsageCalculatedBillingStream<'a> =
BoxStream<'a, AdminStoreResult<UsageCalculatedBillingFact>>;
/// 用量、趋势、诊断与运维错误的只读能力。
#[async_trait]
pub trait ObservabilityStore: Send + Sync {
@@ -318,12 +323,13 @@ pub trait ObservabilityStore: Send + Sync {
filter: UsageFilter,
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
async fn usage_calculated_billing_facts(
/// 流式返回可由 Provider 重新校验的已计算费用事实,不保证顺序。
/// 查询及解码错误由流返回;调用方应逐条聚合,避免收集整个区间。
fn usage_calculated_billing_facts(
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
) -> UsageCalculatedBillingStream<'_>;
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
@@ -8,8 +8,9 @@ use std::{
use async_trait::async_trait;
use gateway_core::{
engine::credential::{AccountStatus, resolve_account_status},
routing::{AccountGroupId, snapshot::SnapshotControl},
account::{AccountStatus, resolve_account_status},
routing::AccountGroupId,
runtime::SnapshotControl,
};
use uuid::Uuid;
@@ -6,11 +6,10 @@ use async_trait::async_trait;
use chrono::{Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeRequest},
},
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeRequest},
routing::{ProviderKind, UpstreamModelId},
runtime::SnapshotControl,
};
use crate::{
@@ -270,6 +269,11 @@ impl DefaultAccountsService {
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
let stored = if refresh_quota {
self.load_account(account_id).await?
} else {
stored
};
self.attach_quota_local_usage(
std::slice::from_ref(&stored),
std::slice::from_mut(&mut quota),
@@ -69,7 +69,7 @@ impl DefaultAuthService {
id: format!("audit_{}", Uuid::now_v7().simple()),
actor_kind: AuditActorKind::AdminSession,
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
actor_ref: format!("admin:{}", self.default_admin_user_id),
actor_ref: crate::model::auth::admin_session_actor_ref(&self.default_admin_user_id),
request_id: None,
action: action.to_owned(),
entity_kind: "admin_session".to_owned(),
@@ -183,7 +183,7 @@ impl AuthService for DefaultAuthService {
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 = format!("admin:{}", session.admin_user_id);
event.actor_ref = crate::model::auth::admin_session_actor_ref(&session.admin_user_id);
event.entity_ref = session.admin_user_id;
self.store
.append_audit_event(event)
@@ -5,7 +5,7 @@ use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::runtime::SnapshotControl;
use secrecy::ExposeSecret as _;
use uuid::Uuid;
@@ -326,7 +326,9 @@ impl DefaultBackupService {
crate::model::MutationActor::System => AuditActorKind::System,
};
let actor_ref = match &context.actor {
crate::model::MutationActor::AdminSession { admin_user_id } => admin_user_id.clone(),
crate::model::MutationActor::AdminSession { admin_user_id } => {
crate::model::auth::admin_session_actor_ref(admin_user_id)
}
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
crate::model::MutationActor::System => "system".to_owned(),
};
@@ -0,0 +1,33 @@
//! Codex 客户端下载信息用例。
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
model::client_distribution::CodexDesktopWindowsDownloads,
ports::client_distribution::ClientDistributionResolver,
};
#[async_trait]
pub trait ClientDistributionService: Send + Sync {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
}
pub(crate) struct DefaultClientDistributionService {
resolver: Arc<dyn ClientDistributionResolver>,
}
impl DefaultClientDistributionService {
#[must_use]
pub(crate) const fn new(resolver: Arc<dyn ClientDistributionResolver>) -> Self {
Self { resolver }
}
}
#[async_trait]
impl ClientDistributionService for DefaultClientDistributionService {
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
self.resolver.resolve_codex_desktop_windows(refresh).await
}
}
@@ -5,7 +5,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use uuid::Uuid;
@@ -4,6 +4,7 @@ pub mod account_groups;
pub mod accounts;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
@@ -29,8 +30,9 @@ use crate::{
},
};
use gateway_core::{
engine::credential::ProviderAccountId,
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
account::ProviderAccountId,
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
@@ -9,7 +9,8 @@ use std::{
use async_trait::async_trait;
use chrono::{DateTime, Duration, Timelike as _, Utc};
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
use futures::TryStreamExt;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use crate::{
model::{
@@ -29,7 +30,7 @@ use crate::{
},
ports::{
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
store::{ObservabilityStore, SettingsStore},
store::{AccountStore, ObservabilityStore, SettingsStore, UsageCalculatedBillingStream},
},
};
@@ -126,6 +127,7 @@ pub trait ObservabilityService: Send + Sync {
pub(crate) struct DefaultObservabilityService {
store: Arc<dyn ObservabilityStore>,
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
dashboard_queries: DashboardQueryCache,
@@ -135,11 +137,13 @@ impl DefaultObservabilityService {
#[must_use]
pub(crate) fn new(
store: Arc<dyn ObservabilityStore>,
accounts: Arc<dyn AccountStore>,
settings: Arc<dyn SettingsStore>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
accounts,
settings,
providers,
dashboard_queries: DashboardQueryCache::default(),
@@ -301,13 +305,21 @@ impl ObservabilityService for DefaultObservabilityService {
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageInsights, AdminError> {
let (overview, trend, billing_facts) = futures::try_join!(
self.store.usage_summary(range, filter.clone()),
self.store.usage_trend(range, filter.clone()),
self.store.usage_calculated_billing_facts(range, filter),
)
.map_err(|error| map_store_error(error, "usage insights"))?;
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
let aggregates = async {
futures::try_join!(
self.store.usage_summary(range, filter.clone()),
self.store.usage_trend(range, filter.clone()),
)
.map_err(|error| map_store_error(error, "usage insights"))
};
let ((overview, trend), standard_costs) = futures::try_join!(
aggregates,
recover_standard_costs(
&self.providers,
self.store
.usage_calculated_billing_facts(range, filter.clone()),
),
)?;
build_usage_insights(overview, trend, standard_costs)
}
@@ -590,12 +602,16 @@ struct UsageCostScenarios {
no_cache: ScenarioCosts,
}
fn recover_standard_costs(
async fn recover_standard_costs(
providers: &ProviderAdminRegistry,
facts: Vec<UsageCalculatedBillingFact>,
mut facts: UsageCalculatedBillingStream<'_>,
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
for fact in facts {
while let Some(fact) = facts
.try_next()
.await
.map_err(|error| map_store_error(error, "usage billing facts"))?
{
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
continue;
};
@@ -654,7 +670,7 @@ fn no_cache_cost(
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(total)
&gateway_core::metering::Decimal::from_scaled(total)
.ok()?
.canonical(),
)
@@ -669,14 +685,14 @@ fn amount_difference(
.scaled()
.checked_sub(decimal(smaller?)?.scaled())?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(difference)
&gateway_core::metering::Decimal::from_scaled(difference)
.ok()?
.canonical(),
)
.ok()
}
fn decimal(value: &DecimalAmount) -> Option<gateway_core::accounting::Decimal> {
fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
@@ -708,8 +724,31 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
impl DefaultObservabilityService {
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
if accounts.is_empty() {
return;
}
let now = Utc::now();
let ids = accounts
.iter()
.map(|account| account.account_id.clone())
.collect::<Vec<_>>();
let mut rolling_usage = self
.accounts
.load_account_usage(
TimeRange {
start: now - Duration::hours(24),
end: now,
},
&ids,
)
.await
.unwrap_or_default()
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<HashMap<_, _>>();
let providers = self.providers.clone();
let quota_reads = accounts.iter().map(|account| {
let rolling_usage = rolling_usage.remove(&account.account_id);
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
let provider = ProviderKind::new(account.provider_kind.clone())
.ok()
@@ -722,16 +761,19 @@ impl DefaultObservabilityService {
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
rolling_usage,
})
.await
.ok()
.and_then(|quota| quota.representative_used_percent())
}
});
let quota_used = futures::future::join_all(quota_reads).await;
for (account, used_percent) in accounts.iter_mut().zip(quota_used) {
account.quota_used_percent = used_percent;
let quotas = futures::future::join_all(quota_reads).await;
for (account, quota) in accounts.iter_mut().zip(quotas) {
if let Some(mut quota) = quota {
quota.apply_limit_reached_display();
account.quota_used_percent = quota.representative_used_percent();
account.quota_window = quota.representative_window().cloned();
}
}
}
@@ -3,7 +3,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
use gateway_core::{account::ProviderAccountId, runtime::SnapshotControl};
use crate::{
model::{
@@ -3,7 +3,8 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::policy::CodexClientVersion;
use gateway_core::runtime::SnapshotControl;
use rand_core::{OsRng, RngCore as _};
use crate::{
@@ -119,6 +120,8 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& i64::try_from(command.request_interval_ms).is_ok();
if valid {
Ok(())
@@ -126,3 +129,7 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
@@ -3,7 +3,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::routing::snapshot::SnapshotControl;
use gateway_core::runtime::SnapshotControl;
use crate::{
model::{
@@ -10,7 +10,7 @@ use gateway_admin::{
},
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
};
use gateway_core::engine::CancellationToken;
use gateway_core::lifecycle::CancellationToken;
use super::support::{
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
+1 -1
View File
@@ -1,7 +1,7 @@
use std::str::FromStr as _;
use chrono::{TimeDelta, Utc};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::account::OpaqueProviderData;
use gateway_admin::{
model::{
@@ -1,4 +1,4 @@
//! 备份领域模型(model/backup.rs)的纯逻辑测试。
//! 备份领域模型(model/backup)的纯逻辑测试。
use chrono::{TimeZone as _, Utc};
use secrecy::SecretString;
@@ -1,3 +1,4 @@
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
mod backup;
mod provider_credentials;
@@ -0,0 +1,134 @@
use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::account::ProviderAccountId;
use gateway_core::routing::ProviderKind;
use serde_json::{Value, json};
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
limit_reached: false,
provider_data: None,
};
assert!(quota.representative_window().is_none());
assert!(quota.representative_used_percent().is_none());
let rolling = ProviderQuotaWindow {
key: "provider_rolling".to_owned(),
group: "requests".to_owned(),
label: "Provider rolling window".to_owned(),
limit_id: None,
limit_name: None,
role: Some(ProviderQuotaWindowRole::Primary),
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: None,
provider_data: None,
};
quota.windows.push(rolling.clone());
assert_eq!(quota.representative_window(), Some(&rolling));
assert!(quota.representative_used_percent().is_none());
quota.windows[0].used_percent = Some(99.6);
quota.apply_limit_reached_display();
let actual = quota.representative_window().expect("actual window");
assert_eq!(actual.used_percent, Some(99.6));
assert!(
!actual.limit_reached,
"display rounding cannot exhaust quota"
);
assert_eq!(actual.window_seconds, Some(86_400));
assert_eq!(actual.reset_at, None);
quota.windows.insert(
0,
ProviderQuotaWindow {
key: "model_specific".to_owned(),
used_percent: Some(100.0),
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
..rolling
},
);
assert_eq!(
quota
.representative_window()
.map(|window| window.key.as_str()),
Some("provider_rolling")
);
}
#[test]
fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
for provider in ["openai", "xai"] {
for target in [
AuthorizationMutationTarget::Create {
name: "account".to_owned(),
},
AuthorizationMutationTarget::Reauthorize {
account_id: ProviderAccountId::new("acct_1").expect("account id"),
},
] {
for actor in [
MutationActor::AdminSession {
admin_user_id: "admin-1".to_owned(),
},
MutationActor::AdminApiKey,
MutationActor::System,
] {
let expected = PendingAuthorizationMutation::new(
ProviderKind::new(provider).expect("provider"),
target.clone(),
AuthorizationOwnerBinding::from_context(&MutationContext {
actor,
request_id: "request-1".to_owned(),
}),
);
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
expected.to_storage_v1()
)),
Ok(expected),
);
}
}
}
}
#[test]
fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_documents() {
let legacy = json!({
"provider_kind": "openai",
"target": { "kind": "reauthorize", "account_id": "acct_1" },
"owner": { "kind": "admin_session", "admin_user_id": "admin-1" },
"started_request_id": "request-1"
});
let restored =
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
for (pointer, invalid) in [
("/provider_kind", json!("")),
("/target/account_id", json!("")),
("/target/kind", json!("other")),
("/owner/kind", json!("other")),
("/started_request_id", json!(null)),
] {
let mut document = legacy.clone();
*document.pointer_mut(pointer).expect("field") = invalid;
assert!(
PendingAuthorizationMutation::from_storage_v1(document).is_err(),
"{pointer}"
);
}
let mut document = legacy;
document["schema_version"] = json!(2);
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
}
@@ -24,7 +24,7 @@ use gateway_admin::{
},
};
use gateway_core::{
engine::credential::{AccountStatusFacts, CredentialState, QuotaState},
account::{AccountStatusFacts, CredentialState, QuotaState},
routing::AccountGroupId,
};
@@ -4,20 +4,18 @@ use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use futures::{StreamExt as _, future::BoxFuture};
use gateway_core::{
engine::{
UpstreamSendState,
credential::{
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId,
QuotaEvidence, QuotaState, resolve_account_status,
},
probe::{
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
AccountProbeResult,
},
account::{
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId, QuotaEvidence,
QuotaState, resolve_account_status,
},
engine::probe::{
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
AccountProbeResult,
},
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
operation::{GenerateRequest, Operation, ProtocolPayload},
routing::ProviderKind,
upstream::UpstreamSendState,
};
use gateway_admin::{
@@ -74,6 +72,7 @@ pub(super) struct FakeProviderAdmin {
import_account_ids: Mutex<Vec<String>>,
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
quota: Mutex<ProviderQuota>,
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
}
@@ -91,6 +90,7 @@ impl FakeProviderAdmin {
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
quota_requests: Mutex::new(Vec::new()),
quota: Mutex::new(empty_quota()),
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
})
@@ -348,6 +348,14 @@ impl ProviderAdmin for FakeProviderAdmin {
) -> Result<ProviderQuota, ProviderAdminError> {
if request.refresh {
self.record("provider.quota");
if let Some((store, account)) = self
.quota_refresh_account
.lock()
.expect("quota refresh account")
.take()
{
store.set_accounts(vec![account]);
}
}
self.quota_requests
.lock()
@@ -798,6 +806,8 @@ impl SettingsStore for StaticSettingsStore {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -1098,6 +1108,47 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
assert_eq!(store.audit_requests(), ["refresh-request"]);
}
#[tokio::test]
async fn accounts_quota_refresh_should_return_the_updated_account_status() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let mut account = account_record("openai");
account.quota = QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
std::time::SystemTime::now(),
None,
);
let store = FakeAccountStore::with_account(account.clone(), events.clone());
account.quota = QuotaState::allowed(std::time::SystemTime::now());
*provider
.quota_refresh_account
.lock()
.expect("quota refresh account") = Some((store.clone(), account));
let result = accounts_service(provider, store)
.await
.accounts()
.quota(
&ProviderAccountId::new("acct_test").expect("account ID"),
true,
)
.await
.expect("refresh quota");
assert_eq!(
result.account.quota.access(),
gateway_core::account::QuotaAccessState::Allowed
);
assert_eq!(
result.projection.status,
gateway_admin::model::accounts::AccountStatus::Normal
);
assert_eq!(
recorded(&events),
["store.load_account", "provider.quota", "store.load_account"]
);
}
#[tokio::test]
async fn accounts_recover_should_commit_facts_then_return_normal_account() {
let events = events();
@@ -1110,8 +1161,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
std::time::SystemTime::now(),
None,
);
account.last_error_reason =
Some(gateway_core::engine::credential::AccountErrorReason::CredentialInvalid);
account.last_error_reason = Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
account.last_error_message = Some("invalid credential".to_owned());
let store = FakeAccountStore::with_account(account, events.clone());
let services = accounts_service(provider, store.clone()).await;
@@ -1133,7 +1183,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
);
assert_eq!(
result.account.account.quota.access(),
gateway_core::engine::credential::QuotaAccessState::Allowed
gateway_core::account::QuotaAccessState::Allowed
);
assert_eq!(
result.account.projection.status,
@@ -1166,7 +1216,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1202,7 +1252,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1243,7 +1293,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: Vec::new(),
},
)
@@ -1896,7 +1946,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
next_refresh_at: Some(now + TimeDelta::minutes(30)),
enabled: true,
concurrency_limit: None,
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
weight: gateway_core::account::AccountWeight::DEFAULT,
credential_state: CredentialState::Ready,
credential_observed_at: now,
quota: QuotaState::allowed(now.into()),
@@ -31,6 +31,7 @@ use gateway_admin::{
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::CodexDesktopWindowsDownloads,
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -55,6 +56,7 @@ use gateway_admin::{
},
ports::{
backup::BackupStorePorts,
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -68,13 +70,12 @@ use gateway_admin::{
},
};
use gateway_core::{
engine::{
credential::ProviderAccountId,
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
error::{GatewayError, GatewayErrorKind},
policy::ClientApiKeyId,
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
routing::{ConfigRevision, ProviderKind},
runtime::SnapshotControl,
};
pub(super) struct AdminHarness {
@@ -205,6 +206,7 @@ impl AdminHarness {
self.providers,
Arc::new(NoopSnapshot),
self.probe,
Arc::new(NoopClientDistribution),
self.system,
)
.await
@@ -213,6 +215,20 @@ impl AdminHarness {
}
}
struct NoopClientDistribution;
#[async_trait]
impl ClientDistributionResolver for NoopClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: Vec::new(),
}
}
}
#[derive(Default)]
struct BootstrapAuthStore {
password_hash: Mutex<Option<String>>,
@@ -520,13 +536,14 @@ impl ObservabilityStore for UnavailableStore {
Err(unavailable("usage trend"))
}
async fn usage_calculated_billing_facts(
fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
{
Err(unavailable("usage billing facts"))
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
Box::pin(futures::stream::once(async {
Err(unavailable("usage billing facts"))
}))
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
@@ -2,7 +2,7 @@ use std::{
str::FromStr as _,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration as StdDuration,
};
@@ -537,10 +537,41 @@ async fn usage_records_should_enrich_provider_reported_totals_when_pricing_match
));
}
#[tokio::test]
async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails() {
let now = Utc::now();
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
bucket_start: quarter_hour_start(now),
provider_kind: "openai".to_owned(),
upstream_model_id: "gpt-5.5".to_owned(),
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
cached_tokens: Some(0),
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "1.25".parse().expect("cost"),
},
}]);
store.billing_stream_fails.store(true, Ordering::SeqCst);
let services = observability_services_with_calculated_billing(store).await;
assert!(
services
.observability()
.usage_insights(range, UsageFilter::default())
.await
.is_err()
);
}
struct FixtureObservabilityStore {
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
billing_stream_fails: AtomicBool,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
@@ -561,6 +592,7 @@ impl FixtureObservabilityStore {
providers: Vec::new(),
}),
calculated_billing_facts: Mutex::new(Vec::new()),
billing_stream_fails: AtomicBool::new(false),
diagnostics: Mutex::new(Vec::new()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
@@ -663,16 +695,23 @@ impl ObservabilityStore for FixtureObservabilityStore {
Ok(self.trend.lock().expect("trend").clone())
}
async fn usage_calculated_billing_facts(
fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>> {
Ok(self
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
let facts = self
.calculated_billing_facts
.lock()
.expect("calculated billing facts")
.clone())
.clone();
let error = self
.billing_stream_fails
.load(Ordering::SeqCst)
.then(|| Err(super::unavailable("billing stream failed")));
Box::pin(futures::stream::iter(
facts.into_iter().map(Ok).chain(error),
))
}
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
@@ -721,6 +760,8 @@ impl SettingsStore for FixtureSettingsStore {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
@@ -67,6 +67,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_concurrent_per_account: 1,
request_interval_ms: 0,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gateway_core::engine::credential::ProviderAccountId;
use gateway_core::account::ProviderAccountId;
use gateway_admin::{
AdminServices,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,488 @@
//! Provider-owned 凭据请求、命令转换与敏感材料校验。
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AccountProvider {
OpenAi,
Xai,
}
impl AccountProvider {
fn parse(value: &str) -> Result<Self, WireValidationError> {
match value.trim() {
"openai" => Ok(Self::OpenAi),
"xai" => Ok(Self::Xai),
_ => Err(WireValidationError::new("provider")),
}
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub provider: String,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
{
return Err(WireValidationError::new("data"));
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, ImportCredentials), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
ImportCredentials {
context,
document: provider_document(self.data, "data")?,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub provider: String,
pub name: String,
pub account_id: Option<String>,
}
impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
if let Some(account_id) = self.account_id.as_deref() {
require_account_id(account_id, "accountId")?;
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, StartAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
let reauthorization = self
.account_id
.map(ProviderAccountId::new)
.transpose()
.map_err(|_| WireValidationError::new("accountId"))?;
Ok((
provider,
StartAuthorization {
context,
name: self.name,
reauthorization,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub provider: String,
pub flow_id: String,
pub callback_url: String,
}
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
if !URL_SAFE_NO_PAD
.decode(&self.flow_id)
.is_ok_and(|decoded| decoded.len() == 32)
{
return Err(WireValidationError::new("flowId"));
}
}
AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?,
}
require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl")
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CompleteAuthorization {
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
},
))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAccountRequest {
pub account_id: String,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
impl UpdateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
Ok(())
}
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
self.validate()?;
Ok(UpdateAccount {
account_id: self.account_id,
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)?,
})
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatedAccountData {
pub account_id: String,
pub config_revision: u64,
}
impl From<AccountUpdateResult> for UpdatedAccountData {
fn from(result: AccountUpdateResult) -> Self {
Self {
account_id: result.account_id.to_string(),
config_revision: result.config_revision.get(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountDeletionRequest {
pub provider: String,
pub account_ids: Vec<String>,
}
impl AccountDeletionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH {
return Err(WireValidationError::new("accountIds"));
}
let mut unique = BTreeSet::new();
for account_id in &self.account_ids {
require_account_id(account_id, "accountIds")?;
if !unique.insert(account_id.as_str()) {
return Err(WireValidationError::new("accountIds"));
}
}
Ok(())
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> {
self.validate()?;
let provider = AccountProvider::parse(&self.provider)?;
Ok((
provider,
CredentialDeletion {
context,
account_ids: self
.account_ids
.into_iter()
.map(ProviderAccountId::new)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| WireValidationError::new("accountIds"))?,
},
))
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
}
impl RotateAccountRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
pub(super) fn into_command(
self,
context: gateway_admin::model::MutationContext,
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
Ok(RotateCredential {
mutation: CredentialMutation {
context,
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
},
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportData {
pub imported_count: usize,
pub account_ids: Vec<String>,
}
impl AccountImportData {
pub fn from_result(result: CredentialImportResult) -> Self {
let account_ids = result
.credential_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
imported_count: account_ids.len(),
account_ids,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountAuthorizationData {
pub flow_id: String,
pub authorization_url: String,
pub expires_at: DateTime<Utc>,
}
impl From<AuthorizationStarted> for AccountAuthorizationData {
fn from(started: AuthorizationStarted) -> Self {
Self {
flow_id: started.flow_id,
authorization_url: started.authorization_url,
expires_at: started.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMutationData {
pub account_id: String,
}
impl From<CredentialMutationResult> for AccountMutationData {
fn from(result: CredentialMutationResult) -> Self {
Self {
account_id: result.account_id.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDeletionData {
pub deleted_count: usize,
pub account_ids: Vec<String>,
}
impl From<CredentialDeletionResult> for AccountDeletionData {
fn from(result: CredentialDeletionResult) -> Self {
let account_ids = result
.account_ids
.into_iter()
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
deleted_count: account_ids.len(),
account_ids,
}
}
}
pub(super) fn require_account_id(
value: &str,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty()
|| value.len() > 128
|| value.chars().any(char::is_control)
|| !value.starts_with("acct_")
{
return Err(WireValidationError::new(field));
}
Ok(())
}
pub(super) fn parse_concurrency_limit(
value: Option<u64>,
) -> Result<Option<AccountConcurrencyLimit>, WireValidationError> {
value
.map(|value| {
u32::try_from(value)
.ok()
.and_then(AccountConcurrencyLimit::new)
.ok_or_else(|| WireValidationError::new("concurrencyLimit"))
})
.transpose()
}
pub(super) fn deserialize_required_nullable<'de, D>(
deserializer: D,
) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
Option::<u64>::deserialize(deserializer)
}
pub(super) fn parse_account_weight(value: u64) -> Result<AccountWeight, WireValidationError> {
u16::try_from(value)
.ok()
.and_then(AccountWeight::new)
.ok_or_else(|| WireValidationError::new("weight"))
}
pub(super) fn validate_wire_group_ids(
values: &[String],
) -> Result<Vec<AccountGroupId>, WireValidationError> {
if values.len() > MAX_ACCOUNT_GROUP_BATCH
|| values.iter().collect::<BTreeSet<_>>().len() != values.len()
{
return Err(WireValidationError::new("groupIds"));
}
values
.iter()
.cloned()
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
.collect()
}
fn provider_document(
value: Value,
field: &'static str,
) -> Result<ProviderDocument, WireValidationError> {
match value {
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
_ => Err(WireValidationError::new(field)),
}
}
fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
require_text(value, MAX_ID_BYTES, field)?;
if value.starts_with("__")
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
{
return Err(WireValidationError::new(field));
}
Ok(())
}
fn require_text(
value: &str,
max_bytes: usize,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn validate_oauth_material(
access_token: &str,
refresh_token: Option<&str>,
id_token: Option<&str>,
) -> Result<(), WireValidationError> {
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|| !valid_visible_ascii(access_token)
|| !valid_compact_jwt_shape(access_token)
{
return Err(WireValidationError::new("accessToken"));
}
if refresh_token.is_some_and(|token| {
token.len() > MAX_REFRESH_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| token == access_token
}) {
return Err(WireValidationError::new("refreshToken"));
}
if id_token.is_some_and(|token| {
token.len() > MAX_ID_TOKEN_BYTES
|| !valid_visible_ascii(token)
|| !valid_compact_jwt_shape(token)
}) {
return Err(WireValidationError::new("idToken"));
}
Ok(())
}
fn valid_compact_jwt_shape(value: &str) -> bool {
let mut segments = value.split('.');
matches!(
(segments.next(), segments.next(), segments.next(), segments.next()),
(Some(header), Some(payload), Some(signature), None)
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
)
}
fn valid_visible_ascii(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
}
pub(super) fn provider_document_value(document: ProviderDocument) -> Value {
Value::Object(document.into_provider_data().into_inner())
}
@@ -0,0 +1,573 @@
//! 账号管理路由与 HTTP handler 编排。
use super::*;
/// 构造统一账号管理路由。
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/accounts", get(list_accounts::<S>))
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
.route("/api/admin/accounts/recover", post(recover_account::<S>))
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
.route("/api/admin/accounts/update", post(update_account::<S>))
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
.route(
"/api/admin/accounts/batch-update",
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
)
.route(
"/api/admin/accounts/quota/refresh",
post(refresh_account_quota::<S>),
)
.route("/api/admin/accounts/models", get(account_models::<S>))
.route(
"/api/admin/accounts/models/refresh",
post(refresh_account_models::<S>),
)
.route(
"/api/admin/accounts/connection-test",
get(test_account_connection::<S>),
)
.route(
"/api/admin/accounts/oauth/start",
post(start_account_authorization::<S>),
)
.route(
"/api/admin/accounts/oauth/complete",
post(complete_account_authorization::<S>),
)
}
async fn batch_update_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.batch_update(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(BatchUpdatedAccountsData::from(result)),
))
}
async fn list_accounts<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = query.validate().map_err(map_wire_error)?;
let page = command.page;
let page_size = command.page_size.get();
let result = state
.admin_services()
.accounts()
.list(command)
.await
.map_err(map_service_error)?;
let data = account_page_data(result, page, page_size, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn export_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let ids = query.into_ids().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.export(&auth.context().mutation_context(), ids)
.await
.map_err(map_service_error)?;
let data = AccountExportData::from_result(result);
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn import_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.import_document(command)
.await
}
AccountProvider::Xai => state.admin_services().xai().import_document(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountImportData::from_result(result)),
))
}
async fn start_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.start_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.start_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountAuthorizationData::from(result)),
))
}
async fn complete_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => {
state
.admin_services()
.openai()
.complete_authorization(command)
.await
}
AccountProvider::Xai => {
state
.admin_services()
.xai()
.complete_authorization(command)
.await
}
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn rotate_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = state
.admin_services()
.openai()
.rotate(command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountMutationData::from(result)),
))
}
async fn update_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.update(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(UpdatedAccountData::from(result)),
))
}
async fn delete_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (provider, command) = request
.into_command(auth.context().mutation_context())
.map_err(map_wire_error)?;
let result = match provider {
AccountProvider::OpenAi => state.admin_services().openai().delete(command).await,
AccountProvider::Xai => state.admin_services().xai().delete(command).await,
}
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountDeletionData::from(result)),
))
}
async fn refresh_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.refresh(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn recover_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.recover(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.profile_statistics(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
))
}
async fn account_profile_avatar<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let avatar = state
.admin_services()
.accounts()
.profile_avatar(&account_id)
.await
.map_err(map_service_error)?;
Ok(profile_avatar_response(avatar))
}
/// 将 Provider 头像流投影为受保护的同源 HTTP 响应。
#[must_use]
pub fn profile_avatar_response(avatar: ProviderProfileAvatar) -> Response {
let ProviderProfileAvatar {
content_type,
content_length,
etag,
body,
} = avatar;
let mut response = Response::new(Body::from_stream(body));
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
content_type
.and_then(|value| HeaderValue::from_str(&value).ok())
.unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")),
);
if let Some(value) =
content_length.and_then(|length| HeaderValue::from_str(&length.to_string()).ok())
{
headers.insert(header::CONTENT_LENGTH, value);
}
if let Some(value) = etag.and_then(|value| HeaderValue::from_str(&value).ok()) {
headers.insert(header::ETAG, value);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=3600"),
);
headers.insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
headers.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
headers.insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("sandbox; default-src 'none'"),
);
response
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.quota(&account_id, true)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
account: account_view(result, Utc::now()),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_reset_credits<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.reset_credits(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
))
}
async fn consume_account_reset_credit<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.consume_reset_credit(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
))
}
async fn account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.models(&account_id, false)
.await
.map_err(map_service_error)?;
let data = account_models_data(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn refresh_account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = request.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.models(&account_id, true)
.await
.map_err(map_service_error)?;
let data = account_models_data(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn test_account_connection<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
let stream = state
.admin_services()
.accounts()
.test_connection(account_id, upstream_model)
.await
.map_err(map_service_error)?
.map(|event| {
let event = AccountConnectionTestEvent::from(event);
let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned());
Ok(Event::default().data(data))
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
@@ -0,0 +1,81 @@
//! 管理端账号目录的 HTTP 合同、凭据动作、路由处理与安全响应投影。
use std::{collections::BTreeSet, convert::Infallible, fmt};
use axum::{
Router,
body::Body,
extract::State,
http::{HeaderName, HeaderValue, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, FixedOffset, Utc};
use futures::{Stream, StreamExt as _};
use gateway_admin::model::{
AdminError as AdminServiceError, PageSize,
accounts::{
AccountConcurrencyLimit, AccountConnectionTestEvent as DomainConnectionTestEvent,
AccountCost, AccountGroupFilter, AccountListQuery, AccountModelUsage, AccountSort,
AccountSortField, AccountStatus as DomainAccountStatus, AccountUpdateResult, AccountUsage,
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
account::{OpaqueProviderData, ProviderAccountId},
routing::{AccountGroupId, ProviderKind, UpstreamModelId},
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
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,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
const MAX_PAGE_SIZE: u32 = 200;
const MAX_SEARCH_BYTES: usize = 256;
const MAX_ID_BYTES: usize = 256;
const MAX_NAME_BYTES: usize = 512;
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod credentials;
mod handlers;
mod presenter;
mod wire;
pub use credentials::*;
pub use handlers::{profile_avatar_response, router};
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,
};
use presenter::*;
use wire::BatchUpdatedAccountsData;
@@ -0,0 +1,417 @@
//! Admin 领域结果到安全 HTTP wire 的展示投影。
use super::*;
pub(super) fn account_page_data(
result: AccountDirectoryPage,
page: u32,
page_size: u16,
now: DateTime<Utc>,
) -> AccountPageData {
let total_pages = if result.total == 0 {
0
} else {
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
};
AccountPageData {
items: result
.items
.into_iter()
.map(|item| account_view(item, now))
.collect(),
page: PageMeta::new(page, u32::from(page_size), result.total, total_pages),
summary: AccountSummaryView {
total: result.summary.total,
normal: result.summary.normal,
quota_exhausted: result.summary.quota_exhausted,
rate_limited: result.summary.rate_limited,
disabled: result.summary.disabled,
error: result.summary.error,
},
}
}
pub(super) fn account_refresh_data(
result: AccountRefreshResult,
now: DateTime<Utc>,
) -> AccountRefreshData {
AccountRefreshData {
account: account_view(result.account, now),
}
}
pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
AccountModelsData {
models: result
.models
.into_iter()
.map(|model| {
let id = model.id.to_string();
AccountModelView {
label: id.clone(),
id,
}
})
.collect(),
}
}
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
projection,
usage,
quota,
} = item;
let status = projection.status.as_str().to_owned();
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
AccountView {
id: account.id.clone(),
name: account.name,
provider: account.provider_kind.to_string(),
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
resource_ref: account.id,
email: account.email,
account_id: account.upstream_account_id,
user_id: account.upstream_user_id,
label: None,
plan_type: account.plan_type,
authentication_kind: account.authentication_kind,
has_refresh_token: account.has_refresh_token,
status,
error_reason: projection
.error_reason
.map(|reason| reason.as_str().to_owned()),
error_message: projection.error_message,
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
access_token_expires_at: expires_at,
access_token_expires_at_display: account
.access_token_expires_at
.as_ref()
.map(china_datetime),
refresh_token_expires_at,
next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)),
added_at,
added_at_display: china_datetime(&account.created_at),
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, now),
}
}
pub(super) fn account_quota_view(
mut quota: ProviderQuota,
rate_limited_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> (AccountQuotaView, Option<String>) {
quota.apply_limit_reached_display();
let refresh_token_expires_at = quota
.refresh_token_expires_at
.map(|value| china_rfc3339(&value));
let refreshed_at_display = quota
.observed_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
let windows = quota.windows.into_iter().map(quota_window_view).collect();
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
(
AccountQuotaView {
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
windows,
},
refresh_token_expires_at,
)
}
pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
let ProviderQuotaWindow {
key,
group,
label,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
reset_at,
limit_reached,
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display,
window_label_display: label,
key,
group,
limit_id,
limit_name,
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
limit_reached,
local_usage: local_usage.as_ref().map(quota_local_usage),
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
}
}
pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
let total_tokens = usage.total_tokens.unwrap_or_default();
serde_json::json!({
"requestCount": usage.request_count,
"requestCountDisplay": format_number(usage.request_count),
"inputTokens": usage.input_tokens.unwrap_or_default(),
"inputTokensDisplay": display_optional_tokens(usage.input_tokens),
"outputTokens": usage.output_tokens.unwrap_or_default(),
"outputTokensDisplay": display_optional_tokens(usage.output_tokens),
"cachedTokens": usage.cached_tokens.unwrap_or_default(),
"cachedTokensDisplay": display_optional_tokens(usage.cached_tokens),
"imageInputTokens": usage.image_input_tokens.unwrap_or_default(),
"imageInputTokensDisplay": display_optional_tokens(usage.image_input_tokens),
"imageOutputTokens": usage.image_output_tokens.unwrap_or_default(),
"imageOutputTokensDisplay": display_optional_tokens(usage.image_output_tokens),
"imageRequestCount": usage.image_request_count,
"imageRequestFailedCount": usage.image_request_failed_count,
"totalTokens": total_tokens,
"totalTokensDisplay": format_compact_number(total_tokens),
"requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({
"bucketStart": bucket.bucket_start,
"requestCount": bucket.request_count,
})).collect::<Vec<_>>(),
})
}
pub(super) fn account_usage_view(
usage: Option<AccountUsage>,
now: DateTime<Utc>,
) -> AccountUsageView {
let Some(usage) = usage else {
return empty_account_usage();
};
let known_count = usage
.cost_coverage
.provider_reported_count
.saturating_add(usage.cost_coverage.calculated_count);
let cost_estimate_status = if known_count == 0 {
"unknown"
} else if usage.cost_coverage.unavailable_count > 0 {
"partial"
} else {
"known"
};
AccountUsageView {
request_count: Some(usage.request_count),
request_count_display: format_number(usage.request_count),
input_tokens: usage.input_tokens,
input_tokens_display: display_optional_tokens(usage.input_tokens),
output_tokens: usage.output_tokens,
output_tokens_display: display_optional_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
reasoning_tokens: usage.reasoning_tokens,
reasoning_tokens_display: display_optional_tokens(usage.reasoning_tokens),
image_input_tokens: usage.image_input_tokens,
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
image_output_tokens: usage.image_output_tokens,
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
image_request_count: Some(usage.image_request_count),
image_request_count_display: format_number(usage.image_request_count),
image_request_failed_count: Some(usage.image_request_failed_count),
image_request_failed_count_display: format_number(usage.image_request_failed_count),
total_tokens: usage.total_tokens,
total_tokens_display: display_optional_tokens(usage.total_tokens),
created_tokens: usage.cache_write_tokens,
created_tokens_display: display_optional_tokens(usage.cache_write_tokens),
read_tokens: usage.cached_tokens,
read_tokens_display: display_optional_tokens(usage.cached_tokens),
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
last_used_at_display: usage
.last_used_at
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now)),
cost_estimate_status: cost_estimate_status.to_owned(),
known_cost_count: Some(known_count),
partial_cost_count: Some(u64::from(cost_estimate_status == "partial")),
unknown_cost_count: Some(usage.cost_coverage.unavailable_count),
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
models: usage
.models
.into_iter()
.map(|model| account_model_usage_view(model, now))
.collect(),
}
}
pub(super) fn account_model_usage_view(
usage: AccountModelUsage,
now: DateTime<Utc>,
) -> ModelUsageView {
let known_count = usage
.cost_coverage
.provider_reported_count
.saturating_add(usage.cost_coverage.calculated_count);
let cost_estimate_status = if known_count == 0 {
"unknown"
} else if usage.cost_coverage.unavailable_count > 0 {
"partial"
} else {
"known"
};
let usd = usage
.costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"));
ModelUsageView {
model: usage.model,
request_count: usage.request_count,
request_count_display: format_number(usage.request_count),
success_rate: (usage.request_count > 0)
.then(|| usage.success_count as f64 * 100.0 / usage.request_count as f64),
success_rate_display: if usage.request_count == 0 {
"—".to_owned()
} else {
format!(
"{:.1}%",
usage.success_count as f64 * 100.0 / usage.request_count as f64
)
},
input_tokens: usage.input_tokens,
input_tokens_display: display_optional_tokens(usage.input_tokens),
output_tokens: usage.output_tokens,
output_tokens_display: display_optional_tokens(usage.output_tokens),
cached_tokens: usage.cached_tokens,
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
image_input_tokens: usage.image_input_tokens,
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
image_output_tokens: usage.image_output_tokens,
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
image_request_count: usage.image_request_count,
image_request_count_display: format_number(usage.image_request_count),
image_request_failed_count: usage.image_request_failed_count,
image_request_failed_count_display: format_number(usage.image_request_failed_count),
total_tokens: usage.total_tokens,
total_tokens_display: display_optional_tokens(usage.total_tokens),
billing_amount_usd: usd.map(|cost| cost.amount.as_str().to_owned()),
billing_amount_usd_display: usd.map_or_else(
|| "—".to_owned(),
|cost| format_decimal_currency(cost.amount.as_str(), "USD"),
),
cost_estimate_status: cost_estimate_status.to_owned(),
known_cost_count: known_count,
partial_cost_count: u64::from(cost_estimate_status == "partial"),
unknown_cost_count: usage.cost_coverage.unavailable_count,
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
last_used_at: china_rfc3339(&usage.last_used_at),
last_used_at_display: relative_time(usage.last_used_at, now),
}
}
pub(super) fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView {
CurrencyCostView {
currency: cost.currency.clone(),
estimated_amount: cost.amount.as_str().to_owned(),
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
}
}
pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
value.map_or_else(|| "—".to_owned(), format_compact_number)
}
pub(super) fn empty_account_usage() -> AccountUsageView {
AccountUsageView {
request_count: None,
request_count_display: "—".to_owned(),
input_tokens: None,
input_tokens_display: "—".to_owned(),
output_tokens: None,
output_tokens_display: "—".to_owned(),
cached_tokens: None,
cached_tokens_display: "—".to_owned(),
reasoning_tokens: None,
reasoning_tokens_display: "—".to_owned(),
image_input_tokens: None,
image_input_tokens_display: "—".to_owned(),
image_output_tokens: None,
image_output_tokens_display: "—".to_owned(),
image_request_count: None,
image_request_count_display: "—".to_owned(),
image_request_failed_count: None,
image_request_failed_count_display: "—".to_owned(),
total_tokens: None,
total_tokens_display: "—".to_owned(),
created_tokens: None,
created_tokens_display: "—".to_owned(),
read_tokens: None,
read_tokens_display: "—".to_owned(),
last_used_at: None,
last_used_at_display: "—".to_owned(),
cost_estimate_status: "unavailable".to_owned(),
known_cost_count: None,
partial_cost_count: None,
unknown_cost_count: None,
costs: Vec::new(),
models: Vec::new(),
}
}
pub(super) fn relative_time(value: DateTime<Utc>, now: DateTime<Utc>) -> String {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return china_datetime(&value);
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
}
format!("{} 天前", elapsed.num_days())
}
pub(super) fn china_offset() -> FixedOffset {
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset")
}
pub(super) fn china_rfc3339(value: &DateTime<Utc>) -> String {
value.with_timezone(&china_offset()).to_rfc3339()
}
pub(super) fn china_datetime(value: &DateTime<Utc>) -> String {
value
.with_timezone(&china_offset())
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub(super) fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("{} 字段不合法", error.field()))
}
pub(super) fn map_service_error(error: AdminServiceError) -> AdminError {
super::super::wire::map_admin_service_error(error)
}
@@ -0,0 +1,840 @@
//! 账号管理请求、响应与查询 wire contract。
use super::*;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub provider: Option<String>,
pub group_id: Option<String>,
pub search: Option<String>,
pub status: Option<String>,
pub sort_by: Option<String>,
pub sort_direction: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
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>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct BatchUpdatedAccountsData {
account_ids: Vec<String>,
config_revision: u64,
}
impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
fn from(result: AccountsUpdateResult) -> Self {
Self {
account_ids: result
.account_ids
.into_iter()
.map(|id| id.to_string())
.collect(),
config_revision: result.config_revision.get(),
}
}
}
impl BatchUpdateAccountsRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if self.account_ids.is_empty()
|| self.account_ids.len() > MAX_ACCOUNT_GROUP_BATCH
|| self
.account_ids
.iter()
.any(|id| require_account_id(id, "accountIds").is_err())
|| self.account_ids.iter().collect::<BTreeSet<_>>().len() != self.account_ids.len()
{
return Err(WireValidationError::new("accountIds"));
}
validate_wire_group_ids(&self.group_ids)?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
Ok(())
}
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
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)?,
})
}
}
impl ListQuery {
/// 解析并校验全部 wire 字段,生成 Admin 查询命令。
pub fn validate(self) -> Result<AccountListQuery, WireValidationError> {
let page = self.page.unwrap_or(1);
if page == 0 {
return Err(WireValidationError::new("page"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
let provider_kind = parse_provider(self.provider.as_deref().unwrap_or("all"))?;
let group_filter = match self.group_id.as_deref().map(str::trim) {
None | Some("") => None,
Some("ungrouped") => Some(AccountGroupFilter::Ungrouped),
Some(value) => Some(AccountGroupFilter::Group(
AccountGroupId::new(value.to_owned())
.map_err(|_| WireValidationError::new("groupId"))?,
)),
};
let search = self
.search
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
if search.as_deref().is_some_and(|value| {
value.len() > MAX_SEARCH_BYTES || value.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
let status = match self.status.as_deref().map(str::trim) {
None | Some("") => None,
Some(value) => Some(
DomainAccountStatus::parse(&value.to_ascii_lowercase())
.ok_or_else(|| WireValidationError::new("status"))?,
),
};
let sort = match (self.sort_by.as_deref(), self.sort_direction.as_deref()) {
(None, None) => None,
(Some(field), Some(direction)) => Some(AccountSort {
field: parse_sort_field(field).ok_or_else(|| WireValidationError::new("sortBy"))?,
direction: parse_sort_direction(direction)
.ok_or_else(|| WireValidationError::new("sortDirection"))?,
}),
_ => return Err(WireValidationError::new("sort")),
};
Ok(AccountListQuery {
page,
page_size: PageSize::new(
u16::try_from(page_size).map_err(|_| WireValidationError::new("pageSize"))?,
)
.map_err(|_| WireValidationError::new("pageSize"))?,
provider_kind,
group_filter,
search,
status,
sort,
})
}
}
fn parse_provider(value: &str) -> Result<Option<ProviderKind>, WireValidationError> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("all") {
return Ok(None);
}
ProviderKind::new(value.to_owned())
.map(Some)
.map_err(|_| WireValidationError::new("provider"))
}
fn parse_sort_field(value: &str) -> Option<AccountSortField> {
match value.trim() {
"email" => Some(AccountSortField::Email),
"status" => Some(AccountSortField::Status),
"planType" => Some(AccountSortField::PlanType),
"usage" => Some(AccountSortField::Usage),
"lastUsedAt" => Some(AccountSortField::LastUsedAt),
"expiresAt" => Some(AccountSortField::ExpiresAt),
_ => None,
}
}
fn parse_sort_direction(value: &str) -> Option<SortDirection> {
match value.trim().to_ascii_lowercase().as_str() {
"asc" => Some(SortDirection::Asc),
"desc" => Some(SortDirection::Desc),
_ => None,
}
}
/// 账号列表响应数据。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPageData {
pub items: Vec<AccountView>,
pub page: PageMeta,
pub summary: AccountSummaryView,
}
/// 账号概览计数。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSummaryView {
pub total: u64,
pub normal: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 一条安全账号视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub id: String,
pub name: String,
pub provider: String,
pub groups: Vec<AccountGroupRefView>,
pub resource_ref: String,
pub email: Option<String>,
pub account_id: Option<String>,
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
pub authentication_kind: String,
pub has_refresh_token: bool,
pub status: String,
/// `status == "error"` 时的具体原因;其余状态为 `null`。
pub error_reason: Option<String>,
/// 最近一次失败的上游错误描述;仅错误状态存在。
pub error_message: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<u32>,
pub weight: u16,
pub access_token_expires_at: Option<String>,
pub access_token_expires_at_display: Option<String>,
pub refresh_token_expires_at: Option<String>,
pub next_refresh_at: Option<String>,
pub added_at: String,
pub added_at_display: String,
pub updated_at: String,
pub updated_at_display: String,
pub quota: AccountQuotaView,
pub usage: AccountUsageView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountGroupRefView {
pub id: String,
pub name: String,
pub color: String,
pub enabled: bool,
}
/// Provider quota 安全视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaView {
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
pub rate_limited_until: Option<String>,
pub windows: Vec<AccountQuotaWindowView>,
}
/// 一个 quota 时间窗口。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub limit_reached: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_usage: Option<serde_json::Value>,
pub reset_at_display: String,
}
/// 账号观测用量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageView {
pub request_count: Option<u64>,
pub request_count_display: String,
pub input_tokens: Option<u64>,
pub input_tokens_display: String,
pub output_tokens: Option<u64>,
pub output_tokens_display: String,
pub cached_tokens: Option<u64>,
pub cached_tokens_display: String,
pub reasoning_tokens: Option<u64>,
pub reasoning_tokens_display: String,
pub image_input_tokens: Option<u64>,
pub image_input_tokens_display: String,
pub image_output_tokens: Option<u64>,
pub image_output_tokens_display: String,
pub image_request_count: Option<u64>,
pub image_request_count_display: String,
pub image_request_failed_count: Option<u64>,
pub image_request_failed_count_display: String,
pub total_tokens: Option<u64>,
pub total_tokens_display: String,
pub created_tokens: Option<u64>,
pub created_tokens_display: String,
pub read_tokens: Option<u64>,
pub read_tokens_display: String,
pub last_used_at: Option<String>,
pub last_used_at_display: String,
pub cost_estimate_status: String,
pub known_cost_count: Option<u64>,
pub partial_cost_count: Option<u64>,
pub unknown_cost_count: Option<u64>,
pub costs: Vec<CurrencyCostView>,
pub models: Vec<ModelUsageView>,
}
/// 凭据在单个上游模型上的观测用量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsageView {
pub model: String,
pub request_count: u64,
pub request_count_display: String,
pub success_rate: Option<f64>,
pub success_rate_display: String,
pub input_tokens: Option<u64>,
pub input_tokens_display: String,
pub output_tokens: Option<u64>,
pub output_tokens_display: String,
pub cached_tokens: Option<u64>,
pub cached_tokens_display: String,
pub image_input_tokens: Option<u64>,
pub image_input_tokens_display: String,
pub image_output_tokens: Option<u64>,
pub image_output_tokens_display: String,
pub image_request_count: u64,
pub image_request_count_display: String,
pub image_request_failed_count: u64,
pub image_request_failed_count_display: String,
pub total_tokens: Option<u64>,
pub total_tokens_display: String,
pub billing_amount_usd: Option<String>,
pub billing_amount_usd_display: String,
pub cost_estimate_status: String,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unknown_cost_count: u64,
pub costs: Vec<CurrencyCostView>,
pub last_used_at: String,
pub last_used_at_display: String,
}
/// 单一货币的可查询成本。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrencyCostView {
pub currency: String,
pub estimated_amount: String,
pub estimated_amount_display: String,
}
/// 账号详情类 GET 的固定 ID query。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountIdQuery {
pub account_id: String,
}
impl AccountIdQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 头像 GET 的固定 query;`version` 只参与浏览器缓存键,不进入上游请求。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountProfileAvatarQuery {
pub account_id: String,
pub version: Option<String>,
}
impl AccountProfileAvatarQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.version.as_deref().is_some_and(|version| {
version.is_empty()
|| version.len() > MAX_AVATAR_VERSION_BYTES
|| !version.bytes().all(|byte| byte.is_ascii_alphanumeric())
}) {
return Err(WireValidationError::new("version"));
}
Ok(())
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountExportQuery {
pub account_ids: String,
pub confirm: String,
}
impl AccountExportQuery {
pub fn into_ids(self) -> Result<Vec<ProviderAccountId>, WireValidationError> {
if self.confirm != "export_sensitive_accounts" {
return Err(WireValidationError::new("confirm"));
}
let ids = self
.account_ids
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if ids.is_empty()
|| ids.len() > 200
|| ids
.iter()
.any(|id| require_account_id(id, "accountIds").is_err())
{
return Err(WireValidationError::new("accountIds"));
}
let unique = ids.iter().collect::<BTreeSet<_>>();
if unique.len() != ids.len() {
return Err(WireValidationError::new("accountIds"));
}
ids.into_iter()
.map(|id| {
ProviderAccountId::new(id).map_err(|_| WireValidationError::new("accountIds"))
})
.collect()
}
}
/// 账号运行期动作。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountActionRequest {
pub account_id: String,
}
impl AccountActionRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 主动额度重置卡消费请求。幂等键由 UI 生成并在不确定重试时复用,与官方一致。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditConsumeRequest {
pub account_id: String,
#[serde(default)]
pub credit_id: Option<String>,
pub redeem_request_id: String,
}
impl AccountResetCreditConsumeRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.credit_id.as_deref().is_some_and(|value| {
let value = value.trim();
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("creditId"));
}
let value = self.redeem_request_id.trim();
let redeem_request_id =
Uuid::parse_str(value).map_err(|_| WireValidationError::new("redeemRequestId"))?;
if redeem_request_id.get_version() != Some(Version::Random)
|| redeem_request_id.hyphenated().to_string() != value
{
return Err(WireValidationError::new("redeemRequestId"));
}
Ok(())
}
pub(super) fn into_command(self) -> Result<ConsumeProviderResetCredit, WireValidationError> {
self.validate()?;
Ok(ConsumeProviderResetCredit {
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
credit_id: self.credit_id.map(|value| value.trim().to_owned()),
redeem_request_id: Uuid::parse_str(&self.redeem_request_id)
.map_err(|_| WireValidationError::new("redeemRequestId"))?,
})
}
}
/// 手工 OAuth 刷新会变更持久 credential。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountRefreshRequest {
pub account_id: String,
}
impl AccountRefreshRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")
}
pub(super) fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 连接测试 query;测试仍经唯一 Core/Provider 模型请求路径执行。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountTestQuery {
pub account_id: String,
pub model_id: String,
}
impl AccountTestQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.model_id.trim().is_empty() || self.model_id.chars().any(char::is_control) {
return Err(WireValidationError::new("modelId"));
}
Ok(())
}
pub(super) fn into_command(
self,
) -> Result<(ProviderAccountId, UpstreamModelId), WireValidationError> {
self.validate()?;
Ok((
ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
UpstreamModelId::new(self.model_id).map_err(|_| WireValidationError::new("modelId"))?,
))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountModelView {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountModelsData {
pub models: Vec<AccountModelView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountRefreshData {
pub account: AccountView,
}
#[derive(Debug, Clone, Serialize)]
pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsData {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: AccountProfileStatisticsSummaryView,
pub daily_usage: Option<Vec<AccountProfileDailyUsageView>>,
pub activity_insights: AccountProfileActivityInsightsView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsSummaryView {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileDailyUsageView {
pub date: String,
pub tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileActivityInsightsView {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<AccountProfileInvocationView>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileInvocationView {
#[serde(rename = "type")]
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
fn from(statistics: ProviderProfileStatistics) -> Self {
Self {
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: profile_statistics_summary_view(statistics.summary),
daily_usage: statistics
.daily_usage
.map(|daily| daily.into_iter().map(profile_daily_usage_view).collect()),
activity_insights: profile_activity_insights_view(statistics.activity_insights),
}
}
}
const fn profile_statistics_summary_view(
summary: ProviderProfileStatisticsSummary,
) -> AccountProfileStatisticsSummaryView {
AccountProfileStatisticsSummaryView {
total_text_tokens: summary.total_text_tokens,
peak_tokens: summary.peak_tokens,
longest_task_duration_ms: summary.longest_task_duration_ms,
current_streak_days: summary.current_streak_days,
longest_streak_days: summary.longest_streak_days,
}
}
fn profile_daily_usage_view(daily: ProviderProfileDailyUsage) -> AccountProfileDailyUsageView {
AccountProfileDailyUsageView {
date: daily.date.to_string(),
tokens: daily.tokens,
}
}
fn profile_activity_insights_view(
insights: ProviderProfileActivityInsights,
) -> AccountProfileActivityInsightsView {
AccountProfileActivityInsightsView {
fast_mode_percent: insights.fast_mode_percent,
reasoning_effort: insights.reasoning_effort,
reasoning_effort_percent: insights.reasoning_effort_percent,
skills_explored: insights.skills_explored,
total_skills_used: insights.total_skills_used,
total_threads: insights.total_threads,
invocations: insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(profile_invocation_view)
.collect()
}),
}
}
fn profile_invocation_view(invocation: ProviderProfileInvocation) -> AccountProfileInvocationView {
AccountProfileInvocationView {
invocation_type: invocation.invocation_type,
plugin_id: invocation.plugin_id,
plugin_name: invocation.plugin_name,
skill_id: invocation.skill_id,
skill_name: invocation.skill_name,
usage_count: invocation.usage_count,
}
}
/// 主动额度重置卡列表响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditsData {
pub available_count: u64,
pub credits: Vec<AccountResetCreditView>,
}
/// 一张安全主动额度重置卡视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditView {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<String>,
pub reset_type: Option<String>,
}
/// 主动额度重置卡消费响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditResultData {
pub code: String,
pub credit: Option<AccountResetCreditView>,
}
impl From<ProviderResetCredit> for AccountResetCreditView {
fn from(credit: ProviderResetCredit) -> Self {
Self {
id: credit.id,
status: credit.status,
title: credit.title,
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
reset_type: credit.reset_type,
}
}
}
impl From<ProviderResetCredits> for AccountResetCreditsData {
fn from(credits: ProviderResetCredits) -> Self {
Self {
available_count: credits.available_count,
credits: credits
.credits
.into_iter()
.map(AccountResetCreditView::from)
.collect(),
}
}
}
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
fn from(result: ProviderResetCreditResult) -> Self {
Self {
code: result.code,
credit: result.credit.map(AccountResetCreditView::from),
}
}
}
/// Provider-owned 明文导出文档;Debug 永远不输出内部 JSON。
#[derive(Serialize)]
#[serde(transparent)]
pub struct AccountExportData(Value);
impl AccountExportData {
#[must_use]
pub const fn new(value: Value) -> Self {
Self(value)
}
pub(super) fn from_result(bundle: AccountExportBundle) -> Self {
let documents = bundle
.documents
.into_iter()
.map(|document| {
serde_json::json!({
"provider": document.provider_kind.to_string(),
"document": provider_document_value(document.document),
})
})
.collect::<Vec<_>>();
Self::new(serde_json::json!({
"exportedAt": bundle.exported_at.to_rfc3339(),
"documents": documents,
}))
}
}
impl fmt::Debug for AccountExportData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AccountExportData(<redacted>)")
}
}
pub struct AccountConnectionTestEvent {
pub data: Value,
}
impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
fn from(event: DomainConnectionTestEvent) -> Self {
let data = match event {
DomainConnectionTestEvent::Started { model } => serde_json::json!({
"type": "test_start",
"model": model,
"text": "正在连接上游 Responses"
}),
DomainConnectionTestEvent::Request {
model,
input_text,
stream,
store,
} => serde_json::json!({
"type": "request",
"payload": {
"model": model,
"input": [{
"role": "user",
"content": [{ "type": "input_text", "text": input_text }]
}],
"stream": stream,
"store": store
}
}),
DomainConnectionTestEvent::Content { text } => {
serde_json::json!({ "type": "content", "text": text })
}
DomainConnectionTestEvent::Completed => serde_json::json!({
"type": "test_complete",
"success": true
}),
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
send_state,
message,
provider_error_code,
provider_error_type,
upstream_status,
upstream_content_type,
upstream_body,
} => serde_json::json!({
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
"providerErrorType": provider_error_type,
"upstreamStatus": upstream_status,
"upstreamContentType": upstream_content_type,
"upstreamBody": upstream_body
}),
};
Self { data }
}
}
@@ -505,6 +505,8 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetailView {
UsageRecordDetailView {
trace: detail.trace,
related_requests: detail.related_requests,
request: usage_record_view(detail.request),
attempts: detail
.attempts
@@ -558,8 +560,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
transport: error.upstream_transport,
attempt_index: error.attempt_index,
failure_class: error.failure_kind,
upstream_send_state: error.upstream_send_state,
provider_error_code: error.provider_error_code,
occurrence_count: error.occurrence_count,
response_id: error.client_response_id,
upstream_request_id: error.upstream_request_id,
latency_ms: error.latency_ms,
@@ -571,11 +573,24 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
subagent_kind: error.subagent_kind,
compact: error.compact,
message: error.message,
raw_upstream_error: error.raw_upstream_error,
metadata: OpsErrorMetadataView {
source: error.source,
component: error.component,
attempt_id: None,
account_label,
continuation_affinity_hash: error.continuation_affinity_hash,
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
continuation_unavailable_reason: error.continuation_unavailable_reason,
upstream_connection_id: error.upstream_connection_id,
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
upstream_connection_age_ms: error.upstream_connection_age_ms,
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
recovery_request_id: error.recovery_request_id,
recovered_at: error.recovered_at,
recovery_attempt_count: error.recovery_attempt_count,
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
recovery_total_latency_ms: error.recovery_total_latency_ms,
},
created_at: error.occurred_at,
created_at_display: china_datetime(&error.occurred_at),
@@ -850,6 +865,28 @@ pub(crate) fn dashboard_view(
} = observation;
let mut account_usage_views = Vec::with_capacity(account_usage.len());
for credential in account_usage {
let local_requests = credential
.quota_window
.as_ref()
.filter(|window| window.used_percent.is_none())
.and_then(|window| window.local_usage.as_ref())
.map(|usage| usage.request_count);
let (metric_label, metric_value) = match local_requests {
Some(count) => (
"次数".to_owned(),
if count > 0 {
format_compact_number(count)
} else {
"—".to_owned()
},
),
None => (
"今日 Token".to_owned(),
credential
.total_tokens
.map_or_else(|| "—".to_owned(), format_compact_number),
),
};
account_usage_views.push(DashboardAccountUsageView {
id: credential.account_id.clone(),
provider: credential.provider_kind.clone(),
@@ -872,6 +909,11 @@ pub(crate) fn dashboard_view(
})
.collect(),
quota_used_percent: credential.quota_used_percent,
usage_window: credential
.quota_window
.map(crate::admin::accounts::quota_window_view),
metric_label,
metric_value,
last_used: relative_time(credential.last_used_at, range.end),
});
}
@@ -107,14 +107,12 @@ pub struct OpsQuery {
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub failure_class: Option<String>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
@@ -352,13 +350,11 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.kind.clone()),
endpoint: non_empty(query.route.clone()),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
@@ -247,6 +247,8 @@ pub struct UsageAttemptView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordDetailView {
pub trace: Option<serde_json::Value>,
pub related_requests: Vec<serde_json::Value>,
#[serde(flatten)]
pub request: UsageRecordView,
pub attempts: Vec<UsageAttemptView>,
@@ -375,6 +377,9 @@ pub struct DashboardAccountUsageView {
pub request_count: u64,
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
pub quota_used_percent: Option<f64>,
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
pub metric_label: String,
pub metric_value: String,
pub last_used: String,
}
@@ -776,8 +781,8 @@ pub struct OpsErrorView {
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub failure_class: String,
pub upstream_send_state: Option<String>,
pub provider_error_code: Option<String>,
pub occurrence_count: u32,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
@@ -789,6 +794,8 @@ pub struct OpsErrorView {
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub message: String,
/// 上游错误正文或 WebSocket close/error frame 原文;不做脱敏。
pub raw_upstream_error: Option<String>,
pub metadata: OpsErrorMetadataView,
pub created_at: DateTime<Utc>,
pub created_at_display: String,
@@ -802,4 +809,16 @@ pub struct OpsErrorMetadataView {
pub component: String,
pub attempt_id: Option<String>,
pub account_label: Option<String>,
pub continuation_affinity_hash: Option<String>,
pub continuation_previous_response_id_hash: Option<String>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_id: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub upstream_connection_age_ms: Option<u64>,
pub upstream_connection_idle_ms: Option<u64>,
pub recovery_request_id: Option<String>,
pub recovered_at: Option<DateTime<Utc>>,
pub recovery_attempt_count: u32,
pub recovery_retry_delay_ms: Option<u64>,
pub recovery_total_latency_ms: Option<u64>,
}
@@ -3,7 +3,7 @@
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
//! 各自维护格式化规则。
use gateway_core::accounting::Decimal;
use gateway_core::metering::Decimal;
/// 千分位格式化。
#[must_use]
@@ -10,14 +10,18 @@ use axum::{
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::client_distribution::{
ClientDownloadPackage, CodexDesktopWindowsDownloads,
};
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::policy::CodexClientVersion;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
@@ -34,6 +38,8 @@ pub struct RuntimeSettingsView {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -50,6 +56,8 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_concurrent_per_account: u64,
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
@@ -75,6 +83,14 @@ impl UpdateRuntimeSettingsRequest {
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
return Err(WireValidationError::new("rotationStrategy"));
}
validate_optional_client_version(
self.min_codex_desktop_version.as_deref(),
"minCodexDesktopVersion",
)?;
validate_optional_client_version(
self.min_codex_cli_version.as_deref(),
"minCodexCliVersion",
)?;
Ok(())
}
@@ -90,6 +106,8 @@ impl UpdateRuntimeSettingsRequest {
request_interval_ms: self.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
min_codex_cli_version: self.min_codex_cli_version,
usage_retention_days: u32::try_from(self.usage_retention_days)
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
@@ -109,6 +127,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
request_interval_ms: settings.request_interval_ms,
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,
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
@@ -145,6 +165,58 @@ pub struct DeletedAdminApiKey {
pub message: &'static str,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ClientDownloadsQuery {
refresh: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClientDownloadPackageView {
architecture: String,
source: String,
version: Option<String>,
file_name: String,
size_bytes: Option<u64>,
download_url: String,
expires_at: Option<DateTime<Utc>>,
}
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
fn from(package: ClientDownloadPackage) -> Self {
Self {
architecture: package.architecture.as_str().to_owned(),
source: package.source.as_str().to_owned(),
version: package.version,
file_name: package.file_name,
size_bytes: package.size_bytes,
download_url: package.download_url,
expires_at: package.expires_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexDesktopWindowsDownloadsView {
resolved_at: DateTime<Utc>,
cached: bool,
warning: Option<String>,
packages: Vec<ClientDownloadPackageView>,
}
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
Self {
resolved_at: downloads.resolved_at,
cached: downloads.cached,
warning: downloads.warning,
packages: downloads.packages.into_iter().map(Into::into).collect(),
}
}
}
impl Default for DeletedAdminApiKey {
fn default() -> Self {
Self {
@@ -161,6 +233,10 @@ where
Router::new()
.route("/api/admin/settings", get(settings::<S>))
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
get(codex_desktop_windows_downloads::<S>),
)
.route(
"/api/admin/settings/admin-api-key",
get(admin_api_key_status::<S>),
@@ -175,6 +251,25 @@ where
)
}
async fn codex_desktop_windows_downloads<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
) -> impl IntoResponse
where
S: AdminSessionState + Send + Sync,
{
let downloads = state
.admin_services()
.client_distribution()
.codex_desktop_windows(query.refresh)
.await;
AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
)
}
async fn settings<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -322,6 +417,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn validate_optional_client_version(
value: Option<&str>,
field: &'static str,
) -> Result<(), WireValidationError> {
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
return Err(WireValidationError::new(field));
}
Ok(())
}
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
@@ -329,6 +434,8 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
};
AdminError::bad_request(message)
+8 -5
View File
@@ -180,13 +180,16 @@ pub fn initialize(
"request",
request_id = %request_id,
method = %request.method().as_str(),
uri = %request
.uri()
.path_and_query()
.map(axum::http::uri::PathAndQuery::as_str)
.unwrap_or_default(),
uri = %request.uri().path(),
)
}
})
.on_request(|_request: &Request<_>, _span: &tracing::Span| {
tracing::info!(target: "request_trace", stage = "http.received", "HTTP request received");
})
.on_response(|response: &axum::http::Response<_>, latency: Duration, _span: &tracing::Span| {
tracing::info!(target: "request_trace", stage = "http.response", status = response.status().as_u16(),
headers_ms = latency.as_millis(), "HTTP response headers ready");
});
let router = router
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
+153 -9
View File
@@ -5,9 +5,13 @@ use axum::{
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::AuthenticatedClient;
use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion};
use super::{
error::{missing_client_api_key_response, runtime_unavailable_response},
error::{
client_version_rejection_response, missing_client_api_key_response,
runtime_unavailable_response,
},
service::OpenAiService,
};
@@ -26,6 +30,44 @@ pub enum ClientApiKeyAuthError {
RuntimeUnavailable,
}
/// Client API Key 或最低版本准入失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientAccessError {
Authentication(ClientApiKeyAuthError),
Version(ClientVersionRejection),
}
impl From<ClientApiKeyAuthError> for ClientAccessError {
fn from(error: ClientApiKeyAuthError) -> Self {
Self::Authentication(error)
}
}
impl From<ClientVersionRejection> for ClientAccessError {
fn from(error: ClientVersionRejection) -> Self {
Self::Version(error)
}
}
/// 从有界请求头中识别出的客户端及其可选合法版本。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentifiedCodexClient {
kind: CodexClientKind,
version: Option<CodexClientVersion>,
}
impl IdentifiedCodexClient {
#[must_use]
pub const fn kind(&self) -> CodexClientKind {
self.kind
}
#[must_use]
pub const fn version(&self) -> Option<&CodexClientVersion> {
self.version.as_ref()
}
}
impl ClientApiKeyAuthError {
/// 返回可用于日志和指标的稳定失败原因。
#[must_use]
@@ -67,19 +109,121 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
pub(crate) fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
) -> Result<AuthenticatedClient, ClientAccessError> {
let key = bearer_client_api_key(headers)?;
let client = service.authenticate(key)?;
if let Some(identified) = identify_codex_client(headers) {
client
.snapshot()
.min_codex_client_versions()
.enforce(identified.kind(), identified.version())?;
}
Ok(client)
}
pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response {
match error {
ClientAccessError::Authentication(error) => {
log_client_api_key_auth_failure(error);
if error == ClientApiKeyAuthError::RuntimeUnavailable {
runtime_unavailable_response().into_response()
} else {
missing_client_api_key_response().into_response()
}
}
ClientAccessError::Version(error) => {
tracing::info!(
client = error.kind().as_str(),
current_version = error.current().map(ToString::to_string),
min_version = %error.min(),
"Codex client version requirement rejected request"
);
client_version_rejection_response(&error).into_response()
}
}
}
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH);
let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH);
let is_desktop = originator.is_some_and(|value| value.eq_ignore_ascii_case("Codex Desktop"))
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
if is_desktop {
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
let version = match explicit_version {
Some(value) => CodexClientVersion::parse(value).ok(),
None => user_agent
.and_then(desktop_version_from_user_agent)
.and_then(|value| CodexClientVersion::parse(value).ok()),
};
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Desktop,
version,
});
}
let user_agent = user_agent?;
for product in user_agent.split(|character: char| {
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
}) {
for prefix in ["codex_cli_rs", "codex-cli"] {
if product.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: None,
});
}
let Some((name, version)) = product.split_once('/') else {
continue;
};
if name.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: CodexClientVersion::parse(version).ok(),
});
}
}
}
None
}
fn bounded_ascii_header<'a>(
headers: &'a HeaderMap,
name: &'static str,
maximum_length: usize,
) -> Option<&'a str> {
let value = headers.get(name)?.to_str().ok()?;
if !value.is_ascii() {
return None;
}
Some(&value[..value.len().min(maximum_length)])
}
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
let marker = "Codex Desktop;";
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
let version = user_agent[start..]
.trim_start()
.split([')', ' ', ';', ','])
.next()?;
(!version.is_empty()).then_some(version)
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
find_ascii_case_insensitive(haystack, needle).is_some()
}
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
haystack
.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
match error {
ClientApiKeyAuthError::RuntimeUnavailable => {
@@ -9,6 +9,7 @@ use axum::{
use gateway_core::{
engine::EngineError,
error::{GatewayError, GatewayErrorKind},
policy::{ClientVersionRejection, CodexClientKind},
};
use serde_json::{Value, json};
@@ -58,6 +59,47 @@ pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
)
}
/// 已识别 Codex 客户端不满足最低版本要求。
pub fn client_version_rejection_response(
rejection: &ClientVersionRejection,
) -> (StatusCode, Json<Value>) {
let client_label = match rejection.kind() {
CodexClientKind::Desktop => "Codex Desktop",
CodexClientKind::Cli => "Codex CLI",
};
let (message, code, current_version) = match rejection.current() {
Some(current) => (
format!(
"{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.",
rejection.min()
),
"client_version_too_old",
Some(current.to_string()),
),
None => (
format!(
"A valid {client_label} version is required. Upgrade to version {} or newer and retry.",
rejection.min()
),
"client_version_unavailable",
None,
),
};
(
StatusCode::UPGRADE_REQUIRED,
Json(json!({
"error": {
"message": message,
"type": "invalid_request_error",
"code": code,
"client": rejection.kind().as_str(),
"current_version": current_version,
"min_version": rejection.min().to_string()
}
})),
)
}
/// 对外模型不存在。
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
openai_error_response(
@@ -3,22 +3,21 @@
use std::net::SocketAddr;
use axum::{
body::{Body, Bytes},
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
http::HeaderMap,
response::Response,
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
use serde_json::{Map, Value};
use serde_json::Value;
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
error::{engine_error_response, gateway_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
auth::{authenticate_client, client_access_error_response},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::{OpenAiRequestHeaders, request_client_context},
};
const OPENAI_PROTOCOL: &str = "openai";
@@ -71,7 +70,7 @@ async fn handle_image_request(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
@@ -82,13 +81,13 @@ async fn handle_image_request(
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_image(client, operation, client_ip, user_agent, endpoint)
.start_provider_endpoint(client, operation, client_ip, user_agent, endpoint)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_image_response(started).await
collect_raw_json_response(started).await
}
fn image_operation(
@@ -96,7 +95,7 @@ fn image_operation(
headers: &HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = Map::new();
let mut context = OpenAiRequestHeaders::from_headers(headers).session_context();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
@@ -118,116 +117,3 @@ fn image_operation(
kind, payload,
)))
}
async fn collect_image_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return invalid_upstream_response();
}
execution.disarm();
response
}
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options =
crate::openai::responses::response_connection_options(response_headers);
for header in response_headers {
if !crate::openai::responses::response_header_is_forwardable(
header.name(),
&connection_options,
) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream image response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -4,6 +4,8 @@ pub mod auth;
pub mod error;
pub mod images;
pub mod models;
mod provider_endpoint;
pub mod responses;
pub mod router;
pub mod search;
pub(crate) mod service;
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use crate::ApiState;
use super::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::model_not_found_response,
};
@@ -34,7 +34,7 @@ pub(crate) async fn models(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
if query
@@ -81,7 +81,7 @@ pub(crate) async fn model_detail(
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
@@ -97,50 +97,6 @@ pub(crate) async fn model_detail(
.into_response()
}
/// `GET /v1/models/catalog`。
///
/// 新架构的目录由 core 按客户端 Provider 范围裁剪;这里不读取或重建任一
/// Provider 的 wire catalog,只恢复 Codex 客户端依赖的稳定展示合同。
pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: HeaderMap) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let models = service
.public_models(&client)
.into_iter()
.map(|model| catalog_model_json(model.as_str()))
.collect::<Vec<_>>();
(StatusCode::OK, Json(models)).into_response()
}
/// `GET /v1/models/{model_id}/info`。
pub(crate) async fn model_info(
State(state): State<ApiState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let Ok(public_model) = PublicModelId::new(model_id) else {
return model_not_found_response().into_response();
};
if !service.contains_public_model(&client, &public_model) {
return model_not_found_response().into_response();
}
(
StatusCode::OK,
Json(catalog_model_json(public_model.as_str())),
)
.into_response()
}
fn openai_model_json(id: &str) -> Value {
json!({
"id": id,
@@ -150,22 +106,6 @@ fn openai_model_json(id: &str) -> Value {
})
}
fn catalog_model_json(id: &str) -> Value {
json!({
"id": id,
"displayName": id,
"description": "",
"isDefault": false,
"supportedReasoningEfforts": [],
"defaultReasoningEffort": "",
"inputModalities": ["text"],
"outputModalities": ["text"],
"supportsPersonality": false,
"upgrade": Value::Null,
"source": "gateway",
})
}
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
let id = profile.model().as_str();
let presentation = profile.presentation();
@@ -0,0 +1,124 @@
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界。
use axum::{
body::{Body, Bytes},
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use super::{
error::{engine_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody},
};
/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。
pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return invalid_upstream_response();
}
execution.disarm();
response
}
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options = super::responses::response_connection_options(response_headers);
for header in response_headers {
if !super::responses::response_header_is_forwardable(header.name(), &connection_options) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream JSON response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -3,6 +3,7 @@
use std::collections::VecDeque;
use std::convert::Infallible;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use axum::{
body::{Body, Bytes},
@@ -13,9 +14,10 @@ use axum::{
},
response::{IntoResponse, Response},
};
use futures::stream;
use gateway_core::engine::CommitRequirement;
use futures::{StreamExt, stream};
use gateway_core::diagnostics::TraceContext;
use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution};
use gateway_core::engine::{CommitRequirement, EngineError};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::lifecycle::ConnectionGuard;
@@ -23,7 +25,7 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::{
engine_error_response, gateway_error_contract, gateway_error_from_engine,
gateway_error_response, protocol_error_response, runtime_unavailable_response,
@@ -32,46 +34,25 @@ use crate::openai::{
use super::{
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
request::{decode_request_with_headers, decode_review_request_with_headers},
request::decode_request_with_headers,
};
const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
/// `POST /v1/responses`。
pub(crate) async fn responses(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, false).await
}
/// `POST /v1/responses/review`。
pub(crate) async fn review_responses(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, connect_info, headers, body, true).await
}
async fn handle_responses(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
review: bool,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let decoded = match if review {
decode_review_request_with_headers(&body, &headers)
} else {
decode_request_with_headers(&body, &headers)
} {
let decoded = match decode_request_with_headers(&body, &headers) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -100,26 +81,32 @@ async fn handle_responses(
} else {
ClientTransport::HttpJson
},
if review {
"/v1/responses/review"
} else {
"/v1/responses"
},
"/v1/responses",
)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
let trace = started.session.trace();
trace.headers("client.request", serde_json::json!({
"ingressRequestId": ingress_id.as_ref().and_then(|Extension(id)| id.header_value().to_str().ok()),
}), headers.iter().map(|(name, value)| (name.as_str(), value.as_bytes())));
trace.capture("client.request.body", &body);
let request_id_header = HeaderValue::from_str(started.request_id.as_str()).ok();
let StartedExecution {
stream, session, ..
} = started;
if stream {
let mut response = if stream {
stream_execution_response(session, connection_guard).await
} else {
drop(connection_guard);
collect_execution_response(session).await
};
if let Some(id) = request_id_header {
response.headers_mut().insert("x-gateway-request-id", id);
}
response
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
@@ -180,7 +167,8 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
let response_headers = session.response_headers().to_vec();
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
};
@@ -196,6 +184,11 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
}
};
let response_headers = session.response_headers().to_vec();
session.trace().record(
"downstream.encoded",
serde_json::json!({"transport": "http_json", "bytes": encoded.len()}),
);
session.trace().dump("downstream.body", &encoded);
let response = json_body_response(encoded, &response_headers);
let Some(session) = execution.session_mut() else {
@@ -206,7 +199,7 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
@@ -274,6 +267,13 @@ fn apply_response_headers(
response
}
fn engine_error_response_with_headers(
error: &EngineError,
response_headers: &[ProviderResponseHeader],
) -> Response {
apply_response_headers(engine_error_response(error), response_headers)
}
/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。
pub async fn stream_execution_response(
session: Box<dyn ExecutionSession>,
@@ -295,7 +295,8 @@ pub async fn stream_execution_response(
return response;
}
Err(error) => {
let response = engine_error_response(&error);
let response_headers = session.response_headers().to_vec();
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
};
@@ -327,7 +328,7 @@ pub async fn stream_execution_response(
.await
{
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
let response = engine_error_response_with_headers(&error, &response_headers);
return execution.record_response_status(response).await;
}
let Some(session) = execution.into_session() else {
@@ -337,9 +338,11 @@ pub async fn stream_execution_response(
if state.encoder.is_completed() {
state.finish_completed(Vec::new()).await;
}
let body = Body::from_stream(stream::unfold(state, |mut state| async move {
let output = Box::pin(stream::unfold(state, |mut state| async move {
loop {
if let Some(chunk) = state.pending.pop_front() {
state.trace.dump("downstream.chunk", &chunk);
state.handed_off_bytes += chunk.len() as u64;
return Some((Ok::<Bytes, Infallible>(chunk), state));
}
if state.output_finished {
@@ -348,19 +351,35 @@ pub async fn stream_execution_response(
state.advance().await;
}
}));
// 保活只轮询已固定的输出 stream;不能取消并重建 advance/next_event future,
// 否则一次心跳就可能丢失正在等待的上游事件或执行终态清理。
let body = Body::from_stream(stream::unfold(output, |mut output| async move {
let chunk = tokio::select! {
biased;
chunk = output.next() => chunk?,
() = tokio::time::sleep(SSE_KEEPALIVE_INTERVAL) => {
Ok(Bytes::from_static(b": keep-alive\n\n"))
}
};
Some((chunk, output))
}));
event_stream_response(body, &response_headers)
}
fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]) -> Response {
let mut response = Response::new(body);
let mut response = apply_response_headers(Response::new(body), response_headers);
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-transform"),
);
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
apply_response_headers(response, response_headers)
.insert("x-accel-buffering", HeaderValue::from_static("no"));
response
}
fn internal_gateway_response(message: &'static str) -> Response {
@@ -422,12 +441,18 @@ impl Drop for PendingExecution {
if session.is_finalized() {
return;
}
session.trace().record(
"downstream.cancelled",
serde_json::json!({"reason": "response_guard_dropped"}),
);
session.cancel();
detach_finalize(session);
}
}
struct ResponsesStreamState {
trace: TraceContext,
handed_off_bytes: u64,
session: Option<Box<dyn ExecutionSession>>,
encoder: OpenAiResponsesEncoder,
pending: VecDeque<Bytes>,
@@ -444,6 +469,8 @@ impl ResponsesStreamState {
connection_guard: Option<Box<dyn ConnectionGuard>>,
) -> Self {
Self {
trace: session.trace(),
handed_off_bytes: 0,
session: Some(session),
encoder,
pending: initial_frames.into_iter().collect(),
@@ -599,6 +626,11 @@ impl ResponsesStreamState {
impl Drop for ResponsesStreamState {
fn drop(&mut self) {
self.trace.record("downstream.body.closed", serde_json::json!({
"handedOffBytes": self.handed_off_bytes,
"pendingChunks": self.pending.len(),
"outputFinished": self.output_finished, "executionTerminal": self.execution_terminal,
}));
if self.execution_terminal {
return;
}
@@ -4,12 +4,12 @@ mod error;
mod http;
mod request;
mod response;
mod websocket;
pub mod websocket;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(crate) use http::responses;
pub(super) use http::{PendingExecution, request_client_context};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use http::{responses, review_responses};
pub use request::{
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
decode_request_with_headers,
@@ -13,9 +13,19 @@ use serde_json::{Map, Value};
use super::error::RequestDecodeError;
const OPENAI_PROTOCOL: &str = "openai";
const REVIEW_SUBAGENT: &str = "review";
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
const CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY: &str = "downstream_websocket_connection_id";
/// Responses 请求进入共享解码内核时的下游传输来源。
#[derive(Clone, Copy)]
pub(super) enum RequestDecodeSource {
/// 单次 HTTP 请求,请求头与正文属于同一请求。
Http,
/// 复用连接上的 WebSocket `response.create` 帧。
WebSocketFrame,
}
#[derive(Clone, Default)]
pub struct OpenAiRequestHeaders {
@@ -26,6 +36,7 @@ pub struct OpenAiRequestHeaders {
include_timing_metrics: Option<String>,
codex_window_id: Option<String>,
parent_thread_id: Option<String>,
downstream_websocket_connection_id: Option<String>,
conversation_id: Option<String>,
session_id: Option<String>,
@@ -51,6 +62,7 @@ impl OpenAiRequestHeaders {
include_timing_metrics: header_string(headers, "x-responsesapi-include-timing-metrics"),
codex_window_id: header_string(headers, "x-codex-window-id"),
parent_thread_id: header_string(headers, "x-codex-parent-thread-id"),
downstream_websocket_connection_id: None,
conversation_id: header_string(headers, "conversation-id")
.or_else(|| header_string(headers, "conversation_id")),
session_id: header_string(headers, "session-id")
@@ -65,16 +77,43 @@ impl OpenAiRequestHeaders {
}
}
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
/// 绑定代理为当前下游 WebSocket 分配的连接身份。
#[must_use]
pub(super) fn with_downstream_websocket_connection_id(
mut self,
connection_id: impl Into<String>,
) -> Self {
self.downstream_websocket_connection_id = Some(connection_id.into());
self
}
fn apply_subagent(&self, body: &mut Map<String, Value>) {
if let Some(subagent) = self.subagent.as_deref() {
inject_subagent_metadata(body, subagent);
}
}
fn protocol_context(&self, use_websocket: Option<bool>) -> Map<String, Value> {
/// 独立端点只消费会话身份与 turn metadata,不携带 Responses 的连接状态。
pub(crate) fn session_context(&self) -> Map<String, Value> {
let mut context = Map::new();
insert_protocol_context(&mut context, "session_id", self.session_id.as_ref());
insert_protocol_context(&mut context, "thread_id", self.thread_id.as_ref());
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
context
}
fn protocol_context(
&self,
use_websocket: Option<bool>,
frame_turn_metadata: Option<&String>,
) -> Map<String, Value> {
let mut context = Map::new();
insert_protocol_context(&mut context, "turn_state", self.turn_state.as_ref());
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
insert_protocol_context(
&mut context,
"turn_metadata",
frame_turn_metadata.or(self.turn_metadata.as_ref()),
);
insert_protocol_context(&mut context, "beta_features", self.beta_features.as_ref());
insert_protocol_context(&mut context, "version", self.version.as_ref());
insert_protocol_context(
@@ -92,6 +131,11 @@ impl OpenAiRequestHeaders {
"parent_thread_id",
self.parent_thread_id.as_ref(),
);
insert_protocol_context(
&mut context,
DOWNSTREAM_WEBSOCKET_CONNECTION_ID_CONTEXT_KEY,
self.downstream_websocket_connection_id.as_ref(),
);
insert_protocol_context(
&mut context,
"conversation_id",
@@ -283,20 +327,11 @@ pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers))
}
/// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。
pub(super) fn decode_review_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers))
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
}
pub(super) fn decode_request_inner(
body: &[u8],
review: bool,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let value =
@@ -304,14 +339,14 @@ pub(super) fn decode_request_inner(
let Value::Object(object) = value else {
return Err(RequestDecodeError::ExpectedObject);
};
decode_request_object(object, review, request_headers)
decode_request_object(object, request_headers, RequestDecodeSource::Http)
}
/// 解码已解析的顶层 object;WebSocket 帧解码复用此内核,避免序列化往返。
/// 解码已解析的顶层 object;按下游传输来源恢复连接级协议上下文。
pub(super) fn decode_request_object(
mut object: Map<String, Value>,
review: bool,
request_headers: &OpenAiRequestHeaders,
source: RequestDecodeSource,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议。
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
@@ -340,8 +375,12 @@ pub(super) fn decode_request_object(
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
.unwrap_or(ContinuationIntent::None);
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
let protocol_context = request_headers.protocol_context(use_websocket);
request_headers.apply_subagent(&mut object);
let frame_turn_metadata = match source {
RequestDecodeSource::Http => None,
RequestDecodeSource::WebSocketFrame => frame_turn_metadata(&object),
};
let protocol_context = request_headers.protocol_context(use_websocket, frame_turn_metadata);
let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object)
.map_err(|_| RequestDecodeError::CanonicalContract {
@@ -362,6 +401,16 @@ pub(super) fn decode_request_object(
})
}
fn frame_turn_metadata(body: &Map<String, Value>) -> Option<&String> {
body.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| metadata.get(CODEX_TURN_METADATA_KEY))
.and_then(|value| match value {
Value::String(value) if !value.trim().is_empty() => Some(value),
_ => None,
})
}
fn inject_subagent_metadata(body: &mut Map<String, Value>, subagent: &str) {
let metadata = body
.entry("client_metadata".to_owned())
@@ -443,6 +492,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
| "authorization"
| "x-api-key"
// Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。
| "x-openai-actor-authorization"
| "cookie"
| "cookie2"
| "chatgpt-account-id"
@@ -11,7 +11,7 @@ use std::{
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use futures::{Sink, SinkExt, Stream, StreamExt};
use gateway_core::engine::CancellationToken;
use gateway_core::lifecycle::CancellationToken;
use thiserror::Error;
use tokio::{
sync::{mpsc, oneshot},
@@ -24,21 +24,23 @@ const INBOUND_EVENT_BUFFER: usize = 32;
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
/// WebSocket pump 的写入与生命周期预算。
#[derive(Clone, Copy)]
pub(super) struct ConnectionConfig {
pub(super) write_timeout: Duration,
pub(super) max_age: Duration,
pub struct ConnectionConfig {
write_timeout: Duration,
max_age: Duration,
}
impl ConnectionConfig {
pub(super) const PRODUCTION: Self = Self {
/// 生产环境固定预算。
pub const PRODUCTION: Self = Self {
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
max_age: CONNECTION_MAX_AGE,
};
}
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。
pub(super) enum ConnectionEvent {
pub enum ConnectionEvent {
Text(String),
Binary,
Expired,
@@ -47,7 +49,7 @@ pub(super) enum ConnectionEvent {
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FramePhase {
pub enum FramePhase {
Metadata,
First,
Data,
@@ -85,21 +87,26 @@ impl FramePhase {
}
}
/// 一次下游写入的请求归属与协议阶段。
#[derive(Clone)]
pub(super) struct WriteContext {
pub struct WriteContext {
request_id: Option<Arc<str>>,
phase: FramePhase,
}
impl WriteContext {
pub(super) fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
/// 创建归属于某个请求的写入上下文。
#[must_use]
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
Self {
request_id: Some(Arc::clone(request_id)),
phase,
}
}
pub(super) const fn connection(phase: FramePhase) -> Self {
/// 创建连接级写入上下文。
#[must_use]
pub const fn connection(phase: FramePhase) -> Self {
Self {
request_id: None,
phase,
@@ -107,8 +114,9 @@ impl WriteContext {
}
}
/// WebSocket pump 停止的稳定原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PumpExitReason {
pub enum PumpExitReason {
ClientClose,
PeerEof,
ReadError,
@@ -140,8 +148,9 @@ impl PumpExitReason {
}
}
/// 下游 WebSocket 写入失败。
#[derive(Debug, Error)]
pub(super) enum ConnectionWriteError {
pub enum ConnectionWriteError {
#[error("downstream WebSocket pump is closed")]
Closed,
#[error("downstream WebSocket write timed out after {timeout:?}")]
@@ -179,12 +188,14 @@ impl ConnectionStats {
}
}
pub(super) struct ResponsesWebSocketConnection {
/// 协调层持有的单 owner WebSocket 连接句柄。
pub struct ResponsesWebSocketConnection {
connection_id: Arc<str>,
opened_at: Instant,
expired: Arc<AtomicBool>,
commands: Option<mpsc::Sender<ConnectionCommand>>,
incoming: mpsc::Receiver<ConnectionEvent>,
exited: oneshot::Receiver<PumpExitReason>,
pump_task: Option<JoinHandle<()>>,
stats: Arc<ConnectionStats>,
config: ConnectionConfig,
@@ -209,7 +220,9 @@ impl ResponsesWebSocketConnection {
&self.connection_id
}
pub(super) fn is_expired(&self) -> bool {
/// 返回连接是否已经达到生命周期上限。
#[must_use]
pub fn is_expired(&self) -> bool {
self.expired.load(Ordering::Acquire)
}
@@ -217,8 +230,18 @@ impl ResponsesWebSocketConnection {
self.opened_at.elapsed()
}
pub(super) async fn next_event(&mut self) -> Option<ConnectionEvent> {
let event = self.incoming.recv().await;
/// 等待下一个需要业务层处理的客户端事件。
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
if let Some(reason) = self.exit_reason {
return Some(ConnectionEvent::Exited(reason));
}
let event = tokio::select! {
biased;
reason = &mut self.exited => {
Some(ConnectionEvent::Exited(reason.unwrap_or(PumpExitReason::PumpStopped)))
}
event = self.incoming.recv() => event,
};
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
self.exit_reason.get_or_insert(*reason);
} else if event.is_none() {
@@ -227,7 +250,24 @@ impl ResponsesWebSocketConnection {
event
}
pub(super) async fn send_text(
/// 等待连接退出,不消费留给后续串行请求的业务帧。
pub async fn wait_for_exit(&mut self) -> PumpExitReason {
if let Some(reason) = self.exit_reason {
return reason;
}
let reason = (&mut self.exited)
.await
.unwrap_or(PumpExitReason::PumpStopped);
self.exit_reason = Some(reason);
reason
}
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
///
/// # Errors
///
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误。
pub async fn send_text(
&mut self,
payload: String,
context: WriteContext,
@@ -391,7 +431,8 @@ impl Drop for ResponsesWebSocketConnection {
}
}
pub(super) fn spawn_connection<S, E>(
/// 为一个 socket 启动单 owner pump,并返回协调层句柄。
pub fn spawn_connection<S, E>(
socket: S,
connection_id: Arc<str>,
cancellation: CancellationToken,
@@ -406,10 +447,12 @@ where
let stats = Arc::new(ConnectionStats::default());
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let (exit_tx, exit_rx) = oneshot::channel();
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
incoming_tx,
exit_tx,
Arc::clone(&connection_id),
cancellation,
opened_at,
@@ -423,6 +466,7 @@ where
expired,
commands: Some(command_tx),
incoming: incoming_rx,
exited: exit_rx,
pump_task: Some(pump_task),
stats,
config,
@@ -435,6 +479,7 @@ async fn run_pump<S, E>(
mut socket: S,
mut commands: mpsc::Receiver<ConnectionCommand>,
incoming: mpsc::Sender<ConnectionEvent>,
exited: oneshot::Sender<PumpExitReason>,
connection_id: Arc<str>,
cancellation: CancellationToken,
opened_at: Instant,
@@ -455,8 +500,13 @@ async fn run_pump<S, E>(
() = &mut deadline, if !deadline_elapsed => {
deadline_elapsed = true;
expired.store(true, Ordering::Release);
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Expired) {
break reason;
// 满队列本身已能唤醒空闲协调层;到期标志仍阻止启动下一轮。
// 不能因无法追加 Expired 通知而中断正在收尾的响应。
match incoming.try_send(ConnectionEvent::Expired) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
Err(mpsc::error::TrySendError::Closed(_)) => {
break PumpExitReason::CoordinatorDropped;
}
}
}
command = commands.recv() => {
@@ -531,7 +581,8 @@ async fn run_pump<S, E>(
},
}
};
let _ = incoming.try_send(ConnectionEvent::Exited(reason));
// 退出不能排在待执行请求之后,也不能因业务队列已满而丢失。
let _ = exited.send(reason);
tracing::debug!(
websocket_connection_id = %connection_id,
pump_exit_reason = reason.as_str(),
@@ -13,12 +13,10 @@ use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use super::{
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, PendingExecution, ProtocolErrorBody},
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
connection::{FramePhase, ResponsesWebSocketConnection, WriteContext},
protocol::{error_event, response_metadata_event},
};
const ACTIVE_RESPONSE_VIOLATION: &str = "Only one response.create may be active per connection";
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum ForwardOutcome {
Continue,
@@ -66,29 +64,20 @@ pub(super) async fn forward_execution(
);
return send_gateway_error(connection, &error, &request_id).await;
}
let first = loop {
match next_active_input(connection, &mut execution).await {
ActiveInput::Event(Ok(Some(event))) => break event,
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended before its first event",
);
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Control => continue,
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
.await;
return ForwardOutcome::Disconnect;
}
let first = match next_active_input(connection, &mut execution).await {
ActiveInput::Event(Ok(Some(event))) => event,
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response ended before its first event",
);
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Event(Err(error)) => {
return send_initial_engine_error(connection, &mut execution, &error, &request_id)
.await;
}
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
};
let requirement = first.commit_requirement();
let mut first = first.into_provider_events();
@@ -259,18 +248,35 @@ pub(super) async fn forward_execution(
let error = gateway_error_from_engine(&error);
return send_gateway_error(connection, &error, &request_id).await;
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(&request_id))
.await;
return ForwardOutcome::Disconnect;
}
}
}
}
async fn send_initial_engine_error(
connection: &mut ResponsesWebSocketConnection,
execution: &mut PendingExecution,
error: &EngineError,
request_id: &Arc<str>,
) -> ForwardOutcome {
let response_headers = execution
.session_mut()
.map(|session| session.response_headers().to_vec())
.unwrap_or_default();
if !response_headers.is_empty()
&& !send_metadata(
connection,
request_id,
response_metadata_event(request_id, &response_headers),
)
.await
{
return ForwardOutcome::Disconnect;
}
let error = gateway_error_from_engine(error);
send_gateway_error(connection, &error, request_id).await
}
fn commit_connection_replay(
replay: &mut ConnectionReplaySnapshot,
encoder: &OpenAiResponsesEncoder,
@@ -286,50 +292,39 @@ async fn confirm_completed_execution(
execution: &mut PendingExecution,
request_id: &Arc<str>,
) -> Result<(), ForwardOutcome> {
loop {
match next_active_input(connection, execution).await {
ActiveInput::Event(Ok(None))
if execution
.session_mut()
.is_some_and(|session| session.is_finalized()) =>
{
return Ok(());
}
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response was not finalized after its terminal event",
);
return Err(send_gateway_error(connection, &error, request_id).await);
}
ActiveInput::Event(Ok(Some(_))) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response continued after its terminal event",
);
return Err(send_gateway_error(connection, &error, request_id).await);
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
return Err(send_gateway_error(connection, &error, request_id).await);
}
ActiveInput::Control => {}
ActiveInput::Disconnect => return Err(ForwardOutcome::Disconnect),
ActiveInput::ProtocolViolation => {
connection
.close_policy(ACTIVE_RESPONSE_VIOLATION, Some(request_id))
.await;
return Err(ForwardOutcome::Disconnect);
}
match next_active_input(connection, execution).await {
ActiveInput::Event(Ok(None))
if execution
.session_mut()
.is_some_and(|session| session.is_finalized()) =>
{
Ok(())
}
ActiveInput::Event(Ok(None)) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response was not finalized after its terminal event",
);
Err(send_gateway_error(connection, &error, request_id).await)
}
ActiveInput::Event(Ok(Some(_))) => {
let error = GatewayError::new(
GatewayErrorKind::Internal,
"gateway response continued after its terminal event",
);
Err(send_gateway_error(connection, &error, request_id).await)
}
ActiveInput::Event(Err(error)) => {
let error = gateway_error_from_engine(&error);
Err(send_gateway_error(connection, &error, request_id).await)
}
ActiveInput::Disconnect => Err(ForwardOutcome::Disconnect),
}
}
enum ActiveInput {
Event(Result<Option<CoordinatedEvent>, EngineError>),
Control,
Disconnect,
ProtocolViolation,
}
async fn next_active_input(
@@ -339,15 +334,17 @@ async fn next_active_input(
let Some(session) = execution.session_mut() else {
return ActiveInput::Disconnect;
};
// 与 Codex stream_request 的连接锁一致:本轮结束前不消费下一条请求。
// pump 继续接收有界业务帧和处理 Ping/Pong;退出通过独立通知取消本轮。
tokio::select! {
event = session.next_event() => ActiveInput::Event(event),
message = connection.next_event() => match message {
Some(ConnectionEvent::Expired) => ActiveInput::Control,
Some(ConnectionEvent::Text(_) | ConnectionEvent::Binary) => {
ActiveInput::ProtocolViolation
}
Some(ConnectionEvent::Exited(_)) | None => ActiveInput::Disconnect,
biased;
_ = connection.wait_for_exit() => {
session.trace().record("downstream.cancelled", serde_json::json!({
"reason": "websocket_connection_exit", "connectionId": connection.id(),
}));
ActiveInput::Disconnect
},
event = session.next_event() => ActiveInput::Event(event),
}
}
@@ -459,6 +456,7 @@ fn log_terminal_write_success(
provider_terminal_to_write: std::time::Duration,
) {
tracing::info!(
target: "request_trace", stage = "downstream.terminal.written",
websocket_connection_id = connection.id(),
request_id = %request_id,
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
@@ -473,6 +471,7 @@ fn log_terminal_write_failure(
provider_terminal_to_write: std::time::Duration,
) {
tracing::info!(
target: "request_trace", stage = "downstream.terminal.write_failed",
websocket_connection_id = connection.id(),
request_id = %request_id,
provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(),
@@ -1,6 +1,6 @@
//! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。
mod connection;
pub mod connection;
mod forward;
mod protocol;
@@ -19,6 +19,7 @@ use axum::{
response::{IntoResponse, Response},
};
use gateway_core::{
diagnostics::{TraceContext, diagnostic_json},
engine::execution::{AuthenticatedClient, ClientTransport},
lifecycle::{ConnectionGuard, ConnectionLifecycle},
};
@@ -26,7 +27,7 @@ use gateway_core::{
use crate::{
ApiState,
openai::{
auth::{authenticate_client, authentication_error_response},
auth::{authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
service::OpenAiService,
},
@@ -54,18 +55,20 @@ pub(crate) async fn responses_websocket(
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
ResponsesWebSocketAdapter::new(service).upgrade_with_client_context(
websocket,
client,
client_ip,
user_agent,
OpenAiRequestHeaders::from_headers(&headers),
request_headers,
headers,
)
}
@@ -89,15 +92,24 @@ impl ResponsesWebSocketAdapter {
client_ip: Option<IpAddr>,
user_agent: Option<String>,
request_headers: OpenAiRequestHeaders,
raw_headers: HeaderMap,
) -> Response {
let connection_guard = match self.service.try_register_connection() {
Ok(guard) => guard,
Err(_) => return runtime_unavailable_response().into_response(),
};
let connection_id = self.service.next_request_id().replacen("req_", "ws_", 1);
TraceContext::new(&connection_id).headers(
"client.connection.headers",
serde_json::json!({"transport": "websocket", "method": "GET", "path": "/v1/responses"}),
raw_headers
.iter()
.map(|(name, value)| (name.as_str(), value.as_bytes())),
);
let session = ResponsesWebSocketSession {
service: self.service.clone(),
client,
connection_id: self.service.next_request_id().replacen("req_", "ws_", 1),
connection_id,
client_ip,
user_agent,
request_headers,
@@ -143,6 +155,8 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
);
let _connection_guard = connection_guard;
let cancellation = lifecycle.cancellation();
let request_headers =
request_headers.with_downstream_websocket_connection_id(connection_id.clone());
let mut connection = ResponsesWebSocketConnection::new(socket, connection_id, cancellation);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
@@ -172,11 +186,12 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
let decoded = match decode_response_create_with_context(&payload, &request_headers) {
Ok(decoded) => decoded.with_client_context(client_ip, user_agent.clone()),
Err(error) => {
tracing::info!(
websocket_connection_id = connection.id(),
request_id = %correlation_id,
error = %error,
"Responses WebSocket request rejected"
trace_rejected_request(
&correlation_id,
connection.id(),
&payload,
"decode",
&error.to_string(),
);
if send_protocol_error(
&mut connection,
@@ -195,6 +210,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
let decoded = replay.prepare(decoded);
// deadline 与 Text 可能同时就绪;在任何上游执行开始前再次封住该竞争窗口。
if connection.is_expired() {
trace_rejected_request(
&correlation_id,
connection.id(),
&payload,
"connection_expired",
"",
);
expire_connection(&mut connection).await;
break;
}
@@ -209,6 +231,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
{
Ok(started) => started,
Err(error) => {
trace_rejected_request(
&correlation_id,
connection.id(),
&payload,
"start",
&error.to_string(),
);
if send_gateway_error(&mut connection, &error, &correlation_id).await
== ForwardOutcome::Disconnect
{
@@ -217,6 +246,14 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
continue;
}
};
started.session.trace().record("client.connection", serde_json::json!({
"transport": "websocket", "connectionId": connection.id(), "correlationId": correlation_id,
"requestIndex": request_count,
}));
started
.session
.trace()
.capture("client.request.body", payload.as_bytes());
if forward_execution(&mut connection, started, &mut replay).await
== ForwardOutcome::Disconnect
@@ -232,6 +269,28 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
connection.log_summary(request_count);
}
// 尚未建立模型请求的拒绝也保留原文入口,用返回给客户端的 correlation ID 检索。
fn trace_rejected_request(
correlation_id: &str,
connection_id: &str,
payload: &str,
phase: &'static str,
error: &str,
) {
let trace = TraceContext::new(correlation_id);
trace.record(
"client.connection",
serde_json::json!({"connectionId": connection_id}),
);
trace.capture("client.request.body", payload.as_bytes());
trace.record(
"client.request.rejected",
serde_json::json!({
"phase": phase, "detail": diagnostic_json(&serde_json::json!({"message": error})),
}),
);
}
async fn expire_connection(connection: &mut ResponsesWebSocketConnection) {
if connection
.send_text(
@@ -6,7 +6,8 @@ use serde_json::{Map, Value, json};
use thiserror::Error;
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError, request::OpenAiRequestHeaders,
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
request::{OpenAiRequestHeaders, RequestDecodeSource},
};
/// 使用连接级请求头和 Provider 上下文解码官方 `response.create` 文本帧。
@@ -40,8 +41,12 @@ fn decode_response_create_inner(
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
return Err(ResponseCreateFrameError::StreamingRequired);
}
super::super::request::decode_request_object(body, false, request_headers)
.map_err(ResponseCreateFrameError::Request)
super::super::request::decode_request_object(
body,
request_headers,
RequestDecodeSource::WebSocketFrame,
)
.map_err(ResponseCreateFrameError::Request)
}
/// `response.create` 帧的稳定安全错误。
@@ -8,8 +8,9 @@ use axum::{
use super::{
images::{image_edits, image_generations},
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
};
use crate::ApiState;
@@ -19,11 +20,9 @@ pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/images/generations", post(image_generations))
.route("/v1/images/edits", post(image_edits))
.route("/v1/alpha/search", post(standalone_search))
.route("/v1/responses", get(responses_websocket).post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
.route("/v1/models/{model_id}/info", get(model_info))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
@@ -0,0 +1,67 @@
//! Codex standalone search 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::HeaderMap,
response::Response,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::{OpenAiRequestHeaders, request_client_context},
};
const OPENAI_PROTOCOL: &str = "openai";
/// `POST /v1/alpha/search`。
pub(crate) async fn standalone_search(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match search_operation(body, &headers) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_provider_endpoint(client, operation, client_ip, user_agent, "/v1/alpha/search")
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_raw_json_response(started).await
}
fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, GatewayError> {
let context = OpenAiRequestHeaders::from_headers(headers).session_context();
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::Search(StandaloneSearchRequest::from_raw_json(
payload,
)))
}
@@ -0,0 +1,5 @@
//! Codex standalone search HTTP adapter。
mod http;
pub(crate) use http::standalone_search;
@@ -106,7 +106,7 @@ impl OpenAiService {
.await
}
pub(crate) async fn start_image(
pub(crate) async fn start_provider_endpoint(
&self,
client: AuthenticatedClient,
operation: gateway_core::operation::Operation,
@@ -348,6 +348,8 @@ mod response {
output_tokens_display: "-".to_owned(),
cached_tokens: None,
cached_tokens_display: "-".to_owned(),
reasoning_tokens: None,
reasoning_tokens_display: "-".to_owned(),
image_input_tokens: None,
image_input_tokens_display: "-".to_owned(),
image_output_tokens: None,
@@ -374,6 +376,8 @@ mod response {
let value = serde_json::to_value(view).expect("serialize account usage");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert!(value["reasoningTokens"].is_null());
assert_eq!(value["reasoningTokensDisplay"], "-");
assert_eq!(value["createdTokensDisplay"], "-");
}
@@ -404,10 +408,8 @@ mod actions {
RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::{
engine::{
UpstreamSendState, credential::ProviderAccountId, probe::AccountProbeErrorSource,
},
error::GatewayErrorKind,
account::ProviderAccountId, engine::probe::AccountProbeErrorSource,
error::GatewayErrorKind, upstream::UpstreamSendState,
};
use serde_json::json;
+43 -11
View File
@@ -25,6 +25,10 @@ use gateway_admin::{
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
},
auth::{AdminAuditEvent, AdminSession},
client_distribution::{
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
CodexDesktopWindowsDownloads,
},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
@@ -50,6 +54,7 @@ use gateway_admin::{
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
},
ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
store::{
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
@@ -64,14 +69,11 @@ use gateway_admin::{
};
use gateway_api::admin::AdminSessionState;
use gateway_core::{
engine::{
credential::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
},
account::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
policy::{ClientApiKeyId, RateLimits},
routing::{
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
},
routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId},
runtime::SnapshotControl,
};
mod account_groups;
@@ -143,6 +145,7 @@ impl AdminTestFixture {
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
Arc::new(StaticClientDistribution),
system,
)
.await
@@ -165,6 +168,30 @@ impl AdminTestFixture {
}
}
struct StaticClientDistribution;
#[async_trait]
impl ClientDistributionResolver for StaticClientDistribution {
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
CodexDesktopWindowsDownloads {
resolved_at: Utc::now(),
cached: false,
warning: None,
packages: vec![ClientDownloadPackage {
architecture: ClientArchitecture::X64,
source: ClientDownloadSource::MicrosoftStore,
version: Some("26.825.6671.0".to_owned()),
file_name: "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix".to_owned(),
size_bytes: Some(744_250_000),
download_url:
"https://dl.delivery.mp.microsoft.com/filestreamingservice/files/test"
.to_owned(),
expires_at: Some(Utc::now() + Duration::hours(1)),
}],
}
}
}
#[derive(Clone)]
pub(super) struct AdminTestState(AdminServices);
@@ -319,6 +346,8 @@ impl SettingsStore for MemorySettingsStore {
max_concurrent_per_account: command.max_concurrent_per_account,
request_interval_ms: command.request_interval_ms,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
usage_retention_days: command.usage_retention_days,
ops_event_retention_days: command.ops_event_retention_days,
audit_retention_days: command.audit_retention_days,
@@ -846,13 +875,14 @@ impl ObservabilityStore for UnusedStore {
Err(unavailable("usage trend"))
}
async fn usage_calculated_billing_facts(
fn usage_calculated_billing_facts(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
{
Err(unavailable("usage billing facts"))
) -> gateway_admin::ports::store::UsageCalculatedBillingStream<'_> {
Box::pin(futures::stream::once(async {
Err(unavailable("usage billing facts"))
}))
}
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
@@ -1073,6 +1103,8 @@ fn test_runtime_settings() -> RuntimeSettings {
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
@@ -49,6 +49,12 @@ fn ops_query_should_reject_page_size_above_terminal_limit() {
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
fn ops_query_should_reject_removed_filters() {
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
@@ -71,6 +71,9 @@ fn dashboard_account_usage_should_keep_daily_request_timeline() {
request_count: 3,
}],
quota_used_percent: None,
usage_window: None,
metric_label: "次数".to_owned(),
metric_value: "3".to_owned(),
last_used: "刚刚".to_owned(),
})
.expect("dashboard account usage");
@@ -426,6 +429,8 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
.lock()
.expect("usage detail")
.replace(UsageDetail {
trace: None,
related_requests: Vec::new(),
request: usage_record_with_account(
"req_detail",
"acct_snap_a",
@@ -552,6 +557,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
upstream_model_id: Some("upstream-err".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
failure_kind: "upstream_error".to_owned(),
upstream_send_state: Some("sent".to_owned()),
client_status_code: Some(502),
upstream_status_code: Some(502),
provider_error_code: Some("upstream".to_owned()),
@@ -559,7 +565,9 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
upstream_request_id: None,
latency_ms: Some(90),
message: "snapshot error".to_owned(),
occurrence_count: 1,
raw_upstream_error: Some(
r#"{"error":{"code":"upstream","message":"raw upstream marker"}}"#.to_owned(),
),
client_ip: Some("203.0.113.8".to_owned()),
user_agent: Some("codex-cli/0.144.0".to_owned()),
reasoning_effort: Some("medium".to_owned()),
@@ -567,6 +575,18 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
request_kind: Some("root".to_owned()),
subagent_kind: None,
compact: Some(false),
continuation_affinity_hash: Some("affinity-hash".to_owned()),
continuation_previous_response_id_hash: Some("response-hash".to_owned()),
continuation_unavailable_reason: Some("reused_connection_lost".to_owned()),
upstream_connection_id: Some("connection-id".to_owned()),
upstream_connection_exit_reason: Some("tcp_reset".to_owned()),
upstream_connection_age_ms: Some(12_000),
upstream_connection_idle_ms: Some(4_000),
recovery_request_id: None,
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "model_request:req_err".to_owned(),
});
@@ -601,6 +621,11 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"clientIp": value["data"]["items"][0]["clientIp"],
"userAgent": value["data"]["items"][0]["userAgent"],
"providerErrorCode": value["data"]["items"][0]["providerErrorCode"],
"upstreamSendState": value["data"]["items"][0]["upstreamSendState"],
"rawUpstreamError": value["data"]["items"][0]["rawUpstreamError"],
"continuationUnavailableReason": value["data"]["items"][0]["metadata"]["continuationUnavailableReason"],
"upstreamConnectionExitReason": value["data"]["items"][0]["metadata"]["upstreamConnectionExitReason"],
"upstreamConnectionAgeMs": value["data"]["items"][0]["metadata"]["upstreamConnectionAgeMs"],
}),
serde_json::json!({
"provider": "openai",
@@ -615,6 +640,11 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"clientIp": "203.0.113.8",
"userAgent": "codex-cli/0.144.0",
"providerErrorCode": "upstream",
"upstreamSendState": "sent",
"rawUpstreamError": "{\"error\":{\"code\":\"upstream\",\"message\":\"raw upstream marker\"}}",
"continuationUnavailableReason": "reused_connection_lost",
"upstreamConnectionExitReason": "tcp_reset",
"upstreamConnectionAgeMs": 12000,
})
);
}
@@ -46,6 +46,8 @@ fn update_body() -> Value {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91
@@ -62,6 +64,19 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
}
#[test]
fn settings_request_should_reject_non_semver_client_min() {
let mut body = update_body();
body["minCodexCliVersion"] = json!("v0.40.0");
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(
request.validate().unwrap_err().field(),
"minCodexCliVersion"
);
}
#[test]
fn settings_response_should_cover_the_full_runtime_settings_contract() {
use std::collections::BTreeMap;
@@ -70,7 +85,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
use gateway_admin::model::Revision;
use gateway_admin::model::settings::RuntimeSettings;
use gateway_api::admin::settings::RuntimeSettingsView;
use gateway_core::engine::credential::RotationStrategy;
use gateway_core::account::RotationStrategy;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let settings = RuntimeSettings {
@@ -90,6 +105,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_concurrent_per_account: 5,
request_interval_ms: 25,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
usage_retention_days: 32,
ops_event_retention_days: 31,
audit_retention_days: 91,
@@ -112,6 +129,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 25,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
"usageRetentionDays": 32,
"opsEventRetentionDays": 31,
"auditRetentionDays": 91,
@@ -127,7 +146,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
use gateway_admin::model::Revision;
use gateway_admin::model::settings::RuntimeSettings;
use gateway_api::admin::settings::RuntimeSettingsView;
use gateway_core::engine::credential::RotationStrategy;
use gateway_core::account::RotationStrategy;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let request: UpdateRuntimeSettingsRequest =
@@ -159,6 +178,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
request_interval_ms: request.request_interval_ms,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
min_codex_cli_version: request.min_codex_cli_version,
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
@@ -234,6 +255,31 @@ async fn settings_post_should_replace_global_model_mappings() {
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
}
#[tokio::test]
async fn client_downloads_should_return_validated_direct_links() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(
Method::GET,
"/api/admin/settings/client-downloads/codex-desktop/windows?refresh=true",
None,
))
.await
.expect("client downloads response");
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await["data"].clone();
assert_eq!(data["packages"][0]["architecture"], "x64");
assert_eq!(data["packages"][0]["source"], "microsoft_store");
assert_eq!(data["packages"][0]["version"], "26.825.6671.0");
assert!(
data["packages"][0]["downloadUrl"]
.as_str()
.is_some_and(|url| url.starts_with("https://dl.delivery.mp.microsoft.com/"))
);
}
#[test]
fn settings_request_should_reject_invalid_model_mapping_name() {
let mut body = update_body();
@@ -34,7 +34,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
actual.sort();
let mut expected = vec![
"src/admin/account_groups.rs",
"src/admin/accounts.rs",
"src/admin/accounts/credentials.rs",
"src/admin/accounts/handlers.rs",
"src/admin/accounts/mod.rs",
"src/admin/accounts/presenter.rs",
"src/admin/accounts/wire.rs",
"src/admin/auth.rs",
"src/admin/backups.rs",
"src/admin/client_keys.rs",
@@ -57,6 +61,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/images/mod.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/provider_endpoint.rs",
"src/openai/responses/error.rs",
"src/openai/responses/http.rs",
"src/openai/responses/mod.rs",
@@ -67,6 +72,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/responses/websocket/mod.rs",
"src/openai/responses/websocket/protocol.rs",
"src/openai/router.rs",
"src/openai/search/http.rs",
"src/openai/search/mod.rs",
"src/openai/service.rs",
];
expected.sort_unstable();
@@ -81,7 +88,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
actual.sort();
let mut expected = vec![
"tests/admin/account_groups.rs",
"tests/admin/accounts.rs",
"tests/admin/accounts/mod.rs",
"tests/admin/auth.rs",
"tests/admin/client_keys.rs",
"tests/admin/errors.rs",
@@ -97,15 +104,17 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/main.rs",
"tests/openai/auth.rs",
"tests/openai/error.rs",
"tests/openai/images.rs",
"tests/openai/images/mod.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/provider_endpoint.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/websocket/connection.rs",
"tests/openai/responses/websocket/mod.rs",
"tests/openai/responses/websocket/protocol.rs",
"tests/openai/router.rs",
"tests/openai/search/mod.rs",
];
expected.sort_unstable();
@@ -1,6 +1,9 @@
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
use gateway_api::openai::auth::{
ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client,
};
use gateway_core::policy::CodexClientKind;
#[test]
fn bearer_client_api_key_should_reject_missing_authorization() {
@@ -10,6 +13,32 @@ fn bearer_client_api_key_should_reject_missing_authorization() {
);
}
#[test]
fn actor_authorization_marker_should_not_replace_client_api_key() {
let mut headers = HeaderMap::new();
headers.insert(
"x-openai-actor-authorization",
HeaderValue::from_static("proxy-managed"),
);
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::MissingAuthorization)
);
}
#[test]
fn actor_authorization_marker_should_preserve_existing_bearer_authentication() {
let mut headers = HeaderMap::new();
headers.insert(
"x-openai-actor-authorization",
HeaderValue::from_static("proxy-managed"),
);
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer sk_client"));
assert_eq!(bearer_client_api_key(&headers), Ok("sk_client"));
}
#[test]
fn bearer_client_api_key_should_reject_non_utf8_authorization() {
let mut headers = HeaderMap::new();
@@ -88,3 +117,48 @@ fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
]
);
}
#[test]
fn desktop_headers_should_take_precedence_over_embedded_cli_marker() {
let mut headers = HeaderMap::new();
headers.insert("originator", HeaderValue::from_static("Codex Desktop"));
headers.insert("version", HeaderValue::from_static("26.825.6671"));
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 terminal (Codex Desktop; 26.1.0)"),
);
let identified = identify_codex_client(&headers).expect("recognized desktop");
assert_eq!(identified.kind(), CodexClientKind::Desktop);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("26.825.6671")
);
}
#[test]
fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
let mut headers = HeaderMap::new();
headers.insert(
"user-agent",
HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"),
);
let identified = identify_codex_client(&headers).expect("recognized CLI");
assert_eq!(identified.kind(), CodexClientKind::Cli);
assert_eq!(
identified.version().map(ToString::to_string).as_deref(),
Some("0.39.0")
);
headers.insert(
"user-agent",
HeaderValue::from_static("codex-cli/not-a-version"),
);
assert!(
identify_codex_client(&headers)
.expect("recognized invalid CLI")
.version()
.is_none()
);
}
@@ -1,9 +1,10 @@
use axum::{body::to_bytes, http::StatusCode};
use gateway_core::engine::{EngineError, UpstreamSendState};
use gateway_core::engine::EngineError;
use gateway_core::error::{
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
StoreError, StoreErrorKind,
};
use gateway_core::upstream::UpstreamSendState;
use gateway_api::openai::error::{
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
@@ -397,3 +398,38 @@ async fn gateway_error_response_should_expose_only_structured_client_visible_ups
})
);
}
#[tokio::test]
async fn continuation_recovery_should_preserve_the_official_retry_signal() {
let error = GatewayError::from_provider(
&ProviderError::new(
ProviderErrorKind::ContinuationRecoveryRequired,
UpstreamSendState::NotSent,
)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"Previous response was not found. Retrying the full request.",
Some("previous_response_not_found".to_owned()),
Some("invalid_request_error".to_owned()),
)),
);
let response = gateway_error_response(&error);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read continuation recovery response");
let body: serde_json::Value =
serde_json::from_slice(&body).expect("continuation recovery JSON");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body,
serde_json::json!({
"error": {
"message": "Previous response was not found. Retrying the full request.",
"type": "invalid_request_error",
"code": "previous_response_not_found"
}
})
);
}
@@ -10,7 +10,6 @@ use axum::{
};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::UpstreamSendState;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
@@ -22,6 +21,7 @@ use gateway_core::error::{
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequestKind, Operation};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use tower::ServiceExt;
@@ -260,6 +260,8 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", "application/json")
.header("x-codex-image-turn-id", "turn_image_route")
.header("session-id", "root-image-session")
.header("thread-id", "child-image-thread")
.body(Body::from(body.to_vec()))
.expect("image request"),
)
@@ -286,7 +288,7 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
assert_eq!(captured.body.as_ref(), *body);
assert_eq!(
captured.context,
json!({"image_turn_id": "turn_image_route"})
json!({"image_turn_id": "turn_image_route", "session_id": "root-image-session", "thread_id": "child-image-thread"})
);
}
assert_eq!(
+36 -8
View File
@@ -2,8 +2,10 @@ mod auth;
mod error;
mod images;
mod models;
mod provider_endpoint;
mod responses;
mod router;
mod search;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -12,6 +14,7 @@ use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
use gateway_core::engine::admission::{
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
ClientAdmissionRequest, ClientAdmissionRestoreResult,
@@ -19,9 +22,6 @@ use gateway_core::engine::admission::{
use gateway_core::engine::continuation::{
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
};
use gateway_core::engine::credential::{
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
@@ -29,17 +29,21 @@ use gateway_core::engine::execution::{
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::engine::{
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
NewModelRequest, RecoveryReport, UpstreamSendState,
NewModelRequest, RecoveryReport,
};
use gateway_core::error::StoreError;
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::policy::{
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
PlaintextClientApiKey, RateLimits,
};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::runtime::RuntimeSnapshotHandle;
use gateway_core::upstream::UpstreamSendState;
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
api_router_with_origins(execution, Vec::new()).await
@@ -109,6 +113,30 @@ pub(super) fn authenticated_client_for_provider(
.expect("authenticated client")
}
pub(super) fn authenticated_client_with_min_versions(
plaintext: &str,
desktop: Option<&str>,
cli: Option<&str>,
) -> AuthenticatedClient {
let snapshot =
snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new(
desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")),
cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")),
));
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
source
.authenticate(plaintext)
.expect("authenticated client")
}
struct IgnoredClientApiKeyUsage;
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
@@ -160,7 +188,7 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
#[derive(Default)]
struct TestLifecycle {
cancellation: gateway_core::engine::CancellationToken,
cancellation: gateway_core::lifecycle::CancellationToken,
}
struct TestConnectionGuard;
@@ -172,7 +200,7 @@ impl ConnectionLifecycle for TestLifecycle {
Ok(Box::new(TestConnectionGuard))
}
fn cancellation(&self) -> gateway_core::engine::CancellationToken {
fn cancellation(&self) -> gateway_core::lifecycle::CancellationToken {
self.cancellation.clone()
}
+127 -43
View File
@@ -15,7 +15,7 @@ use gateway_core::routing::{
};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
use super::{api_router, authenticated_client, authenticated_client_with_min_versions};
pub(super) struct ModelsExecution {
client: AuthenticatedClient,
@@ -36,6 +36,24 @@ impl ModelsExecution {
profiles: true,
})
}
fn with_cli_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
profiles: false,
})
}
fn with_desktop_min() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client_with_min_versions(
"sk_models_test",
Some("26.825.51511"),
None,
),
profiles: false,
})
}
}
impl ExecutionService for ModelsExecution {
@@ -144,6 +162,111 @@ async fn models_should_encode_the_service_visible_catalog() {
);
}
#[tokio::test]
async fn models_should_reject_recognized_cli_below_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.39.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_cli");
assert_eq!(value["error"]["current_version"], "0.39.0");
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_recognized_cli_with_invalid_version() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex-cli/not-a-version")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_unavailable");
assert!(value["error"]["current_version"].is_null());
assert_eq!(value["error"]["min_version"], "0.40.0");
}
#[tokio::test]
async fn models_should_reject_desktop_below_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("originator", "Codex Desktop")
.header("version", "26.825.50000")
.header("user-agent", "codex_cli_rs/99.0.0 (Codex Desktop)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_desktop_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read version rejection body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
assert_eq!(value["error"]["code"], "client_version_too_old");
assert_eq!(value["error"]["client"], "codex_desktop");
assert_eq!(value["error"]["current_version"], "26.825.50000");
assert_eq!(value["error"]["min_version"], "26.825.51511");
}
#[tokio::test]
async fn models_should_allow_cli_at_its_configured_min() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "codex_cli_rs/0.40.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_leave_unknown_clients_unrestricted() {
let request = Request::get("/v1/models")
.header(AUTHORIZATION, "Bearer sk_models_test")
.header("user-agent", "curl/8.14.1")
.body(Body::empty())
.expect("build models request");
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(request)
.await
.expect("list models response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
let response = api_router(ModelsExecution::with_profiles())
@@ -261,51 +384,12 @@ async fn model_detail_should_hide_unknown_models() {
}
#[tokio::test]
async fn model_catalog_should_keep_the_codex_catalog_contract() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/catalog"))
.await
.expect("catalog response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read catalog body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("catalog JSON");
assert_eq!(value[0]["id"], "model-a");
assert_eq!(value[0]["displayName"], "model-a");
assert_eq!(value[0]["source"], "gateway");
}
#[tokio::test]
async fn model_info_should_return_a_single_visible_catalog_entry() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-a/info"))
.await
.expect("model info response");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn model_info_should_hide_unknown_models() {
let response = api_router(ModelsExecution::new())
.await
.oneshot(authorized_request("/v1/models/model-private/info"))
.await
.expect("missing model info response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn models_should_require_a_valid_bearer_client_key() {
let response = api_router(ModelsExecution::new())
async fn models_should_authenticate_before_applying_the_version_gate() {
let response = api_router(ModelsExecution::with_cli_min())
.await
.oneshot(
Request::get("/v1/models")
.header("user-agent", "codex_cli_rs/0.1.0 (Linux; x86_64)")
.body(Body::empty())
.expect("build unauthenticated request"),
)
@@ -0,0 +1,107 @@
use std::sync::{Arc, Mutex, atomic::AtomicBool, atomic::Ordering};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::execution::ExecutionSession;
use gateway_core::engine::{CoordinatedEvent, EngineError};
use gateway_core::error::ProviderError;
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
pub(super) struct BufferedJsonSession {
body: Option<Bytes>,
response_headers: Vec<ProviderResponseHeader>,
response_status: Option<u16>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
finalized: AtomicBool,
failure: Option<ProviderError>,
}
impl BufferedJsonSession {
pub(super) fn success(
body: Bytes,
response_status: u16,
response_headers: Vec<ProviderResponseHeader>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
) -> Self {
Self {
body: Some(body),
response_headers,
response_status: Some(response_status),
committed_statuses,
finalized: AtomicBool::new(false),
failure: None,
}
}
pub(super) fn failure(error: ProviderError) -> Self {
Self {
body: None,
response_headers: Vec::new(),
response_status: None,
committed_statuses: Arc::new(Mutex::new(Vec::new())),
finalized: AtomicBool::new(false),
failure: Some(error),
}
}
}
impl ExecutionSession for BufferedJsonSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async { unreachable!("buffered JSON delivery does not stream") })
}
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async move {
if let Some(error) = self.failure.take() {
return Err(EngineError::Provider(error));
}
Ok(vec![ProviderEvent::wire(
ProtocolWireEvent::raw_json(
"openai",
self.body.take().expect("single buffered JSON response"),
)
.expect("OpenAI protocol"),
)])
})
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
&self.response_headers
}
fn response_status_code(&self) -> Option<u16> {
self.response_status
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
self.committed_statuses
.lock()
.expect("committed status lock")
.push(client_status_code.expect("buffered JSON response status"));
self.finalized.store(true, Ordering::Release);
Ok(())
})
}
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async { Ok(()) })
}
fn is_finalized(&self) -> bool {
self.finalized.load(Ordering::Acquire)
}
fn cancel(&self) {
self.finalized.store(true, Ordering::Release);
}
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
self.finalized.store(true, Ordering::Release);
})
}
}
@@ -4,6 +4,7 @@ use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use axum::{
body::{Body, to_bytes},
@@ -14,12 +15,13 @@ use axum::{
},
};
use bytes::Bytes;
use futures::StreamExt;
use futures::future::{BoxFuture, pending};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind,
ProviderError, ProviderErrorKind,
@@ -30,12 +32,13 @@ use gateway_core::event::{
};
use gateway_core::operation::{Operation, OperationKind};
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
use tower::ServiceExt;
use crate::openai::{api_router, authenticated_client, authenticated_client_for_provider};
use crate::openai::{api_router, authenticated_client_for_provider};
#[derive(Default)]
struct Trace {
@@ -78,6 +81,7 @@ impl Trace {
enum NextStep {
Event(CoordinatedEvent),
DelayedEvent(Duration, CoordinatedEvent),
Error(EngineError),
FinalizeCancelled,
FinalizeSuccess,
@@ -279,6 +283,12 @@ impl ExecutionSession for FakeSession {
self.trace.push("next_event");
Ok(Some(event))
}
NextStep::DelayedEvent(delay, event) => {
self.trace.push("wait_event");
tokio::time::sleep(delay).await;
self.trace.push("next_event");
Ok(Some(event))
}
NextStep::Error(error) => {
self.trace.push("next_error");
self.finalized = true;
@@ -688,92 +698,6 @@ async fn xai_private_headers_should_not_enter_openai_request_facts() {
assert!(captured.prompt_cache_key.is_none());
}
#[tokio::test]
async fn review_route_should_inject_review_subagent_and_record_its_endpoint() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client("sk_context_test"),
});
let request = Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_context_test")
.header("x-openai-subagent", "compact")
.body(Body::from(
json!({
"model": "smart-code",
"input": "review this",
"client_metadata": {"existing": "preserved"}
})
.to_string(),
))
.expect("review request");
let response = api_router(execution)
.await
.oneshot(request)
.await
.expect("review response");
let captured = observed
.lock()
.expect("review capture lock")
.clone()
.expect("captured review request");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(captured.endpoint, "/v1/responses/review");
assert_eq!(
captured
.client_metadata
.as_ref()
.and_then(|metadata| metadata.get("x-openai-subagent")),
Some(&json!("review"))
);
assert_eq!(
captured
.client_metadata
.as_ref()
.and_then(|metadata| metadata.get("existing")),
Some(&json!("preserved"))
);
}
#[tokio::test]
async fn review_route_should_not_replace_non_object_client_metadata() {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: authenticated_client("sk_context_test"),
});
let request = Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_context_test")
.body(Body::from(
json!({
"model": "smart-code",
"input": "review this",
"client_metadata": ["opaque", "client", "value"]
})
.to_string(),
))
.expect("review request");
let response = api_router(execution)
.await
.oneshot(request)
.await
.expect("review response");
let captured = observed
.lock()
.expect("review capture lock")
.clone()
.expect("captured review request");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
captured.client_metadata,
Some(json!(["opaque", "client", "value"]))
);
}
#[tokio::test]
async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
let trace = Arc::new(Trace::default());
@@ -794,6 +718,154 @@ async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
std::mem::forget(response);
}
#[tokio::test(start_paused = true)]
async fn streaming_should_keep_alive_during_silent_compaction_without_restarting_execution() {
let trace = Arc::new(Trace::default());
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::DelayedEvent(
Duration::from_secs(240),
delivery(completed(), CommitRequirement::AlreadyCommitted),
),
NextStep::FinalizeSuccess,
],
);
let response = stream_execution_response(Box::new(session), None).await;
let mut body = response.into_body().into_data_stream();
let first = body.next().await.expect("first frame").expect("body bytes");
assert!(String::from_utf8_lossy(&first).contains("response.created"));
let start = tokio::time::Instant::now();
for heartbeat in 1..16 {
let chunk = tokio::time::timeout(Duration::from_secs(20), body.next())
.await
.expect("silent upstream must not leave the downstream idle")
.expect("heartbeat frame")
.expect("heartbeat bytes");
assert_eq!(chunk, Bytes::from_static(b": keep-alive\n\n"));
assert_eq!(start.elapsed(), Duration::from_secs(15 * heartbeat));
}
let terminal = body
.next()
.await
.expect("terminal frame")
.expect("body bytes");
assert!(String::from_utf8_lossy(&terminal).contains("response.completed"));
assert_eq!(start.elapsed(), Duration::from_secs(240));
assert_eq!(
body.next().await.expect("done frame").expect("body bytes"),
Bytes::from_static(b"data: [DONE]\n\n")
);
assert!(body.next().await.is_none());
assert_eq!(
trace.snapshot(),
vec![
"next_event",
"commit",
"wait_event",
"next_event",
"next_end"
]
);
assert!(!trace.is_cancelled());
}
#[tokio::test(start_paused = true)]
async fn dropping_stream_during_keepalive_should_cancel_and_finalize_execution() {
let trace = Arc::new(Trace::default());
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::DelayedEvent(
Duration::from_secs(240),
delivery(completed(), CommitRequirement::AlreadyCommitted),
),
NextStep::FinalizeCancelled,
],
);
let response = stream_execution_response(Box::new(session), None).await;
let mut body = response.into_body().into_data_stream();
let _ = body.next().await.expect("first frame").expect("body bytes");
let heartbeat = tokio::time::timeout(Duration::from_secs(20), body.next())
.await
.expect("heartbeat before client disconnect")
.expect("heartbeat frame")
.expect("heartbeat bytes");
assert_eq!(heartbeat, Bytes::from_static(b": keep-alive\n\n"));
drop(body);
tokio::task::yield_now().await;
assert!(trace.is_cancelled());
assert_eq!(
trace.snapshot(),
vec!["next_event", "commit", "wait_event", "cancel_finalize"]
);
}
#[tokio::test(start_paused = true)]
async fn streaming_keepalive_should_wait_for_first_event_before_committing_http_status() {
let trace = Arc::new(Trace::default());
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::DelayedEvent(
Duration::from_secs(45),
delivery(started(), CommitRequirement::CommitBeforeDelivery),
),
NextStep::FinalizeCancelled,
],
);
let response = tokio::spawn(stream_execution_response(Box::new(session), None));
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(30)).await;
assert!(!response.is_finished());
assert!(trace.client_statuses().is_empty());
let response = response.await.expect("response task");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(trace.snapshot(), vec!["wait_event", "next_event", "commit"]);
}
#[tokio::test]
async fn streaming_response_should_disable_proxy_buffering_and_cache_transformation() {
let session = FakeSession::streaming(
Arc::new(Trace::default()),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::FinalizeCancelled,
],
)
.with_response_headers(vec![
ProviderResponseHeader::new("x-accel-buffering", Bytes::from_static(b"yes")),
ProviderResponseHeader::new("cache-control", Bytes::from_static(b"public, max-age=60")),
]);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.headers()["x-accel-buffering"], "no");
assert_eq!(
response.headers()["cache-control"],
"no-cache, no-transform"
);
assert_eq!(
response
.headers()
.get_all("x-accel-buffering")
.iter()
.count(),
1
);
assert_eq!(
response.headers().get_all("cache-control").iter().count(),
1
);
}
#[tokio::test]
async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresentable_or_sensitive_ones()
{
@@ -978,6 +1050,41 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429
assert_eq!(trace.client_statuses(), vec![429]);
}
#[tokio::test]
async fn streaming_failure_before_first_frame_should_return_safe_observed_headers() {
let trace = Arc::new(Trace::default());
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::Ambiguous,
)))],
)
.with_response_headers(vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
]);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-before-close")
);
assert!(response.headers().get("authorization").is_none());
assert_eq!(trace.client_statuses(), vec![502]);
}
#[tokio::test]
async fn streaming_upstream_http_failure_before_first_frame_should_preserve_raw_response() {
let trace = Arc::new(Trace::default());
@@ -1064,11 +1171,29 @@ async fn buffered_upstream_http_failure_should_preserve_raw_response() {
raw_body.clone(),
));
let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()])
.with_collect_error(EngineError::Provider(error));
.with_collect_error(EngineError::Provider(error))
.with_response_headers(vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
]);
let response = collect_execution_response(Box::new(session)).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-before-close")
);
assert!(response.headers().get("authorization").is_none());
assert_eq!(
to_bytes(response.into_body(), usize::MAX)
.await
@@ -196,7 +196,13 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
HeaderValue::from_static("{\"kind\":\"review\"}"),
);
headers.insert("conversation-id", HeaderValue::from_static("conversation"));
let body = json!({"model": "smart-code", "input": "hello"});
let body = json!({
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": "{\"kind\":\"turn\"}"
}
});
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
.expect("request should decode");
@@ -275,6 +281,10 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
headers.insert("content-length", HeaderValue::from_static("999"));
headers.insert("authorization", HeaderValue::from_static("Bearer client"));
headers.insert("x-api-key", HeaderValue::from_static("client-key"));
headers.insert(
"x-openai-actor-authorization",
HeaderValue::from_static("proxy-managed"),
);
headers.insert("cookie", HeaderValue::from_static("client=cookie"));
headers.insert(
"chatgpt-account-id",
@@ -342,6 +352,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
"content-length",
"authorization",
"x-api-key",
"x-openai-actor-authorization",
"cookie",
"chatgpt-account-id",
"chatgpt-project-id",
@@ -2,8 +2,8 @@ use std::{
fmt,
pin::Pin,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
task::{Context, Poll},
time::{Duration, SystemTime},
@@ -16,7 +16,7 @@ use axum::{
use futures::{Sink, SinkExt, Stream, StreamExt, future::BoxFuture};
use gateway_core::{
engine::{
CancellationToken, CommitRequirement, CoordinatedEvent, EngineError,
CommitRequirement, CoordinatedEvent, EngineError,
execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
@@ -24,6 +24,8 @@ use gateway_core::{
},
error::GatewayError,
event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta},
lifecycle::CancellationToken,
operation::Operation,
routing::PublicModelId,
};
use serde_json::{Value, json};
@@ -31,7 +33,7 @@ use tokio::sync::mpsc::{
UnboundedReceiver, UnboundedSender, error::TryRecvError, unbounded_channel,
};
use tokio_tungstenite::{
connect_async,
MaybeTlsStream, WebSocketStream, connect_async,
tungstenite::{
Message as ClientMessage, client::IntoClientRequest, protocol::frame::coding::CloseCode,
},
@@ -43,27 +45,33 @@ use crate::openai::{api_router, authenticated_client};
#[derive(Default)]
struct CrossingLimitTrace {
release_terminal: tokio::sync::Notify,
started_emitted: AtomicBool,
terminal_emitted: AtomicBool,
starts: AtomicUsize,
inputs: Mutex<Vec<Value>>,
cancelled: AtomicBool,
cancellation_observed: tokio::sync::Notify,
finalized: AtomicBool,
}
struct CrossingLimitSession {
trace: Arc<CrossingLimitTrace>,
started_emitted: bool,
terminal_emitted: bool,
finalized: bool,
}
impl ExecutionSession for CrossingLimitSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async move {
if !self.trace.started_emitted.swap(true, Ordering::AcqRel) {
if !self.started_emitted {
self.started_emitted = true;
return Ok(Some(crossing_limit_started_batch()));
}
if !self.trace.terminal_emitted.load(Ordering::Acquire) {
if !self.terminal_emitted {
self.trace.release_terminal.notified().await;
if !self.trace.terminal_emitted.swap(true, Ordering::AcqRel) {
return Ok(Some(crossing_limit_terminal_batch()));
}
self.terminal_emitted = true;
return Ok(Some(crossing_limit_terminal_batch()));
}
self.finalized = true;
self.trace.finalized.store(true, Ordering::Release);
Ok(None)
})
@@ -86,10 +94,13 @@ impl ExecutionSession for CrossingLimitSession {
}
fn is_finalized(&self) -> bool {
self.trace.finalized.load(Ordering::Acquire)
self.finalized
}
fn cancel(&self) {}
fn cancel(&self) {
self.trace.cancelled.store(true, Ordering::Release);
self.trace.cancellation_observed.notify_one();
}
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
@@ -126,6 +137,14 @@ impl ExecutionService for CrossingLimitExecution {
request: StartExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
self.trace.starts.fetch_add(1, Ordering::AcqRel);
if let Operation::Generate(generate) = &request.operation {
self.trace
.inputs
.lock()
.expect("input trace lock")
.push(generate.protocol_payload().body()["input"].clone());
}
Ok(StartedExecution {
request_id: gateway_core::engine::ModelRequestId::new("req_ws_crossing_limit")
.expect("request id"),
@@ -133,6 +152,9 @@ impl ExecutionService for CrossingLimitExecution {
stream: request.metadata.stream,
session: Box::new(CrossingLimitSession {
trace: Arc::clone(&self.trace),
started_emitted: false,
terminal_emitted: false,
finalized: false,
}),
})
})
@@ -190,17 +212,8 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent {
.expect("terminal WebSocket batch")
}
// 连接 runtime 没有正式公共 API;镜像测试直接编译该私有模块,以测试 socket pump,
// 不把测试钩子暴露给 gateway-api 的消费者。
#[expect(
dead_code,
reason = "the mirrored test only exercises pump-facing seams"
)]
#[path = "../../../../src/openai/responses/websocket/connection.rs"]
mod connection_under_test;
use connection_under_test::{
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase,
use gateway_api::openai::responses::websocket::connection::{
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase, PumpExitReason,
ResponsesWebSocketConnection, WriteContext, spawn_connection,
};
@@ -263,12 +276,14 @@ struct PumpHarness {
incoming: UnboundedSender<Result<Message, TestSocketError>>,
written: UnboundedReceiver<Message>,
dropped: Arc<AtomicBool>,
cancellation: CancellationToken,
}
fn test_connection(stall_writes: bool) -> PumpHarness {
let (incoming_tx, incoming_rx) = unbounded_channel();
let (written_tx, written_rx) = unbounded_channel();
let dropped = Arc::new(AtomicBool::new(false));
let cancellation = CancellationToken::new();
let socket = TestSocket {
incoming: incoming_rx,
written: written_tx,
@@ -278,7 +293,7 @@ fn test_connection(stall_writes: bool) -> PumpHarness {
let connection = spawn_connection(
socket,
Arc::from("ws_test"),
CancellationToken::new(),
cancellation.clone(),
ConnectionConfig::PRODUCTION,
);
PumpHarness {
@@ -286,6 +301,7 @@ fn test_connection(stall_writes: bool) -> PumpHarness {
incoming: incoming_tx,
written: written_rx,
dropped,
cancellation,
}
}
@@ -324,6 +340,110 @@ async fn pump_consumes_pong_without_forwarding_a_business_event() {
assert!(result.is_err(), "Pong unexpectedly reached the coordinator");
}
#[tokio::test(start_paused = true)]
async fn waiting_for_exit_should_preserve_queued_business_frames() {
let PumpHarness {
mut connection,
incoming,
..
} = test_connection(false);
for payload in ["first", "second"] {
incoming
.send(Ok(Message::Text(payload.into())))
.expect("queue text");
}
assert!(
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
.await
.is_err()
);
for expected in ["first", "second"] {
assert!(
matches!(connection.next_event().await, Some(ConnectionEvent::Text(payload)) if payload == expected)
);
}
}
#[tokio::test]
async fn client_close_should_take_priority_over_queued_business_frames() {
let PumpHarness {
mut connection,
incoming,
..
} = test_connection(false);
incoming
.send(Ok(Message::Text("queued".into())))
.expect("queue text");
incoming
.send(Ok(Message::Close(None)))
.expect("close socket");
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
.await
.expect("observe close"),
PumpExitReason::ClientClose
);
assert!(matches!(
connection.next_event().await,
Some(ConnectionEvent::Exited(PumpExitReason::ClientClose))
));
}
#[tokio::test]
async fn full_business_queue_should_not_block_lifecycle_shutdown() {
let PumpHarness {
mut connection,
incoming,
mut written,
cancellation,
..
} = test_connection(false);
for _ in 0..32 {
incoming
.send(Ok(Message::Text("queued".into())))
.expect("queue text");
}
incoming
.send(Ok(Message::Ping(vec![1].into())))
.expect("send Ping after queue fills");
assert!(matches!(written.recv().await, Some(Message::Pong(_))));
cancellation.cancel();
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
.await
.expect("observe shutdown"),
PumpExitReason::LifecycleShutdown
);
}
#[tokio::test]
async fn inbound_overload_should_close_without_executing_queued_frames() {
let PumpHarness {
mut connection,
incoming,
..
} = test_connection(false);
for _ in 0..33 {
incoming
.send(Ok(Message::Text("queued".into())))
.expect("queue text");
}
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
.await
.expect("observe overload"),
PumpExitReason::InboundOverload
);
assert!(matches!(
connection.next_event().await,
Some(ConnectionEvent::Exited(PumpExitReason::InboundOverload))
));
}
#[tokio::test(start_paused = true)]
async fn pump_does_not_emit_an_active_ping() {
let PumpHarness {
@@ -379,6 +499,7 @@ async fn stalled_write_times_out_at_three_hundred_seconds_and_aborts_the_pump()
incoming: _incoming,
written: _written,
dropped,
..
} = test_connection(true);
let request_id = Arc::<str>::from("req_test");
@@ -421,6 +542,7 @@ async fn dropping_the_connection_aborts_and_drops_the_socket_owner() {
incoming: _incoming,
written: _written,
dropped,
..
} = test_connection(false);
drop(connection);
@@ -436,6 +558,7 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -547,6 +670,27 @@ async fn active_response_crossing_the_limit_writes_terminal_before_the_limit_err
}
}
for _ in 0..32 {
socket
.send(ClientMessage::Text(
json!({"type": "response.create", "model": "model-a", "input": "queued after limit"})
.to_string()
.into(),
))
.await
.expect("fill request queue before expiry");
}
socket
.send(ClientMessage::Ping(vec![1].into()))
.await
.expect("send Ping after queued request");
let pong = tokio::time::timeout(Duration::from_secs(1), socket.next())
.await
.expect("Pong timeout")
.expect("connection remains open")
.expect("valid frame");
assert!(matches!(pong, ClientMessage::Pong(_)));
tokio::time::pause();
tokio::time::advance(Duration::from_secs(60 * 60)).await;
tokio::task::yield_now().await;
@@ -575,15 +719,21 @@ async fn active_response_crossing_the_limit_writes_terminal_before_the_limit_err
assert_eq!(event_types, ["response.completed", "error"]);
assert!(trace.finalized.load(Ordering::Acquire));
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
server.abort();
}
#[tokio::test]
async fn second_text_frame_during_an_active_response_keeps_the_single_response_contract() {
type TestClientSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
async fn start_active_response() -> (
Arc<CrossingLimitTrace>,
TestClientSocket,
tokio::task::JoinHandle<()>,
) {
let trace = Arc::new(CrossingLimitTrace::default());
let execution = Arc::new(CrossingLimitExecution {
client: authenticated_client("sk_ws_crossing_limit"),
trace,
trace: Arc::clone(&trace),
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -615,7 +765,7 @@ async fn second_text_frame_during_an_active_response_keeps_the_single_response_c
.into(),
);
socket
.send(response_create.clone())
.send(response_create)
.await
.expect("send first response.create");
loop {
@@ -633,19 +783,169 @@ async fn second_text_frame_during_an_active_response_keeps_the_single_response_c
}
}
(trace, socket, server)
}
#[tokio::test]
async fn requests_received_during_an_active_response_should_execute_in_order() {
let (trace, mut socket, server) = start_active_response().await;
for input in ["second", "third"] {
socket
.send(ClientMessage::Text(
json!({"type": "response.create", "model": "model-a", "input": input})
.to_string()
.into(),
))
.await
.expect("send queued response.create");
}
socket
.send(response_create)
.send(ClientMessage::Ping(vec![1, 2, 3].into()))
.await
.expect("send overlapping response.create");
.expect("send Ping after queued requests");
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("Pong timeout while response is active")
.expect("WebSocket remains open")
.expect("valid WebSocket frame");
assert!(matches!(message, ClientMessage::Pong(_)));
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
for completed_count in 1..=3 {
trace.release_terminal.notify_one();
let expected_types = if completed_count < 3 {
vec![
"response.completed",
"response.metadata",
"response.created",
]
} else {
vec!["response.completed"]
};
let mut event_types = Vec::new();
for _ in &expected_types {
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("serial response timeout")
.expect("WebSocket remains open")
.expect("valid WebSocket frame");
let ClientMessage::Text(text) = message else {
panic!("unexpected frame during serial execution: {message:?}");
};
let event: Value = serde_json::from_str(&text).expect("response event JSON");
event_types.push(event["type"].as_str().expect("event type").to_owned());
}
assert_eq!(event_types, expected_types);
assert_eq!(
trace.starts.load(Ordering::Acquire),
(completed_count + 1).min(3)
);
}
assert_eq!(
*trace.inputs.lock().expect("input trace lock"),
vec![json!("hello"), json!("second"), json!("third")]
);
assert!(!trace.cancelled.load(Ordering::Acquire));
socket.close(None).await.expect("close WebSocket");
server.abort();
}
#[tokio::test]
async fn queued_unknown_message_should_be_rejected_before_the_next_valid_request() {
let (trace, mut socket, server) = start_active_response().await;
for payload in [
json!({"type": "response.future"}),
json!({"type": "response.create", "model": "model-a", "input": "next"}),
] {
socket
.send(ClientMessage::Text(payload.to_string().into()))
.await
.expect("queue frame");
}
trace.release_terminal.notify_one();
let mut event_types = Vec::new();
for _ in 0..4 {
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("response timeout")
.expect("connection remains open")
.expect("valid frame");
let ClientMessage::Text(text) = message else {
panic!("unexpected frame: {message:?}");
};
let event: Value = serde_json::from_str(&text).expect("event JSON");
event_types.push(event["type"].as_str().expect("event type").to_owned());
}
assert_eq!(
event_types,
[
"response.completed",
"error",
"response.metadata",
"response.created"
]
);
assert_eq!(trace.starts.load(Ordering::Acquire), 2);
assert_eq!(
*trace.inputs.lock().expect("input trace lock"),
vec![json!("hello"), json!("next")]
);
socket.close(None).await.expect("close WebSocket");
server.abort();
}
#[tokio::test]
async fn queued_binary_frame_should_be_rejected_after_the_active_response_finishes() {
let (trace, mut socket, server) = start_active_response().await;
socket
.send(ClientMessage::Binary(b"{}".to_vec().into()))
.await
.expect("queue binary frame");
trace.release_terminal.notify_one();
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("terminal timeout")
.expect("connection remains open")
.expect("valid frame");
let ClientMessage::Text(text) = message else {
panic!("active response did not finish before binary rejection: {message:?}");
};
let event: Value = serde_json::from_str(&text).expect("event JSON");
assert_eq!(event["type"], "response.completed");
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("policy close timeout")
.expect("WebSocket returns a policy close")
.expect("valid WebSocket frame");
let ClientMessage::Close(Some(frame)) = message else {
panic!("overlapping response.create must close the connection");
};
.expect("connection closes")
.expect("valid frame");
assert!(
matches!(message, ClientMessage::Close(Some(frame)) if frame.code == CloseCode::Policy)
);
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
server.abort();
}
assert_eq!(frame.code, CloseCode::Policy);
#[tokio::test]
async fn client_close_should_cancel_active_execution_without_starting_queued_requests() {
let (trace, mut socket, server) = start_active_response().await;
socket
.send(ClientMessage::Text(
json!({"type": "response.create", "model": "model-a", "input": "queued"})
.to_string()
.into(),
))
.await
.expect("queue request");
socket.close(None).await.expect("close WebSocket");
tokio::time::timeout(
Duration::from_secs(2),
trace.cancellation_observed.notified(),
)
.await
.expect("cancel active execution promptly");
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
assert!(trace.cancelled.load(Ordering::Acquire));
server.abort();
}
@@ -2,29 +2,34 @@ mod connection;
mod protocol;
use std::sync::{
Arc,
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::{Duration, SystemTime};
use axum::{
body::Body,
http::{Request, StatusCode, header::AUTHORIZATION},
http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION},
};
use bytes::Bytes;
use futures::{SinkExt, StreamExt, future::BoxFuture};
use gateway_api::openai::responses::ResponseCreateFrameError;
use gateway_api::openai::responses::{
DecodedResponsesRequest, OpenAiRequestHeaders, ResponseCreateFrameError,
decode_response_create_with_context,
};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
use gateway_core::event::{
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
};
use gateway_core::operation::Operation;
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use gateway_protocol::openai::codex_responses_request_semantics;
use serde_json::{Value, json};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as ClientMessage;
@@ -34,6 +39,22 @@ use tower::ServiceExt;
use super::decode_response_create;
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
fn decode_response_create_with_turn_header(
payload: Value,
opening_turn_metadata: &'static str,
) -> DecodedResponsesRequest {
let mut headers = HeaderMap::new();
headers.insert(
"x-codex-turn-metadata",
HeaderValue::from_static(opening_turn_metadata),
);
decode_response_create_with_context(
&payload.to_string(),
&OpenAiRequestHeaders::from_headers(&headers),
)
.expect("response.create should decode")
}
#[test]
fn response_create_should_default_to_the_websocket_streaming_contract() {
let decoded = decode_response_create(
@@ -118,6 +139,87 @@ fn response_create_should_preserve_compaction_trigger_for_openai() {
);
}
#[test]
fn response_create_should_prefer_frame_turn_metadata_over_opening_headers() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": r#"{"request_kind":"turn"}"#
}
}),
r#"{"request_kind":"compaction"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("turn"), false)
);
}
#[test]
fn response_create_should_accept_frame_compaction_after_a_turn_opening_header() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello",
"client_metadata": {
"x-codex-turn-metadata": r#"{"request_kind":"compaction"}"#
}
}),
r#"{"request_kind":"turn"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("compaction"), true)
);
}
#[test]
fn response_create_should_fall_back_to_opening_turn_metadata_when_the_frame_omits_it() {
let decoded = decode_response_create_with_turn_header(
json!({
"type": "response.create",
"model": "smart-code",
"input": "hello"
}),
r#"{"request_kind":"compaction"}"#,
);
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
let semantics = codex_responses_request_semantics(
request.protocol_payload().body(),
request.protocol_payload().context(),
);
assert_eq!(
(semantics.request_kind.as_deref(), semantics.compact),
(Some("compaction"), true)
);
}
#[test]
fn response_create_should_reject_explicit_non_streaming_requests() {
let error = decode_response_create(
@@ -178,17 +280,26 @@ struct AtomicFailureTrace {
next_calls: AtomicUsize,
committed: AtomicBool,
finalized: AtomicBool,
downstream_websocket_connection_ids: Mutex<Vec<String>>,
}
struct AtomicFailureSession {
trace: Arc<AtomicFailureTrace>,
response_headers: Vec<ProviderResponseHeader>,
fail_before_first_event: bool,
}
impl ExecutionSession for AtomicFailureSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async move {
match self.trace.next_calls.fetch_add(1, Ordering::AcqRel) {
let next_call = self.trace.next_calls.fetch_add(1, Ordering::AcqRel);
if self.fail_before_first_event && next_call == 0 {
return Err(EngineError::Provider(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::Ambiguous,
)));
}
match next_call {
0 => Ok(Some(atomic_failure_batch())),
1 => {
self.trace.finalized.store(true, Ordering::Release);
@@ -238,6 +349,7 @@ struct AtomicFailureExecution {
client: AuthenticatedClient,
trace: Arc<AtomicFailureTrace>,
response_headers: Vec<ProviderResponseHeader>,
fail_before_first_event: bool,
}
impl ExecutionService for AtomicFailureExecution {
@@ -263,6 +375,19 @@ impl ExecutionService for AtomicFailureExecution {
request: StartExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
self.trace.starts.fetch_add(1, Ordering::AcqRel);
if let Operation::Generate(generate) = &request.operation
&& let Some(connection_id) = generate
.protocol_payload()
.context()
.get("downstream_websocket_connection_id")
.and_then(Value::as_str)
{
self.trace
.downstream_websocket_connection_ids
.lock()
.expect("downstream connection trace lock")
.push(connection_id.to_owned());
}
Box::pin(async move {
Ok(StartedExecution {
request_id: gateway_core::engine::ModelRequestId::new("req_ws_atomic")
@@ -272,6 +397,7 @@ impl ExecutionService for AtomicFailureExecution {
session: Box::new(AtomicFailureSession {
trace: Arc::clone(&self.trace),
response_headers: self.response_headers.clone(),
fail_before_first_event: self.fail_before_first_event,
}),
})
})
@@ -337,6 +463,7 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -408,6 +535,14 @@ async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
assert!(trace.committed.load(Ordering::Acquire));
assert_eq!(trace.next_calls.load(Ordering::Acquire), 2);
assert!(trace.finalized.load(Ordering::Acquire));
{
let downstream_connection_ids = trace
.downstream_websocket_connection_ids
.lock()
.expect("downstream connection trace lock");
assert_eq!(downstream_connection_ids.len(), 1);
assert!(downstream_connection_ids[0].starts_with("ws_"));
}
socket.close(None).await.expect("close WebSocket");
server.abort();
@@ -438,6 +573,7 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
ProviderResponseHeader::new("content-type", Bytes::from_static(b"application/private")),
ProviderResponseHeader::new("x-request-id", Bytes::from_static(b"req_upstream")),
],
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -522,6 +658,91 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
server.abort();
}
#[tokio::test]
async fn websocket_failure_before_first_event_should_return_observed_metadata_then_error() {
let trace = Arc::new(AtomicFailureTrace::default());
let execution = Arc::new(AtomicFailureExecution {
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
response_headers: vec![
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-before-close"),
),
ProviderResponseHeader::new(
"authorization",
Bytes::from_static(b"must-not-cross-boundary"),
),
],
fail_before_first_event: true,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket test server");
let address = listener.local_addr().expect("test server address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("serve WebSocket test router");
});
let mut request = format!("ws://{address}/v1/responses")
.into_client_request()
.expect("WebSocket request");
request.headers_mut().insert(
AUTHORIZATION,
"Bearer sk_ws_atomic".parse().expect("authorization"),
);
let (mut socket, response) = connect_async(request).await.expect("upgrade WebSocket");
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
socket
.send(ClientMessage::Text(
json!({
"type": "response.create",
"model": "model-a",
"input": "hello"
})
.to_string()
.into(),
))
.await
.expect("send response.create");
let mut messages = Vec::<Value>::new();
while messages.len() < 2 {
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("WebSocket response timeout")
.expect("WebSocket remains open")
.expect("valid WebSocket frame");
let ClientMessage::Text(text) = message else {
continue;
};
messages.push(serde_json::from_str(&text).expect("JSON WebSocket event"));
}
assert_eq!(
messages
.iter()
.filter_map(|message| message.get("type").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec!["response.metadata", "error"]
);
let headers = messages[0]
.get("headers")
.and_then(Value::as_object)
.expect("response metadata headers");
assert_eq!(
headers.get("x-codex-turn-state"),
Some(&json!("turn-state-before-close"))
);
assert!(!headers.contains_key("authorization"));
assert!(!trace.committed.load(Ordering::Acquire));
socket.close(None).await.expect("close WebSocket");
server.abort();
}
#[tokio::test]
async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit() {
let trace = Arc::new(AtomicFailureTrace::default());
@@ -529,6 +750,7 @@ async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit(
client: authenticated_client("sk_ws_atomic"),
trace,
response_headers: Vec::new(),
fail_before_first_event: false,
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -1,6 +1,6 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode},
http::{Method, Request, StatusCode, header::AUTHORIZATION},
};
use tower::ServiceExt;
@@ -9,6 +9,54 @@ use super::models::ModelsExecution;
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
#[tokio::test]
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::post("/v1/responses/review")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed review request"),
)
.await
.expect("route removed review request");
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn removed_models_catalog_extension_should_use_standard_model_lookup() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::get("/v1/models/catalog")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed model catalog request"),
)
.await
.expect("route removed model catalog request");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn removed_model_info_route_should_return_not_found() {
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
.await
.oneshot(
Request::get("/v1/models/model-a/info")
.header(AUTHORIZATION, "Bearer sk_models_test")
.body(Body::empty())
.expect("build removed model info request"),
)
.await
.expect("route removed model info request");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn responses_body_should_accept_payload_above_the_removed_private_limit() {
let router = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
@@ -0,0 +1,234 @@
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::ModelRequestId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::Operation;
use gateway_core::routing::PublicModelId;
use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::provider_endpoint::BufferedJsonSession;
use super::{api_router, authenticated_client};
const SEARCH_RESPONSE: &[u8] = br#"{ "encrypted_output":"ciphertext", "output":"search result", "results":[{"type":"text_result","ref_id":"turn0search0","future":9007199254740993}] }"#;
#[derive(Debug, Clone, PartialEq)]
struct CapturedSearchRequest {
provider: String,
endpoint: String,
transport: ClientTransport,
body: Bytes,
context: Value,
}
struct SearchExecution {
client: AuthenticatedClient,
captured: Mutex<Vec<CapturedSearchRequest>>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
fail_with_upstream_response: bool,
}
impl SearchExecution {
fn new(fail_with_upstream_response: bool) -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_search_test"),
captured: Mutex::new(Vec::new()),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response,
})
}
fn captured(&self) -> Vec<CapturedSearchRequest> {
self.captured.lock().expect("capture lock").clone()
}
}
impl ExecutionService for SearchExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
(plaintext == "sk_search_test")
.then(|| self.client.clone())
.ok_or(ClientAuthenticationError::InvalidKey)
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
Vec::new()
}
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
false
}
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"search test must use provider endpoint execution",
))
})
}
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
let Operation::Search(search) = &request.operation else {
return Err(GatewayError::new(
GatewayErrorKind::Internal,
"search test received a non-search operation",
));
};
let payload = search.payload();
self.captured
.lock()
.expect("capture lock")
.push(CapturedSearchRequest {
provider: request.provider.as_str().to_owned(),
endpoint: request.metadata.endpoint,
transport: request.metadata.transport,
body: payload.body().clone(),
context: Value::Object(payload.context().clone()),
});
let session = if self.fail_with_upstream_response {
BufferedJsonSession::failure(
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(StatusCode::UNPROCESSABLE_ENTITY.as_u16())
.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
Some(b"application/problem+json".to_vec()),
Bytes::from_static(
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#,
),
)
.with_headers(vec![ProviderResponseHeader::new(
"x-future-search-error",
Bytes::from_static(b"preserved"),
)]),
),
)
} else {
BufferedJsonSession::success(
Bytes::from_static(SEARCH_RESPONSE),
StatusCode::CREATED.as_u16(),
vec![ProviderResponseHeader::new(
"x-search-rate-limit",
Bytes::from_static(b"42"),
)],
Arc::clone(&self.committed_statuses),
)
};
Ok(StartedExecution {
request_id: ModelRequestId::new("req_search_test").expect("request ID"),
created_at: SystemTime::now(),
stream: false,
session: Box::new(session),
})
})
}
}
#[tokio::test]
async fn search_route_should_preserve_request_and_success_response_bytes() {
let execution = SearchExecution::new(false);
let request_body = br#"{ "id":"search-session", "model":"gpt-future", "commands":{"search_query":[{"q":"private query"}]}, "future":1, "future":2 }"#;
let turn_metadata =
r#"{"session_id":"session","thread_id":"thread","turn_id":"turn","future":true}"#;
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/alpha/search")
.header(AUTHORIZATION, "Bearer sk_search_test")
.header("content-type", "application/json")
.header("x-codex-turn-metadata", turn_metadata)
.header("session-id", "root-session")
.header("thread-id", "child-thread")
.body(Body::from(request_body.to_vec()))
.expect("search request"),
)
.await
.expect("search response");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
response.headers().get("x-search-rate-limit"),
Some(&"42".parse().expect("header value"))
);
let response_body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read search response");
assert_eq!(response_body.as_ref(), SEARCH_RESPONSE);
let captured = execution.captured();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].provider, "openai");
assert_eq!(captured[0].endpoint, "/v1/alpha/search");
assert_eq!(captured[0].transport, ClientTransport::HttpJson);
assert_eq!(captured[0].body.as_ref(), request_body);
assert_eq!(
captured[0].context,
json!({"turn_metadata": turn_metadata, "session_id": "root-session", "thread_id": "child-thread"})
);
assert_eq!(
*execution
.committed_statuses
.lock()
.expect("committed status lock"),
vec![StatusCode::CREATED.as_u16()]
);
}
#[tokio::test]
async fn search_route_should_return_the_exact_upstream_error_response() {
let response = api_router(SearchExecution::new(true))
.await
.oneshot(
Request::post("/v1/alpha/search")
.header(AUTHORIZATION, "Bearer sk_search_test")
.header("content-type", "application/json")
.body(Body::from(
br#"{ "model":"gpt-future", "future_invalid":9007199254740993 }"#.to_vec(),
))
.expect("search request"),
)
.await
.expect("search response");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
response.headers().get("content-type"),
Some(&"application/problem+json".parse().expect("content type"))
);
assert_eq!(
response.headers().get("x-future-search-error"),
Some(&"preserved".parse().expect("header value"))
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read search response");
assert_eq!(
body.as_ref(),
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#
);
}
+3
View File
@@ -13,11 +13,14 @@ path = "tests/main.rs"
[dependencies]
async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
futures = "0.3.32"
futures-timer = "3.0.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11.0"
semver = "1.0.27"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
@@ -0,0 +1,16 @@
//! Provider credential 值对象构造错误。
use thiserror::Error;
/// Credential 值对象构造错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum CredentialError {
#[error("credential revision must be greater than zero")]
InvalidRevision,
#[error("credential revision overflow")]
RevisionOverflow,
#[error("credential CAS profile belongs to a different account")]
ProfileAccountMismatch,
#[error("credential refresh schedule requires a refresh token")]
InvalidRefreshSchedule,
}
@@ -0,0 +1,13 @@
//! Provider 账号领域、持久化端口与同一 target 内的账号选择。
mod error;
mod model;
pub mod scope;
mod selection;
mod store;
pub use error::CredentialError;
pub use model::*;
pub use selection::*;
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
pub use store::ProviderAccountStore;

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