Compare commits

...
49 Commits
Author SHA1 Message Date
ZyyandCodex a38c328975 style(frontend): format account usage summary
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:57:06 +08:00
ZyyandCodex 97d95d25f1 chore: bump version to 3.3.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:49 +08:00
ZyyandCodex abfd24267b fix(frontend): refine usage visual hierarchy
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:05 +08:00
ZyyandCodex a363682929 feat(frontend): refine surfaces and draggable modals
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:25:40 +08:00
ZyyandCodex 57422275bb feat(accounts): add official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 02:36:45 +08:00
Zyy cb7f69850b feat(openai): add session affinity observability 2026-08-28 02:20:55 +08:00
ZyyandCodex 5ea3740de3 refactor(openai): remove agent identity support
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 13:27:10 +08:00
ZyyandCodex 3920624862 chore: bump version to 3.3.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 01:53:05 +08:00
Zyy d804bca495 fix(openai): strip unsupported Codex request fields 2026-08-27 01:51:30 +08:00
ZyyandCodex ca527d5833 fix(accounts): use personal usage reports only
Remove the workspace token-breakdown probe and its dedicated statistics mode so usage aggregation always uses the personal model-credit and token-total reports.

Drop the invariant mode field from provider projections, the admin response, and the account usage UI.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 11:31:55 +08:00
ZyyandCodex ae4eb49afc chore: bump version to 3.3.2
Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:51:31 +08:00
ZyyandCodex 5d97086511 feat(accounts): add official usage statistics
Fetch Codex cycle usage in the provider, calculate model and daily Token costs with the shared OpenAI pricing rules, and expose the result through the admin API.

Add the account usage statistics modal, structured loading state, and feature-scoped quota components.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:46:53 +08:00
ZyyandCodex 0e6da1e731 refactor: consolidate migration baseline and remove design source
Fold the nullable requested-model change into the initial migration and keep the frozen manifest and project guidance aligned.

Remove the staged Pen design source.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:53:29 +08:00
ZyyandCodex 5d0922b9d1 fix(theme): align functional colors with Ant Design
Keep functional base tokens on Ant Design P6 so light themes preserve the configured seed while dark themes retain palette adaptation.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:06:05 +08:00
ZyyandCodex ed3d622554 chore: bump version to 3.3.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:53:32 +08:00
dependabot[bot] 0f71cf6df2 chore(deps): bump rust in /deploy in the minor-and-patch group (#46)
Bumps the minor-and-patch group in /deploy with 1 update: rust.


Updates `rust` from 1.97-bookworm to 1.98-bookworm

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:37:44 +08:00
dependabot[bot] a1d3eba874 chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#45)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.31.0` | `1.33.0` |
| [dayjs](https://github.com/iamkun/dayjs) | `1.11.21` | `1.11.23` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.50.0` | `1.51.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.10` | `3.3.11` |


Updates `@lucide/vue` from 1.31.0 to 1.33.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/vue)

Updates `dayjs` from 1.11.21 to 1.11.23
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/v1.11.23/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.21...v1.11.23)

Updates `dompurify` from 3.4.13 to 3.4.14
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14)

Updates `es-toolkit` from 1.50.0 to 1.51.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.50.0...v1.51.0)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `eslint` from 10.8.1 to 10.9.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.0)

Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)

Updates `vue-tsc` from 3.3.10 to 3.3.11
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.11/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dayjs
  dependency-version: 1.11.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:37:10 +08:00
dependabot[bot] d22c1d4ab9 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#44)
Bumps the minor-and-patch group in /backend with 4 updates: [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) and [redis](https://github.com/redis-rs/redis-rs).


Updates `uuid` from 1.24.0 to 1.24.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1)

Updates `aws-sdk-s3` from 1.141.0 to 1.142.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `redis` from 1.5.0 to 1.6.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.5.0...redis-1.6.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.142.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:39 +08:00
dependabot[bot] cc8dfe3dd8 chore(deps): bump the minor-and-patch group with 2 updates (#43)
Bumps the minor-and-patch group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:01 +08:00
ZyyandCodex f25edadd5f fix(ci): keep OpenAI regression tests out of sources
Move the thread-spawn transport identity regression behind the public initialized-provider boundary so production source files remain free of embedded test modules.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:27:28 +08:00
ZyyandCodex 4f50c67332 fix(openai): preserve root session account affinity
Share the preferred account across derived child executions while keeping their continuation and WebSocket transport identities isolated.

Claim and migrate affinity atomically, clear account-bound state on fallback, reject cross-account previous-response continuations, and preserve complete replay transcripts.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 14:22:36 +08:00
ZyyandCodex 75f9d2a290 fix(openai): remove websocket pool slot caps
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 11:22:07 +08:00
ZyyandCodex b93b8859f9 fix(frontend): polish theme controls and page headers
Remove the redundant preview mode switch, round the color picker input, and restore inline right-aligned page header actions.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 20:21:16 +08:00
ZyyandCodex 3510a45742 chore: bump version to 3.3.0
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:07:37 +08:00
ZyyandCodex 85eb283b40 docs(readme): streamline project introduction
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:06:49 +08:00
ZyyandCodex b8e1c5077d docs(theme): document runtime theme architecture
Describe the token model, preset themes, runtime stylesheet, isolated editor preview, naming conventions, and extension workflow.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:59 +08:00
ZyyandCodex 8422240967 feat(theme): add runtime token theme editor
Introduce Seed-to-Component token derivation, isolated live previews, semantic base component theming, and responsive theme controls.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:22 +08:00
ZyyandCodex fbd922117e fix(accounts): return persisted OAuth account ID
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 19:37:03 +08:00
ZyyandCodex 941c4d57fa chore: bump version to 3.2.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:28:34 +08:00
ZyyandCodex cf046ad9a4 fix(admin): close quota reset modal on success
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:25:05 +08:00
ZyyandCodex 7699a954ac fix(admin): format account counts with separators
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:21:11 +08:00
ZyyandCodex 2c31708d1b feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:19:20 +08:00
ZyyandCodex 80f338ce01 fix(admin): show raw account counts
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 23:12:54 +08:00
ZyyandCodex 299088a6e7 chore: bump version to 3.2.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 20:00:59 +08:00
ZyyandCodex a23bfca848 fix(admin): normalize OAuth credential formats
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:57:16 +08:00
ZyyandCodex 8951a64a05 fix(admin): raise billing abbreviation threshold
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:15:22 +08:00
ZyyandCodex eb20561893 fix(admin): align xAI quota usage displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:58:02 +08:00
ZyyandCodex a33637a99e fix(openai): use Codex image endpoint paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:02:40 +08:00
ZyyandCodex 97cd7a7097 chore: bump version to 3.2.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:11:57 +08:00
ZyyandCodex b0fe132736 docs: refresh architecture and project guides
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:01:07 +08:00
ZyyandCodex e2c75d1054 feat(admin): add direct token import and polish displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:26 +08:00
ZyyandCodex 94f298927d feat(openai): add active quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:21 +08:00
ZyyandCodex 3be827204f fix(openai): keep image routes model-agnostic
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 19:05:14 +08:00
ZyyandCodex 227a1174b9 feat(openai): proxy image generation and edits
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 17:41:15 +08:00
ZyyandCodex c0defcb6df fix(admin): prefer account-wide dashboard quota
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 16:06:05 +08:00
ZyyandCodex 53739ebe5c chore: bump version to 3.2.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:20:29 +08:00
ZyyandCodex 7f420257a6 fix(api): keep provider degradation out of healthz
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 4f85209dc9 fix(config): centralize runtime data paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex cd89e2e8fc fix(admin): align quota metadata and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
321 changed files with 16902 additions and 38303 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -79,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2 -2
View File
@@ -448,7 +448,7 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -519,7 +519,7 @@ jobs:
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+73 -70
View File
@@ -12,31 +12,32 @@
[![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) · [接口](docs/api.md) · [架构](docs/architecture.md)
</div>
> [!NOTE]
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
## 能力
## 功能
| 领域 | 实现 |
| -------- | ----------------------------------------------------------------------------------------------------- |
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
| 透明边界 | OpenAI 请求保留未知字段和字段顺序,SSE 与 WebSocket 原样转发业务字节;xAI 在 Provider 内做协议转换 |
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
| 账号 | 导入、OAuth、credential 刷新、主动额度刷新,以及正常响应中的被动额度/状态观测 |
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因,并记录响应确认的实际 `service_tier` |
| 领域 | 当前能力 |
| --- | --- |
| 数据面 | 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` |
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
## 快速开始
需要 Docker Engine 与 Docker Compose Plugin。
### 1. 准备
### 1. 准备配置和目录
```bash
git clone https://github.com/zyycn/codex-proxy-rs.git
@@ -56,16 +57,16 @@ openssl rand -hex 24
openssl rand -hex 24
```
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
| 配置 | 约束 |
| -------------------------- | ---------------------------- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
| 配置 | 约束 |
| --- | --- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
导出环境变量。管理员密码只在首次创建管理员时生效。
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
@@ -83,37 +84,36 @@ docker compose -f deploy/compose.yaml up -d --no-build
curl -i http://127.0.0.1:8080/healthz
```
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
`http://127.0.0.1:8080`。
### 3. 初始化
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
和兼容账号文件导入。
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
[账号接口](docs/api.md#5-账号)。
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
额度上游暂时失败不会回滚已提交的账号。
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
> [!NOTE]
> `admin.default_password` 只在首次创建管理员时生效。
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
## 客户端接入
| 配置 | 值 |
| -------- | ---------------------------------------- |
| 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` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
```bash
curl http://127.0.0.1:8080/v1/models \
@@ -130,67 +130,70 @@ curl http://127.0.0.1:8080/v1/responses \
```
<details>
<summary>API 路由</summary>
<summary>公开数据面路由</summary>
| 路由 | 用途 |
| ---------------------------------- | ------------------------------ |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
| 路由 | 用途 |
| --- | --- |
| `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/*` 路由都需要客户端 API Key。
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
文本模型映射。
</details>
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.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
```
> [!IMPORTANT]
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
> 运行状态。
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
> 本地运行状态。
> [!WARNING]
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
> 并重新导入或重新授权 Provider 账号与客户端 Key。
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
> Provider 账号与 Client Key。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
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 构建,使构建缓存可以跨版本
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
仓库发版和实例升级是三个独立状态。
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
独立状态;脚本不会登录服务器或改变任何运行实例。
## 文档
- [部署](deploy/README.md)
- [部署与运维](deploy/README.md)
- [客户端与管理端接口](docs/api.md)
- [架构与数据边界](docs/architecture.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)
+18 -197
View File
@@ -8,16 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -216,9 +206,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.141.0"
version = "1.142.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e"
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -308,9 +298,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.1"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -341,9 +331,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -385,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.12.1"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -411,9 +401,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -721,17 +711,6 @@ dependencies = [
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -961,7 +940,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -976,36 +954,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek 4.1.3",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1016,48 +964,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1169,31 +1075,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1246,7 +1127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1295,18 +1176,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1631,7 +1500,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -2073,15 +1941,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2318,12 +2177,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2498,17 +2351,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2579,8 +2421,6 @@ dependencies = [
"bytes",
"chrono",
"cookie",
"crypto_box",
"ed25519-dalek",
"flate2",
"futures",
"gateway-admin",
@@ -2714,7 +2554,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2813,9 +2653,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3257df217f7eab0044627a268c9cc6cdb60c0c421c88f83ac41c4e31520b6b84"
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
dependencies = [
"arc-swap",
"arcstr",
@@ -2984,7 +2824,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3049,15 +2889,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -3628,7 +3459,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4046,16 +3877,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4100,9 +3921,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
+3 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
+8
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -119,11 +119,11 @@ pub struct RetentionDecision {
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
///
/// 规则(`docs/s3-backup-design-audit.md` §4.4):
/// 自动保留规则:
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
/// - 自动计划备份满足任一启用阈值即可进入删除。
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
/// - 手动备份不受自动阈值影响;无 TTL,仅由管理员手动删除。
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
#[must_use]
pub fn decide_retention(
retention_days: u32,
+4 -1
View File
@@ -347,7 +347,10 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
}
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
AdminErrorKind::Unavailable
}
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, "Provider registry initialization failed")
@@ -288,7 +288,7 @@ pub struct BackupRecord {
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
/// 手动过期时间;创建时确定,到期自动清理。
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -557,7 +557,7 @@ pub struct UsageRecord {
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub requested_model_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -3,11 +3,12 @@
use std::fmt;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use chrono::{DateTime, NaiveDate, Utc};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
};
use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
@@ -570,13 +571,34 @@ pub enum QuotaLocalUsageAttribution {
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
@@ -605,6 +627,90 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
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>,
}
/// Provider 官方个人资料中的单日 Token bucket。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
/// Provider 官方个人资料中的插件或 Skill 调用排行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileInvocation {
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>,
}
/// Provider 官方个人资料中的活动洞察。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileActivityInsights {
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<ProviderProfileInvocation>>,
}
/// Provider 已解释的官方个人资料统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: ProviderProfileStatisticsSummary,
pub daily_usage: Option<Vec<ProviderProfileDailyUsage>>,
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub reset_type: Option<String>,
}
/// Provider 主动额度重置卡列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredits {
pub available_count: u64,
pub credits: Vec<ProviderResetCredit>,
}
/// 一次主动额度重置卡消费命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumeProviderResetCredit {
pub account_id: ProviderAccountId,
pub credit_id: Option<String>,
pub redeem_request_id: Uuid,
}
/// Provider 返回的主动额度重置卡消费结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCreditResult {
pub code: String,
pub credit: Option<ProviderResetCredit>,
}
impl ProviderQuota {
/// 返回账号展开面板使用的当前额度窗口用量。
///
@@ -627,8 +733,9 @@ impl ProviderQuota {
/// 返回 Dashboard 使用的代表性额度比例。
///
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
/// 语义,绝不读取 Provider 私有 JSON。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
if self.limit_reached {
@@ -687,7 +794,7 @@ impl ProviderQuota {
Some((index, quota_usage_priority(window), used_percent))
})
.fold(
None::<(usize, u8, f64)>,
None::<(usize, (u8, u8), f64)>,
|selected, candidate| match selected {
Some(current)
if current.1 < candidate.1
@@ -702,13 +809,18 @@ impl ProviderQuota {
}
}
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
match window.group.as_str() {
fn quota_usage_priority(window: &ProviderQuotaWindow) -> (u8, u8) {
let attribution = match window.local_usage_attribution {
QuotaLocalUsageAttribution::AccountWide => 0,
QuotaLocalUsageAttribution::Unavailable => 1,
};
let duration = match window.group.as_str() {
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
"shortTerm" => 0,
"monthly" => 2,
_ => 3,
}
};
(attribution, duration)
}
fn is_week_window(seconds: u64) -> bool {
@@ -13,11 +13,12 @@ use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
};
use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -28,6 +29,8 @@ pub enum ProviderAdminErrorKind {
NotFound,
Conflict,
Unavailable,
CredentialRefreshRequired,
BadGateway,
Internal,
}
@@ -144,6 +147,30 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderResetCredits, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn consume_reset_credit(
&self,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -24,8 +24,9 @@ use crate::{
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, QuotaLocalUsageAttribution,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -79,6 +80,35 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid(
"Provider profile statistics are not supported",
))
}
async fn reset_credits(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
Err(AdminError::invalid(
"Provider reset credits are not supported",
))
}
async fn consume_reset_credit(
&self,
_context: &MutationContext,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
Err(AdminError::invalid(
"Provider reset credits are not supported",
))
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -97,11 +127,13 @@ pub(crate) struct DefaultAccountsService {
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
reset_credit_locks:
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
}
impl DefaultAccountsService {
#[must_use]
pub(crate) const fn new(
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
@@ -112,9 +144,22 @@ impl DefaultAccountsService {
providers,
snapshot,
probe,
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
}
}
async fn reset_credit_lock(
&self,
account_id: &ProviderAccountId,
) -> Arc<futures::lock::Mutex<()>> {
let mut locks = self.reset_credit_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(futures::lock::Mutex::new(()))),
)
}
async fn load_account(
&self,
account_id: &ProviderAccountId,
@@ -502,6 +547,68 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile statistics"))
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
async fn consume_reset_credit(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费。
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -582,6 +689,19 @@ impl AccountsService for DefaultAccountsService {
}
}
fn map_reset_credits_error_after_refresh(
error: crate::ports::provider::ProviderAdminError,
) -> AdminError {
if error.kind() == crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired {
return AdminError::bad_gateway(
error
.message()
.unwrap_or("OpenAI reset-credit upstream rejected the refreshed credential"),
);
}
map_provider_error(error, "provider reset credits")
}
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
@@ -57,6 +57,8 @@ fn map_provider_error(
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
let message = error
+24 -2
View File
@@ -136,6 +136,24 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn representative_quota_should_prefer_account_wide_window_over_model_specific_window() {
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
model_specific,
quota_window("shortTerm", Some(604_800), Some(5.0)),
],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(5.0));
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
let usage = AccountUsage {
@@ -165,7 +183,9 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
@@ -234,7 +254,9 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
@@ -30,14 +30,16 @@ use gateway_admin::{
provider_credentials::{
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
CredentialCommitGuard, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
QuotaLocalUsageAttribution,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -69,6 +71,7 @@ pub(super) struct FakeProviderAdmin {
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
quota: Mutex<ProviderQuota>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
}
impl FakeProviderAdmin {
@@ -85,6 +88,7 @@ impl FakeProviderAdmin {
quota_requests: Mutex::new(Vec::new()),
quota: Mutex::new(empty_quota()),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
})
}
@@ -118,6 +122,13 @@ impl FakeProviderAdmin {
.clone()
}
fn reset_credit_commands(&self) -> Vec<ConsumeProviderResetCredit> {
self.reset_credit_commands
.lock()
.expect("provider reset-credit commands")
.clone()
}
pub(super) fn set_quota(&self, quota: ProviderQuota) {
*self.quota.lock().expect("provider quota") = quota;
}
@@ -349,6 +360,22 @@ impl ProviderAdmin for FakeProviderAdmin {
Ok(self.quota.lock().expect("provider quota").clone())
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
self.record("provider.consume_reset_credit");
self.reset_credit_commands
.lock()
.expect("provider reset-credit commands")
.push(command);
self.require_available()?;
Ok(ProviderResetCreditResult {
code: "reset".to_owned(),
credit: None,
})
}
async fn models(
&self,
_: &ProviderAccountId,
@@ -1262,7 +1289,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1391,7 +1420,9 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1436,7 +1467,9 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1482,7 +1515,9 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1547,7 +1582,9 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
key: "core-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
source: Some("codex".to_owned()),
limit_id: Some("codex".to_owned()),
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(1.0),
@@ -1559,8 +1596,10 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
ProviderQuotaWindow {
key: "codex-bengalfox-primary".to_owned(),
group: "weekly".to_owned(),
label: "GPT-5.3-Codex-Spark · 周额度".to_owned(),
source: Some("codex_bengalfox".to_owned()),
label: "周额度".to_owned(),
limit_id: Some("codex_bengalfox".to_owned()),
limit_name: Some("GPT-5.3-Codex-Spark".to_owned()),
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(0.0),
@@ -1655,6 +1694,33 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
);
}
#[tokio::test]
async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider.clone(), store).await;
let redeem_request_id =
uuid::Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("UUID v4");
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
credit_id: Some("credit_1".to_owned()),
redeem_request_id,
};
let result = services
.accounts()
.consume_reset_credit(&context("reset-credit-refresh"), command.clone())
.await
.expect("consume after OAuth refresh");
assert_eq!(result.code, "reset");
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert!(recorded(&events).contains(&"provider.prepare_refresh"));
assert!(recorded(&events).contains(&"store.commit_refresh"));
}
#[tokio::test]
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
let events = events();
@@ -662,7 +662,7 @@ fn total_record(
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "gpt-5.5".to_owned(),
requested_model_id: Some("gpt-5.5".to_owned()),
provider_kind: provider_kind.map(str::to_owned),
provider_account_ref: None,
provider_account_name: None,
@@ -25,10 +25,13 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderQuota, ProviderQuotaWindow, RotateCredential,
StartAuthorization,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -37,6 +40,7 @@ use gateway_core::{
};
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::{
@@ -305,8 +309,12 @@ pub struct AccountQuotaView {
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,
@@ -472,6 +480,48 @@ impl AccountActionRequest {
}
}
/// 主动额度重置卡消费请求。幂等键由 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(())
}
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)]
@@ -539,6 +589,186 @@ 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)]
@@ -1051,6 +1281,14 @@ where
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/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
)
.route(
"/api/admin/accounts/quota/refresh",
post(refresh_account_quota::<S>),
@@ -1381,6 +1619,27 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<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 refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -1402,6 +1661,48 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_reset_credits<S>(
auth: AdminAuth,
State(state): State<S>,
Query(query): Query<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>,
Json(request): Json<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>,
@@ -1603,7 +1904,9 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
key,
group,
label,
source: _,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
@@ -1612,10 +1915,17 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display: label,
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
@@ -1772,7 +2082,7 @@ 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!("{} {}", cost.currency, cost.amount.as_str()),
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
}
}
@@ -142,11 +142,19 @@ pub(crate) fn format_service_tier(service_tier: Option<&str>) -> String {
match service_tier {
Some("priority" | "fast") => "Fast".to_owned(),
Some("flex") => "Flex".to_owned(),
Some("default") | None => "Default".to_owned(),
Some(other) => other.to_owned(),
Some("default" | "standard") | None => "Standard".to_owned(),
Some(other) => capitalize_first(other),
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
chars
.next()
.map(|first| first.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { total, .. } => Some(BillingView {
@@ -222,7 +230,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
let model = record
.upstream_model_id
.clone()
.unwrap_or_else(|| record.requested_model_id.clone());
.or_else(|| record.requested_model_id.clone());
let transport = record
.upstream_transport
.clone()
@@ -243,7 +251,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
account_name: record.provider_account_name,
route: record.endpoint,
model,
requested_model: Some(record.requested_model_id),
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
service_tier: record.service_tier,
status_code,
@@ -351,9 +359,7 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
provider: attempt
.provider_kind
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt
.upstream_model_id
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt.upstream_model_id,
transport: attempt
.upstream_transport
.unwrap_or_else(|| "unknown".to_owned()),
@@ -731,21 +737,19 @@ pub(crate) fn dashboard_view(
last_used: relative_time(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
.total
.saturating_sub(provider_accounts.normal);
DashboardDataView {
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total: provider_accounts.total.to_string(),
total_value: provider_accounts.total,
available: format_compact_number(provider_accounts.normal),
available: provider_accounts.normal.to_string(),
available_value: provider_accounts.normal,
unavailable: format_compact_number(
provider_accounts
.total
.saturating_sub(provider_accounts.normal),
),
unavailable_value: provider_accounts
.total
.saturating_sub(provider_accounts.normal),
unavailable: unavailable_accounts.to_string(),
unavailable_value: unavailable_accounts,
},
traffic: DashboardTrafficCardView {
today_requests: format_compact_number(today.request_count),
@@ -94,7 +94,7 @@ pub struct UsageRecordView {
pub account_email: Option<String>,
pub account_name: Option<String>,
pub route: String,
pub model: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
@@ -183,7 +183,7 @@ pub struct UsageAttemptView {
pub attempt_index: u32,
pub trigger: String,
pub provider: String,
pub model: String,
pub model: Option<String>,
pub transport: String,
pub send_state: String,
pub outcome: String,
@@ -1,7 +1,7 @@
//! Admin API 的展示层辅助:数值格式化。
//!
//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终
//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
//! 各自维护格式化规则。
use gateway_core::accounting::Decimal;
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
@@ -0,0 +1,233 @@
//! Codex Images 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::{Body, Bytes},
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, 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 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},
};
const OPENAI_PROTOCOL: &str = "openai";
const IMAGE_TURN_ID_CONTEXT_KEY: &str = "image_turn_id";
/// `POST /v1/images/generations`。
pub(crate) async fn image_generations(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Generation,
"/v1/images/generations",
)
.await
}
/// `POST /v1/images/edits`。
pub(crate) async fn image_edits(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Edit,
"/v1/images/edits",
)
.await
}
async fn handle_image_request(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
kind: ImageRequestKind,
endpoint: &'static str,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match image_operation(body, &headers, kind) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_image(client, operation, client_ip, user_agent, endpoint)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_image_response(started).await
}
fn image_operation(
body: Bytes,
headers: &HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = Map::new();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
{
context.insert(
IMAGE_TURN_ID_CONTEXT_KEY.to_owned(),
Value::String(turn_id.to_owned()),
);
}
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::GenerateImage(ImageRequest::from_raw_json(
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()
}
@@ -0,0 +1,5 @@
//! OpenAI/Codex Images JSON 透明 adapter。
mod http;
pub(crate) use http::{image_edits, image_generations};
@@ -2,6 +2,7 @@
pub mod auth;
pub mod error;
pub mod images;
pub mod models;
pub mod responses;
pub mod router;
@@ -123,7 +123,7 @@ async fn handle_responses(
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
pub(super) fn request_client_context(
pub(in crate::openai) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
@@ -367,22 +367,24 @@ fn internal_gateway_response(message: &'static str) -> Response {
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
}
pub(super) struct PendingExecution {
pub(in crate::openai) struct PendingExecution {
session: Option<Box<dyn ExecutionSession>>,
}
impl PendingExecution {
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
pub(in crate::openai) fn new(session: Box<dyn ExecutionSession>) -> Self {
Self {
session: Some(session),
}
}
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
pub(in crate::openai) fn session_mut(
&mut self,
) -> Option<&mut (dyn ExecutionSession + 'static)> {
self.session.as_deref_mut()
}
async fn cancel_and_finalize(&mut self) {
pub(in crate::openai) async fn cancel_and_finalize(&mut self) {
if let Some(session) = self.session.as_mut() {
session.cancel();
if !session.is_finalized() {
@@ -391,7 +393,10 @@ impl PendingExecution {
}
}
async fn record_response_status(&mut self, response: Response) -> Response {
pub(in crate::openai) async fn record_response_status(
&mut self,
response: Response,
) -> Response {
if let Some(session) = self.session.as_mut() {
let _ = session
.record_client_status(response.status().as_u16())
@@ -400,7 +405,7 @@ impl PendingExecution {
response
}
pub(super) fn disarm(&mut self) {
pub(in crate::openai) fn disarm(&mut self) {
self.session = None;
}
@@ -6,7 +6,8 @@ mod request;
mod response;
mod websocket;
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
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::{
@@ -165,7 +165,6 @@ impl fmt::Debug for ContinuationIntent {
#[derive(Clone, PartialEq, Eq)]
pub struct ResponsesRequestMetadata {
requested_model: String,
public_model: String,
stream: bool,
store: bool,
continuation: ContinuationIntent,
@@ -180,12 +179,6 @@ impl ResponsesRequestMetadata {
&self.requested_model
}
/// 返回协议层公开模型候选。
#[must_use]
pub fn public_model(&self) -> &str {
&self.public_model
}
/// 返回客户端是否请求 SSE。
#[must_use]
pub const fn stream(&self) -> bool {
@@ -222,7 +215,6 @@ impl fmt::Debug for ResponsesRequestMetadata {
formatter
.debug_struct("ResponsesRequestMetadata")
.field("requested_model", &self.requested_model)
.field("public_model", &self.public_model)
.field("stream", &self.stream)
.field("store", &self.store)
.field("continuation", &self.continuation)
@@ -366,8 +358,7 @@ pub(super) fn decode_request_object(
Ok(DecodedResponsesRequest {
operation,
metadata: ResponsesRequestMetadata {
requested_model: model.clone(),
public_model: model,
requested_model: model,
stream,
store,
continuation,
@@ -7,6 +7,7 @@ use axum::{
};
use super::{
images::{image_edits, image_generations},
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
};
@@ -16,6 +17,8 @@ use crate::ApiState;
/// 构造 OpenAI 客户端协议路由。
pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/images/generations", post(image_generations))
.route("/v1/images/edits", post(image_edits))
.route("/v1/responses", get(responses_websocket).post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/models", get(models))
@@ -23,7 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
.route("/v1/models/{model_id}/info", get(model_info))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
}
@@ -1,11 +1,12 @@
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
use std::net::IpAddr;
use std::sync::Arc;
use gateway_core::engine::continuation::PreviousResponseId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
ExecutionService, StartExecution, StartedExecution,
ExecutionService, StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
@@ -74,13 +75,13 @@ impl OpenAiService {
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let (operation, metadata) = request.into_parts();
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
let previous_response_id = match metadata.continuation() {
ContinuationIntent::None => None,
ContinuationIntent::PreviousResponseId(value) => {
@@ -105,6 +106,38 @@ impl OpenAiService {
.await
}
pub(crate) async fn start_image(
&self,
client: AuthenticatedClient,
operation: gateway_core::operation::Operation,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let provider = gateway_core::routing::ProviderKind::new("openai").map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI provider identifier is invalid",
)
})?;
self.execution
.start_provider_endpoint(StartProviderExecution {
client,
provider,
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: endpoint.to_owned(),
transport: ClientTransport::HttpJson,
stream: false,
client_ip,
user_agent,
previous_response_id: None,
},
})
.await
}
pub(crate) fn try_register_connection(
&self,
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
@@ -70,6 +70,72 @@ mod query {
}
}
mod profile_statistics {
use chrono::NaiveDate;
use gateway_admin::model::provider_credentials::{
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::AccountProfileStatisticsData;
use serde_json::json;
#[test]
fn profile_statistics_response_preserves_nullable_official_fields() {
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
}]),
},
});
let value = serde_json::to_value(response).expect("serialize profile statistics");
assert_eq!(value["displayName"], "Ada");
assert_eq!(value["summary"]["totalTextTokens"], 409_500_000);
assert_eq!(
value["dailyUsage"][0],
json!({"date": "2026-08-25", "tokens": 42})
);
assert_eq!(
value["activityInsights"]["invocations"][0]["type"],
"plugin"
);
assert_eq!(
value["activityInsights"]["invocations"][0]["usageCount"],
29
);
assert!(value.get("cycle").is_none());
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
}
}
mod batch_update {
use gateway_api::admin::accounts::{BatchUpdateAccountsRequest, UpdateAccountRequest};
use serde_json::json;
@@ -266,8 +332,8 @@ mod actions {
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
AccountTestQuery, CompleteAccountAuthorizationRequest, RotateAccountRequest,
StartAccountAuthorizationRequest,
AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest,
RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::engine::credential::ProviderAccountId;
use serde_json::json;
@@ -325,6 +391,44 @@ mod actions {
);
}
#[test]
fn reset_credit_consume_should_require_canonical_v4_idempotency_key() {
let valid: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": "credit_1",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode reset-credit consume");
valid.validate().expect("validate reset-credit consume");
for (redeem_request_id, field) in [
("019c0000-0000-7000-8000-000000000000", "redeemRequestId"),
("8FBF302D-11DF-4BD5-82E4-08E4B3DF7874", "redeemRequestId"),
("invalid", "redeemRequestId"),
] {
let request: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"redeemRequestId": redeem_request_id
}))
.expect("decode invalid reset-credit consume");
assert_eq!(request.validate().expect_err("reject UUID").field(), field);
}
let invalid_credit: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": " ",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode invalid credit");
assert_eq!(
invalid_credit
.validate()
.expect_err("reject credit")
.field(),
"creditId"
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -221,9 +221,9 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
attempts: AttemptMetrics::default(),
totals: Default::default(),
provider_accounts: AccountPoolMetrics {
total: 807,
normal: 15,
quota_exhausted: 785,
total: 1002,
normal: 943,
quota_exhausted: 52,
rate_limited: 0,
disabled: 3,
error: 4,
@@ -259,9 +259,21 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
&value["data"]["cards"]["credentials"]["unavailableValue"],
),
(
&serde_json::json!(807),
&serde_json::json!(15),
&serde_json::json!(792)
&serde_json::json!(1002),
&serde_json::json!(943),
&serde_json::json!(59)
),
);
assert_eq!(
(
&value["data"]["cards"]["credentials"]["total"],
&value["data"]["cards"]["credentials"]["available"],
&value["data"]["cards"]["credentials"]["unavailable"],
),
(
&serde_json::json!("1002"),
&serde_json::json!("943"),
&serde_json::json!("59")
),
);
}
@@ -681,7 +693,7 @@ fn usage_record_with_account(
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
requested_model_id: Some("coding".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some(account_id.to_owned()),
provider_account_name: Some(name.to_owned()),
@@ -810,7 +822,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "grok-4.5".to_owned(),
requested_model_id: Some("grok-4.5".to_owned()),
provider_kind: Some("xai".to_owned()),
provider_account_ref: Some("acct_snapshot".to_owned()),
provider_account_name: Some("Snapshot Alpha".to_owned()),
@@ -820,7 +832,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: Some("reuse".to_owned()),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
provider_metadata_json: Some(
serde_json::json!({
"effectiveModel": "grok-4.5",
@@ -865,7 +877,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
output_price_per_million: usd("60.0000"),
cache_read_price_per_million: usd("1.0000"),
cache_write_price_per_million: usd("12.5000"),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
multiplier_percent: 100,
},
))),
@@ -921,6 +933,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
value["data"]["items"][0]["billing"]["cacheWritePriceDisplay"],
"$12.5 / 1M Token"
);
assert_eq!(
value["data"]["items"][0]["billing"]["serviceTierDisplay"],
"Standard"
);
assert_eq!(
serde_json::json!({
@@ -961,7 +977,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
"routingScope": "groups",
"routingGroupRefs": ["grp_usage"],
"routingGroupNamesSnapshot": ["Usage group"],
"serviceTier": "priority",
"serviceTier": "default",
"accountId": "acct_snapshot",
"accountName": "Snapshot Alpha",
"accountEmail": "alpha@example.invalid",
@@ -52,6 +52,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/lib.rs",
"src/openai/auth.rs",
"src/openai/error.rs",
"src/openai/images/http.rs",
"src/openai/images/mod.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/responses/error.rs",
@@ -90,6 +92,7 @@ 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/mod.rs",
"tests/openai/models.rs",
"tests/openai/responses/http.rs",
+79 -1
View File
@@ -8,10 +8,12 @@ use futures::future::BoxFuture;
use gateway_core::{
engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
StartProviderExecution, StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
@@ -31,6 +33,75 @@ async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_ignore_provider_maintenance_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_reject_critical_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
impl WorkerHealthSource for StaticWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
self.0.clone()
}
}
fn worker_snapshot(kind: WorkerKind, state: WorkerRuntimeState) -> WorkerHealthSnapshot {
let id = WorkerId::try_new(kind, "health-test").expect("worker ID");
WorkerHealthSnapshot {
key: WorkerHealthKey::Task(id),
state,
consecutive_failures: 1,
completed_cycles: 0,
last_fencing_token: None,
last_success_at: None,
last_failure_at: None,
last_error: Some("test worker failure".to_owned()),
}
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
@@ -49,4 +120,11 @@ impl ExecutionService for UnusedExecution {
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("health check does not execute requests") })
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("health check does not execute provider endpoints") })
}
}
@@ -0,0 +1,331 @@
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
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::UpstreamSendState;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CoordinatedEvent, EngineError, ModelRequestId};
use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
};
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequestKind, Operation};
use gateway_core::routing::PublicModelId;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
const IMAGE_RESPONSE: &[u8] =
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#;
#[derive(Debug, Clone, PartialEq)]
struct CapturedImageRequest {
provider: String,
endpoint: String,
transport: ClientTransport,
kind: ImageRequestKind,
body: Bytes,
context: Value,
}
struct ImageExecution {
client: AuthenticatedClient,
captured: Arc<Mutex<Vec<CapturedImageRequest>>>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
fail_with_upstream_response: bool,
}
impl ImageExecution {
fn new() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_images_test"),
captured: Arc::new(Mutex::new(Vec::new())),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response: false,
})
}
fn failing() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_images_test"),
captured: Arc::new(Mutex::new(Vec::new())),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response: true,
})
}
fn captured(&self) -> Vec<CapturedImageRequest> {
self.captured.lock().expect("capture lock").clone()
}
fn committed_statuses(&self) -> Vec<u16> {
self.committed_statuses
.lock()
.expect("committed status lock")
.clone()
}
}
impl ExecutionService for ImageExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
(plaintext == "sk_images_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,
"images test must use provider endpoint execution",
))
})
}
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
let Operation::GenerateImage(image) = &request.operation else {
return Err(GatewayError::new(
GatewayErrorKind::Internal,
"images test received a non-image operation",
));
};
let payload = image.payload();
self.captured
.lock()
.expect("capture lock")
.push(CapturedImageRequest {
provider: request.provider.as_str().to_owned(),
endpoint: request.metadata.endpoint,
transport: request.metadata.transport,
kind: image.kind(),
body: payload.body().clone(),
context: Value::Object(payload.context().clone()),
});
Ok(StartedExecution {
request_id: ModelRequestId::new("req_images_test").expect("request ID"),
created_at: SystemTime::now(),
stream: false,
session: Box::new(ImageSession {
response: Some(Bytes::from_static(IMAGE_RESPONSE)),
response_headers: vec![ProviderResponseHeader::new(
"x-image-rate-limit",
Bytes::from_static(b"42"),
)],
committed_statuses: Arc::clone(&self.committed_statuses),
finalized: Arc::new(AtomicBool::new(false)),
fail_with_upstream_response: self.fail_with_upstream_response,
}),
})
})
}
}
struct ImageSession {
response: Option<Bytes>,
response_headers: Vec<ProviderResponseHeader>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
finalized: Arc<AtomicBool>,
fail_with_upstream_response: bool,
}
impl ExecutionSession for ImageSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async { unreachable!("buffered image delivery does not stream") })
}
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async move {
if self.fail_with_upstream_response {
return Err(EngineError::Provider(
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 image validation"}, "future":9007199254740993 }"#,
),
)
.with_headers(vec![ProviderResponseHeader::new(
"x-future-image-error",
Bytes::from_static(b"preserved"),
)]),
),
));
}
Ok(vec![ProviderEvent::wire(
ProtocolWireEvent::raw_json(
"openai",
self.response.take().expect("single image response"),
)
.expect("OpenAI protocol"),
)])
})
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
&self.response_headers
}
fn response_status_code(&self) -> Option<u16> {
Some(StatusCode::CREATED.as_u16())
}
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("image 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);
})
}
}
#[tokio::test]
async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directions() {
let execution = ImageExecution::new();
let router = api_router(execution.clone()).await;
let cases = [
(
"/v1/images/generations",
ImageRequestKind::Generation,
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "future":9007199254740993 }"#.as_slice(),
),
(
"/v1/images/edits",
ImageRequestKind::Edit,
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate stays raw"}"#.as_slice(),
),
];
for (path, _, body) in &cases {
let response = router
.clone()
.oneshot(
Request::post(*path)
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", "application/json")
.header("x-codex-image-turn-id", "turn_image_route")
.body(Body::from(body.to_vec()))
.expect("image request"),
)
.await
.expect("image response");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
response.headers().get("x-image-rate-limit"),
Some(&"42".parse().expect("header value"))
);
let response_body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read image response");
assert_eq!(response_body.as_ref(), IMAGE_RESPONSE);
}
let captured = execution.captured();
assert_eq!(captured.len(), cases.len());
for (captured, (endpoint, kind, body)) in captured.iter().zip(cases.iter()) {
assert_eq!(captured.provider, "openai");
assert_eq!(captured.endpoint, *endpoint);
assert_eq!(captured.transport, ClientTransport::HttpJson);
assert_eq!(captured.kind, *kind);
assert_eq!(captured.body.as_ref(), *body);
assert_eq!(
captured.context,
json!({"image_turn_id": "turn_image_route"})
);
}
assert_eq!(
execution.committed_statuses(),
vec![StatusCode::CREATED.as_u16(); cases.len()]
);
}
#[tokio::test]
async fn image_route_should_return_the_exact_upstream_error_response() {
let execution = ImageExecution::failing();
let response = api_router(execution)
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", "application/json")
.body(Body::from(
br#"{ "model":"gpt-image-2", "future_invalid":9007199254740993 }"#.to_vec(),
))
.expect("image request"),
)
.await
.expect("image 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-image-error"),
Some(&"preserved".parse().expect("header value"))
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read image response");
assert_eq!(
body.as_ref(),
br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"#
);
}
+22 -1
View File
@@ -1,5 +1,6 @@
mod auth;
mod error;
mod images;
mod models;
mod responses;
mod router;
@@ -44,9 +45,29 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
api_router_with_origins_and_worker_health(execution, Vec::new(), worker_health).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
api_router_with_origins_and_worker_health(
execution,
cors_allowed_origins,
Arc::new(EmptyWorkerHealth),
)
.await
}
async fn api_router_with_origins_and_worker_health(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
@@ -59,7 +80,7 @@ pub(super) async fn api_router_with_origins(
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
worker_health,
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
@@ -7,7 +7,7 @@ use axum::{
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
StartProviderExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
@@ -99,6 +99,18 @@ impl ExecutionService for ModelsExecution {
))
})
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"models test must not start a provider endpoint",
))
})
}
}
fn authorized_request(path: &str) -> Request<Body> {
@@ -17,7 +17,7 @@ use bytes::Bytes;
use futures::future::{BoxFuture, pending};
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartedExecution,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::error::{
@@ -190,6 +190,18 @@ impl ExecutionService for ContextCaptureExecution {
))
})
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"responses context test must not start a provider endpoint",
))
})
}
}
impl FakeSession {
@@ -130,7 +130,7 @@ fn decoder_should_preserve_opaque_client_model_values() {
"input": "hello"
}));
assert_eq!(decoded.metadata().public_model(), model);
assert_eq!(decoded.metadata().requested_model(), model);
assert_eq!(
openai_wire_body(&decoded)
.get("model")
@@ -454,14 +454,20 @@ fn decoder_should_not_reject_large_bodies_using_catalog_context_limits() {
let snapshot = super::snapshot("sk_context_test", "openai");
let plan = snapshot
.plan(
&PublicModelId::new(decoded.metadata().public_model()).expect("public model"),
&PublicModelId::new(decoded.metadata().requested_model()).expect("public model"),
decoded.operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect("large body must not be locally context-gated");
assert_eq!(plan.candidates()[0].upstream_model().as_str(), "model-a");
assert_eq!(
plan.candidates()[0]
.upstream_model()
.expect("model route candidate")
.as_str(),
"model-a"
);
}
#[test]
@@ -13,7 +13,7 @@ use futures::{SinkExt, StreamExt, future::BoxFuture};
use gateway_api::openai::responses::ResponseCreateFrameError;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartedExecution,
StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
@@ -271,6 +271,13 @@ impl ExecutionService for AtomicFailureExecution {
})
})
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("WebSocket test does not execute provider endpoints") })
}
}
fn atomic_failure_batch() -> CoordinatedEvent {
@@ -455,6 +455,15 @@ where
.unwrap_or_default()
}
/// 返回最终选中 attempt 观察到的上游 HTTP 状态码。
#[must_use]
pub fn response_status_code(&self) -> Option<u16> {
self.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::status_code)
}
/// 将已完成响应的账号事实与 Provider 私有状态封装为可丢失的亲和记录。
///
/// Core 不读取 `state` 内容;Provider 将在后续同账号 continuation 时自行解释。
@@ -828,7 +837,7 @@ where
provider_kind: metadata.provider().clone(),
provider_account_id: metadata.provider_account_id().cloned(),
provider_account_ref: metadata.provider_account_id().cloned(),
upstream_model_id: metadata.upstream_model().clone(),
upstream_model_id: metadata.upstream_model().cloned(),
upstream_transport: metadata.transport().as_str().to_owned(),
http_version: None,
};
@@ -1074,7 +1083,7 @@ where
trigger: current.trigger,
provider_kind: current.metadata.provider().clone(),
account_id: current.metadata.provider_account_id().cloned(),
upstream_model_id: current.metadata.upstream_model().clone(),
upstream_model_id: current.metadata.upstream_model().cloned(),
upstream_status_code: current
.response_observation
.as_ref()
@@ -1482,8 +1491,13 @@ where
}
fn image_generation_succeeded(&self) -> Option<bool> {
self.image_generation_requested
.then(|| self.usage.image_output_tokens.unwrap_or_default() > 0)
self.image_generation_requested.then(|| {
if matches!(self.operation, Operation::GenerateImage(_)) {
self.upstream_complete
} else {
self.usage.image_output_tokens.unwrap_or_default() > 0
}
})
}
fn current_send_state(&self) -> UpstreamSendState {
@@ -118,6 +118,35 @@ pub struct StartExecution {
pub metadata: ExecutionRequestMetadata,
}
/// 启动一个由协议 adapter 明确绑定到 Provider 自有端点的请求。
pub struct StartProviderExecution {
pub client: AuthenticatedClient,
pub provider: ProviderKind,
pub operation: Operation,
pub metadata: ExecutionRequestMetadata,
}
enum ExecutionTarget {
Model(PublicModelId),
ProviderEndpoint(ProviderKind),
}
impl ExecutionTarget {
fn into_public_model(self) -> Option<PublicModelId> {
match self {
Self::Model(model) => Some(model),
Self::ProviderEndpoint(_) => None,
}
}
}
struct PendingStartExecution {
client: AuthenticatedClient,
target: ExecutionTarget,
operation: Operation,
metadata: ExecutionRequestMetadata,
}
pub struct StartedExecution {
pub request_id: ModelRequestId,
pub created_at: SystemTime,
@@ -129,6 +158,9 @@ pub trait ExecutionSession: Send {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>>;
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>>;
fn response_headers(&self) -> &[ProviderResponseHeader];
fn response_status_code(&self) -> Option<u16> {
None
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
@@ -156,6 +188,10 @@ pub trait ExecutionService: Send + Sync {
&self,
request: StartExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>>;
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>>;
}
/// 成功认证后的 API Key 使用事实接收器。
@@ -247,9 +283,44 @@ impl DefaultExecutionService {
}
}
async fn start_inner(
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
let StartExecution {
client,
public_model,
operation,
metadata,
} = request;
self.start_inner_with_target(PendingStartExecution {
client,
target: ExecutionTarget::Model(public_model),
operation,
metadata,
})
.await
}
async fn start_provider_endpoint_inner(
&self,
mut request: StartExecution,
request: StartProviderExecution,
) -> Result<StartedExecution, GatewayError> {
let StartProviderExecution {
client,
provider,
operation,
metadata,
} = request;
self.start_inner_with_target(PendingStartExecution {
client,
target: ExecutionTarget::ProviderEndpoint(provider),
operation,
metadata,
})
.await
}
async fn start_inner_with_target(
&self,
mut request: PendingStartExecution,
) -> Result<StartedExecution, GatewayError> {
request.client.policy.authorize().map_err(|_| {
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
@@ -264,16 +335,24 @@ impl DefaultExecutionService {
let routing_context = self
.route_context(request.client.policy.account_scope().provider_kinds())
.await?;
let plan = request
.client
.snapshot
.plan(
&request.public_model,
let account_scope = Arc::clone(request.client.policy.account_scope());
let plan = match &request.target {
ExecutionTarget::ProviderEndpoint(provider) => {
request.client.snapshot.plan_provider_endpoint(
provider,
&request.operation,
account_scope,
&routing_context,
)
}
ExecutionTarget::Model(public_model) => request.client.snapshot.plan(
public_model,
&request.operation,
Arc::clone(request.client.policy.account_scope()),
account_scope,
&routing_context,
)
.map_err(map_routing_error)?;
),
}
.map_err(map_routing_error)?;
let continuation = match request.metadata.previous_response_id.as_ref() {
Some(previous) => {
let resolve = self
@@ -375,18 +454,24 @@ impl DefaultExecutionService {
async fn start_without_continuation(
&self,
request: StartExecution,
request: PendingStartExecution,
request_id: ModelRequestId,
started_at: SystemTime,
deadline_at: SystemTime,
plan: crate::routing::RoutingPlan,
continuation: Option<ContinuationBinding>,
) -> Result<StartedExecution, GatewayError> {
let PendingStartExecution {
client,
target,
operation,
metadata,
} = request;
let admission_request = ClientAdmissionRequest {
model_request_id: request_id.clone(),
client_api_key_id: request.client.policy.key_id().clone(),
client_api_key_id: client.policy.key_id().clone(),
lease_ttl: MODEL_REQUEST_DEADLINE,
limits: request.client.policy.limits(),
limits: client.policy.limits(),
};
match self
.admissions
@@ -411,7 +496,7 @@ impl DefaultExecutionService {
}
let admission = AdmissionLease {
port: Arc::clone(&self.admissions),
client_api_key_id: request.client.policy.key_id().clone(),
client_api_key_id: client.policy.key_id().clone(),
model_request_id: request_id.clone(),
};
let observation = plan
@@ -419,27 +504,27 @@ impl DefaultExecutionService {
.first()
.map_or_else(Default::default, |candidate| {
self.providers
.request_observation(candidate.provider(), &request.operation)
.request_observation(candidate.provider(), &operation)
});
let new_request = NewModelRequest {
id: request_id.clone(),
client_api_key_id: Some(request.client.policy.key_id().clone()),
client_api_key_ref: request.client.policy.key_id().clone(),
client_api_key_id: Some(client.policy.key_id().clone()),
client_api_key_ref: client.policy.key_id().clone(),
config_revision: plan.config_revision(),
routing: request.client.policy.account_scope().routing_snapshot(),
protocol: request.metadata.protocol,
operation: request.operation.kind(),
endpoint: request.metadata.endpoint,
client_transport: request.metadata.transport.as_str().to_owned(),
requested_model: request.public_model,
client_ip: request.metadata.client_ip,
user_agent: request.metadata.user_agent,
routing: client.policy.account_scope().routing_snapshot(),
protocol: metadata.protocol,
operation: operation.kind(),
endpoint: metadata.endpoint,
client_transport: metadata.transport.as_str().to_owned(),
requested_model: target.into_public_model(),
client_ip: metadata.client_ip,
user_agent: metadata.user_agent,
reasoning_effort: observation.reasoning_effort,
reasoning_preset: observation.reasoning_preset,
request_kind: observation.request_kind,
subagent_kind: observation.subagent_kind,
compact: observation.compact,
image_generation_requested: request.operation.image_generation_requested(),
image_generation_requested: operation.image_generation_requested(),
started_at,
deadline_at,
};
@@ -447,7 +532,7 @@ impl DefaultExecutionService {
.coordinator
.start(
new_request,
request.operation,
operation,
plan,
None,
continuation,
@@ -464,7 +549,7 @@ impl DefaultExecutionService {
Ok(StartedExecution {
request_id,
created_at: started_at,
stream: request.metadata.stream,
stream: metadata.stream,
session: Box::new(DefaultExecutionSession::new(
core,
admission,
@@ -572,7 +657,7 @@ impl DefaultExecutionService {
operation: operation.kind(),
endpoint: "/api/admin/accounts/connection-test".to_owned(),
client_transport: ClientTransport::InternalProbe.as_str().to_owned(),
requested_model: public_model,
requested_model: Some(public_model),
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -783,6 +868,13 @@ impl ExecutionService for DefaultExecutionService {
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move { self.start_inner(request).await })
}
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move { self.start_provider_endpoint_inner(request).await })
}
}
impl AccountProbe for DefaultExecutionService {
@@ -948,6 +1040,12 @@ impl ExecutionSession for DefaultExecutionSession {
.unwrap_or_default()
}
fn response_status_code(&self) -> Option<u16> {
self.core
.as_ref()
.and_then(ResponseExecutionSession::response_status_code)
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
@@ -1076,6 +1174,7 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
fn map_routing_error(error: crate::error::RoutingError) -> GatewayError {
match error {
crate::error::RoutingError::NoCapableProvider { .. }
| crate::error::RoutingError::NoCapableProviderEndpoint { .. }
| crate::error::RoutingError::EmptyAccountScope => GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no provider can execute this request",
@@ -560,7 +560,7 @@ pub struct NewModelRequest {
pub operation: OperationKind,
pub endpoint: String,
pub client_transport: String,
pub requested_model: PublicModelId,
pub requested_model: Option<PublicModelId>,
pub client_ip: Option<IpAddr>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -582,7 +582,7 @@ pub struct AttemptRecord {
pub provider_kind: ProviderKind,
pub provider_account_id: Option<ProviderAccountId>,
pub provider_account_ref: Option<ProviderAccountId>,
pub upstream_model_id: UpstreamModelId,
pub upstream_model_id: Option<UpstreamModelId>,
pub upstream_transport: String,
pub http_version: Option<String>,
}
@@ -595,7 +595,7 @@ pub struct IntermediateFailure {
pub trigger: AttemptTrigger,
pub provider_kind: ProviderKind,
pub account_id: Option<ProviderAccountId>,
pub upstream_model_id: UpstreamModelId,
pub upstream_model_id: Option<UpstreamModelId>,
pub upstream_status_code: Option<u16>,
pub upstream_request_id: Option<String>,
pub error: ProviderError,
@@ -123,7 +123,7 @@ impl UpstreamTransport {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCallMetadata {
provider: ProviderKind,
upstream_model: UpstreamModelId,
upstream_model: Option<UpstreamModelId>,
resource: ProviderResource,
upstream_request_id: Option<OpaqueUpstreamValue>,
transport: UpstreamTransport,
@@ -140,7 +140,23 @@ impl ProviderCallMetadata {
) -> Self {
Self {
provider,
upstream_model,
upstream_model: Some(upstream_model),
resource,
upstream_request_id: None,
transport,
}
}
/// 创建一次不声明模型的 Provider 原生端点调用事实。
#[must_use]
pub const fn for_provider_endpoint(
provider: ProviderKind,
resource: ProviderResource,
transport: UpstreamTransport,
) -> Self {
Self {
provider,
upstream_model: None,
resource,
upstream_request_id: None,
transport,
@@ -160,10 +176,10 @@ impl ProviderCallMetadata {
&self.provider
}
/// 返回实际模型;必须与冻结 target 一致。
/// 返回实际模型;Provider 原生端点没有模型事实。
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
pub const fn upstream_model(&self) -> Option<&UpstreamModelId> {
self.upstream_model.as_ref()
}
/// 返回匿名资源;账号调用返回 `None`。
@@ -199,7 +215,8 @@ impl ProviderCallMetadata {
/// 确认 metadata 没有替换请求计划中冻结的 Provider 候选。
#[must_use]
pub fn confirms(&self, candidate: &ProviderCandidate) -> bool {
candidate.provider() == &self.provider && candidate.upstream_model() == &self.upstream_model
candidate.provider() == &self.provider
&& candidate.upstream_model() == self.upstream_model.as_ref()
}
}
@@ -517,7 +534,7 @@ impl fmt::Debug for ProviderRequest {
.debug_struct("ProviderRequest")
.field("operation", &self.operation)
.field("provider", self.candidate.provider())
.field("upstream_model", self.candidate.upstream_model())
.field("upstream_model", &self.candidate.upstream_model())
.finish()
}
}
+6
View File
@@ -98,6 +98,12 @@ pub enum RoutingError {
/// 客户端提交的模型名称。
model: String,
},
/// 固定 Provider 的原生端点当前不可执行。
#[error("provider endpoint `{provider}` is unavailable")]
NoCapableProviderEndpoint {
/// adapter 已绑定的 Provider。
provider: String,
},
}
/// 调用方策略不满足约束或拒绝请求。
+55 -4
View File
@@ -16,7 +16,7 @@ use crate::operation::ProviderSessionState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseMeta {
response_id: String,
model: String,
model: Option<String>,
finish_reason: Option<FinishReason>,
}
@@ -26,7 +26,17 @@ impl ResponseMeta {
pub fn new(response_id: impl Into<String>, model: impl Into<String>) -> Self {
Self {
response_id: response_id.into(),
model: model.into(),
model: Some(model.into()),
finish_reason: None,
}
}
/// 创建不声明模型的 Provider 原生端点响应元数据。
#[must_use]
pub fn for_provider_endpoint(response_id: impl Into<String>) -> Self {
Self {
response_id: response_id.into(),
model: None,
finish_reason: None,
}
}
@@ -46,8 +56,8 @@ impl ResponseMeta {
/// 返回对客户端公开的模型名。
#[must_use]
pub fn model(&self) -> &str {
&self.model
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
/// 返回规范化终止原因。
@@ -190,6 +200,7 @@ pub struct ProtocolWireEvent {
data: Value,
has_json_data: bool,
raw_sse_frame: Option<Bytes>,
raw_json_body: Option<Bytes>,
sse_id: Option<String>,
sse_retry: Option<u64>,
}
@@ -228,6 +239,7 @@ impl ProtocolWireEvent {
data,
has_json_data: true,
raw_sse_frame: None,
raw_json_body: None,
sse_id,
sse_retry,
})
@@ -274,6 +286,32 @@ impl ProtocolWireEvent {
data: Value::Null,
has_json_data: false,
raw_sse_frame: Some(raw_sse_frame),
raw_json_body: None,
sse_id: None,
sse_retry: None,
})
}
/// 创建未经改写的完整 JSON 响应正文。
///
/// 该载荷用于非流式协议端点;Core 不解析或重编码其中的值。
///
/// # Errors
///
/// 协议名不满足内部路由标识约束时返回错误。
pub fn raw_json(
protocol: impl Into<String>,
raw_json_body: Bytes,
) -> Result<Self, IdentifierError> {
let protocol = protocol.into();
validate_text(&protocol, 64, true, None)?;
Ok(Self {
protocol,
event_type: None,
data: Value::Null,
has_json_data: false,
raw_sse_frame: None,
raw_json_body: Some(raw_json_body),
sse_id: None,
sse_retry: None,
})
@@ -321,6 +359,18 @@ impl ProtocolWireEvent {
self.raw_sse_frame.as_ref()
}
/// 返回可直接写给 JSON 客户端的未经改写响应正文。
#[must_use]
pub const fn raw_json_body(&self) -> Option<&Bytes> {
self.raw_json_body.as_ref()
}
/// 拆出未经改写的完整 JSON 响应正文。
#[must_use]
pub fn into_raw_json_body(self) -> Option<Bytes> {
self.raw_json_body
}
/// 拆出协议原生 JSON 数据。
#[must_use]
pub fn into_data(self) -> Value {
@@ -336,6 +386,7 @@ impl fmt::Debug for ProtocolWireEvent {
.field("event_type", &self.event_type)
.field("has_json_data", &self.has_json_data)
.field("has_raw_sse_frame", &self.raw_sse_frame.is_some())
.field("has_raw_json_body", &self.raw_json_body.is_some())
.field("has_sse_id", &self.sse_id.is_some())
.field("sse_retry", &self.sse_retry)
.field("data", &"<not included in Debug>")
@@ -1,13 +1,14 @@
//! 协议无关的业务 operation。
//!
//! 这里只保留网关需要解释、路由或结算的稳定语义。协议 adapter 无法写入
//! wire body 的连接级事实随 [`ProtocolPayload`] 不透明传递,由对应 Provider
//! 自己解释。
//! wire body 与连接级事实随 [`ProtocolPayload`] 或 [`RawJsonPayload`] 不透明传递,
//! 由对应 Provider 自己解释。
use std::collections::BTreeSet;
use std::fmt;
use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -239,6 +240,72 @@ impl fmt::Debug for ProtocolPayload {
}
}
/// 客户端协议交给 Provider 的未经重编码 JSON 正文。
///
/// 协议 adapter 只确定路由端点;Core 保留原始字节与非 wire 上下文,
/// 不验证、解析或改写正文。
#[derive(Clone, PartialEq, Eq)]
pub struct RawJsonPayload {
protocol: String,
body: Bytes,
context: Map<String, Value>,
}
impl RawJsonPayload {
/// 创建未经重编码的协议 JSON 正文。
///
/// # Errors
///
/// 协议名称为空、过长或含控制字符时返回错误。
pub fn new(protocol: impl Into<String>, body: Bytes) -> Result<Self, OperationError> {
let protocol = protocol.into();
validate_text(&protocol, 64, true, None).map_err(|_| OperationError::EmptyField {
field: "raw_json_payload protocol",
})?;
Ok(Self {
protocol,
body,
context: Map::new(),
})
}
/// 返回协议名称。
#[must_use]
pub fn protocol(&self) -> &str {
&self.protocol
}
/// 返回 adapter 收到的原始 JSON 字节。
#[must_use]
pub const fn body(&self) -> &Bytes {
&self.body
}
/// 附着不写入 wire body 的协议连接上下文。
#[must_use]
pub fn with_context(mut self, context: Map<String, Value>) -> Self {
self.context = context;
self
}
/// 返回仅供对应 Provider 解释的非 wire 上下文。
#[must_use]
pub const fn context(&self) -> &Map<String, Value> {
&self.context
}
}
impl fmt::Debug for RawJsonPayload {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RawJsonPayload")
.field("protocol", &self.protocol)
.field("body", &"<not included in Debug>")
.field("context", &"<not included in Debug>")
.finish()
}
}
/// 通用生成请求。
#[derive(Clone, PartialEq)]
pub struct GenerateRequest {
@@ -501,32 +568,39 @@ impl fmt::Debug for RerankRequest {
}
}
/// 图像生成请求。
#[derive(Clone, PartialEq, Eq)]
/// 图像 API 的稳定端点语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ImageRequestKind {
/// 从文本生成图像。
Generation,
/// 使用输入图像执行编辑。
Edit,
}
/// 图像生成或编辑请求。
#[derive(Clone, PartialEq)]
pub struct ImageRequest {
prompt: String,
count: u32,
kind: ImageRequestKind,
payload: RawJsonPayload,
}
impl ImageRequest {
/// 创建图像生成请求。
///
/// # Errors
///
/// Prompt 为空时返回错误。
pub fn new(prompt: impl Into<String>) -> Result<Self, OperationError> {
let prompt = prompt.into();
if prompt.is_empty() {
return Err(OperationError::EmptyField { field: "prompt" });
}
Ok(Self { prompt, count: 1 })
/// 创建携带原始协议 JSON 正文的图像请求。
#[must_use]
pub const fn from_raw_json(kind: ImageRequestKind, payload: RawJsonPayload) -> Self {
Self { kind, payload }
}
/// 设置图像数量。
/// 返回图像 API 端点语义。
#[must_use]
pub const fn with_count(mut self, count: u32) -> Self {
self.count = count;
self
pub const fn kind(&self) -> ImageRequestKind {
self.kind
}
/// 返回未经重编码的协议 JSON 正文。
#[must_use]
pub const fn payload(&self) -> &RawJsonPayload {
&self.payload
}
}
@@ -534,8 +608,8 @@ impl fmt::Debug for ImageRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ImageRequest")
.field("prompt", &"<not included in Debug>")
.field("count", &self.count)
.field("kind", &self.kind)
.field("payload", &"<not included in Debug>")
.finish()
}
}
@@ -649,7 +723,8 @@ impl Operation {
pub fn image_generation_requested(&self) -> bool {
match self {
Self::Generate(request) => request.image_generation_requested(),
Self::Embed(_) | Self::Rerank(_) | Self::GenerateImage(_) | Self::Speech(_) => false,
Self::GenerateImage(_) => true,
Self::Embed(_) | Self::Rerank(_) | Self::Speech(_) => false,
}
}
@@ -231,6 +231,32 @@ pub trait ProviderSessionAffinityPort: Send + Sync {
ttl: Duration,
) -> BoxFuture<'a, Result<(), ProviderStoreError>>;
/// 仅在亲和键尚未绑定时写入候选账号,并返回原子操作后的实际绑定。
///
/// 已存在的绑定绝不会被候选账号覆盖;同一根会话的并发首次请求据此收敛到
/// 单一账号。TTL 只在首次写入时设置,已有绑定由成功反馈负责刷新。
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
/// 仅当当前绑定等于 `expected_account_id`(或键已过期)时写入新账号,
/// 并返回原子操作后的实际绑定。
///
/// Provider 用它迁移不可用账号,以及在成功后以 `expected == replacement`
/// 刷新 TTL;迟到的旧账号成功不能覆盖较新的会话 winner。
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
+4 -10
View File
@@ -780,11 +780,11 @@ pub struct RoutingContext {
pub blocked_providers: BTreeSet<ProviderKind>,
}
/// 已绑定 Provider 与真实上游模型的请求候选。
/// 已绑定 Provider 的请求候选;模型端点携带真实上游模型,原生端点不虚构模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCandidate {
provider: ProviderKind,
upstream_model: UpstreamModelId,
upstream_model: Option<UpstreamModelId>,
emulated_features: BTreeSet<Feature>,
account_scope: Arc<FrozenAccountScope>,
}
@@ -796,8 +796,8 @@ impl ProviderCandidate {
}
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
pub const fn upstream_model(&self) -> Option<&UpstreamModelId> {
self.upstream_model.as_ref()
}
#[must_use]
@@ -816,7 +816,6 @@ impl ProviderCandidate {
pub struct RoutingPlan {
config_revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
public_model: PublicModelId,
operation: OperationKind,
max_attempts: NonZeroU32,
account_scope: Arc<FrozenAccountScope>,
@@ -834,11 +833,6 @@ impl RoutingPlan {
self.account_selection_policy
}
#[must_use]
pub const fn public_model(&self) -> &PublicModelId {
&self.public_model
}
#[must_use]
pub const fn operation(&self) -> OperationKind {
self.operation
@@ -765,7 +765,7 @@ impl RuntimeSnapshot {
};
candidates.push(ProviderCandidate {
provider: provider.clone(),
upstream_model,
upstream_model: Some(upstream_model),
emulated_features,
account_scope: Arc::clone(&account_scope),
});
@@ -780,7 +780,6 @@ impl RuntimeSnapshot {
Ok(RoutingPlan {
config_revision: self.revision,
account_selection_policy: self.account_selection_policy,
public_model: public_model.clone(),
operation: operation.kind(),
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
.expect("constant request attempt limit is non-zero"),
@@ -788,6 +787,47 @@ impl RuntimeSnapshot {
candidates: Arc::from(candidates),
})
}
/// 为 Provider 自有、且不属于文本模型目录的端点冻结请求计划。
///
/// 端点 adapter 已经确定 Provider,因此这里只执行账号范围、circuit 和注册
/// 状态检查;不会读取业务正文、构造模型或查询 Provider 文本模型目录。
pub fn plan_provider_endpoint(
&self,
provider: &ProviderKind,
operation: &Operation,
account_scope: Arc<FrozenAccountScope>,
context: &RoutingContext,
) -> Result<RoutingPlan, RoutingError> {
let allowed = !account_scope.provider_kinds().is_empty()
&& account_scope.provider_kinds().contains(provider)
&& self.providers.contains(provider)
&& context
.required_provider
.as_ref()
.is_none_or(|required| required == provider)
&& !context.blocked_providers.contains(provider);
if !allowed {
return Err(RoutingError::NoCapableProviderEndpoint {
provider: provider.as_str().to_owned(),
});
}
let candidate = ProviderCandidate {
provider: provider.clone(),
upstream_model: None,
emulated_features: BTreeSet::new(),
account_scope: Arc::clone(&account_scope),
};
Ok(RoutingPlan {
config_revision: self.revision,
account_selection_policy: self.account_selection_policy,
operation: operation.kind(),
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
.expect("constant request attempt limit is non-zero"),
account_scope,
candidates: Arc::from([candidate]),
})
}
}
/// 请求级冻结失败;此状态必须 fail closed。
@@ -317,7 +317,10 @@ impl Provider for ScriptedProvider {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().clone(),
candidate
.upstream_model()
.cloned()
.expect("model route candidate"),
ProviderResource::Account {
id: ProviderAccountId::new(account_id).expect("account id"),
revision: CredentialRevision::new(1).expect("revision"),
@@ -337,7 +340,10 @@ impl Provider for ScriptedProvider {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().clone(),
candidate
.upstream_model()
.cloned()
.expect("model route candidate"),
ProviderResource::Account {
id: ProviderAccountId::new(account_id).expect("account id"),
revision: CredentialRevision::new(1).expect("revision"),
@@ -357,7 +363,10 @@ impl Provider for ScriptedProvider {
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
candidate.upstream_model().clone(),
candidate
.upstream_model()
.cloned()
.expect("model route candidate"),
ProviderResource::Account {
id: ProviderAccountId::new(account_id).expect("account id"),
revision: CredentialRevision::new(1).expect("revision"),
@@ -623,7 +632,7 @@ fn model_request(operation: &Operation, deadline: SystemTime) -> NewModelRequest
operation: operation.kind(),
endpoint: "responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model: PublicModelId::new("gpt-5").expect("model"),
requested_model: Some(PublicModelId::new("gpt-5").expect("model")),
client_ip: Some("127.0.0.1".parse().expect("client IP")),
user_agent: Some("gateway-core-test".to_owned()),
reasoning_effort: Some("medium".to_owned()),
@@ -18,17 +18,17 @@ use gateway_core::engine::continuation::{
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
};
use gateway_core::engine::credential::{
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
AccountSelectionPolicy, CredentialRevision, ProviderAccountId, RotationStrategy,
};
use gateway_core::engine::execution::{
ClientApiKeyUsageSink, ClientTransport, DefaultExecutionService, ExecutionRequestMetadata,
ExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
StartExecution, provider_failure_affects_circuit,
StartExecution, StartProviderExecution, provider_failure_affects_circuit,
};
use gateway_core::engine::probe::{AccountProbe, AccountProbeRequest};
use gateway_core::engine::provider::{
Provider, ProviderCatalogGeneration, ProviderModelCapabilities, ProviderRegistry,
ProviderRequest, ProviderStream,
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
ProviderRegistry, ProviderRequest, ProviderResource, ProviderStream, UpstreamTransport,
};
use gateway_core::engine::{
AttemptContext, AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization,
@@ -38,7 +38,10 @@ use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayErrorKind, ProviderError, ProviderErrorKind, StoreError,
StoreErrorKind,
};
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProtocolPayload};
use gateway_core::operation::{
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProtocolPayload,
RawJsonPayload,
};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
use gateway_core::routing::{
@@ -193,6 +196,50 @@ impl Provider for FailingProvider {
}
}
struct ColdFailingProvider;
#[async_trait]
impl Provider for ColdFailingProvider {
fn name(&self) -> &'static str {
"openai"
}
fn catalog_generation(&self) -> ProviderCatalogGeneration {
ProviderCatalogGeneration::default()
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Ok(Vec::new())
}
async fn execute(
&self,
request: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
let metadata = ProviderCallMetadata::for_provider_endpoint(
request.candidate().provider().clone(),
ProviderResource::Account {
id: ProviderAccountId::new("acct_usage").expect("account"),
revision: CredentialRevision::new(1).expect("revision"),
},
UpstreamTransport::new("http_json").expect("transport"),
);
Ok(ProviderStream::new(
metadata,
futures::stream::once(async {
Err(ProviderError::new(
ProviderErrorKind::Transport,
UpstreamSendState::NotSent,
))
}),
(),
))
}
}
#[test]
fn successful_authentication_should_record_client_key_usage() {
let usage = Arc::new(RecordingClientApiKeyUsage::default());
@@ -213,6 +260,60 @@ fn successful_authentication_should_record_client_key_usage() {
assert_eq!(usage.recorded(), vec!["key_usage_test".to_owned()]);
}
#[test]
fn provider_endpoint_should_persist_its_real_v1_endpoint() {
let store = Arc::new(TrackingExecutionStore::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(client_snapshot()),
store.clone(),
ProviderRegistry::new([Arc::new(ColdFailingProvider) as Arc<dyn Provider>])
.expect("provider registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service
.authenticate("sk_usage_test")
.expect("authenticated client");
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"model":"gpt-image-2","prompt":"hello"}"#),
)
.expect("image payload"),
));
let mut started = block_on(service.start_provider_endpoint(StartProviderExecution {
client,
provider: ProviderKind::new("openai").expect("provider"),
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: "/v1/images/generations".to_owned(),
transport: ClientTransport::HttpJson,
stream: false,
client_ip: None,
user_agent: None,
previous_response_id: None,
},
}))
.expect("provider endpoint request should start without a text catalog entry");
let error = block_on(started.session.collect_uncommitted())
.expect_err("the cold provider stops execution after persistence");
assert_eq!(
store.model_request_endpoints(),
vec!["/v1/images/generations".to_owned()],
"execution error: {error:?}"
);
assert_eq!(store.requested_models(), vec![None]);
let upstream_models = store.upstream_models();
assert!(!upstream_models.is_empty());
assert!(upstream_models.iter().all(Option::is_none));
}
#[test]
fn circuit_store_failure_should_fail_open_during_request_start() {
let service = DefaultExecutionService::new(
@@ -412,6 +513,9 @@ impl ClientApiKeyUsageSink for RecordingClientApiKeyUsage {
struct TrackingExecutionStore {
touched: AtomicBool,
probe_failures: Mutex<Vec<String>>,
model_request_endpoints: Mutex<Vec<String>>,
requested_models: Mutex<Vec<Option<String>>>,
upstream_models: Mutex<Vec<Option<String>>>,
fail_probe_observation: AtomicBool,
}
@@ -426,17 +530,60 @@ impl TrackingExecutionStore {
.expect("probe failures lock")
.clone()
}
fn model_request_endpoints(&self) -> Vec<String> {
self.model_request_endpoints
.lock()
.expect("model request endpoints lock")
.clone()
}
fn requested_models(&self) -> Vec<Option<String>> {
self.requested_models
.lock()
.expect("requested models lock")
.clone()
}
fn upstream_models(&self) -> Vec<Option<String>> {
self.upstream_models
.lock()
.expect("upstream models lock")
.clone()
}
}
#[async_trait]
impl ExecutionStore for TrackingExecutionStore {
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
async fn create_model_request(&self, request: NewModelRequest) -> Result<(), StoreError> {
self.touch();
self.requested_models
.lock()
.expect("requested models lock")
.push(
request
.requested_model
.as_ref()
.map(|model| model.as_str().to_owned()),
);
self.model_request_endpoints
.lock()
.expect("model request endpoints lock")
.push(request.endpoint);
Ok(())
}
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
async fn record_attempt(&self, attempt: AttemptRecord) -> Result<(), StoreError> {
self.touch();
self.upstream_models
.lock()
.expect("upstream models lock")
.push(
attempt
.upstream_model_id
.as_ref()
.map(|model| model.as_str().to_owned()),
);
Ok(())
}
@@ -77,6 +77,17 @@ fn protocol_wire_event_should_preserve_opaque_sse_metadata() {
assert_eq!(wire.sse_id(), Some(sse_id.as_str()));
}
#[test]
fn protocol_wire_event_should_preserve_raw_json_body_without_parsing_it() {
let raw = Bytes::from_static(
br#"{ "created": 1, "data": [{"b64_json":"AAAA"}], "future": 9007199254740993 }"#,
);
let wire = ProtocolWireEvent::raw_json("openai", raw.clone()).expect("raw JSON event");
assert_eq!(wire.raw_json_body(), Some(&raw));
assert_eq!(wire.into_raw_json_body(), Some(raw));
}
#[test]
fn validator_should_accept_started_content_delta_completed_sequence() {
let mut validator = EventSequenceValidator::new();
@@ -1,7 +1,9 @@
use bytes::Bytes;
use gateway_core::error::OperationError;
use gateway_core::operation::{
EmbedRequest, Feature, GenerateRequest, ImageRequest, Operation, OperationKind,
ProtocolPayload, ProviderSessionState, RerankRequest, RetrySafety, SpeechRequest,
EmbedRequest, Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
OperationKind, ProtocolPayload, ProviderSessionState, RawJsonPayload, RerankRequest,
RetrySafety, SpeechRequest,
};
use serde_json::{Map, Value, json};
@@ -12,6 +14,17 @@ fn generate(body: Value) -> GenerateRequest {
)
}
fn image(kind: ImageRequestKind, body: Value) -> ImageRequest {
ImageRequest::from_raw_json(
kind,
RawJsonPayload::new(
"openai",
Bytes::from(serde_json::to_vec(&body).expect("image JSON")),
)
.expect("OpenAI payload"),
)
}
#[test]
fn generate_request_should_keep_client_body_opaque_and_redacted() {
let secret = "private prompt body";
@@ -113,10 +126,6 @@ fn non_generate_requests_should_reject_missing_required_fields() {
RerankRequest::new("", vec!["document".to_owned()]).expect_err("query is required"),
OperationError::EmptyField { field: "query" }
);
assert_eq!(
ImageRequest::new("").expect_err("prompt is required"),
OperationError::EmptyField { field: "prompt" }
);
assert_eq!(
SpeechRequest::new("hello", "").expect_err("voice is required"),
OperationError::EmptyField { field: "voice" }
@@ -132,7 +141,10 @@ fn operation_kind_and_retry_safety_should_remain_stable() {
let rerank = Operation::Rerank(
RerankRequest::new("query", vec!["document".to_owned()]).expect("rerank request is valid"),
);
let image = Operation::GenerateImage(ImageRequest::new("draw").expect("image request"));
let image = Operation::GenerateImage(image(
ImageRequestKind::Generation,
json!({"model": "gpt-image-2", "prompt": "draw"}),
));
let speech = Operation::Speech(SpeechRequest::new("hello", "alloy").expect("speech request"));
assert_eq!(generate.kind(), OperationKind::Generate);
@@ -140,5 +152,35 @@ fn operation_kind_and_retry_safety_should_remain_stable() {
assert_eq!(embed.retry_safety(), RetrySafety::Idempotent);
assert_eq!(rerank.retry_safety(), RetrySafety::Idempotent);
assert_eq!(image.kind(), OperationKind::GenerateImage);
assert!(image.image_generation_requested());
assert_eq!(speech.kind(), OperationKind::Speech);
}
#[test]
fn image_request_should_preserve_generation_and_edit_payloads_opaque() {
let generation_body = json!({
"model": "gpt-image-2",
"prompt": "draw",
"future_official_field": {"keep": true},
});
let edit_body = json!({
"model": "gpt-image-2",
"images": [{"image_url": "data:image/png;base64,AAAA"}],
"prompt": "edit",
"future_official_field": [1, 2, 3],
});
let generation = image(ImageRequestKind::Generation, generation_body.clone());
let edit = image(ImageRequestKind::Edit, edit_body.clone());
assert_eq!(generation.kind(), ImageRequestKind::Generation);
assert_eq!(edit.kind(), ImageRequestKind::Edit);
assert_eq!(
serde_json::from_slice::<Value>(generation.payload().body()).expect("generation JSON"),
generation_body
);
assert_eq!(
serde_json::from_slice::<Value>(edit.payload().body()).expect("edit JSON"),
edit_body
);
assert!(!format!("{edit:?}").contains("data:image/png"));
}
@@ -3,11 +3,13 @@ use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use gateway_core::engine::credential::{
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
};
use gateway_core::operation::{
CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload,
CapabilityRequirements, Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
OperationKind, ProtocolPayload, RawJsonPayload,
};
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
use gateway_core::routing::{
@@ -50,6 +52,21 @@ fn operation() -> Operation {
))
}
fn image_operation() -> Operation {
let body = serde_json::json!({
"model": "gpt-image-2",
"prompt": "draw",
});
Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
RawJsonPayload::new(
"openai",
Bytes::from(serde_json::to_vec(&body).expect("image JSON")),
)
.expect("OpenAI payload"),
))
}
fn account_directory() -> Arc<RuntimeAccountDirectory> {
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
(
@@ -267,7 +284,13 @@ fn selected_provider_should_use_global_model_mapping() {
assert_eq!(plan.candidates().len(), 1);
assert_eq!(plan.candidates()[0].provider().as_str(), "openai");
assert_eq!(plan.candidates()[0].upstream_model().as_str(), "gpt-5.5");
assert_eq!(
plan.candidates()[0]
.upstream_model()
.expect("model route candidate")
.as_str(),
"gpt-5.5"
);
}
#[test]
@@ -311,6 +334,51 @@ fn known_provider_catalog_missing_mapped_model_should_be_filtered() {
));
}
#[test]
fn provider_endpoint_plan_should_not_consult_or_publish_the_text_model_catalog() {
let snapshot = snapshot();
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new("gpt-image-2").expect("model");
assert!(!snapshot.contains_public_model_for_provider(&public_model, &provider));
let plan = snapshot
.plan_provider_endpoint(
&provider,
&image_operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect("provider-owned endpoint remains routable");
assert_eq!(plan.operation(), OperationKind::GenerateImage);
assert_eq!(plan.candidates().len(), 1);
assert_eq!(plan.candidates()[0].provider(), &provider);
assert_eq!(plan.candidates()[0].upstream_model(), None);
assert!(!snapshot.contains_public_model_for_provider(&public_model, &provider));
}
#[test]
fn provider_endpoint_plan_should_still_respect_circuit_filtering() {
let snapshot = snapshot();
let provider = ProviderKind::new("openai").expect("provider");
let error = snapshot
.plan_provider_endpoint(
&provider,
&image_operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([provider.clone()]),
..RoutingContext::default()
},
)
.expect_err("blocked provider has no endpoint candidate");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProviderEndpoint { .. }
));
}
#[test]
fn unmapped_model_should_pass_through_unchanged() {
let snapshot = RuntimeSnapshot::new(
@@ -340,7 +408,13 @@ fn unmapped_model_should_pass_through_unchanged() {
)
.expect("unknown model remains transparent");
assert_eq!(plan.candidates()[0].upstream_model().as_str(), requested);
assert_eq!(
plan.candidates()[0]
.upstream_model()
.expect("model route candidate")
.as_str(),
requested
);
}
}
+12 -1
View File
@@ -41,6 +41,7 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub listen: ListenConfig,
pub runtime_data_dir: PathBuf,
pub logging: LoggingConfig,
#[serde(default)]
pub system_update: SystemUpdateConfig,
@@ -76,11 +77,21 @@ impl HostConfig {
"host.worker_shutdown_timeout_seconds",
));
}
if self.runtime_data_dir.as_os_str().is_empty() {
return Err(ConfigError::InvalidField("host.runtime_data_dir"));
}
resolve_relative_path(source_dir, &mut self.runtime_data_dir);
self.logging.resolve_and_validate(source_dir)?;
self.system_update.resolve_and_validate(source_dir)?;
self.system_update
.resolve_and_validate(source_dir, &self.runtime_data_dir)?;
Ok(())
}
#[must_use]
pub fn runtime_data_dir(&self) -> &Path {
&self.runtime_data_dir
}
#[must_use]
pub const fn drain_timeout(&self) -> Duration {
Duration::from_secs(self.drain_timeout_seconds)
@@ -81,10 +81,10 @@ impl Default for SystemUpdateConfig {
});
let update_state_file = environment_value("CPR_UPDATE_STATE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/app/.runtime/data/update-state.json"));
.unwrap_or_else(|| PathBuf::from("update-state.json"));
let update_lock_file = environment_value("CPR_UPDATE_LOCK_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| update_state_file.with_extension("lock"));
.unwrap_or_else(|| update_state_file.with_file_name("update.lock"));
let update_temp_dir = environment_value("CPR_UPDATE_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| state::default_temp_dir(&update_state_file));
@@ -120,21 +120,28 @@ impl Default for SystemUpdateConfig {
}
impl SystemUpdateConfig {
pub(crate) fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
for path in [
self.executable_path.as_mut(),
Some(&mut self.web_dist_dir),
Some(&mut self.update_state_file),
Some(&mut self.update_lock_file),
Some(&mut self.update_temp_dir),
]
.into_iter()
.flatten()
pub(crate) fn resolve_and_validate(
&mut self,
source_dir: &Path,
runtime_data_dir: &Path,
) -> Result<(), ConfigError> {
for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)]
.into_iter()
.flatten()
{
if path.is_relative() {
*path = source_dir.join(&*path);
}
}
for path in [
&mut self.update_state_file,
&mut self.update_lock_file,
&mut self.update_temp_dir,
] {
if path.is_relative() {
*path = runtime_data_dir.join(&*path);
}
}
if self.version.trim().is_empty() {
return Err(ConfigError::InvalidField("host.system_update.version"));
}
+38 -3
View File
@@ -34,6 +34,40 @@ fn host_config_resolves_only_host_owned_relative_paths() {
);
}
#[test]
fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
config.runtime_data_dir(),
PathBuf::from("/srv/gateway/runtime-data")
);
}
#[test]
fn host_config_derives_update_paths_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
(
config.system_update.update_state_file,
config.system_update.update_lock_file,
config.system_update.update_temp_dir,
),
(
PathBuf::from("/srv/gateway/runtime-data/update-state.json"),
PathBuf::from("/srv/gateway/runtime-data/update.lock"),
PathBuf::from("/srv/gateway/runtime-data/update-tmp"),
)
);
}
#[test]
fn host_config_rejects_zero_drain_window() {
let mut config = valid_config();
@@ -48,9 +82,9 @@ fn host_config_rejects_zero_drain_window() {
fn valid_config() -> HostConfig {
let system_update = SystemUpdateConfig {
update_state_file: PathBuf::from(".runtime/update-state.json"),
update_lock_file: PathBuf::from(".runtime/update-state.lock"),
update_temp_dir: PathBuf::from(".runtime/update-tmp"),
update_state_file: PathBuf::from("update-state.json"),
update_lock_file: PathBuf::from("update.lock"),
update_temp_dir: PathBuf::from("update-tmp"),
..SystemUpdateConfig::default()
};
HostConfig {
@@ -58,6 +92,7 @@ fn valid_config() -> HostConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: true,
@@ -23,6 +23,7 @@ fn logging_requires_at_least_one_sink() {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: false,
@@ -98,6 +99,7 @@ fn write_oauth_recovery_log(directory: PathBuf) {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "off".to_owned(),
stdout: false,
+2 -2
View File
@@ -50,7 +50,7 @@ impl StoreBundle {
pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
const REDIS_NAMESPACE: &str = "codex-proxy-rs";
config.resolve_and_validate(std::path::Path::new("."))?;
config.validate_resolved()?;
let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?;
let redis_client = ::redis::Client::open(config.redis_url()?)
.map_err(|_| redis_unavailable("create Redis client"))?;
@@ -209,7 +209,7 @@ pub(crate) fn backup_ports(
config: &StoreConfig,
) -> StoreResult<BackupStorePorts> {
let staging = Arc::new(backup::staging::StagingArea::open(
std::path::PathBuf::from(".runtime/data/backup-staging"),
config.backup_staging_dir().to_path_buf(),
backup::staging::DEFAULT_MAX_ARCHIVE_BYTES,
)?);
let repository = Arc::new(postgres::PgBackupRepository::new(pool));
+28 -1
View File
@@ -1,5 +1,7 @@
//! Store 启动配置、环境变量解析与校验。
use std::path::{Path, PathBuf};
use super::*;
pub(crate) const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL";
@@ -15,6 +17,8 @@ pub struct StoreConfig {
pub(crate) redis: StoreConnectionConfig,
#[serde(default)]
pub(crate) pool: StorePoolConfig,
#[serde(skip)]
backup_staging_dir: PathBuf,
}
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
@@ -47,7 +51,18 @@ impl StorePoolConfig {
}
impl StoreConfig {
pub fn resolve_and_validate(&mut self, _source_dir: &std::path::Path) -> StoreResult<()> {
pub fn resolve_and_validate(&mut self, runtime_data_dir: &Path) -> StoreResult<()> {
if runtime_data_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir must not be empty".to_owned(),
});
}
self.backup_staging_dir = runtime_data_dir.join("backup-staging");
self.validate_resolved()
}
pub(crate) fn validate_resolved(&mut self) -> StoreResult<()> {
if let Some(url) = optional_environment_value(DATABASE_URL_ENV)? {
self.database.url = url;
}
@@ -63,6 +78,12 @@ impl StoreConfig {
self.database.validate("database")?;
self.redis.validate("redis")?;
self.pool.validate()?;
if self.backup_staging_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir was not resolved".to_owned(),
});
}
Ok(())
}
@@ -73,6 +94,12 @@ impl StoreConfig {
pub(crate) fn redis_url(&self) -> StoreResult<String> {
self.redis.connection_url("redis")
}
/// 返回由统一运行数据根目录派生的备份暂存目录。
#[must_use]
pub fn backup_staging_dir(&self) -> &Path {
&self.backup_staging_dir
}
}
pub(crate) fn optional_environment_value(name: &'static str) -> StoreResult<Option<String>> {
+1 -1
View File
@@ -1,6 +1,6 @@
//! 多 Provider 网关的 PostgreSQL 持久化与 Redis 协调 adapter。
//!
//! 业务规则与 port 由 `gateway-core` 拥有。本 crate 只负责把终态的七张业务表
//! 业务规则与 port 由 `gateway-core` / `gateway-admin` 拥有。本 crate 只负责把当前十二张业务表
//! 和可丢失 Redis 状态映射为明确的基础设施操作。
use std::sync::Arc;
@@ -91,7 +91,7 @@ pub struct NewModelRequest {
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub requested_model_id: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -112,7 +112,9 @@ impl NewModelRequest {
require_nonempty(ENTITY, "operation", &self.operation)?;
require_nonempty(ENTITY, "endpoint", &self.endpoint)?;
require_nonempty(ENTITY, "client_transport", &self.client_transport)?;
require_nonempty(ENTITY, "requested_model_id", &self.requested_model_id)?;
if let Some(requested_model_id) = self.requested_model_id.as_deref() {
require_nonempty(ENTITY, "requested_model_id", requested_model_id)?;
}
validate_routing_snapshot(
&self.routing_scope,
&self.routing_group_refs,
@@ -141,7 +143,7 @@ pub struct ModelRequestAttemptStart {
pub provider_kind: String,
pub provider_account_id: Option<String>,
pub provider_account_ref: Option<String>,
pub upstream_model_id: String,
pub upstream_model_id: Option<String>,
pub upstream_transport: String,
pub http_version: Option<String>,
}
@@ -151,11 +153,13 @@ impl ModelRequestAttemptStart {
for (field, value) in [
("model_request_id", self.model_request_id.as_str()),
("provider_kind", self.provider_kind.as_str()),
("upstream_model_id", self.upstream_model_id.as_str()),
("upstream_transport", self.upstream_transport.as_str()),
] {
require_nonempty(ENTITY, field, value)?;
}
if let Some(upstream_model_id) = self.upstream_model_id.as_deref() {
require_nonempty(ENTITY, "upstream_model_id", upstream_model_id)?;
}
if self.attempt_count == 0 {
return Err(invalid("attempt_count must be positive"));
}
@@ -841,7 +845,10 @@ impl ExecutionStore for PgExecutionStore {
provider_kind: Some(failure.provider_kind.as_str().to_owned()),
provider_account_id: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
provider_account_ref: failure.account_id.as_ref().map(|id| id.as_str().to_owned()),
upstream_model_id: Some(failure.upstream_model_id.as_str().to_owned()),
upstream_model_id: failure
.upstream_model_id
.as_ref()
.map(|model| model.as_str().to_owned()),
failure_kind: error.kind().as_str().to_owned(),
status_code: error.upstream_status().or(failure.upstream_status_code),
provider_error_code: error.upstream_code().map(|code| code.as_str().to_owned()),
@@ -1081,7 +1088,9 @@ fn new_model_request_row(request: CoreNewModelRequest) -> NewModelRequest {
operation: request.operation.as_str().to_owned(),
endpoint: request.endpoint,
client_transport: request.client_transport,
requested_model_id: request.requested_model.as_str().to_owned(),
requested_model_id: request
.requested_model
.map(|model| model.as_str().to_owned()),
client_ip: request.client_ip.map(|address| address.to_string()),
user_agent: request.user_agent,
reasoning_effort: request.reasoning_effort,
@@ -1150,7 +1159,9 @@ fn attempt_start_row(attempt: CoreAttemptRecord) -> ModelRequestAttemptStart {
.provider_account_ref
.as_ref()
.map(|id| id.as_str().to_owned()),
upstream_model_id: attempt.upstream_model_id.as_str().to_owned(),
upstream_model_id: attempt
.upstream_model_id
.map(|model| model.as_str().to_owned()),
upstream_transport: attempt.upstream_transport,
http_version: attempt.http_version,
}
@@ -551,7 +551,10 @@ impl ExecutionObservationWrite {
Some(failure.request_id.as_str()),
Some(failure.provider_kind.as_str()),
failure.account_id.as_ref().map(|value| value.as_str()),
Some(failure.upstream_model_id.as_str()),
failure
.upstream_model_id
.as_ref()
.map(|model| model.as_str()),
failure.upstream_request_id.as_deref(),
])
.saturating_add(provider_error_bytes(&failure.error)),
@@ -638,7 +641,7 @@ fn new_request_bytes(request: &NewModelRequest) -> usize {
Some(request.protocol.as_str()),
Some(request.endpoint.as_str()),
Some(request.client_transport.as_str()),
Some(request.requested_model.as_str()),
request.requested_model.as_ref().map(|model| model.as_str()),
request.user_agent.as_deref(),
request.reasoning_effort.as_deref(),
request.reasoning_preset.as_deref(),
@@ -659,7 +662,10 @@ fn attempt_bytes(attempt: &AttemptRecord) -> usize {
.provider_account_ref
.as_ref()
.map(|value| value.as_str()),
Some(attempt.upstream_model_id.as_str()),
attempt
.upstream_model_id
.as_ref()
.map(|model| model.as_str()),
Some(attempt.upstream_transport.as_str()),
attempt.http_version.as_deref(),
]))
@@ -1,4 +1,4 @@
//! 九张终态业务表的 PostgreSQL adapters。
//! 当前十二张业务表的 PostgreSQL adapters。
use async_trait::async_trait;
use sqlx::{PgPool, postgres::PgPoolOptions};
@@ -504,7 +504,7 @@ pub struct UsageRecord {
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub requested_model_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -259,6 +259,7 @@ pub(crate) async fn account_model_rows(
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id)
order by mr.provider_account_ref, request_count desc, model",
)
@@ -286,6 +287,7 @@ pub(crate) async fn account_model_costs(
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null and mr.cost_currency is not null
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id),
mr.cost_currency
order by mr.provider_account_ref, model, mr.cost_currency",
@@ -97,13 +97,19 @@ impl PgAdminAccountStore {
.iter()
.map(|window| window.account_id.clone())
.collect::<Vec<_>>();
let (usage_rows, model_rows, model_cost_rows) = futures::try_join!(
let (usage_rows, cost_rows, model_rows, model_cost_rows) = futures::try_join!(
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_COSTS_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
@@ -123,6 +129,11 @@ impl PgAdminAccountStore {
postgres_unavailable("load provider account quota window usage"),
)
})?;
let mut costs_by_window = BTreeMap::<(String, String), Vec<AccountCost>>::new();
for row in &cost_rows {
let (key, cost) = admin_account_usage_window_cost(row)?;
costs_by_window.entry(key).or_default().push(cost);
}
let mut model_costs = BTreeMap::<(String, String, String), Vec<AccountCost>>::new();
for row in &model_cost_rows {
let (key, cost) = admin_account_usage_window_model_cost(row)?;
@@ -145,6 +156,9 @@ impl PgAdminAccountStore {
.map(admin_account_usage_window)
.collect::<AdminStoreResult<Vec<_>>>()?;
for result in &mut results {
result.usage.costs = costs_by_window
.remove(&(result.account_id.clone(), result.key.clone()))
.unwrap_or_default();
result.usage.models = models_by_window
.remove(&(result.account_id.clone(), result.key.clone()))
.unwrap_or_default();
@@ -682,8 +696,10 @@ impl AccountStore for PgAdminAccountStore {
) -> AdminStoreResult<CredentialMutationResult> {
match command.credential {
AuthorizationCredentialCommit::Create(credential) => {
let account_id = credential.account_id.clone();
let result = self
let CredentialImportResult {
config_revision,
credential_ids,
} = self
.commit_prepared_import(
PreparedCredentialImport {
provider_kind: credential.provider_kind.clone(),
@@ -693,6 +709,14 @@ impl AccountStore for PgAdminAccountStore {
"authorize",
)
.await?;
let [account_id]: [CoreProviderAccountId; 1] =
credential_ids.try_into().map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
ENTITY,
"authorization import returned an unexpected account count",
)
})?;
let details = self
.accounts
.load_provider_account(account_id.as_str())
@@ -706,7 +730,7 @@ impl AccountStore for PgAdminAccountStore {
)
})?;
Ok(CredentialMutationResult {
config_revision: result.config_revision,
config_revision,
account_id,
credential_revision: Some(admin_revision(details.summary.credential_revision)?),
})
@@ -339,6 +339,30 @@ pub(crate) fn admin_account_usage_window_model_cost(
))
}
pub(crate) fn admin_account_usage_window_cost(
row: &sqlx::postgres::PgRow,
) -> AdminStoreResult<((String, String), AccountCost)> {
let key = (
window_usage_value(row, "account_id")?,
window_usage_value(row, "window_key")?,
);
let amount = window_usage_value::<String>(row, "amount")?;
let amount = AdminDecimalAmount::from_str(&amount).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted account cost is invalid",
)
})?;
Ok((
key,
AccountCost {
currency: window_usage_value(row, "cost_currency")?,
amount,
},
))
}
pub(crate) fn window_usage_count(
row: &sqlx::postgres::PgRow,
column: &'static str,
@@ -51,6 +51,28 @@ pub(crate) const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as
group by requested.account_id, requested.window_key
order by requested.account_id, requested.window_key";
pub(crate) const ACCOUNT_USAGE_COSTS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
as requested(account_id, window_key, window_start, window_end)
)
select requested.account_id,
requested.window_key,
mr.cost_currency,
sum(mr.cost_amount)::text as amount
from requested_windows requested
join model_requests mr
on mr.provider_account_ref = requested.account_id
and mr.started_at >= requested.window_start
and mr.started_at < requested.window_end
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null
and mr.cost_currency is not null
group by requested.account_id, requested.window_key, mr.cost_currency
order by requested.account_id, requested.window_key, mr.cost_currency";
pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
@@ -91,6 +113,7 @@ pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_win
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
group by requested.account_id, requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id)
order by requested.account_id, requested.window_key, request_count desc, model";
@@ -115,6 +138,7 @@ pub(crate) const ACCOUNT_USAGE_MODEL_COSTS_BY_WINDOWS_SQL: &str = "with requeste
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null
and mr.cost_currency is not null
and coalesce(mr.upstream_model_id, mr.requested_model_id) is not null
group by requested.account_id, requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id), mr.cost_currency
order by requested.account_id, requested.window_key, model, mr.cost_currency";
@@ -16,6 +16,24 @@ use super::{namespace, resource_fingerprint};
const MAX_SESSION_AFFINITY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const CLAIM_OR_LOAD_SCRIPT: &str = r#"
local current = redis.call('GET', KEYS[1])
if current then
return current
end
redis.call('PSETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
return ARGV[1]
"#;
const COMPARE_AND_BIND_SCRIPT: &str = r#"
local current = redis.call('GET', KEYS[1])
if not current or current == ARGV[1] then
redis.call('PSETEX', KEYS[1], tonumber(ARGV[3]), ARGV[2])
return ARGV[2]
end
return current
"#;
#[derive(Clone)]
pub struct RedisProviderSessionAffinityRepository {
connection: ConnectionManager,
@@ -79,11 +97,7 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<(), ProviderStoreError>> {
Box::pin(async move {
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
return Err(provider_invalid("validate provider session affinity TTL"));
}
let ttl_millis = u64::try_from(ttl.as_millis())
.map_err(|_| provider_invalid("validate provider session affinity TTL"))?;
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
redis::cmd("PSETEX")
.arg(self.key(provider_kind, key)?)
@@ -95,6 +109,50 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
})
}
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
let effective_account_id = redis::Script::new(CLAIM_OR_LOAD_SCRIPT)
.key(self.key(provider_kind, key)?)
.arg(candidate_account_id.as_str())
.arg(ttl_millis)
.invoke_async::<String>(&mut connection)
.await
.map_err(|_| provider_unavailable("claim provider session affinity"))?;
decode_account_id(effective_account_id)
})
}
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let ttl_millis = session_affinity_ttl_millis(ttl)?;
let mut connection = self.connection.clone();
let effective_account_id = redis::Script::new(COMPARE_AND_BIND_SCRIPT)
.key(self.key(provider_kind, key)?)
.arg(expected_account_id.as_str())
.arg(replacement_account_id.as_str())
.arg(ttl_millis)
.invoke_async::<String>(&mut connection)
.await
.map_err(|_| provider_unavailable("compare provider session affinity"))?;
decode_account_id(effective_account_id)
})
}
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -112,6 +170,19 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
}
}
fn session_affinity_ttl_millis(ttl: Duration) -> Result<u64, ProviderStoreError> {
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
return Err(provider_invalid("validate provider session affinity TTL"));
}
u64::try_from(ttl.as_millis())
.map_err(|_| provider_invalid("validate provider session affinity TTL"))
}
fn decode_account_id(account_id: String) -> Result<ProviderAccountId, ProviderStoreError> {
ProviderAccountId::new(account_id)
.map_err(|_| provider_invalid("decode provider session affinity"))
}
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
}
@@ -0,0 +1,32 @@
use gateway_store::StoreConfig;
const PASSWORD: &str = "111111111111111111111111111111111111111111111111";
#[test]
fn store_config_should_derive_backup_staging_from_runtime_data_dir() {
let root = tempfile::tempdir().expect("runtime data root");
let mut config = valid_config();
config
.resolve_and_validate(root.path())
.expect("valid Store configuration");
assert_eq!(
config.backup_staging_dir(),
root.path().join("backup-staging")
);
}
fn valid_config() -> StoreConfig {
serde_json::from_value(serde_json::json!({
"database": {
"url": "postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
"password": PASSWORD,
},
"redis": {
"url": "redis://127.0.0.1:6379/",
"password": PASSWORD,
},
}))
.expect("test Store configuration")
}
@@ -1,3 +1,4 @@
mod config;
mod postgres;
mod redis;
mod s3;
@@ -28,7 +28,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
requested_model_id: Some("coding".to_owned()),
routing_scope: "all".to_owned(),
routing_group_refs: Vec::new(),
routing_group_names_snapshot: serde_json::json!([]),
@@ -47,7 +47,7 @@ fn model_request_rejects_mismatched_client_key_live_id() {
}
#[tokio::test]
async fn merged_first_attempt_insert_should_match_sequential_semantics() {
async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
let Some(database) = TestDatabase::create("execution_merged_insert").await else {
return;
};
@@ -75,10 +75,10 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
client_api_key_ref: "key_merged".to_owned(),
config_revision: 1,
protocol: "openai".to_owned(),
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
operation: "generate_image".to_owned(),
endpoint: "/v1/images/generations".to_owned(),
client_transport: "http_json".to_owned(),
requested_model_id: None,
routing_scope: "all".to_owned(),
routing_group_refs: Vec::new(),
routing_group_names_snapshot: serde_json::json!([]),
@@ -99,8 +99,8 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
provider_kind: "openai".to_owned(),
provider_account_id: Some("acct_merged".to_owned()),
provider_account_ref: Some("acct_merged".to_owned()),
upstream_model_id: "gpt-test".to_owned(),
upstream_transport: "http_sse".to_owned(),
upstream_model_id: None,
upstream_transport: "http_json".to_owned(),
http_version: None,
};
@@ -109,22 +109,31 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
.await
.expect("merged insert");
let (attempt_count, send_state, provider_kind, outcome): (i32, String, String, String) =
sqlx::query_as(
"select attempt_count, upstream_send_state, provider_kind, outcome
let (attempt_count, send_state, provider_kind, outcome, requested_model, upstream_model): (
i32,
String,
String,
String,
Option<String>,
Option<String>,
) = sqlx::query_as(
"select attempt_count, upstream_send_state, provider_kind, outcome,
requested_model_id, upstream_model_id
from model_requests where id = 'req_merged'",
)
.fetch_one(&database.pool)
.await
.expect("load merged request");
)
.fetch_one(&database.pool)
.await
.expect("load merged request");
assert_eq!(
(
attempt_count,
send_state.as_str(),
provider_kind.as_str(),
outcome.as_str()
outcome.as_str(),
requested_model,
upstream_model,
),
(1, "not_sent", "openai", "running")
(1, "not_sent", "openai", "running", None, None)
);
// 后续 attempt 沿用常规 CAS 递增路径;已持久化的 sent 水位不被重试重置。
@@ -142,8 +151,8 @@ async fn merged_first_attempt_insert_should_match_sequential_semantics() {
provider_kind: "openai".to_owned(),
provider_account_id: Some("acct_merged".to_owned()),
provider_account_ref: Some("acct_merged".to_owned()),
upstream_model_id: "gpt-test".to_owned(),
upstream_transport: "http_sse".to_owned(),
upstream_model_id: None,
upstream_transport: "http_json".to_owned(),
http_version: None,
})
.await
@@ -182,7 +191,7 @@ async fn model_request_persists_group_routing_snapshot_without_live_group_foreig
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
requested_model_id: Some("coding".to_owned()),
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -10,7 +10,12 @@ use gateway_admin::{
UpdateAccount,
},
observability::TimeRange,
provider_credentials::{CredentialListQuery, CredentialListWindow, CredentialStateFilter},
provider_credentials::{
AuthorizationCommit, AuthorizationCredentialCommit, AuthorizationMutationTarget,
AuthorizationOwnerBinding, CredentialListQuery, CredentialListWindow,
CredentialStateFilter, PendingAuthorizationMutation, PreparedCredentialCreate,
ProviderDocument,
},
},
ports::store::AccountStore,
};
@@ -882,10 +887,16 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
("gpt-window-recent", Some(700)),
],
);
assert_eq!(usage[0].usage.costs.len(), 1);
assert_eq!(usage[0].usage.costs[0].currency, "USD");
assert_eq!(usage[0].usage.costs[0].amount.as_str(), "3.75");
assert_eq!(usage[0].usage.models[0].costs[0].amount.as_str(), "2.5");
assert_eq!(usage[0].usage.models[1].costs[0].amount.as_str(), "1.25");
assert_eq!(usage[1].key, "short");
assert_eq!(usage[1].usage.total_tokens, Some(700));
assert_eq!(usage[1].usage.costs.len(), 1);
assert_eq!(usage[1].usage.costs[0].currency, "USD");
assert_eq!(usage[1].usage.costs[0].amount.as_str(), "1.25");
assert_eq!(usage[1].usage.models.len(), 1);
assert_eq!(usage[1].usage.models[0].model, "gpt-window-recent");
assert_eq!(usage[1].usage.models[0].total_tokens, Some(700));
@@ -1326,6 +1337,75 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
database.close().await;
}
#[tokio::test]
async fn authorization_create_returns_existing_account_id_when_identity_is_upserted() {
let Some(database) = TestDatabase::create("provider_account_authorization_upsert").await else {
return;
};
PgProviderAccountRepository::new(database.pool.clone())
.insert_provider_account(account(
"acct_authorization_existing",
"user-authorization-upsert",
))
.await
.expect("seed existing authorized identity");
let context = MutationContext {
actor: MutationActor::System,
request_id: "request_authorization_upsert".to_owned(),
};
let provider_kind = ProviderKind::new("openai").expect("OpenAI Provider kind");
let serde_json::Value::Object(provider_material) =
json!({ "access_token": "authorized-secret" })
else {
unreachable!("credential fixture must be an object");
};
let result = PgAdminAccountStore::new(database.pool.clone(), None)
.commit_authorization(
AuthorizationCommit {
pending: PendingAuthorizationMutation::new(
provider_kind.clone(),
AuthorizationMutationTarget::Create {
name: "authorized account".to_owned(),
},
AuthorizationOwnerBinding::from_context(&context),
),
credential: AuthorizationCredentialCommit::Create(PreparedCredentialCreate {
account_id: ProviderAccountId::new("acct_authorization_candidate")
.expect("candidate account ID"),
provider_kind,
name: "authorized account".to_owned(),
email: Some("authorized@example.invalid".to_owned()),
upstream_user_id: Some("user-authorization-upsert".to_owned()),
upstream_account_id: None,
plan_type: Some("free".to_owned()),
authentication_kind: "oauth".to_owned(),
provider_material: ProviderDocument::new(OpaqueProviderData::new(
provider_material,
)),
has_refresh_token: true,
access_token_expires_at: Some(Utc::now() + TimeDelta::hours(1)),
next_refresh_at: None,
enabled: true,
credential_state: CredentialState::Ready,
credential_observed_at: Utc::now(),
}),
},
&context,
)
.await
.expect("authorize existing identity");
assert_eq!(
(
result.account_id.as_str(),
result.credential_revision.map(|revision| revision.get()),
),
("acct_authorization_existing", Some(2)),
);
database.close().await;
}
#[tokio::test]
async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
@@ -436,7 +436,7 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
provider_kind: "openai".to_owned(),
provider_account_id: None,
provider_account_ref: Some("acct_legacy_deleted".to_owned()),
upstream_model_id: "legacy-model".to_owned(),
upstream_model_id: Some("legacy-model".to_owned()),
upstream_transport: "http_sse".to_owned(),
http_version: None,
},
@@ -547,7 +547,7 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
provider_kind: "openai".to_owned(),
provider_account_id: None,
provider_account_ref: Some("acct_key".to_owned()),
upstream_model_id: "upstream-model".to_owned(),
upstream_model_id: Some("upstream-model".to_owned()),
upstream_transport: "http_sse".to_owned(),
http_version: None,
};
@@ -664,7 +664,7 @@ fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
requested_model_id: Some("coding".to_owned()),
routing_scope: "all".to_owned(),
routing_group_refs: Vec::new(),
routing_group_names_snapshot: serde_json::json!([]),
@@ -688,7 +688,7 @@ fn attempt(id: &str, count: u32, account_id: &str) -> ModelRequestAttemptStart {
provider_kind: "openai".to_owned(),
provider_account_id: Some(account_id.to_owned()),
provider_account_ref: Some(account_id.to_owned()),
upstream_model_id: "upstream-model".to_owned(),
upstream_model_id: Some("upstream-model".to_owned()),
upstream_transport: "http_sse".to_owned(),
http_version: None,
}
@@ -65,6 +65,55 @@ async fn session_affinity_should_overwrite_the_previous_account() {
);
}
#[tokio::test]
async fn session_affinity_claim_should_keep_the_first_account() {
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
return;
};
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("claim-session").expect("affinity key");
let first = ProviderAccountId::new("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
let first_winner = repository
.claim_or_load(&provider, &key, &first, Duration::from_secs(60))
.await
.expect("claim first affinity");
let second_winner = repository
.claim_or_load(&provider, &key, &second, Duration::from_secs(60))
.await
.expect("load existing affinity");
assert_eq!((first_winner, second_winner), (first.clone(), first));
}
#[tokio::test]
async fn session_affinity_compare_should_not_replace_a_newer_winner() {
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
return;
};
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("compare-session").expect("affinity key");
let first = ProviderAccountId::new("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
let stale = ProviderAccountId::new("acct_stale").expect("stale account");
repository
.bind(&provider, &key, &first, Duration::from_secs(60))
.await
.expect("seed affinity");
let migrated = repository
.compare_and_bind(&provider, &key, &first, &second, Duration::from_secs(60))
.await
.expect("migrate expected affinity");
let stale_result = repository
.compare_and_bind(&provider, &key, &first, &stale, Duration::from_secs(60))
.await
.expect("reject stale affinity migration");
assert_eq!((migrated, stale_result), (second.clone(), second));
}
#[tokio::test]
async fn session_affinity_should_apply_ttl_and_support_explicit_clear() {
let Some((repository, mut connection, namespace)) = affinity_repository().await else {
@@ -21,12 +21,18 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
return;
};
let database = TestDatabase::create(&database_url).await;
let config = store_config(&database.url, &redis_url);
let runtime_data = tempfile::tempdir().expect("Store runtime data");
let mut config = store_config(&database.url, &redis_url);
config
.resolve_and_validate(runtime_data.path())
.expect("resolved Store config");
let mut first = initialize(config.clone())
.await
.expect("first Store bundle");
let second = initialize(config).await.expect("second Store bundle");
assert!(runtime_data.path().join("backup-staging").is_dir());
let kinds = first
.take_worker_contributions()
.into_iter()
@@ -14,8 +14,6 @@ base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
cookie = "0.18.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
futures = "0.3.32"
flate2 = "1.1.9"
gateway-admin = { path = "../../gateway-admin" }
+242 -70
View File
@@ -13,13 +13,16 @@ use gateway_admin::model::observability::{
};
use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwner, AuthorizationOwnerBinding,
AuthorizationStarted, CompleteAuthorization, CredentialCommitGuard,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit, CredentialCommitGuard,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential,
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderProfileActivityInsights,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -44,9 +47,10 @@ use serde_json::{Map, Number, Value};
use crate::credential::{
CodexAccountQuotaSnapshot, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexCredentialProfileService, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
CodexOAuthPendingStoreError, CodexPendingAuthorization, CodexProfileStatisticsError,
CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
@@ -57,7 +61,7 @@ use crate::transport::CodexWebSocketPool;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::{OpenAiBillingUsage, openai_billing_breakdown};
use crate::transport::{CodexProfileStatistics, OpenAiBillingUsage, openai_billing_breakdown};
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
@@ -69,6 +73,7 @@ pub(crate) struct OpenAiAdminProvider {
accounts: Arc<dyn ProviderAccountStore>,
credentials: Arc<CodexCredentialAdminService>,
oauth: Arc<dyn CodexOAuthAdmin>,
profile_statistics: Arc<CodexCredentialProfileService>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
websocket_pool: Arc<CodexWebSocketPool>,
@@ -78,6 +83,7 @@ pub(crate) struct OpenAiAdminProvider {
pub(crate) struct OpenAiAdminServices {
pub(crate) credentials: Arc<CodexCredentialAdminService>,
pub(crate) oauth: Arc<dyn CodexOAuthAdmin>,
pub(crate) profile_statistics: Arc<CodexCredentialProfileService>,
pub(crate) quota: Arc<CodexCredentialQuotaService>,
pub(crate) catalog: Arc<CodexCredentialCatalogService>,
}
@@ -98,6 +104,7 @@ impl OpenAiAdminProvider {
accounts,
credentials: services.credentials,
oauth: services.oauth,
profile_statistics: services.profile_statistics,
quota: services.quota,
catalog: services.catalog,
websocket_pool,
@@ -442,6 +449,50 @@ impl ProviderAdmin for OpenAiAdminProvider {
Ok(project_quota(snapshot, &account))
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
self.account(account_id).await?;
let statistics = self
.profile_statistics
.profile_statistics(account_id)
.await
.map_err(map_profile_statistics_error)?;
Ok(project_profile_statistics(statistics))
}
async fn reset_credits(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderResetCredits, ProviderAdminError> {
self.account(account_id).await?;
self.quota
.list_reset_credits(account_id)
.await
.map(project_reset_credits)
.map_err(map_reset_credits_error)
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
self.account(&command.account_id).await?;
self.quota
.consume_reset_credit(
&command.account_id,
command.credit_id.as_deref(),
command.redeem_request_id,
)
.await
.map(|result| ProviderResetCreditResult {
code: result.code,
credit: result.credit.map(project_reset_credit),
})
.map_err(map_reset_credits_error)
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -734,39 +785,24 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
.windows()
.iter()
.filter(|window| should_project_quota_window(window))
.map(|window| {
let mut data = Map::new();
data.insert(
"source".to_owned(),
Value::String(window.source().to_owned()),
);
data.insert(
"role".to_owned(),
Value::String(quota_role(window.role()).to_owned()),
);
ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(
window.limit_name(),
window.kind(),
window.role(),
window.source(),
window.window_seconds(),
),
source: Some(window.source().to_owned()),
local_usage_attribution: if window.is_account_wide() {
QuotaLocalUsageAttribution::AccountWide
} else {
QuotaLocalUsageAttribution::Unavailable
},
window_seconds: window.window_seconds(),
used_percent: window.used_percent(),
reset_at: window.reset_at(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: Some(ProviderDocument::new(OpaqueProviderData::new(data))),
}
.map(|window| ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(window.kind(), window.role(), window.window_seconds()),
limit_id: Some(window.source().to_owned()),
limit_name: window.limit_name().map(str::to_owned),
role: Some(quota_role(window.role())),
local_usage_attribution: if window.is_account_wide() {
QuotaLocalUsageAttribution::AccountWide
} else {
QuotaLocalUsageAttribution::Unavailable
},
window_seconds: window.window_seconds(),
used_percent: window.used_percent(),
reset_at: window.reset_at(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: None,
})
.collect();
// 快照级 limit_reached 只看滚动后的窗口触顶:顶层标记是观测事实,不能
@@ -783,6 +819,77 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
}
}
fn project_profile_statistics(statistics: CodexProfileStatistics) -> ProviderProfileStatistics {
ProviderProfileStatistics {
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: statistics.summary.total_text_tokens,
peak_tokens: statistics.summary.peak_tokens,
longest_task_duration_ms: statistics.summary.longest_task_duration_ms,
current_streak_days: statistics.summary.current_streak_days,
longest_streak_days: statistics.summary.longest_streak_days,
},
daily_usage: statistics.daily_usage.map(|daily| {
daily
.into_iter()
.map(|entry| ProviderProfileDailyUsage {
date: entry.date,
tokens: entry.tokens,
})
.collect()
}),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: statistics.activity_insights.fast_mode_percent,
reasoning_effort: statistics.activity_insights.reasoning_effort,
reasoning_effort_percent: statistics.activity_insights.reasoning_effort_percent,
skills_explored: statistics.activity_insights.skills_explored,
total_skills_used: statistics.activity_insights.total_skills_used,
total_threads: statistics.activity_insights.total_threads,
invocations: statistics.activity_insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(|invocation| ProviderProfileInvocation {
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,
})
.collect()
}),
},
}
}
fn project_reset_credits(
credits: crate::transport::CodexRateLimitResetCredits,
) -> ProviderResetCredits {
ProviderResetCredits {
available_count: credits.available_count,
credits: credits
.credits
.into_iter()
.map(project_reset_credit)
.collect(),
}
}
fn project_reset_credit(
credit: crate::transport::CodexRateLimitResetCredit,
) -> ProviderResetCredit {
ProviderResetCredit {
id: credit.id,
status: credit.status,
title: credit.title,
expires_at: credit.expires_at,
reset_type: credit.reset_type,
}
}
/// `secondary_window` 有时只是上游的空占位:没有时长、重置时间,也没有用量。
/// 它不能被可靠翻译成 5 小时或周额度,因此不应占用账号面板;带有实际事实的
/// 次级窗口(时长、重置、非零用量或触顶)仍完整保留。
@@ -806,23 +913,21 @@ const fn quota_group(kind: CodexQuotaWindowKind) -> &'static str {
}
}
const fn quota_role(role: CodexQuotaWindowRole) -> &'static str {
const fn quota_role(role: CodexQuotaWindowRole) -> ProviderQuotaWindowRole {
match role {
CodexQuotaWindowRole::Primary => "primary",
CodexQuotaWindowRole::Secondary => "secondary",
CodexQuotaWindowRole::Monthly => "monthly",
CodexQuotaWindowRole::Primary => ProviderQuotaWindowRole::Primary,
CodexQuotaWindowRole::Secondary => ProviderQuotaWindowRole::Secondary,
CodexQuotaWindowRole::Monthly => ProviderQuotaWindowRole::Monthly,
}
}
/// 按窗口时长显示汉化额度名;非核心桶补上上游桶名称作为前缀。
/// 按窗口时长显示汉化额度名;额度桶名称通过独立字段投影。
fn codex_quota_window_label(
limit_name: Option<&str>,
kind: CodexQuotaWindowKind,
role: CodexQuotaWindowRole,
source: &str,
window_seconds: Option<u64>,
) -> String {
let base = match kind {
match kind {
CodexQuotaWindowKind::Monthly => "月额度".to_owned(),
CodexQuotaWindowKind::Weekly => "周额度".to_owned(),
CodexQuotaWindowKind::ShortTerm => {
@@ -833,29 +938,7 @@ fn codex_quota_window_label(
}
}
CodexQuotaWindowKind::Other => custom_quota_window_label(window_seconds, role),
};
if let Some(prefix) = codex_quota_window_prefix(limit_name, source) {
return format!("{prefix} · {base}");
}
base
}
fn codex_quota_window_prefix(limit_name: Option<&str>, source: &str) -> Option<String> {
let source_identifier = quota_identifier(source);
if matches!(source_identifier.as_str(), "core" | "codex") {
return None;
}
let label = limit_name.unwrap_or(source);
match quota_identifier(label).as_str() {
"premium" => Some("Premium".to_owned()),
"code_review" => Some("代码审查".to_owned()),
"spend_control" => Some("消费控制".to_owned()),
_ => Some(label.replace('_', " ")),
}
}
fn quota_identifier(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
}
fn custom_quota_window_label(window_seconds: Option<u64>, role: CodexQuotaWindowRole) -> String {
@@ -1322,6 +1405,8 @@ const fn provider_admin_error_code(kind: ProviderAdminErrorKind) -> &'static str
ProviderAdminErrorKind::NotFound => "not_found",
ProviderAdminErrorKind::Conflict => "conflict",
ProviderAdminErrorKind::Unavailable => "unavailable",
ProviderAdminErrorKind::CredentialRefreshRequired => "credential_refresh_required",
ProviderAdminErrorKind::BadGateway => "bad_gateway",
ProviderAdminErrorKind::Internal => "internal",
}
}
@@ -1377,6 +1462,93 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
})
}
fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderAdminError {
use CodexProfileStatisticsError as Error;
match error {
Error::InvalidCredentialData => provider_admin_error(ProviderAdminErrorKind::Invalid),
Error::NotFound => provider_admin_error(ProviderAdminErrorKind::NotFound),
Error::Store { .. } | Error::TransportUnavailable => {
provider_admin_error(ProviderAdminErrorKind::Unavailable)
}
Error::CredentialRefreshRequired { upstream_body } => {
let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
if let Some(body) = upstream_body {
error = error.with_message(format!(
"OpenAI profile-statistics upstream returned HTTP 401: {}",
bounded_upstream_body(&body)
));
}
error
}
Error::Upstream {
status,
body,
retry_after_seconds,
} => {
let retry_after = retry_after_seconds
.map(|seconds| format!("; retry-after={seconds}s"))
.unwrap_or_default();
provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!(
"OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}",
bounded_upstream_body(&body)
))
}
}
}
fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError {
use CodexResetCreditsError as Error;
match error {
Error::InvalidCredentialData => provider_admin_error(ProviderAdminErrorKind::Invalid),
Error::NotFound => provider_admin_error(ProviderAdminErrorKind::NotFound),
Error::Store { .. } | Error::TransportUnavailable => {
provider_admin_error(ProviderAdminErrorKind::Unavailable)
}
Error::CredentialRefreshRequired { upstream_body } => {
let mut error =
provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
if let Some(body) = upstream_body {
error = error.with_message(format!(
"OpenAI reset-credit upstream returned HTTP 401: {}",
bounded_upstream_body(&body)
));
}
error
}
Error::Upstream {
status,
body,
retry_after_seconds,
} => {
let retry_after = retry_after_seconds
.map(|seconds| format!("; retry-after={seconds}s"))
.unwrap_or_default();
provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!(
"OpenAI reset-credit upstream returned HTTP {status}{retry_after}: {}",
bounded_upstream_body(&body)
))
}
Error::ConsumeResultUnknown => provider_admin_error(ProviderAdminErrorKind::BadGateway)
.with_message(
"OpenAI reset-credit consume result is unknown; refresh the credit list before retrying",
),
}
}
fn bounded_upstream_body(body: &str) -> String {
const MAX_ADMIN_UPSTREAM_BODY_BYTES: usize = 8 * 1024;
if body.len() <= MAX_ADMIN_UPSTREAM_BODY_BYTES {
return body.to_owned();
}
let mut end = MAX_ADMIN_UPSTREAM_BODY_BYTES;
while !body.is_char_boundary(end) {
end -= 1;
}
body[..end].to_owned()
}
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
+8 -17
View File
@@ -36,15 +36,17 @@ pub struct OpenAiConfig {
}
impl OpenAiConfig {
/// 校验 Provider-owned 字段,并定位旧版会话锚点持续使用的身份密钥。
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), OpenAiConfigError> {
/// 校验 Provider-owned 字段,并从统一运行数据目录定位会话身份密钥。
pub fn resolve_and_validate(
&mut self,
runtime_data_dir: &Path,
) -> Result<(), OpenAiConfigError> {
self.api.validate()?;
self.ws_pool.validate()?;
self.quota.validate()?;
self.auth.validate()?;
self.wire_profile.validate()?;
let project_root = source_dir.parent().unwrap_or(source_dir);
self.identity_secret_path = project_root.join(".runtime/data/identity_hmac_secret");
self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret");
Ok(())
}
@@ -101,7 +103,7 @@ impl Default for OpenAiConfig {
quota: CodexQuotaSettings::default(),
auth: CodexAuthSettings::default(),
wire_profile: CodexWireProfileConfig::default(),
identity_secret_path: PathBuf::from(".runtime/data/identity_hmac_secret"),
identity_secret_path: PathBuf::new(),
}
}
}
@@ -153,8 +155,6 @@ impl CodexApiConfig {
pub struct CodexWebSocketPoolSettings {
pub enabled: bool,
pub max_age_ms: u64,
pub max_per_account: usize,
pub max_total: usize,
pub max_connecting: usize,
pub initial_event_timeout_ms: u64,
}
@@ -164,8 +164,6 @@ impl Default for CodexWebSocketPoolSettings {
Self {
enabled: true,
max_age_ms: 55 * 60 * 1000,
max_per_account: 8,
max_total: 64,
max_connecting: 8,
initial_event_timeout_ms: 20_000,
}
@@ -174,12 +172,7 @@ impl Default for CodexWebSocketPoolSettings {
impl CodexWebSocketPoolSettings {
fn validate(&self) -> Result<(), OpenAiConfigError> {
if self.max_age_ms == 0
|| self.max_per_account == 0
|| self.max_total == 0
|| self.max_connecting == 0
|| self.max_connecting > self.max_total
{
if self.max_age_ms == 0 || self.max_connecting == 0 {
return Err(OpenAiConfigError::InvalidField("openai.ws_pool"));
}
Ok(())
@@ -189,8 +182,6 @@ impl CodexWebSocketPoolSettings {
CodexWebSocketPoolConfig {
enabled: self.enabled,
max_age: Duration::from_millis(self.max_age_ms),
max_per_account: self.max_per_account,
max_total: self.max_total,
max_connecting: self.max_connecting,
initial_event_timeout: (self.initial_event_timeout_ms != 0)
.then(|| Duration::from_millis(self.initial_event_timeout_ms)),
@@ -12,7 +12,7 @@ use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, CredentialCasUpdate, CredentialRevision, CredentialState, LoadedCredential,
NewProviderAccount, ProviderAccount, ProviderAccountId, ProviderAccountIdentity,
ProviderAccountUpdate, QuotaEvidence, QuotaState,
ProviderAccountUpdate, QuotaState,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
@@ -28,14 +28,12 @@ use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::security::CodexCredentialCodec;
use super::token_client::{RefreshFailure, TokenRefresher};
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration,
parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_BATCH: usize = 200;
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
@@ -121,56 +119,31 @@ impl fmt::Debug for PreparedCodexAccountImport {
}
struct ParsedCodexImportAccount {
id: Option<String>,
name: Option<String>,
email: Option<String>,
plan_type: Option<String>,
chatgpt_account_id: Option<String>,
chatgpt_user_id: Option<String>,
authentication: ParsedCodexAuthentication,
status: Option<String>,
}
enum ParsedCodexAuthentication {
OAuth {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
},
AgentIdentity {
runtime_id: String,
private_key: String,
task_id: Option<String>,
},
struct ParsedCodexAuthentication {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
}
impl fmt::Debug for ParsedCodexAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth {
access_token,
refresh_token,
id_token,
} => formatter
.debug_struct("OAuth")
.field("access_token", &access_token.as_ref().map(|_| "<redacted>"))
.field(
"refresh_token",
&refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &id_token.as_ref().map(|_| "<redacted>"))
.finish(),
Self::AgentIdentity {
runtime_id: _,
private_key: _,
task_id,
} => formatter
.debug_struct("AgentIdentity")
.field("runtime_id", &"<redacted>")
.field("private_key", &"<redacted>")
.field("task_id", &task_id.as_ref().map(|_| "<redacted>"))
.finish(),
}
formatter
.debug_struct("OAuth")
.field(
"access_token",
&self.access_token.as_ref().map(|_| "<redacted>"),
)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
@@ -178,20 +151,9 @@ impl fmt::Debug for ParsedCodexImportAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ParsedCodexImportAccount")
.field("id", &self.id)
.field("name", &self.name)
.field("email", &self.email.as_ref().map(|_| "<redacted>"))
.field("plan_type", &self.plan_type)
.field(
"chatgpt_account_id",
&self.chatgpt_account_id.as_ref().map(|_| "<redacted>"),
)
.field(
"chatgpt_user_id",
&self.chatgpt_user_id.as_ref().map(|_| "<redacted>"),
)
.field("authentication", &self.authentication)
.field("status", &self.status)
.finish()
}
}
@@ -222,7 +184,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
source_format: &'static str,
accounts: Vec<CodexCprExportAccount>,
accounts: Vec<CodexCprOAuthExportAccount>,
}
impl CodexCprExportDocument {
@@ -277,25 +239,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprAgentIdentityExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
auth_mode: &'static str,
agent_runtime_id: String,
agent_private_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
AgentIdentity(CodexCprAgentIdentityExportAccount),
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
@@ -567,7 +510,7 @@ impl CodexCredentialAdmin {
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
CodexCprOAuthExportAccount {
common,
access_token: data.access_token,
refresh_token: data.refresh_token,
@@ -576,22 +519,7 @@ impl CodexCredentialAdmin {
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
})
}
CodexCredentialData::AgentIdentity(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|| account.has_refresh_token()
|| account.access_token_expires_at().is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
common,
auth_mode: "agentIdentity",
agent_runtime_id: data.agent_runtime_id,
agent_private_key: data.agent_private_key,
task_id: data.task_id,
})
}
};
accounts.push(exported);
@@ -861,7 +789,7 @@ impl CodexCredentialAdminService {
Ok(prepared)
}
/// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。
/// 归一导入 OAuth 凭据到唯一 `NewProviderAccount` 写入路径。
///
/// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方
/// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。
@@ -880,101 +808,57 @@ impl CodexCredentialAdminService {
if candidates.is_empty() || candidates.len() > MAX_BATCH {
return Err(CodexCredentialAdminError::InvalidInput);
}
let mut account_ids = BTreeSet::new();
let mut upstream_identities = BTreeSet::new();
let mut accounts = Vec::with_capacity(candidates.len());
for candidate in candidates {
let prepared = match &candidate.authentication {
ParsedCodexAuthentication::OAuth {
access_token,
refresh_token,
id_token,
} => {
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
access_token.clone(),
refresh_token.clone(),
id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
CodexCredentialAdmin.prepare_unresolved_oauth(
UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
},
)?
}
ParsedCodexAuthentication::AgentIdentity { .. } => {
let account_id = candidate
.id
.clone()
.filter(|id| ProviderAccountId::new(id.clone()).is_ok())
.unwrap_or_else(|| format!("acct_{}", uuid::Uuid::now_v7().simple()));
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let facts =
parse_cpr_account_facts(candidate.status.as_deref(), SystemTime::now())?;
let mut account =
self.prepare_agent_identity_import(&candidate, account_id, facts.enabled)?;
let upstream_user_id = account
.account
.upstream_user_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
let upstream_account_id = account
.account
.upstream_account_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
if !upstream_identities.insert((upstream_user_id, upstream_account_id)) {
return Err(CodexCredentialAdminError::InvalidInput);
}
account.account = facts.apply(account.account);
account
}
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
candidate.authentication.access_token.clone(),
candidate.authentication.refresh_token.clone(),
candidate.authentication.id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret()).unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
})?;
accounts.push(prepared);
}
Ok(PreparedCodexAccountImport { accounts })
@@ -1044,79 +928,6 @@ impl CodexCredentialAdminService {
.map(ExposeSecret::expose_secret),
);
}
fn prepare_agent_identity_import(
&self,
candidate: &ParsedCodexImportAccount,
account_id: String,
enabled: bool,
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
let ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id,
} = &candidate.authentication
else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
let upstream_account_id = candidate
.chatgpt_account_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let upstream_user_id = candidate
.chatgpt_user_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let account_id = ProviderAccountId::new(account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: uuid::Uuid::new_v4().to_string(),
agent_runtime_id: runtime_id.clone(),
agent_private_key: private_key.clone(),
task_id: task_id.clone(),
cookies: Vec::new(),
})
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let name = candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "OpenAI Agent Identity".to_owned());
let account = ProviderAccount::new(
account_id,
provider,
name,
Some(upstream_user_id),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
revision,
None,
)
.with_profile(
candidate.email.clone(),
Some(upstream_account_id),
candidate.plan_type.clone(),
)
.with_account_facts(
enabled,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
Ok(NewProviderAccount {
account,
credential,
})
}
}
fn optional_time(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<SystemTime> {
@@ -1177,10 +988,6 @@ fn parse_import_document(
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
let mut accounts = Vec::new();
for value in import_account_values(payload)? {
if looks_like_agent_identity_account(value) {
accounts.push(parse_agent_identity_account(value)?);
continue;
}
if !is_openai_oauth_candidate(value) {
continue;
}
@@ -1194,72 +1001,9 @@ fn parse_oauth_import_account(
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let credentials = value.get("credentials").unwrap_or(value);
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
plan_type: first_string(value, &["plan_type", "planType"])
.or_else(|| first_string(credentials, &["plan_type", "planType"])),
chatgpt_account_id: None,
chatgpt_user_id: None,
authentication: parse_oauth_import_tokens(value)?,
status: first_string(value, &["status"]),
})
}
fn parse_agent_identity_account(
value: &Value,
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let account = value
.as_object()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
if account
.get("platform")
.and_then(Value::as_str)
.is_some_and(|platform| !platform.eq_ignore_ascii_case("openai"))
|| account
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| !kind.eq_ignore_ascii_case("oauth"))
{
return Err(CodexCredentialAdminError::InvalidInput);
}
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
let auth_mode = first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
if auth_mode.as_deref() != Some("agentIdentity") {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(identity, &["email"]),
plan_type: first_string(identity, &["plan_type", "planType"]),
chatgpt_account_id: first_string(
identity,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
),
chatgpt_user_id: first_string(
identity,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
authentication: ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id: first_string(identity, &["task_id", "taskId"]),
},
status: first_string(value, &["status"]),
})
}
@@ -1288,9 +1032,9 @@ fn parse_oauth_import_tokens(
Value::Object(object) => {
for (key, value) in object {
let token = match key.as_str() {
"accessToken" => &mut access_token,
"refreshToken" => &mut refresh_token,
"idToken" => &mut id_token,
"accessToken" | "access_token" => &mut access_token,
"refreshToken" | "refresh_token" => &mut refresh_token,
"idToken" | "id_token" => &mut id_token,
_ => {
pending.push(value);
continue;
@@ -1308,24 +1052,13 @@ fn parse_oauth_import_tokens(
if access_token.is_none() && refresh_token.is_none() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(ParsedCodexAuthentication::OAuth {
Ok(ParsedCodexAuthentication {
access_token,
refresh_token,
id_token,
})
}
fn looks_like_agent_identity_account(value: &Value) -> bool {
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
.is_some_and(|mode| mode == "agentIdentity")
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
}
fn is_openai_oauth_candidate(value: &Value) -> bool {
let Some(account) = value.as_object() else {
return false;
@@ -1354,66 +1087,3 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
struct ImportedCprAccountFacts {
enabled: bool,
credential_state: CredentialState,
quota: QuotaState,
error_reason: Option<AccountErrorReason>,
}
impl ImportedCprAccountFacts {
fn apply(self, account: ProviderAccount) -> ProviderAccount {
account.with_account_facts(
self.enabled,
self.credential_state,
self.quota,
self.error_reason,
None,
)
}
}
fn parse_cpr_account_facts(
status: Option<&str>,
observed_at: SystemTime,
) -> Result<ImportedCprAccountFacts, CodexCredentialAdminError> {
match status
.map(str::trim)
.unwrap_or("active")
.to_ascii_lowercase()
.as_str()
{
"active" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"disabled" => Ok(ImportedCprAccountFacts {
enabled: false,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"expired" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Expired,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::CredentialExpired),
}),
"quota_exhausted" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::exhausted(QuotaEvidence::ProviderDenied, observed_at, None),
error_reason: None,
}),
"banned" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Banned,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::AccountBanned),
}),
_ => Err(CodexCredentialAdminError::InvalidInput),
}
}
@@ -1,4 +1,4 @@
//! OpenAI 会话锚点到 Store 不透明亲和键的单向派生。
//! OpenAI 根会话锚点到 Store 不透明账号亲和键及诊断上下文的单向派生。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
@@ -7,46 +7,109 @@ use sha2::{Digest, Sha256};
use crate::transport::protocol::responses::CodexResponsesRequest;
use crate::transport::request::derive_conversation_anchor;
pub(crate) fn derive_codex_session_affinity_key(
const AFFINITY_KEY_HASH_LENGTH: usize = 12;
/// 一次请求派生出的账号亲和键及其结构化日志上下文。
pub(crate) struct CodexSessionAffinity {
key: ProviderSessionAffinityKey,
key_hash: String,
anchor_source: &'static str,
anchor: String,
session_id: Option<String>,
}
impl CodexSessionAffinity {
#[must_use]
pub(crate) const fn key(&self) -> &ProviderSessionAffinityKey {
&self.key
}
#[must_use]
pub(crate) fn key_hash(&self) -> &str {
&self.key_hash
}
#[must_use]
pub(crate) const fn anchor_source(&self) -> &'static str {
self.anchor_source
}
#[must_use]
pub(crate) fn anchor(&self) -> &str {
&self.anchor
}
#[must_use]
pub(crate) fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
#[must_use]
pub(crate) const fn session_id_present(&self) -> bool {
self.session_id.is_some()
}
#[must_use]
pub(crate) fn into_key(self) -> ProviderSessionAffinityKey {
self.key
}
}
pub(crate) fn derive_codex_session_affinity(
request: &CodexResponsesRequest,
client_api_key_id: &ClientApiKeyId,
) -> Option<ProviderSessionAffinityKey> {
let session_key = if let Some(conversation_id) = request
.local_conversation_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Ok(key) = ProviderSessionAffinityKey::try_new(conversation_id.to_owned()) {
Some(key)
} else {
opaque_affinity_key("local-conversation", conversation_id)
}
} else {
let (domain, value) = derive_conversation_anchor(request)?;
opaque_affinity_key(domain, &value)
};
let session_discriminator = request.subagent_kind();
let session_key =
session_key.and_then(|session_key| match session_discriminator.as_deref() {
None => Some(session_key),
Some(discriminator) => {
// 仅扩展原会话锚点的键空间;后续查找、绑定和调度仍使用同一套亲和逻辑。
opaque_affinity_key(
"subagent-session",
&format!("{discriminator}\0{}", session_key.expose_to_store()),
)
}
})?;
opaque_affinity_key(
) -> Option<CodexSessionAffinity> {
let session_id = non_empty(request.client_session_id.as_deref()).map(str::to_owned);
let (anchor_source, anchor) = derive_account_affinity_anchor(request)?;
let session_key = opaque_affinity_key(anchor_source, &anchor)?;
let key = opaque_affinity_key(
"client-session",
&format!(
"{}\0{}",
client_api_key_id.as_str(),
session_key.expose_to_store()
),
)
)?;
// 亲和键本身已经是 SHA-256;日志沿用 WebSocket 诊断的 12 位短哈希长度。
let key_hash = key
.expose_to_store()
.chars()
.take(AFFINITY_KEY_HASH_LENGTH)
.collect();
Some(CodexSessionAffinity {
key,
key_hash,
anchor_source,
anchor,
session_id,
})
}
/// 账号选择优先使用根会话事实;`local_conversation_id` 仍可按 child thread 隔离
/// WebSocket 与 continuation 状态,但 child 身份不再拆分账号首选项。账号不可调度时
/// selector 仍可按既有策略切换,并由 request scope 清理账号绑定状态。
fn derive_account_affinity_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
non_empty(request.client_session_id.as_deref())
.map(|value| ("root-session", value.to_owned()))
.or_else(|| {
non_empty(request.client_conversation_id.as_deref())
.map(|value| ("root-conversation", value.to_owned()))
})
.or_else(|| {
request
.explicit_prompt_cache_key
.then(|| request.prompt_cache_key())
.flatten()
.and_then(|value| non_empty(Some(value)))
.map(|value| ("root-prompt-cache", value.to_owned()))
})
.or_else(|| {
non_empty(request.local_conversation_id.as_deref())
.map(|value| ("local-conversation", value.to_owned()))
})
.or_else(|| derive_conversation_anchor(request))
}
fn opaque_affinity_key(domain: &str, value: &str) -> Option<ProviderSessionAffinityKey> {
@@ -1,500 +0,0 @@
//! OpenAI Agent Identity 签名、task 注册与 revision-fenced 恢复。
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{DateTime, SecondsFormat, Utc};
use crypto_box::SecretKey as BoxSecretKey;
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
use ed25519_dalek::{Signer as _, SigningKey};
use gateway_core::engine::credential::{ProviderAccount, ProviderAccountId};
use reqwest::StatusCode;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha512};
use thiserror::Error;
use tokio::sync::Mutex;
use url::Url;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::{CodexRuntimeAuthentication, CodexRuntimeCredential};
use crate::transport::headers::build_codex_profile_headers;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::websocket::CodexWebSocketExchangeError;
use crate::transport::{CodexClientError, CodexWebSocketPool};
const OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL: &str = "https://auth.openai.com/api/accounts/";
const MAX_TASK_RESPONSE_BYTES: usize = 64 * 1024;
/// 已验证且只存在于 OpenAI Provider 运行时的 Agent Identity 密钥。
pub struct CodexAgentIdentitySecret {
runtime_id: String,
signing_key: SigningKey,
task_id: Option<String>,
}
impl CodexAgentIdentitySecret {
pub fn from_pkcs8(
runtime_id: String,
encoded_private_key: &str,
task_id: Option<String>,
) -> Result<Self, CodexAgentIdentityError> {
if !valid_identifier(&runtime_id)
|| task_id
.as_deref()
.is_some_and(|value| !valid_identifier(value))
{
return Err(CodexAgentIdentityError::InvalidCredential);
}
let der = STANDARD
.decode(encoded_private_key.trim())
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
let signing_key = SigningKey::from_pkcs8_der(&der)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(Self {
runtime_id,
signing_key,
task_id,
})
}
#[must_use]
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
#[must_use]
pub fn task_id(&self) -> Option<&str> {
self.task_id.as_deref()
}
pub fn authorization_header(
&self,
now: DateTime<Utc>,
) -> Result<SecretString, CodexAgentIdentityError> {
let task_id = self.task_id().ok_or(CodexAgentIdentityError::MissingTask)?;
let timestamp = timestamp(now);
let payload = format!("{}:{task_id}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
let envelope = AgentAssertionEnvelope {
agent_runtime_id: &self.runtime_id,
task_id,
timestamp: &timestamp,
signature: STANDARD.encode(signature.to_bytes()),
};
let encoded = serde_json::to_vec(&envelope)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(SecretString::from(format!(
"AgentAssertion {}",
URL_SAFE_NO_PAD.encode(encoded)
)))
}
fn registration(&self, now: DateTime<Utc>) -> AgentTaskRegistration {
let timestamp = timestamp(now);
let payload = format!("{}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
AgentTaskRegistration {
timestamp,
signature: STANDARD.encode(signature.to_bytes()),
}
}
fn decrypt_task_id(&self, encoded: &str) -> Result<String, CodexAgentIdentityError> {
let ciphertext = STANDARD
.decode(encoded.trim())
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let digest = Sha512::digest(self.signing_key.to_bytes());
let mut curve_key = [0_u8; 32];
curve_key.copy_from_slice(&digest[..32]);
let secret = BoxSecretKey::from(curve_key);
let plaintext = secret
.unseal(&ciphertext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = String::from_utf8(plaintext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = task_id.trim().to_owned();
if !valid_identifier(&task_id) {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
Ok(task_id)
}
}
impl fmt::Debug for CodexAgentIdentitySecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentitySecret")
.field("runtime_id", &"<redacted>")
.field("signing_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
#[derive(Serialize)]
struct AgentAssertionEnvelope<'a> {
agent_runtime_id: &'a str,
task_id: &'a str,
timestamp: &'a str,
signature: String,
}
#[derive(Serialize)]
struct AgentTaskRegistration {
timestamp: String,
signature: String,
}
#[derive(Deserialize)]
struct AgentTaskRegistrationResponse {
#[serde(default, alias = "taskId")]
task_id: Option<String>,
#[serde(default, alias = "encryptedTaskId")]
encrypted_task_id: Option<String>,
}
#[async_trait]
pub trait CodexAgentIdentityTaskRegistrar: Send + Sync + 'static {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError>;
}
pub struct OfficialCodexAgentIdentityTaskRegistrar {
client: reqwest::Client,
base_url: Url,
profile: CodexWireProfileState,
}
impl OfficialCodexAgentIdentityTaskRegistrar {
pub fn new(
client: reqwest::Client,
profile: CodexWireProfileState,
) -> Result<Self, CodexAgentIdentityError> {
let base_url = Url::parse(OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
Ok(Self {
client,
base_url,
profile,
})
}
}
impl fmt::Debug for OfficialCodexAgentIdentityTaskRegistrar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("OfficialCodexAgentIdentityTaskRegistrar")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for OfficialCodexAgentIdentityTaskRegistrar {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
let mut endpoint = self.base_url.clone();
endpoint
.path_segments_mut()
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?
.extend(["v1", "agent", credential.runtime_id(), "task", "register"]);
let profile = self.profile.snapshot();
let headers = build_codex_profile_headers(&profile)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
let response = self
.client
.post(endpoint)
.headers(headers)
.header(reqwest::header::ACCEPT, "application/json")
.json(&credential.registration(Utc::now()))
.send()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if !response.status().is_success() {
return Err(CodexAgentIdentityError::TaskRegistrationRejected);
}
if response
.content_length()
.is_some_and(|length| length > MAX_TASK_RESPONSE_BYTES as u64)
{
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let body = response
.bytes()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if body.len() > MAX_TASK_RESPONSE_BYTES {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let result = serde_json::from_slice::<AgentTaskRegistrationResponse>(&body)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
if let Some(task_id) = result.task_id.map(|value| value.trim().to_owned())
&& valid_identifier(&task_id)
{
return Ok(task_id);
}
let encrypted = result
.encrypted_task_id
.as_deref()
.ok_or(CodexAgentIdentityError::InvalidTaskResponse)?;
credential.decrypt_task_id(encrypted)
}
}
pub struct PreparedCodexRuntimeCredential {
pub account: ProviderAccount,
pub credential: CodexRuntimeCredential,
}
/// Agent task 的唯一注册与恢复 owner。
pub struct CodexAgentIdentityTaskService {
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
account_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
}
impl CodexAgentIdentityTaskService {
#[must_use]
pub fn new(
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Self {
Self {
repository,
registrar,
websocket_pool,
account_locks: Mutex::new(HashMap::new()),
}
}
pub async fn prepare(
&self,
account: &ProviderAccount,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let prepared = self.load_current(account.id()).await?;
if !matches!(
&prepared.credential.authentication,
CodexRuntimeAuthentication::AgentIdentity(secret) if secret.task_id().is_none()
) {
return Ok(prepared);
}
self.ensure_task(account.id(), None).await
}
pub async fn recover(
&self,
account_id: &ProviderAccountId,
expected_task_id: &str,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
self.ensure_task(account_id, Some(expected_task_id)).await
}
pub async fn recover_after_rejected_task(
&self,
account_id: &ProviderAccountId,
authentication: &CodexRuntimeAuthentication,
error: &CodexClientError,
) -> Result<Option<PreparedCodexRuntimeCredential>, CodexAgentIdentityError> {
let CodexRuntimeAuthentication::AgentIdentity(secret) = authentication else {
return Ok(None);
};
let Some(task_id) = secret.task_id() else {
return Ok(None);
};
if !is_agent_identity_task_invalid_client_error(error) {
return Ok(None);
}
self.recover(account_id, task_id).await.map(Some)
}
async fn ensure_task(
&self,
account_id: &ProviderAccountId,
expected_task_id: Option<&str>,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let lock = {
let mut locks = self.account_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let _guard = lock.lock().await;
let current = self.load_current(account_id).await?;
let CodexRuntimeAuthentication::AgentIdentity(secret) = &current.credential.authentication
else {
return Ok(current);
};
match (expected_task_id, secret.task_id()) {
(None, Some(_)) => return Ok(current),
(Some(expected), Some(actual)) if actual != expected => return Ok(current),
_ => {}
}
let task_id = self.registrar.register(secret).await?;
let mut data = self.repository.load_complete_data(&current.account).await?;
let agent = data
.agent_identity_mut()
.ok_or(CodexAgentIdentityError::InvalidCredential)?;
match (expected_task_id, agent.task_id.as_deref()) {
(None, Some(_)) => return self.load_current(account_id).await,
(Some(expected), Some(actual)) if actual != expected => {
return self.load_current(account_id).await;
}
_ => agent.task_id = Some(task_id),
}
self.repository
.compare_and_swap_data(&current.account, data)
.await?;
self.websocket_pool.evict_account(account_id.as_str()).await;
self.load_current(account_id).await
}
pub(crate) async fn load_current(
&self,
account_id: &ProviderAccountId,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let account = self
.repository
.store()
.get_account(account_id)
.await?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexAgentIdentityError::NotFound)?;
let credential = self.repository.load_runtime_credential(&account).await?;
Ok(PreparedCodexRuntimeCredential {
account,
credential,
})
}
}
impl fmt::Debug for CodexAgentIdentityTaskService {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityTaskService")
.field("repository", &"CodexCredentialRepository")
.field("registrar", &"CodexAgentIdentityTaskRegistrar")
.finish_non_exhaustive()
}
}
#[derive(Debug, Error)]
pub enum CodexAgentIdentityError {
#[error("OpenAI Agent Identity credential is invalid")]
InvalidCredential,
#[error("OpenAI Agent Identity task is unavailable")]
MissingTask,
#[error("OpenAI Agent Identity task registration is unavailable")]
TaskRegistrationUnavailable,
#[error("OpenAI Agent Identity task registration was rejected")]
TaskRegistrationRejected,
#[error("OpenAI Agent Identity task registration response is invalid")]
InvalidTaskResponse,
#[error("OpenAI Agent Identity configuration is invalid")]
InvalidConfiguration,
#[error("OpenAI Agent Identity account was not found")]
NotFound,
#[error("OpenAI Agent Identity credential store is unavailable")]
Repository,
}
impl From<CredentialRepositoryError> for CodexAgentIdentityError {
fn from(_: CredentialRepositoryError) -> Self {
Self::Repository
}
}
impl From<gateway_core::error::StoreError> for CodexAgentIdentityError {
fn from(_: gateway_core::error::StoreError) -> Self {
Self::Repository
}
}
#[must_use]
pub fn is_agent_identity_task_invalid_response(status: StatusCode, body: &str) -> bool {
if status != StatusCode::UNAUTHORIZED {
return false;
}
let lower = body.to_ascii_lowercase();
let compact = lower
.chars()
.filter(|character| !character.is_ascii_whitespace())
.collect::<String>();
[
"\"code\":\"invalid_task_id\"",
"\"code\":\"task_not_found\"",
"\"code\":\"task_expired\"",
"\"error\":\"invalid_task_id\"",
]
.iter()
.any(|marker| compact.contains(marker))
|| [
"invalid task_id",
"invalid task id",
"task_id is invalid",
"task id is invalid",
"task not found",
"task expired",
"unknown task_id",
"unknown task id",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn is_agent_identity_task_invalid_client_error(error: &CodexClientError) -> bool {
match error {
CodexClientError::Upstream { status, body, .. } => {
is_agent_identity_task_invalid_response(*status, body)
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
}) => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn is_agent_identity_task_invalid_websocket_error(error: &CodexWebSocketExchangeError) -> bool {
match error {
CodexWebSocketExchangeError::Upstream(upstream) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
} => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn timestamp(now: DateTime<Utc>) -> String {
now.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn valid_identifier(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
@@ -19,7 +19,6 @@ use thiserror::Error;
use tokio::sync::Notify;
use uuid::Uuid;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
@@ -182,7 +181,6 @@ pub struct CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
etags: Arc<Mutex<CatalogEtagState>>,
@@ -195,7 +193,6 @@ impl CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
) -> Self {
Self {
@@ -203,7 +200,6 @@ impl CodexCredentialCatalogService {
profile,
http,
base_url,
agent_identity,
plan_catalog_cache,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
@@ -429,52 +425,24 @@ impl CodexCredentialCatalogService {
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut request_id = format!("catalog_{}", Uuid::now_v7().simple());
let mut authorization = prepared
.credential
let request_id = format!("catalog_{}", Uuid::now_v7().simple());
let authorization = credential
.authentication
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut result = client
let result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
account.upstream_account_id(),
&request_id,
None,
))
.await;
if let Err(error) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?
{
prepared = recovered;
request_id = format!("catalog_{}", Uuid::now_v7().simple());
authorization = prepared
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&request_id,
None,
))
.await;
}
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?;
@@ -2,10 +2,10 @@
mod admin;
mod affinity;
mod agent_identity;
mod catalog;
mod cookie;
mod oauth;
mod profile_statistics;
mod quota;
mod recovery_log;
mod refresh;
@@ -16,17 +16,11 @@ pub mod token_client;
mod types;
pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
CodexSessionAffinity, derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
CodexAgentIdentityTaskService, OfficialCodexAgentIdentityTaskRegistrar,
PreparedCodexRuntimeCredential, is_agent_identity_task_invalid_response,
};
pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
@@ -44,10 +38,11 @@ pub use oauth::{
CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthAuthorization,
CompletedCodexOAuthCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
pub use profile_statistics::{CodexCredentialProfileService, CodexProfileStatisticsError};
pub use quota::{
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
CodexQuotaWindowKind, CodexQuotaWindowRole, parse_codex_quota_usage,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, parse_codex_quota_usage,
};
pub use refresh::{
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
@@ -58,15 +53,13 @@ pub use security::{
CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeAuthentication,
CodexRuntimeCredential,
};
pub(crate) use selector::CodexCyberPolicyScope;
pub use selector::{
CodexAccountFailure, CodexCredentialLease, CodexCredentialSelector, CredentialSelectionError,
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub use types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCookie,
CodexCookieCaptureOutcome, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RotateCodexCredential, RuntimeCodexCookie,
UpsertCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
RotateCodexCredential, RuntimeCodexCookie, UpsertCodexCookie,
};
@@ -0,0 +1,154 @@
//! OpenAI 当前账号个人资料统计查询编排。
use std::time::SystemTime;
use gateway_core::engine::credential::ProviderAccountId;
use reqwest::Client;
use secrecy::ExposeSecret as _;
use thiserror::Error;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexProfileStatistics, CodexRequestContext,
};
use super::CodexCredentialRepository;
#[derive(Error)]
pub enum CodexProfileStatisticsError {
#[error("Codex profile-statistics credential data is invalid")]
InvalidCredentialData,
#[error("Codex OAuth access token must be refreshed before querying profile statistics")]
CredentialRefreshRequired { upstream_body: Option<String> },
#[error("Codex profile-statistics account was not found")]
NotFound,
#[error("provider account store is unavailable: {detail}")]
Store { detail: String },
#[error("Codex profile-statistics upstream returned HTTP {status}")]
Upstream {
status: u16,
body: String,
retry_after_seconds: Option<u64>,
},
#[error("Codex profile-statistics query transport is unavailable")]
TransportUnavailable,
}
impl std::fmt::Debug for CodexProfileStatisticsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCredentialData => formatter.write_str("InvalidCredentialData"),
Self::CredentialRefreshRequired { .. } => {
formatter.write_str("CredentialRefreshRequired { upstream_body: <redacted> }")
}
Self::NotFound => formatter.write_str("NotFound"),
Self::Store { .. } => formatter.write_str("Store { detail: <redacted> }"),
Self::Upstream {
status,
retry_after_seconds,
..
} => formatter
.debug_struct("Upstream")
.field("status", status)
.field("body", &"<redacted>")
.field("retry_after_seconds", retry_after_seconds)
.finish(),
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
}
}
}
pub struct CodexCredentialProfileService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
base_url: String,
}
impl CodexCredentialProfileService {
#[must_use]
pub const fn new(
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
base_url: String,
) -> Self {
Self {
repository,
profile,
http,
base_url,
}
}
pub async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let account = self
.repository
.store()
.get_account(account_id)
.await
.map_err(|error| CodexProfileStatisticsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexProfileStatisticsError::NotFound)?;
if account
.access_token_expires_at()
.is_some_and(|expires_at| expires_at <= SystemTime::now())
{
return Err(CodexProfileStatisticsError::CredentialRefreshRequired {
upstream_body: None,
});
}
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexProfileStatisticsError::InvalidCredentialData)?;
let authorization = credential
.authentication
.authorization_header()
.map_err(|_| CodexProfileStatisticsError::InvalidCredentialData)?;
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.fetch_profile_statistics(CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
))
.await
.map_err(map_client_error)
}
}
fn map_client_error(error: CodexClientError) -> CodexProfileStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED =>
{
CodexProfileStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
..
} => CodexProfileStatisticsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
},
_ => CodexProfileStatisticsError::TransportUnavailable,
}
}
@@ -12,12 +12,10 @@ pub use snapshot::{
CodexAccountQuotaSnapshot, CodexQuotaFact, CodexQuotaWindow, CodexQuotaWindowKind,
CodexQuotaWindowRole, parse_codex_quota_usage,
};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant, SystemTime};
use chrono::Utc;
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
@@ -36,10 +34,13 @@ use tokio::sync::Mutex;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext};
use crate::transport::{
CodexBackendClient, CodexClientError, CodexRateLimitResetCredits,
CodexRateLimitResetCreditsConsumeResult, CodexRequestContext,
};
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::CodexRuntimeCredential;
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
@@ -118,6 +119,54 @@ pub enum CodexCredentialQuotaError {
Upstream { detail: String },
}
/// 主动额度重置卡查询/消费失败。
#[derive(Error)]
pub enum CodexResetCreditsError {
#[error("Codex reset-credit credential data is invalid")]
InvalidCredentialData,
#[error("Codex OAuth access token must be refreshed before using reset credits")]
CredentialRefreshRequired { upstream_body: Option<String> },
#[error("Codex reset-credit account was not found")]
NotFound,
#[error("provider account store is unavailable: {detail}")]
Store { detail: String },
#[error("Codex reset-credit upstream returned HTTP {status}")]
Upstream {
status: u16,
body: String,
retry_after_seconds: Option<u64>,
},
#[error("Codex reset-credit query transport is unavailable")]
TransportUnavailable,
#[error("Codex reset-credit consume result is unknown")]
ConsumeResultUnknown,
}
impl std::fmt::Debug for CodexResetCreditsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCredentialData => formatter.write_str("InvalidCredentialData"),
Self::CredentialRefreshRequired { .. } => {
formatter.write_str("CredentialRefreshRequired { upstream_body: <redacted> }")
}
Self::NotFound => formatter.write_str("NotFound"),
Self::Store { .. } => formatter.write_str("Store { detail: <redacted> }"),
Self::Upstream {
status,
retry_after_seconds,
..
} => formatter
.debug_struct("Upstream")
.field("status", status)
.field("body", &"<redacted>")
.field("retry_after_seconds", retry_after_seconds)
.finish(),
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
Self::ConsumeResultUnknown => formatter.write_str("ConsumeResultUnknown"),
}
}
}
impl From<gateway_core::error::StoreError> for CodexCredentialQuotaError {
fn from(error: gateway_core::error::StoreError) -> Self {
Self::Store {
@@ -132,9 +181,9 @@ pub struct CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
scheduling: CodexQuotaSchedulingProjection,
reset_consume_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -175,6 +224,11 @@ struct FetchedCodexQuota {
value: Value,
}
struct PreparedCodexRuntimeCredential {
account: ProviderAccount,
credential: CodexRuntimeCredential,
}
enum CodexQuotaFetchAttemptError {
InvalidCredential,
Upstream(CodexClientError),
@@ -182,13 +236,17 @@ enum CodexQuotaFetchAttemptError {
enum CodexQuotaFetchError {
InvalidCredential,
Recovery,
Upstream {
account: Box<ProviderAccount>,
error: CodexClientError,
},
}
enum ResetCreditAttemptError {
InvalidCredential,
Upstream(CodexClientError),
}
impl CodexQuotaSchedulingProjection {
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
let mut state = self
@@ -401,7 +459,6 @@ impl CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
) -> Self {
Self {
@@ -410,12 +467,102 @@ impl CodexCredentialQuotaService {
profile,
http,
base_url,
agent_identity,
cooldowns,
scheduling: CodexQuotaSchedulingProjection::default(),
reset_consume_locks: Mutex::new(HashMap::new()),
}
}
/// 查询当前账号由 Codex Desktop 暴露的主动额度重置卡。
pub async fn list_reset_credits(
&self,
account_id: &ProviderAccountId,
) -> Result<CodexRateLimitResetCredits, CodexResetCreditsError> {
let account = self.reset_credit_account(account_id).await?;
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
);
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credits_{}", Uuid::now_v7().simple());
list_reset_credits_once(&client, &prepared, &request_id)
.await
.map_err(|error| map_reset_credit_attempt_error(error, false))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
pub async fn consume_reset_credit(
&self,
account_id: &ProviderAccountId,
credit_id: Option<&str>,
redeem_request_id: Uuid,
) -> Result<CodexRateLimitResetCreditsConsumeResult, CodexResetCreditsError> {
let lock = {
let mut locks = self.reset_consume_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let _guard = lock.lock().await;
let account = self.reset_credit_account(account_id).await?;
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
);
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credit_consume_{}", Uuid::now_v7().simple());
consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await
.map_err(|error| map_reset_credit_attempt_error(error, true))
}
async fn reset_credit_account(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderAccount, CodexResetCreditsError> {
let account = self
.store
.get_account(account_id)
.await
.map_err(|error| CodexResetCreditsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexResetCreditsError::NotFound)?;
if !access_token_is_current(&account, SystemTime::now()) {
return Err(CodexResetCreditsError::CredentialRefreshRequired {
upstream_body: None,
});
}
Ok(account)
}
/// 真实推理错误只更新额度访问事实,不伪造 Provider JSON 或展示百分比。
pub(crate) async fn record_confirmed_exhaustion(
&self,
@@ -541,7 +688,7 @@ impl CodexCredentialQuotaService {
);
for account in accounts {
let observed_at = SystemTime::now();
match self.fetch_usage_with_recovery(&client, &account).await {
match self.fetch_usage(&client, &account).await {
Ok(FetchedCodexQuota { account, value }) => {
// 单账号解析或落库失败只影响该账号;其余账号继续同步。
if let Err(error) = self
@@ -559,13 +706,6 @@ impl CodexCredentialQuotaService {
Err(CodexQuotaFetchError::InvalidCredential) => {
summary.stale += 1;
}
Err(CodexQuotaFetchError::Recovery) => {
summary.transient += 1;
tracing::warn!(
account_id = %account.id(),
"OpenAI quota credential recovery failed; refresh cycle will retry later"
);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
@@ -924,34 +1064,28 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } =
match self.fetch_usage_with_recovery(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Recovery) => {
return Err(CodexCredentialQuotaError::Upstream {
detail: "credential recovery failed".to_owned(),
});
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
let FetchedCodexQuota { account, value } = match self.fetch_usage(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
}
};
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
}
};
let object = normalize_quota_window_placeholders(
value
.as_object()
@@ -1044,31 +1178,21 @@ impl CodexCredentialQuotaService {
}
}
async fn fetch_usage_with_recovery(
async fn fetch_usage(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedCodexQuota, CodexQuotaFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexQuotaFetchError::InvalidCredential)?;
let mut result = fetch_usage_with_5xx_retry(client, &prepared).await;
if let Err(CodexQuotaFetchAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexQuotaFetchError::Recovery)?
{
prepared = recovered;
result = fetch_usage_with_5xx_retry(client, &prepared).await;
}
let prepared = PreparedCodexRuntimeCredential {
account: account.clone(),
credential,
};
let result = fetch_usage_with_5xx_retry(client, &prepared).await;
match result {
Ok(value) => Ok(FetchedCodexQuota {
account: prepared.account,
@@ -1087,6 +1211,110 @@ impl CodexCredentialQuotaService {
}
}
async fn list_reset_credits_once(
client: &CodexBackendClient,
prepared: &PreparedCodexRuntimeCredential,
request_id: &str,
) -> Result<CodexRateLimitResetCredits, ResetCreditAttemptError> {
let authorization = prepared
.credential
.authentication
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.list_rate_limit_reset_credits(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
request_id,
None,
))
.await
.map_err(ResetCreditAttemptError::Upstream)
}
async fn consume_reset_credit_once(
client: &CodexBackendClient,
prepared: &PreparedCodexRuntimeCredential,
request_id: &str,
credit_id: Option<&str>,
redeem_request_id: Uuid,
) -> Result<CodexRateLimitResetCreditsConsumeResult, ResetCreditAttemptError> {
let authorization = prepared
.credential
.authentication
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.consume_rate_limit_reset_credit(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
request_id,
None,
),
credit_id,
redeem_request_id,
)
.await
.map_err(ResetCreditAttemptError::Upstream)
}
fn map_reset_credit_attempt_error(
error: ResetCreditAttemptError,
consume: bool,
) -> CodexResetCreditsError {
match error {
ResetCreditAttemptError::InvalidCredential => CodexResetCreditsError::InvalidCredentialData,
ResetCreditAttemptError::Upstream(error) => map_reset_credit_client_error(error, consume),
}
}
fn map_reset_credit_client_error(error: CodexClientError, consume: bool) -> CodexResetCreditsError {
match error {
CodexClientError::Upstream {
status,
body,
diagnostics,
..
} if status == reqwest::StatusCode::UNAUTHORIZED
&& reset_credit_response_was_explicit_rejection(status, &diagnostics) =>
{
CodexResetCreditsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
diagnostics,
..
} => {
// 2xx 后发生的解码/响应体上限错误使用 synthetic 502 表示,但额度卡
// 可能已经消费。只有 transport 记录的真实非成功状态与错误状态一致时,
// 才能把它当作确定的上游拒绝并允许前端清除 pending 幂等键。
if consume && !reset_credit_response_was_explicit_rejection(status, &diagnostics) {
CodexResetCreditsError::ConsumeResultUnknown
} else {
CodexResetCreditsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
}
}
}
_ if consume => CodexResetCreditsError::ConsumeResultUnknown,
_ => CodexResetCreditsError::TransportUnavailable,
}
}
fn reset_credit_response_was_explicit_rejection(
status: reqwest::StatusCode,
diagnostics: &crate::transport::CodexUpstreamDiagnostics,
) -> bool {
!status.is_success() && diagnostics.status_code == Some(status.as_u16())
}
fn quota_refresh_allows_recovery(
account: &ProviderAccount,
refreshed: &CodexAccountQuotaSnapshot,
@@ -1132,7 +1360,7 @@ async fn fetch_usage_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| CodexQuotaFetchAttemptError::InvalidCredential)?;
let request_id = format!("quota_{}", Uuid::now_v7().simple());
client
@@ -5,15 +5,13 @@ use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
use thiserror::Error;
use super::agent_identity::{CodexAgentIdentityError, CodexAgentIdentitySecret};
use super::types::{
CodexAccountProfile, CodexAgentIdentityCredentialData, CodexCookie, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
CodexAccountProfile, CodexCookie, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
};
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_CREDENTIAL_BYTES: usize = 256 * 1024;
const MAX_TOKEN_BYTES: usize = 128 * 1024;
const MAX_COOKIES: usize = 128;
/// 已解析且只在 Provider 内可见的认证材料。
@@ -42,27 +40,15 @@ impl std::fmt::Debug for CodexRuntimeCredential {
pub enum CodexRuntimeAuthentication {
OAuth(CodexOAuthSecret),
AgentIdentity(Box<CodexAgentIdentitySecret>),
}
impl CodexRuntimeAuthentication {
#[must_use]
pub const fn is_agent_identity(&self) -> bool {
matches!(self, Self::AgentIdentity(_))
}
pub fn authorization_header(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> Result<SecretString, CodexCredentialDataError> {
pub fn authorization_header(&self) -> Result<SecretString, CodexCredentialDataError> {
match self {
Self::OAuth(secret) => Ok(SecretString::from(format!(
"Bearer {}",
secret.access_token.expose_secret()
))),
Self::AgentIdentity(secret) => secret
.authorization_header(now)
.map_err(|_| CodexCredentialDataError::Invalid),
}
}
@@ -70,7 +56,6 @@ impl CodexRuntimeAuthentication {
pub const fn oauth(&self) -> Option<&CodexOAuthSecret> {
match self {
Self::OAuth(secret) => Some(secret),
Self::AgentIdentity(_) => None,
}
}
}
@@ -79,7 +64,6 @@ impl std::fmt::Debug for CodexRuntimeAuthentication {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OAuth(secret) => secret.fmt(formatter),
Self::AgentIdentity(secret) => secret.fmt(formatter),
}
}
}
@@ -147,12 +131,6 @@ impl CodexCredentialCodec {
}))
}
pub fn encode_agent_identity(
data: CodexAgentIdentityCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::AgentIdentity(data))
}
pub fn encode_complete(
data: CodexCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
@@ -200,21 +178,6 @@ impl CodexCredentialCodec {
data.oauth_client_id,
data.oauth_scope,
),
CodexCredentialData::AgentIdentity(data) => (
CodexRuntimeAuthentication::AgentIdentity(Box::new(
CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id,
&data.agent_private_key,
data.task_id,
)
.map_err(map_agent_identity_error)?,
)),
None,
data.installation_id,
data.cookies,
None,
None,
),
};
Ok(CodexRuntimeCredential {
authentication,
@@ -257,21 +220,6 @@ impl CodexCredentialCodec {
(CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => {
incoming.installation_id = existing.installation_id;
}
(
CodexCredentialData::AgentIdentity(incoming),
CodexCredentialData::AgentIdentity(existing),
) => {
if incoming.agent_runtime_id != existing.agent_runtime_id {
return Err(CodexCredentialDataError::Invalid);
}
incoming.installation_id = existing.installation_id;
if incoming.task_id.is_none()
&& incoming.agent_private_key == existing.agent_private_key
{
incoming.task_id = existing.task_id;
}
}
_ => return Err(CodexCredentialDataError::Invalid),
}
Self::encode_complete(incoming)
}
@@ -285,25 +233,6 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
}
(&data.installation_id, &data.cookies)
}
CodexCredentialData::AgentIdentity(data) => {
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION
|| !valid_identity(&data.agent_runtime_id)
|| !valid_secret(&data.agent_private_key)
|| data
.task_id
.as_deref()
.is_some_and(|value| !valid_identity(value))
|| CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id.clone(),
&data.agent_private_key,
data.task_id.clone(),
)
.is_err()
{
return Err(CodexCredentialDataError::Invalid);
}
(&data.installation_id, &data.cookies)
}
};
if !valid_installation_id(installation_id)
|| cookies.len() > MAX_COOKIES
@@ -323,20 +252,8 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
Ok(())
}
const fn map_agent_identity_error(_: CodexAgentIdentityError) -> CodexCredentialDataError {
CodexCredentialDataError::Invalid
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
fn valid_installation_id(value: &str) -> bool {
uuid::Uuid::parse_str(value)
.ok()
.is_some_and(|uuid| uuid.get_version_num() == 4)
}
fn valid_secret(value: &str) -> bool {
!value.is_empty() && value.len() <= MAX_TOKEN_BYTES && !value.chars().any(char::is_control)
}
@@ -22,7 +22,7 @@ use secrecy::ExposeSecret;
use thiserror::Error;
use url::Url;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::affinity::CodexSessionAffinity;
use super::catalog::CodexCredentialCatalogService;
use super::cookie::CodexCookiePolicy;
use super::quota::CodexCredentialQuotaService;
@@ -30,8 +30,7 @@ use super::refresh::refresh_recovery_deadline;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::CodexRuntimeAuthentication;
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie,
CodexCookieCaptureOutcome, RuntimeCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie, CodexCookieCaptureOutcome, RuntimeCodexCookie,
};
const CLOUDFLARE_RECOVERY_STALE_AFTER: Duration = Duration::from_secs(60 * 60);
@@ -95,6 +94,18 @@ pub struct SelectCodexCredential<'a> {
pub session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
}
pub(crate) struct SelectCodexProviderEndpointCredential<'a> {
pub request_url: &'a Url,
pub attempt: &'a AttemptContext,
}
struct CredentialSelectionInput<'a> {
request_url: &'a Url,
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
session_affinity_observation: Option<&'a CodexSessionAffinity>,
}
#[derive(Clone)]
pub(crate) struct CodexCyberPolicyScope {
key: ProviderSessionAffinityKey,
@@ -109,7 +120,6 @@ pub struct CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cookie_policy: CodexCookiePolicy,
risk_recovery: Mutex<HashMap<String, RiskRecoveryState>>,
account_feedback: Arc<AccountFeedbackStats>,
@@ -240,6 +250,12 @@ struct AffinityTelemetry {
account_switch: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelCatalogEligibility<'a> {
Required(&'a str),
NotApplicable,
}
impl CodexCredentialSelector {
#[must_use]
// 选择器显式持有各能力边界,避免把 Provider 私有服务重新包装成通用容器。
@@ -252,7 +268,6 @@ impl CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
cookie_policy: CodexCookiePolicy,
) -> Self {
@@ -264,7 +279,6 @@ impl CodexCredentialSelector {
session_exclusions,
catalog,
quota,
agent_identity,
cookie_policy,
risk_recovery: Mutex::new(HashMap::new()),
account_feedback,
@@ -275,13 +289,63 @@ impl CodexCredentialSelector {
&self,
request: &SelectCodexCredential<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
self.select_with_cyber_policy(request, None).await
let input = CredentialSelectionInput {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation: None,
};
self.select_inner(
&input,
None,
ModelCatalogEligibility::Required(request.upstream_model),
)
.await
}
pub(crate) async fn select_with_cyber_policy(
&self,
request: &SelectCodexCredential<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
session_affinity_observation: Option<&CodexSessionAffinity>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation,
};
self.select_inner(
&input,
cyber_policy_session_key,
ModelCatalogEligibility::Required(request.upstream_model),
)
.await
}
/// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号。
///
/// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路;
/// 唯一差异是不能用文本模型目录成员关系淘汰账号。
pub(crate) async fn select_for_provider_endpoint(
&self,
request: &SelectCodexProviderEndpointCredential<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: None,
session_affinity_observation: None,
};
self.select_inner(&input, None, ModelCatalogEligibility::NotApplicable)
.await
}
async fn select_inner(
&self,
request: &CredentialSelectionInput<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
model_catalog_eligibility: ModelCatalogEligibility<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let diagnostic = request.attempt.is_diagnostic_required_account();
let accounts = self.repository.list_for_provider().await?;
@@ -294,12 +358,15 @@ impl CodexCredentialSelector {
.attempt
.account_scope()
.is_some_and(|scope| scope.allows(account.id())))
&& (diagnostic || {
let observed_support = self
.catalog
.observed_model_support(account, request.upstream_model);
matches!(observed_support, Ok(None | Some(true)))
})
&& (diagnostic
|| match model_catalog_eligibility {
ModelCatalogEligibility::NotApplicable => true,
ModelCatalogEligibility::Required(upstream_model) => {
let observed_support =
self.catalog.observed_model_support(account, upstream_model);
matches!(observed_support, Ok(None | Some(true)))
}
})
})
.collect::<Vec<_>>();
if !diagnostic {
@@ -410,14 +477,16 @@ impl CodexCredentialSelector {
{
affinity.escape(AffinityEscapeReason::PinnedAccount);
}
let preferred = pinned_account
.clone()
.or_else(|| affinity.preferred_account().cloned());
let mut observed_affinity_account = affinity.bound_account().cloned();
let mut shortest_retry = None;
let policy = request.attempt.account_selection_policy();
loop {
let preferred = pinned_account
.clone()
.or_else(|| affinity.preferred_account().cloned());
let context = AccountSelectionContext {
policy: request.attempt.account_selection_policy(),
policy,
now: SystemTime::now(),
excluded_accounts: excluded.clone(),
preferred_account: preferred.clone(),
@@ -445,7 +514,6 @@ impl CodexCredentialSelector {
.map(|candidate| candidate.account.clone())
.ok_or(CredentialSelectionError::InvalidCredential)?;
let allows_account_state_mutation = !diagnostic || account.enabled();
let policy = request.attempt.account_selection_policy();
match self
.leases
.try_acquire(ProviderLeaseRequest::Scheduling(
@@ -466,7 +534,42 @@ impl CodexCredentialSelector {
excluded.insert(account.id().clone());
}
ProviderLeaseAcquisition::Acquired(guard) => {
let initial_affinity_claim = if !diagnostic
&& observed_affinity_account.is_none()
&& let Some(key) = request.session_affinity_key
{
self.claim_initial_session_affinity(key, account.id()).await
} else {
None
};
// 原生 continuation/required account 比根亲和绑定更严格;它可以
// 使用 owner 账号,但不能把已经迁移的根会话拉回旧号。
if pinned_account.is_none()
&& let Some(effective_account) = initial_affinity_claim
&& &effective_account != account.id()
{
drop(guard);
observed_affinity_account = Some(effective_account.clone());
affinity = affinity_selection_for_bound_account(
effective_account,
&candidates,
SystemTime::now(),
);
continue;
}
let affinity_expected_account_id = if pinned_account
.as_ref()
.zip(observed_affinity_account.as_ref())
.is_some_and(|(pinned, bound)| pinned != bound)
{
account.id().clone()
} else {
observed_affinity_account
.clone()
.unwrap_or_else(|| account.id().clone())
};
let affinity_telemetry = affinity.telemetry(account.id());
let affinity_observation = request.session_affinity_observation;
tracing::info!(
request_id = %request.attempt.request_id(),
attempt_index = request.attempt.attempt_index().get(),
@@ -477,22 +580,20 @@ impl CodexCredentialSelector {
.escape_reason
.map_or("", AffinityEscapeReason::as_str),
account_switch = affinity_telemetry.account_switch,
affinity_key_hash = affinity_observation
.map_or("", CodexSessionAffinity::key_hash),
affinity_anchor_source = affinity_observation
.map_or("", CodexSessionAffinity::anchor_source),
affinity_anchor = affinity_observation
.map_or("", CodexSessionAffinity::anchor),
session_id = affinity_observation
.and_then(CodexSessionAffinity::session_id)
.unwrap_or(""),
session_id_present = affinity_observation
.is_some_and(CodexSessionAffinity::session_id_present),
"OpenAI account selected"
);
let (account, runtime) = if account.authentication_kind()
== CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
{
let prepared = if allows_account_state_mutation {
self.agent_identity.prepare(&account).await
} else {
self.agent_identity.load_current(account.id()).await
}
.map_err(|_| CredentialSelectionError::InvalidCredential)?;
(prepared.account, prepared.credential)
} else {
let runtime = self.repository.load_runtime_credential(&account).await?;
(account, runtime)
};
let runtime = self.repository.load_runtime_credential(&account).await?;
let cookies = runtime
.cookies
.into_iter()
@@ -517,6 +618,7 @@ impl CodexCredentialSelector {
cyber_policy_scope,
allows_account_state_mutation,
affinity_telemetry,
affinity_expected_account_id,
_guard: guard,
});
}
@@ -537,24 +639,7 @@ impl CodexCredentialSelector {
else {
return AffinitySelection::default();
};
let Some(candidate) = candidates
.iter()
.find(|candidate| candidate.account.id() == &account_id)
else {
self.clear_session_affinity(key).await;
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
};
if candidate
.account
.status_projection(now, candidate.signals.rate_limited_until)
.status
== AccountStatus::Normal
{
return AffinitySelection::preferred(account_id);
}
let reason = affinity_unavailable_reason(candidate, now);
self.clear_session_affinity(key).await;
AffinitySelection::escaped(account_id, reason)
affinity_selection_for_bound_account(account_id, candidates, now)
}
async fn lookup_session_affinity(
@@ -583,22 +668,38 @@ impl CodexCredentialSelector {
}
}
async fn clear_session_affinity(&self, key: &ProviderSessionAffinityKey) {
async fn claim_initial_session_affinity(
&self,
key: &ProviderSessionAffinityKey,
selected_account_id: &ProviderAccountId,
) -> Option<ProviderAccountId> {
match tokio::time::timeout(
SESSION_AFFINITY_TIMEOUT,
self.session_affinity.clear(&self.provider_kind, key),
self.session_affinity.claim_or_load(
&self.provider_kind,
key,
selected_account_id,
SESSION_AFFINITY_TTL,
),
)
.await
{
Ok(Ok(_)) => {}
Ok(Ok(account_id)) => Some(account_id),
Ok(Err(error)) => {
tracing::warn!(error = %error, "OpenAI stale session affinity clear failed open");
tracing::warn!(
account_id = %selected_account_id,
error = %error,
"OpenAI initial session affinity claim failed open"
);
None
}
Err(_) => {
tracing::warn!(
account_id = %selected_account_id,
timeout_ms = SESSION_AFFINITY_TIMEOUT.as_millis(),
"OpenAI stale session affinity clear timed out"
"OpenAI initial session affinity claim timed out"
);
None
}
}
}
@@ -835,6 +936,7 @@ impl CodexCredentialSelector {
&self,
account: &ProviderAccount,
session_affinity_key: Option<&ProviderSessionAffinityKey>,
expected_affinity_account_id: &ProviderAccountId,
) {
self.restore_recoverable_account_state(account).await;
self.risk_recovery
@@ -844,24 +946,26 @@ impl CodexCredentialSelector {
let Some(key) = session_affinity_key else {
return;
};
if matches!(
self.lookup_session_affinity(key).await,
SessionAffinityLookup::Unavailable
) {
return;
}
match tokio::time::timeout(
SESSION_AFFINITY_TIMEOUT,
self.session_affinity.bind(
self.session_affinity.compare_and_bind(
&self.provider_kind,
key,
expected_affinity_account_id,
account.id(),
SESSION_AFFINITY_TTL,
),
)
.await
{
Ok(Ok(())) => {}
Ok(Ok(effective_account)) if &effective_account == account.id() => {}
Ok(Ok(effective_account)) => {
tracing::debug!(
account_id = %account.id(),
effective_account_id = %effective_account,
"OpenAI late success left the newer session affinity binding unchanged"
);
}
Ok(Err(error)) => {
tracing::warn!(
account_id = %account.id(),
@@ -1038,6 +1142,32 @@ impl CodexCredentialSelector {
}
}
fn affinity_selection_for_bound_account(
account_id: ProviderAccountId,
candidates: &[AccountCandidate],
now: SystemTime,
) -> AffinitySelection {
let Some(candidate) = candidates
.iter()
.find(|candidate| candidate.account.id() == &account_id)
else {
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
};
match candidate
.account
.status_projection(now, candidate.signals.rate_limited_until)
.status
{
AccountStatus::Normal => AffinitySelection::preferred(account_id),
AccountStatus::QuotaExhausted
| AccountStatus::RateLimited
| AccountStatus::Disabled
| AccountStatus::Error => {
AffinitySelection::escaped(account_id, affinity_unavailable_reason(candidate, now))
}
}
}
fn affinity_unavailable_reason(
candidate: &AccountCandidate,
now: SystemTime,
@@ -1093,6 +1223,7 @@ pub struct CodexCredentialLease {
cyber_policy_scope: Option<CodexCyberPolicyScope>,
allows_account_state_mutation: bool,
affinity_telemetry: AffinityTelemetry,
affinity_expected_account_id: ProviderAccountId,
_guard: Box<dyn ProviderLeaseGuard>,
}
@@ -1150,6 +1281,12 @@ impl CodexCredentialLease {
pub const fn account_switch(&self) -> bool {
self.affinity_telemetry.account_switch
}
/// 成功反馈只能从选择时观察到的绑定迁移,避免迟到请求覆盖较新 winner。
#[must_use]
pub(crate) const fn affinity_expected_account_id(&self) -> &ProviderAccountId {
&self.affinity_expected_account_id
}
}
impl fmt::Debug for CodexCredentialLease {
@@ -280,7 +280,6 @@ impl fmt::Debug for CodexCookie {
}
pub const CODEX_AUTHENTICATION_KIND_OAUTH: &str = "oauth";
pub const CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY: &str = "agent_identity";
/// Codex OAuth 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
@@ -323,49 +322,11 @@ impl fmt::Debug for CodexOAuthCredentialData {
}
}
/// Agent Identity 文档的固定认证模式。
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CodexAgentIdentityAuthMode {
#[serde(rename = "agentIdentity")]
AgentIdentity,
}
/// OpenAI Agent Identity 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CodexAgentIdentityCredentialData {
pub schema_version: u32,
pub auth_mode: CodexAgentIdentityAuthMode,
pub installation_id: String,
pub agent_runtime_id: String,
pub agent_private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default)]
pub cookies: Vec<CodexCookie>,
}
impl fmt::Debug for CodexAgentIdentityCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityCredentialData")
.field("schema_version", &self.schema_version)
.field("auth_mode", &self.auth_mode)
.field("installation_id", &"<pseudonymous>")
.field("agent_runtime_id", &"<redacted>")
.field("agent_private_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.field("cookies", &self.cookies)
.finish()
}
}
/// OpenAI Provider 支持的两种规范化凭据形态。
/// OpenAI Provider 的规范化 OAuth 凭据形态。
#[derive(Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CodexCredentialData {
OAuth(CodexOAuthCredentialData),
AgentIdentity(CodexAgentIdentityCredentialData),
}
impl CodexCredentialData {
@@ -373,7 +334,6 @@ impl CodexCredentialData {
pub const fn authentication_kind(&self) -> &'static str {
match self {
Self::OAuth(_) => CODEX_AUTHENTICATION_KIND_OAUTH,
Self::AgentIdentity(_) => CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY,
}
}
@@ -381,14 +341,12 @@ impl CodexCredentialData {
pub fn installation_id(&self) -> &str {
match self {
Self::OAuth(data) => &data.installation_id,
Self::AgentIdentity(data) => &data.installation_id,
}
}
pub fn set_installation_id(&mut self, installation_id: String) {
match self {
Self::OAuth(data) => data.installation_id = installation_id,
Self::AgentIdentity(data) => data.installation_id = installation_id,
}
}
@@ -396,14 +354,12 @@ impl CodexCredentialData {
pub fn cookies(&self) -> &[CodexCookie] {
match self {
Self::OAuth(data) => &data.cookies,
Self::AgentIdentity(data) => &data.cookies,
}
}
pub fn cookies_mut(&mut self) -> &mut Vec<CodexCookie> {
match self {
Self::OAuth(data) => &mut data.cookies,
Self::AgentIdentity(data) => &mut data.cookies,
}
}
@@ -411,29 +367,12 @@ impl CodexCredentialData {
pub fn oauth(&self) -> Option<&CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
pub fn oauth_mut(&mut self) -> Option<&mut CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
#[must_use]
pub fn agent_identity(&self) -> Option<&CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
pub fn agent_identity_mut(&mut self) -> Option<&mut CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
@@ -448,7 +387,6 @@ impl fmt::Debug for CodexCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth(data) => data.fmt(formatter),
Self::AgentIdentity(data) => data.fmt(formatter),
}
}
}
+10 -17
View File
@@ -16,10 +16,10 @@ use gateway_core::task::WorkerContribution;
use crate::admin::{OpenAiAdminProvider, OpenAiAdminServices, OpenAiOAuthPendingStore};
use crate::credential::token_client::{AuthorizationCodeExchanger, TokenRefresher};
use crate::credential::{
CodexAgentIdentityTaskService, CodexCookiePolicy, CodexCredentialAdmin,
CodexCredentialAdminService, CodexCredentialCatalogService, CodexCredentialQuotaService,
CodexCookiePolicy, CodexCredentialAdmin, CodexCredentialAdminService,
CodexCredentialCatalogService, CodexCredentialProfileService, CodexCredentialQuotaService,
CodexCredentialRefreshService, CodexCredentialRepository, CodexCredentialSelector,
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
CodexOAuthAdmin, CodexOAuthAdminService,
};
use crate::transport::profile::{
CodexArtifactProfileCache, CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport,
@@ -97,20 +97,11 @@ pub async fn initialize(
let websocket_pool = Arc::new(CodexWebSocketPool::with_config(
config.websocket_pool_config(),
));
let agent_identity = Arc::new(CodexAgentIdentityTaskService::new(
repository.clone(),
Arc::new(
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone(), profile.clone())
.map_err(|_| OpenAiInitializeError::AgentIdentity)?,
),
Arc::clone(&websocket_pool),
));
let catalog = Arc::new(CodexCredentialCatalogService::new(
repository.clone(),
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -118,9 +109,14 @@ pub async fn initialize(
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.cooldowns(),
));
let profile_statistics = Arc::new(CodexCredentialProfileService::new(
repository.clone(),
profile.clone(),
http.clone(),
config.base_url().to_owned(),
));
let selector = Arc::new(CodexCredentialSelector::new(
provider_kind.clone(),
repository.clone(),
@@ -129,7 +125,6 @@ pub async fn initialize(
session_exclusions,
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?,
));
@@ -138,7 +133,6 @@ pub async fn initialize(
selector,
Arc::clone(&catalog),
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile.clone(),
@@ -187,6 +181,7 @@ pub async fn initialize(
OpenAiAdminServices {
credentials: credential_admin,
oauth: oauth_admin,
profile_statistics,
quota: Arc::clone(&quota),
catalog: Arc::clone(&catalog),
},
@@ -246,8 +241,6 @@ pub enum OpenAiInitializeError {
CookiePolicy,
#[error("OpenAI token client could not initialize")]
TokenClient,
#[error("OpenAI Agent Identity service could not initialize")]
AgentIdentity,
#[error("OpenAI credential administration could not initialize")]
CredentialAdmin,
#[error("OpenAI credential refresh could not initialize")]
+309 -91
View File
@@ -23,12 +23,17 @@ use gateway_core::error::{
OpaqueUpstreamValue, ProviderError, ProviderErrorKind,
};
use gateway_core::event::{
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
ProviderResponseObservation, ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader,
ProviderResponseMetadata, ProviderResponseObservation, ProviderResponseTimings, ResponseMeta,
UpstreamHttpVersion, WebSocketPoolKind,
};
use gateway_core::operation::{
GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProviderSessionState,
};
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
use gateway_core::provider_ports::ProviderSessionAffinityKey;
use gateway_core::routing::{ModelCapabilities, ModelPresentation, ProviderKind, UpstreamModelId};
use gateway_core::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderKind, UpstreamModelId,
};
use gateway_core::task::{
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
@@ -44,12 +49,12 @@ use serde_json::{Map, Value, json};
use url::Url;
use crate::credential::{
CodexAccountFailure, CodexAgentIdentityTaskService, CodexCredentialCatalogError,
CodexCredentialCatalogService, CodexCredentialLease, CodexCredentialQuotaService,
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, CodexCredentialSelector,
CodexCyberPolicyScope, CodexQuotaRefreshPolicy, CredentialSelectionError, RuntimeCodexCookie,
SelectCodexCredential, derive_codex_cyber_policy_session_key,
derive_codex_session_affinity_key,
CodexAccountFailure, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialLease, CodexCredentialQuotaService, CodexCredentialRefreshOutcome,
CodexCredentialRefreshService, CodexCredentialSelector, CodexCyberPolicyScope,
CodexQuotaRefreshPolicy, CodexSessionAffinity, CredentialSelectionError, RuntimeCodexCookie,
SelectCodexCredential, SelectCodexProviderEndpointCredential,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
};
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
@@ -66,6 +71,9 @@ use crate::transport::profile::{
use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
};
use crate::transport::protocol::websocket::{
PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
};
use crate::transport::request::{
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
};
@@ -73,7 +81,8 @@ use crate::transport::session::CodexSessionIdentity;
use crate::transport::usage::normalize_service_tier;
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
use crate::transport::{
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendJsonResponse,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
@@ -81,6 +90,7 @@ use crate::transport::{
const PROVIDER_NAME: &str = "openai";
const HTTP_SSE_TRANSPORT: &str = "http_sse";
const HTTP_JSON_TRANSPORT: &str = "http_json";
const WEBSOCKET_TRANSPORT: &str = "websocket";
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
/// 提交边界前最多保留 64 KiB 原始上游 chunk;达到阈值后结束无感换号窗口,
@@ -111,10 +121,11 @@ pub struct CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
responses_url: Url,
image_generations_url: Url,
image_edits_url: Url,
session_identity: Option<CodexSessionIdentity>,
}
@@ -125,7 +136,6 @@ impl CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
http: Client,
profile: CodexWireProfileState,
@@ -134,16 +144,22 @@ impl CodexProvider {
) -> Result<Self, CodexProviderConfigError> {
let responses_url = Url::parse(&endpoint_url(&base_url, CODEX_RESPONSES_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let image_generations_url =
Url::parse(&endpoint_url(&base_url, CODEX_IMAGE_GENERATIONS_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let image_edits_url = Url::parse(&endpoint_url(&base_url, CODEX_IMAGE_EDITS_PATH))
.map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?;
let client =
CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool);
Ok(Self {
selector,
catalog,
quota,
agent_identity,
account_feedback,
client,
responses_url,
image_generations_url,
image_edits_url,
session_identity: None,
})
}
@@ -231,17 +247,25 @@ impl Provider for CodexProvider {
UpstreamSendState::NotSent,
));
}
if let Operation::GenerateImage(image) = request.operation() {
return self.execute_image(image, candidate, context).await;
}
let Operation::Generate(generate) = request.operation() else {
return Err(provider_error(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
));
};
let Some(upstream_model) = candidate.upstream_model() else {
return Err(provider_error(
ProviderErrorKind::Protocol,
UpstreamSendState::NotSent,
));
};
let previous_session = decode_openai_session_state(generate);
let continuation_requested = generate.native_continuation_requested();
let mut upstream_request =
encode_generate_request(generate, candidate.upstream_model().as_str())
.map_err(map_request_error)?;
let mut upstream_request = encode_generate_request(generate, upstream_model.as_str())
.map_err(map_request_error)?;
if let Some(conversation_id) = previous_session
.as_ref()
.and_then(|state| state.conversation_id.as_ref())
@@ -261,8 +285,8 @@ impl Provider for CodexProvider {
.as_ref()
.and_then(|state| state.turn_state.clone());
}
let session_affinity_key =
derive_codex_session_affinity_key(&upstream_request, context.client_api_key_ref());
let session_affinity =
derive_codex_session_affinity(&upstream_request, context.client_api_key_ref());
let cyber_policy_session_key =
derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref());
let transport = selected_transport(&upstream_request);
@@ -272,12 +296,13 @@ impl Provider for CodexProvider {
.selector
.select_with_cyber_policy(
&SelectCodexCredential {
upstream_model: candidate.upstream_model().as_str(),
upstream_model: upstream_model.as_str(),
request_url: &self.responses_url,
attempt: &context,
session_affinity_key: session_affinity_key.as_ref(),
session_affinity_key: session_affinity.as_ref().map(|affinity| affinity.key()),
},
cyber_policy_session_key.as_ref(),
session_affinity.as_ref(),
)
.await
.map_err(map_selection_error)?;
@@ -294,8 +319,14 @@ impl Provider for CodexProvider {
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let has_explicit_state_owner =
context.account_state_owner().is_some() || previous_session.is_some();
let cross_account =
state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch());
let account_scope =
if state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch()) {
RequestAccountScope::Different
} else if has_explicit_state_owner || lease.affinity_hit() {
RequestAccountScope::Same
} else {
RequestAccountScope::Unknown
};
if context.continuation_attempt() != ContinuationAttempt::None
&& let Some(continuation) = context.continuation()
{
@@ -342,13 +373,11 @@ impl Provider for CodexProvider {
}
}
}
let account_scope = if !cross_account {
RequestAccountScope::SameAccount
} else if upstream_request.previous_response_id().is_some() {
RequestAccountScope::CrossAccountContinuationProbe
} else {
RequestAccountScope::CrossAccountFullRequest
};
if upstream_request.previous_response_id().is_some()
&& !account_scope.can_reuse_account_state()
{
return Err(continuation_replay_required_error());
}
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
@@ -356,7 +385,7 @@ impl Provider for CodexProvider {
);
let metadata = ProviderCallMetadata::new(
provider_kind,
candidate.upstream_model().clone(),
upstream_model.clone(),
ProviderResource::Account {
id: lease.account_id().clone(),
revision: lease.account().revision(),
@@ -376,17 +405,17 @@ impl Provider for CodexProvider {
continuation_scope: None,
});
let allows_account_state_mutation = lease.allows_account_state_mutation();
let session_affinity_key = session_affinity.map(CodexSessionAffinity::into_key);
let events = cold_response_stream(ColdResponse {
client: self.client.clone(),
response_origin: self.responses_url.clone(),
request: upstream_request,
upstream_model: candidate.upstream_model().clone(),
upstream_model: upstream_model.clone(),
transport_policy: transport,
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
catalog: Arc::clone(&self.catalog),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at,
session_affinity_key,
@@ -401,6 +430,75 @@ impl Provider for CodexProvider {
}
}
impl CodexProvider {
async fn execute_image(
&self,
image: &ImageRequest,
candidate: &ProviderCandidate,
context: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
if image.payload().protocol() != PROVIDER_NAME || candidate.upstream_model().is_some() {
return Err(provider_error(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
));
}
let (endpoint_path, response_origin) = match image.kind() {
ImageRequestKind::Generation => (
CODEX_IMAGE_GENERATIONS_PATH,
self.image_generations_url.clone(),
),
ImageRequestKind::Edit => (CODEX_IMAGE_EDITS_PATH, self.image_edits_url.clone()),
};
let lease = self
.selector
.select_for_provider_endpoint(&SelectCodexProviderEndpointCredential {
request_url: &response_origin,
attempt: &context,
})
.await
.map_err(map_selection_error)?;
let lease = Arc::new(lease);
let allows_account_state_mutation = lease.allows_account_state_mutation();
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let metadata = ProviderCallMetadata::for_provider_endpoint(
provider_kind,
ProviderResource::Account {
id: lease.account_id().clone(),
revision: lease.account().revision(),
},
UpstreamTransport::new(HTTP_JSON_TRANSPORT).map_err(|_| {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent)
})?,
);
let image_turn_id = image
.payload()
.context()
.get("image_turn_id")
.and_then(Value::as_str)
.map(str::to_owned);
let events = cold_json_response_stream(ColdJsonResponse {
client: self.client.clone(),
response_origin,
endpoint_path,
body: image.payload().body().clone(),
image_turn_id,
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
lease: Arc::clone(&lease),
output_started_at: Instant::now(),
});
let stream = ProviderStream::new(metadata, events, lease);
Ok(if allows_account_state_mutation {
stream.with_account_feedback(Arc::clone(&self.account_feedback))
} else {
stream
})
}
}
struct ColdResponse {
client: CodexBackendClient,
response_origin: Url,
@@ -411,13 +509,25 @@ struct ColdResponse {
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
session_affinity_key: Option<ProviderSessionAffinityKey>,
session_capture: Option<OpenAiSessionCapture>,
}
struct ColdJsonResponse {
client: CodexBackendClient,
response_origin: Url,
endpoint_path: &'static str,
body: Bytes,
image_turn_id: Option<String>,
context: AttemptContext,
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
}
#[derive(Clone, Serialize, Deserialize)]
struct OpenAiSessionState {
account_id: String,
@@ -526,7 +636,6 @@ enum CodexHandshakeAttemptError {
Client(CodexClientError),
Cancelled,
Timeout,
AgentIdentityRecovery,
}
async fn create_response_attempt(
@@ -563,12 +672,143 @@ fn map_handshake_attempt_error(error: CodexHandshakeAttemptError) -> MappedProvi
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
)),
CodexHandshakeAttemptError::AgentIdentityRecovery => MappedProviderFailure::plain(
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::Sent),
),
}
}
async fn create_json_attempt(
request: &ColdJsonResponse,
account: &ProviderAccount,
installation_id: &str,
authorization: &SecretString,
cookie_header: Option<&SecretString>,
account_selection: CodexAccountSelectionTelemetry<'_>,
) -> Result<CodexBackendJsonResponse, CodexHandshakeAttemptError> {
let Some(handshake_deadline) = remaining(request.context.deadline()) else {
return Err(CodexHandshakeAttemptError::Timeout);
};
let request_id = request.context.request_id().as_str();
let mut request_context = CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
request_id,
Some(installation_id),
);
request_context.cookie_header = cookie_header.map(ExposeSecret::expose_secret);
request_context.account_selection = account_selection;
tokio::select! {
biased;
_ = request.context.cancellation().cancelled() => Err(CodexHandshakeAttemptError::Cancelled),
_ = tokio::time::sleep(handshake_deadline) => Err(CodexHandshakeAttemptError::Timeout),
response = request.client.post_raw_json(
request.endpoint_path,
request.body.clone(),
request.image_turn_id.as_deref(),
request_context,
) => response.map_err(CodexHandshakeAttemptError::Client),
}
}
fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
Box::pin(async_stream::try_stream! {
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
let failure_context = OpenAiFailureContext {
client: &request.client,
selector: &request.selector,
quota: &request.quota,
response_origin: &request.response_origin,
cyber_policy_scope: None,
allows_account_state_mutation,
};
let active_account = request.lease.account().clone();
let cookie_header = build_cookie_header(request.lease.cookies())?;
let authorization = request
.lease
.authentication()
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
let account_selection = CodexAccountSelectionTelemetry::new(
request.lease.affinity_hit(),
request.lease.escape_reason(),
request.lease.account_switch(),
);
let response = create_json_attempt(
&request,
&active_account,
request.lease.installation_id(),
&authorization,
cookie_header.as_ref(),
account_selection,
)
.await;
let response = match response.map_err(map_handshake_attempt_error) {
Ok(response) => response,
Err(mut failure) => {
if let Some(observation) = failure.observation.take() {
yield ProviderEvent::observation(observation);
}
apply_failure(&failure_context, &active_account, &failure).await;
Err(failure.error)?;
return;
}
};
let mut metrics = response.transport_metrics.clone();
metrics.first_event_ms = Some(
i64::try_from(request.output_started_at.elapsed().as_millis()).unwrap_or(i64::MAX),
);
if let Some(observation) = codex_response_observation(
CodexBackendTransport::HttpJson,
&response.diagnostics,
&response.response_metadata,
&metrics,
None,
openai_response_timings(&metrics, &response.response_metadata),
) {
yield ProviderEvent::observation(observation);
}
if allows_account_state_mutation {
synchronize_passive_quota_headers(
&request.quota,
&active_account,
&response.rate_limit_headers,
)
.await;
if !response.set_cookie_headers.is_empty()
&& let Err(error) = request
.selector
.capture_response_cookies(
&active_account,
&request.response_origin,
&response.set_cookie_headers,
)
.await
{
tracing::warn!(
account_id = %active_account.id(),
error = %error,
"Failed to persist OpenAI image response cookies"
);
}
}
let response_meta =
ResponseMeta::for_provider_endpoint(request.context.request_id().as_str());
yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone()));
let wire = ProtocolWireEvent::raw_json(PROVIDER_NAME, response.body).map_err(|_| {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent)
})?;
yield ProviderEvent::wire(wire);
yield ProviderEvent::canonical(GatewayEvent::Completed(
response_meta.with_finish_reason(FinishReason::Stop),
));
})
}
fn cold_response_stream(response: ColdResponse) -> EventStream {
let ColdResponse {
client,
@@ -580,7 +820,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
selector,
quota,
catalog,
agent_identity,
lease,
output_started_at,
session_affinity_key,
@@ -599,9 +838,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
};
let mut active_account = lease.account().clone();
let cookie_header = build_cookie_header(lease.cookies())?;
let mut authorization = lease
let authorization = lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -615,7 +854,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
lease.escape_reason(),
lease.account_switch(),
);
let mut response = create_response_attempt(
let response = create_response_attempt(
&client,
&request,
codex_request_context(
@@ -632,51 +871,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
&cancellation,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match agent_identity
.recover_after_rejected_task(
active_account.id(),
lease.authentication(),
error,
)
.await
{
Ok(Some(recovered)) => {
authorization = recovered
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
active_account = recovered.account;
response = create_response_attempt(
&client,
&request,
codex_request_context(
&request,
&request_id,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
),
active_account.id().as_str(),
context.deadline(),
&cancellation,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = response.map_err(map_handshake_attempt_error);
let response = match response {
Ok(response) => response,
@@ -898,7 +1092,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
// 完成事件一旦交给下游,Core 可以立刻停止轮询 Provider stream;
// 在此之前持久化亲和关系,保证成功请求不会因流被提前 drop 而丢失绑定。
selector
.record_success(&active_account, session_affinity_key.as_ref())
.record_success(
&active_account,
session_affinity_key.as_ref(),
lease.affinity_expected_account_id(),
)
.await;
selector
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
@@ -1002,7 +1200,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if allows_account_state_mutation && completed && terminal_failure.is_none() {
// 同上:尾部 finish() 也可能产出 completed,亲和记录必须先于任何下游 yield。
selector
.record_success(&active_account, session_affinity_key.as_ref())
.record_success(
&active_account,
session_affinity_key.as_ref(),
lease.affinity_expected_account_id(),
)
.await;
selector
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
@@ -1603,6 +1805,7 @@ async fn synchronize_passive_quota_headers(
const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
match transport {
CodexBackendTransport::HttpSse => HTTP_SSE_TRANSPORT,
CodexBackendTransport::HttpJson => HTTP_JSON_TRANSPORT,
CodexBackendTransport::WebSocket => WEBSOCKET_TRANSPORT,
}
}
@@ -2083,6 +2286,19 @@ fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
map_client_error(error, UpstreamSendState::Ambiguous, true)
}
fn continuation_replay_required_error() -> ProviderError {
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
Some(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned()),
Some("invalid_request_error".to_owned()),
))
}
fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
let allows_pre_delivery_retry = stream_transport_allows_pre_delivery_retry(&error);
let mut failure = map_client_error(error, UpstreamSendState::Sent, false);
@@ -2094,7 +2310,9 @@ fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
fn stream_transport_allows_pre_delivery_retry(error: &CodexClientError) -> bool {
match error {
CodexClientError::Http(_) | CodexClientError::StreamIdleTimeout { .. } => true,
CodexClientError::Http(_)
| CodexClientError::HttpJson(_)
| CodexClientError::StreamIdleTimeout { .. } => true,
CodexClientError::WebSocket(error) => matches!(
error,
CodexWebSocketExchangeError::Transport(_)
@@ -2175,7 +2393,7 @@ fn map_client_error(
ProviderErrorKind::Protocol,
UpstreamSendState::Sent,
)),
CodexClientError::Http(error) => {
CodexClientError::Http(error) | CodexClientError::HttpJson(error) => {
let send_state = if error.is_connect() {
UpstreamSendState::NotSent
} else {
@@ -159,6 +159,9 @@ pub enum CodexClientError {
/// Reqwest 传输失败。
#[error("http transport error: {0}")]
Http(#[from] reqwest::Error),
/// 非流式 JSON 请求的 Reqwest 传输失败。
#[error("HTTP JSON transport error: {0}")]
HttpJson(#[source] reqwest::Error),
/// 自定义 CA 构建失败。
#[error("custom CA transport error: {0}")]
CustomCa(#[from] CustomCaError),
@@ -222,6 +225,7 @@ impl fmt::Debug for CodexClientError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(_) => formatter.write_str("CodexClientError::Http([REDACTED])"),
Self::HttpJson(_) => formatter.write_str("CodexClientError::HttpJson([REDACTED])"),
Self::CustomCa(_) => formatter.write_str("CodexClientError::CustomCa([REDACTED])"),
Self::InvalidHeaderName(_) => {
formatter.write_str("CodexClientError::InvalidHeaderName([REDACTED])")
@@ -274,6 +278,7 @@ impl CodexClientError {
| Self::StreamIdleTimeout { .. }
| Self::InvalidSse(_)
| Self::ModelCatalog(_) => Some(CodexBackendTransport::HttpSse),
Self::HttpJson(_) => Some(CodexBackendTransport::HttpJson),
Self::WebSocket(_) => Some(CodexBackendTransport::WebSocket),
Self::Upstream { transport, .. } => Some(*transport),
Self::CustomCa(_)
@@ -472,6 +477,8 @@ impl fmt::Debug for CodexRequestContext<'_> {
pub enum CodexBackendTransport {
/// HTTP SSE 传输。
HttpSse,
/// 非流式 HTTP JSON 传输。
HttpJson,
/// WebSocket 传输。
WebSocket,
}
@@ -553,6 +560,22 @@ pub struct CodexBackendStreamingResponse {
pub connection_local_continuation: bool,
}
/// Codex 上游非流式 JSON 响应。
pub struct CodexBackendJsonResponse {
/// 未经解析或重编码的完整响应正文。
pub body: Bytes,
/// 上游透传的 `set-cookie` 列表。
pub set_cookie_headers: Vec<String>,
/// 上游透传的限流头。
pub rate_limit_headers: Vec<(String, String)>,
/// 上游诊断元数据。
pub diagnostics: CodexUpstreamDiagnostics,
/// 已筛选、可交给客户端的响应头。
pub response_metadata: CodexResponseMetadata,
/// HTTP 阶段耗时与版本。
pub transport_metrics: CodexTransportMetrics,
}
// ---------------------------------------------------------------------------
// CodexBackendClient
// ---------------------------------------------------------------------------
@@ -0,0 +1,109 @@
//! Codex 非流式 JSON 上游透明传输。
use std::time::Instant;
use bytes::Bytes;
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue};
use super::{
client::{
CodexBackendClient, CodexBackendJsonResponse, CodexBackendTransport, CodexClientError,
CodexClientResult, CodexClientVisibleUpstreamResponse, CodexRequestContext,
CodexTransportMetrics, elapsed_duration_millis, http_version_name,
read_error_response_body, retry_after_seconds,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::endpoint_url,
headers::{build_codex_base_headers, insert_optional_header, insert_optional_protocol_header},
response_meta,
};
impl CodexBackendClient {
/// 向固定 Provider 端点发送一次原始 JSON;请求与成功响应正文均不经过 serde。
pub(crate) async fn post_raw_json(
&self,
endpoint_path: &'static str,
body: Bytes,
image_turn_id: Option<&str>,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexBackendJsonResponse> {
let profile = self.profile.snapshot();
let mut headers =
build_codex_base_headers(&profile, context.authorization, context.account_id)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
insert_optional_header(
&mut headers,
"x-codex-installation-id",
context.installation_id,
)?;
insert_optional_protocol_header(&mut headers, "x-codex-image-turn-id", image_turn_id);
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(context.request_id)?,
);
let headers_started_at = Instant::now();
let response = self
.client
.post(endpoint_url(&self.base_url, endpoint_path))
.headers(headers)
.body(body)
.send()
.await
.map_err(CodexClientError::HttpJson)?;
let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed());
let http_version = http_version_name(response.version()).to_owned();
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let set_cookie_headers = response_meta::set_cookie_headers(response.headers());
let rate_limit_headers = response_meta::rate_limit_headers(response.headers());
let response_metadata = response_meta::response_metadata(response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let transport_metrics = CodexTransportMetrics {
upstream_headers_ms: Some(upstream_headers_ms),
http_version: Some(http_version),
..CodexTransportMetrics::default()
};
if !status.is_success() {
let content_type = response
.headers()
.get(CONTENT_TYPE)
.map(|value| value.as_bytes().to_vec());
let client_headers = response_meta::client_headers(response.headers());
let raw_body = read_error_response_body(response)
.await
.map_err(CodexClientError::HttpJson)?;
let error_body = String::from_utf8_lossy(&raw_body).into_owned();
return Err(CodexClientError::Upstream {
status,
body: error_body,
client_response: Some(Box::new(CodexClientVisibleUpstreamResponse::new(
status,
content_type,
client_headers,
raw_body,
))),
retry_after_seconds,
diagnostics: Box::new(diagnostics),
set_cookie_headers,
rate_limit_headers,
transport: CodexBackendTransport::HttpJson,
transport_metrics: Box::new(transport_metrics),
send_phase: CodexUpstreamSendPhase::AfterPayload,
});
}
let body = response.bytes().await.map_err(CodexClientError::HttpJson)?;
Ok(CodexBackendJsonResponse {
body,
set_cookie_headers,
rate_limit_headers,
diagnostics,
response_metadata,
transport_metrics,
})
}
}
@@ -1,9 +1,20 @@
/// `/codex/responses`
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
/// `/codex/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/codex/images/generations";
/// `/codex/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
/// `/api/codex/usage`
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
pub const WHAM_USAGE_PATH: &str = "/wham/usage";
/// Codex Desktop 当前账号个人资料与累计统计。
pub const WHAM_PROFILE_STATISTICS_PATH: &str = "/wham/profiles/me";
/// Codex Desktop 主动额度重置卡列表。
pub const WHAM_RATE_LIMIT_RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits";
/// Codex Desktop 主动消费额度重置卡。
pub const WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH: &str =
"/wham/rate-limit-reset-credits/consume";
/// 拼接完整 endpoint URL。
pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
@@ -3,13 +3,16 @@
pub mod canonical;
pub mod catalog;
pub mod client;
mod client_json;
mod client_sse;
pub mod diagnostics;
pub mod endpoints;
pub mod headers;
pub mod profile;
pub mod profile_statistics;
pub mod protocol;
pub mod request;
pub mod reset_credits;
mod response_meta;
pub(crate) mod session;
mod time;
@@ -25,18 +28,29 @@ pub use self::{
CodexModelCatalogSnapshot, MAX_CODEX_MODEL_CATALOG_BYTES, parse_codex_model_catalog,
},
client::{
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendSseStream,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult,
CodexRateLimitUpdates, CodexRequestContext, CodexTransportDecision, CodexTransportMetrics,
CodexTurnStateUpdate, build_reqwest_client,
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendJsonResponse,
CodexBackendSseStream, CodexBackendStreamingResponse, CodexBackendTransport,
CodexClientError, CodexClientResult, CodexRateLimitUpdates, CodexRequestContext,
CodexTransportDecision, CodexTransportMetrics, CodexTurnStateUpdate, build_reqwest_client,
},
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, WHAM_USAGE_PATH, endpoint_url,
usage_endpoint_url,
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_PROFILE_STATISTICS_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
},
headers::build_codex_base_headers,
profile_statistics::{
CodexProfileActivityInsights, CodexProfileDailyUsage, CodexProfileInvocation,
CodexProfileStatistics, CodexProfileStatisticsSummary,
MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES,
},
request::{CodexRequestEncodeError, encode_generate_request},
reset_credits::{
CodexRateLimitResetCredit, CodexRateLimitResetCredits,
CodexRateLimitResetCreditsConsumeResult, MAX_CODEX_RESET_CREDITS_BODY_BYTES,
},
response_meta::CodexResponseMetadata,
usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown},
websocket::{
@@ -73,6 +73,19 @@ impl CodexWireProfile {
self.desktop_version,
)
}
/// 按 Codex Desktop 暴露的稳定桌面 surface 格式构造 User-Agent。
///
/// Electron 网络栈会为 renderer 请求附加运行时 User-Agent;非 Electron transport
/// 使用同一官方包中 `getDesktopUserAgent` 的稳定格式。它与 bundled Core 请求使用的
/// 复合 User-Agent 是两个独立 surface。
#[must_use]
pub fn desktop_user_agent(&self) -> String {
format!(
"{}/{} ({}; {})",
self.originator, self.desktop_version, self.os_type, self.arch
)
}
}
/// 跨 Codex 上游请求共享的运行时请求画像。
@@ -0,0 +1,293 @@
//! Codex Desktop 当前账号个人资料与累计统计 HTTP contract。
use chrono::NaiveDate;
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use reqwest::StatusCode;
use serde::Deserialize;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
client::{
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
retry_after_seconds,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{WHAM_PROFILE_STATISTICS_PATH, endpoint_url},
response_meta,
};
/// 单次 profile 响应允许保留和解析的最大字节数。
pub const MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProfileStatisticsSummary {
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, PartialEq, Eq)]
pub struct CodexProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProfileInvocation {
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>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexProfileActivityInsights {
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<CodexProfileInvocation>>,
}
#[derive(Clone, PartialEq)]
pub struct CodexProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: CodexProfileStatisticsSummary,
pub daily_usage: Option<Vec<CodexProfileDailyUsage>>,
pub activity_insights: CodexProfileActivityInsights,
}
impl std::fmt::Debug for CodexProfileStatistics {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexProfileStatistics")
.field(
"display_name",
&self.display_name.as_ref().map(|_| "<redacted>"),
)
.field("username", &self.username.as_ref().map(|_| "<redacted>"))
.field("image_url", &self.image_url.as_ref().map(|_| "<redacted>"))
.field("has_stats_error", &self.has_stats_error)
.field("summary", &"<redacted>")
.field("daily_usage", &"<redacted>")
.field("activity_insights", &"<redacted>")
.finish()
}
}
#[derive(Deserialize)]
struct ProfileStatisticsWire {
profile: ProfileWire,
stats: StatisticsWire,
#[serde(default)]
metadata: MetadataWire,
}
#[derive(Deserialize)]
struct ProfileWire {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
profile_picture_url: Option<String>,
}
#[derive(Deserialize)]
struct StatisticsWire {
#[serde(default)]
lifetime_tokens: Option<u64>,
#[serde(default)]
peak_daily_tokens: Option<u64>,
#[serde(default)]
longest_running_turn_sec: Option<u64>,
#[serde(default)]
current_streak_days: Option<u64>,
#[serde(default)]
longest_streak_days: Option<u64>,
#[serde(default)]
daily_usage_buckets: Option<Vec<DailyUsageWire>>,
#[serde(default)]
fast_mode_usage_percentage: Option<f64>,
#[serde(default)]
most_used_reasoning_effort: Option<String>,
#[serde(default)]
most_used_reasoning_effort_percentage: Option<f64>,
#[serde(default)]
unique_skills_used: Option<u64>,
#[serde(default)]
total_skills_used: Option<u64>,
#[serde(default)]
total_threads: Option<u64>,
#[serde(default)]
top_invocations: Option<Vec<InvocationWire>>,
}
#[derive(Deserialize)]
struct DailyUsageWire {
start_date: NaiveDate,
tokens: u64,
}
#[derive(Deserialize)]
struct InvocationWire {
#[serde(rename = "type")]
invocation_type: String,
#[serde(default)]
plugin_id: Option<String>,
#[serde(default)]
plugin_name: Option<String>,
#[serde(default)]
skill_id: Option<String>,
#[serde(default)]
skill_name: Option<String>,
#[serde(default)]
usage_count: Option<u64>,
}
#[derive(Default, Deserialize)]
struct MetadataWire {
#[serde(default)]
stats_error: Option<String>,
}
impl CodexBackendClient {
pub async fn fetch_profile_statistics(
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexProfileStatistics> {
let response = self
.client
.get(endpoint_url(&self.base_url, WHAM_PROFILE_STATISTICS_PATH))
.headers(self.usage_request_headers(context)?)
.send()
.await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body =
read_capped_response_body(response, MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(upstream_error(
if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
"upstream profile-statistics response exceeded the body limit".to_owned(),
retry_after_seconds,
diagnostics,
));
}
let body = body.into_string();
if !status.is_success() {
return Err(upstream_error(
status,
body.clone(),
retry_after_seconds.or_else(|| retry_after_seconds_from_body(&body)),
diagnostics,
));
}
let wire = serde_json::from_str::<ProfileStatisticsWire>(&body).map_err(|_| {
upstream_error(
StatusCode::BAD_GATEWAY,
"invalid profile-statistics response".to_owned(),
None,
diagnostics,
)
})?;
Ok(profile_statistics(wire))
}
}
fn profile_statistics(wire: ProfileStatisticsWire) -> CodexProfileStatistics {
let longest_task_duration_ms = wire
.stats
.longest_running_turn_sec
.map(|seconds| seconds.saturating_mul(1000));
let mut daily_usage = wire.stats.daily_usage_buckets.map(|daily| {
daily
.into_iter()
.map(|entry| CodexProfileDailyUsage {
date: entry.start_date,
tokens: entry.tokens,
})
.collect::<Vec<_>>()
});
if let Some(daily_usage) = &mut daily_usage {
daily_usage.sort_by_key(|entry| entry.date);
}
let invocations = wire.stats.top_invocations.map(|invocations| {
invocations
.into_iter()
.map(|invocation| CodexProfileInvocation {
invocation_type: invocation.invocation_type.trim().to_owned(),
plugin_id: trimmed(invocation.plugin_id),
plugin_name: trimmed(invocation.plugin_name),
skill_id: trimmed(invocation.skill_id),
skill_name: trimmed(invocation.skill_name),
usage_count: invocation.usage_count,
})
.collect()
});
CodexProfileStatistics {
display_name: trimmed(wire.profile.display_name),
username: trimmed(wire.profile.username),
image_url: trimmed(wire.profile.profile_picture_url),
has_stats_error: trimmed(wire.metadata.stats_error).is_some(),
summary: CodexProfileStatisticsSummary {
total_text_tokens: wire.stats.lifetime_tokens,
peak_tokens: wire.stats.peak_daily_tokens,
longest_task_duration_ms,
current_streak_days: wire.stats.current_streak_days,
longest_streak_days: wire.stats.longest_streak_days,
},
daily_usage,
activity_insights: CodexProfileActivityInsights {
fast_mode_percent: wire.stats.fast_mode_usage_percentage,
reasoning_effort: trimmed(wire.stats.most_used_reasoning_effort),
reasoning_effort_percent: wire.stats.most_used_reasoning_effort_percentage,
skills_explored: wire.stats.unique_skills_used,
total_skills_used: wire.stats.total_skills_used,
total_threads: wire.stats.total_threads,
invocations,
},
}
}
fn trimmed(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
fn upstream_error(
status: StatusCode,
body: String,
retry_after_seconds: Option<u64>,
diagnostics: super::CodexUpstreamDiagnostics,
) -> CodexClientError {
CodexClientError::Upstream {
status,
body,
client_response: None,
retry_after_seconds,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: CodexBackendTransport::HttpSse,
transport_metrics: Box::<CodexTransportMetrics>::default(),
send_phase: CodexUpstreamSendPhase::AfterPayload,
}
}
@@ -525,11 +525,6 @@ impl CodexResponsesRequest {
.map_or(&[], Vec::as_slice)
}
/// 替换输入条目(仅用于代理持有完整历史时的换号重放)。
pub fn set_input(&mut self, input: Vec<Value>) {
self.body.insert("input".to_string(), Value::Array(input));
}
/// 是否流式返回。
pub fn stream(&self) -> bool {
self.body
@@ -617,11 +612,12 @@ impl CodexResponsesRequest {
)
}
/// 返回会话亲和所用的子代理区分值。
/// 返回请求语义与 child transport 隔离所用的子代理区分值。
///
/// Codex 原生请求在 turn metadata 中声明 `subagent_kind`;网关的
/// `/responses/review` 等入口则通过 `client_metadata.x-openai-subagent`
/// 传递给上游。该可选值仅参与会话亲和键派生。
/// 传递给上游。它不拆分根会话的账号首选项;`thread_spawn` 仍用它派生独立
/// WebSocket/continuation transport identity。
pub fn subagent_kind(&self) -> Option<String> {
self.semantics()
.subagent_kind

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