Compare commits

...
38 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
246 changed files with 11269 additions and 37624 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
+2 -5
View File
@@ -16,9 +16,6 @@
</div>
Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提供统一鉴权、模型路由、额度状态、
请求观测和管理控制面。
> [!NOTE]
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
@@ -30,7 +27,7 @@ Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT、账号文件与 Agent Identity 导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
@@ -93,7 +90,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT、OAuth JSON 与 Agent Identity;
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
+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,7 +3,7 @@
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},
@@ -627,6 +627,58 @@ 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 {
@@ -17,7 +17,8 @@ use crate::model::provider_credentials::{
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -146,6 +147,14 @@ 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,
@@ -24,9 +24,9 @@ use crate::{
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, QuotaLocalUsageAttribution,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -80,6 +80,15 @@ 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,
@@ -538,6 +547,17 @@ 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,
@@ -28,8 +28,10 @@ use gateway_admin::model::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -587,6 +589,125 @@ 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")]
@@ -1160,6 +1281,10 @@ 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>),
@@ -1494,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>,
@@ -1936,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 {
@@ -729,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),
@@ -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;
@@ -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")
),
);
}
@@ -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",
@@ -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,
@@ -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[])
@@ -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)
}
@@ -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 {
@@ -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 {
@@ -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" }
+109 -9
View File
@@ -18,9 +18,11 @@ use gateway_admin::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential,
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, 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};
@@ -45,11 +47,12 @@ 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,
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexCredentialProfileService, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
CodexOAuthPendingStoreError, CodexPendingAuthorization, CodexProfileStatisticsError,
CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -58,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;
@@ -70,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>,
@@ -79,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>,
}
@@ -99,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,
@@ -443,6 +449,19 @@ 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,
@@ -800,6 +819,52 @@ 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 {
@@ -1397,6 +1462,41 @@ 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;
+1 -12
View File
@@ -155,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,
}
@@ -166,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,
}
@@ -176,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(())
@@ -191,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,6 +38,7 @@ 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,
@@ -64,9 +59,7 @@ pub use selector::{
};
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, 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,
@@ -41,8 +39,8 @@ use crate::transport::{
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,
@@ -183,7 +181,6 @@ 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<()>>>>,
@@ -227,6 +224,11 @@ struct FetchedCodexQuota {
value: Value,
}
struct PreparedCodexRuntimeCredential {
account: ProviderAccount,
credential: CodexRuntimeCredential,
}
enum CodexQuotaFetchAttemptError {
InvalidCredential,
Upstream(CodexClientError),
@@ -234,7 +236,6 @@ enum CodexQuotaFetchAttemptError {
enum CodexQuotaFetchError {
InvalidCredential,
Recovery,
Upstream {
account: Box<ProviderAccount>,
error: CodexClientError,
@@ -458,7 +459,6 @@ impl CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
) -> Self {
Self {
@@ -467,7 +467,6 @@ impl CodexCredentialQuotaService {
profile,
http,
base_url,
agent_identity,
cooldowns,
scheduling: CodexQuotaSchedulingProjection::default(),
reset_consume_locks: Mutex::new(HashMap::new()),
@@ -485,28 +484,19 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
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());
let mut result = list_reset_credits_once(&client, &prepared, &request_id).await;
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = list_reset_credits_once(&client, &prepared, &request_id).await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
list_reset_credits_once(&client, &prepared, &request_id)
.await
.map_err(|error| map_reset_credit_attempt_error(error, false))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
@@ -531,45 +521,25 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
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());
let mut result = consume_reset_credit_once(
consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
// Agent Identity task recovery is the only automatic retry below. It keeps the exact
// official credit selection and redeem_request_id; generic HTTP/transport failures are
// never retried because the debit result may already be committed upstream.
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, true))
.await
.map_err(|error| map_reset_credit_attempt_error(error, true))
}
async fn reset_credit_account(
@@ -718,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
@@ -736,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)) => {
@@ -1101,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()
@@ -1221,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,
@@ -1272,7 +1219,7 @@ async fn list_reset_credits_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.list_rate_limit_reset_credits(CodexRequestContext::auxiliary(
@@ -1295,7 +1242,7 @@ async fn consume_reset_credit_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.consume_rate_limit_reset_credit(
@@ -1314,22 +1261,15 @@ async fn consume_reset_credit_once(
fn map_reset_credit_attempt_error(
error: ResetCreditAttemptError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
match error {
ResetCreditAttemptError::InvalidCredential => CodexResetCreditsError::InvalidCredentialData,
ResetCreditAttemptError::Upstream(error) => {
map_reset_credit_client_error(error, prepared, consume)
}
ResetCreditAttemptError::Upstream(error) => map_reset_credit_client_error(error, consume),
}
}
fn map_reset_credit_client_error(
error: CodexClientError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
fn map_reset_credit_client_error(error: CodexClientError, consume: bool) -> CodexResetCreditsError {
match error {
CodexClientError::Upstream {
status,
@@ -1337,8 +1277,7 @@ fn map_reset_credit_client_error(
diagnostics,
..
} if status == reqwest::StatusCode::UNAUTHORIZED
&& reset_credit_response_was_explicit_rejection(status, &diagnostics)
&& prepared.credential.authentication.oauth().is_some() =>
&& reset_credit_response_was_explicit_rejection(status, &diagnostics) =>
{
CodexResetCreditsError::CredentialRefreshRequired {
upstream_body: Some(body),
@@ -1421,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);
@@ -104,6 +103,7 @@ struct CredentialSelectionInput<'a> {
request_url: &'a Url,
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
session_affinity_observation: Option<&'a CodexSessionAffinity>,
}
#[derive(Clone)]
@@ -120,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>,
@@ -269,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 {
@@ -281,7 +279,6 @@ impl CodexCredentialSelector {
session_exclusions,
catalog,
quota,
agent_identity,
cookie_policy,
risk_recovery: Mutex::new(HashMap::new()),
account_feedback,
@@ -296,6 +293,7 @@ impl CodexCredentialSelector {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation: None,
};
self.select_inner(
&input,
@@ -309,11 +307,13 @@ impl CodexCredentialSelector {
&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,
@@ -335,6 +335,7 @@ impl CodexCredentialSelector {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: None,
session_affinity_observation: None,
};
self.select_inner(&input, None, ModelCatalogEligibility::NotApplicable)
.await
@@ -476,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(),
@@ -511,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(
@@ -532,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(),
@@ -543,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()
@@ -583,6 +618,7 @@ impl CodexCredentialSelector {
cyber_policy_scope,
allows_account_state_mutation,
affinity_telemetry,
affinity_expected_account_id,
_guard: guard,
});
}
@@ -603,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(
@@ -649,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
}
}
}
@@ -901,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
@@ -910,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(),
@@ -1104,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,
@@ -1159,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>,
}
@@ -1216,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")]
+56 -121
View File
@@ -49,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,
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_key,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
};
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
@@ -71,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,
};
@@ -118,7 +121,6 @@ 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,
@@ -134,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,
@@ -154,7 +155,6 @@ impl CodexProvider {
selector,
catalog,
quota,
agent_identity,
account_feedback,
client,
responses_url,
@@ -285,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);
@@ -299,9 +299,10 @@ impl Provider for CodexProvider {
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)?;
@@ -318,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()
{
@@ -366,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(),
@@ -400,6 +405,7 @@ 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(),
@@ -410,7 +416,6 @@ impl Provider for CodexProvider {
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,
@@ -482,7 +487,6 @@ impl CodexProvider {
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at: Instant::now(),
});
@@ -505,7 +509,6 @@ 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>,
@@ -521,7 +524,6 @@ struct ColdJsonResponse {
context: AttemptContext,
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
}
@@ -634,7 +636,6 @@ enum CodexHandshakeAttemptError {
Client(CodexClientError),
Cancelled,
Timeout,
AgentIdentityRecovery,
}
async fn create_response_attempt(
@@ -671,9 +672,6 @@ fn map_handshake_attempt_error(error: CodexHandshakeAttemptError) -> MappedProvi
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
)),
CodexHandshakeAttemptError::AgentIdentityRecovery => MappedProviderFailure::plain(
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::Sent),
),
}
}
@@ -721,12 +719,12 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
cyber_policy_scope: None,
allows_account_state_mutation,
};
let mut active_account = request.lease.account().clone();
let active_account = request.lease.account().clone();
let cookie_header = build_cookie_header(request.lease.cookies())?;
let mut authorization = request
let authorization = request
.lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -738,7 +736,7 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
request.lease.escape_reason(),
request.lease.account_switch(),
);
let mut response = create_json_attempt(
let response = create_json_attempt(
&request,
&active_account,
request.lease.installation_id(),
@@ -747,44 +745,6 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
account_selection,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match request
.agent_identity
.recover_after_rejected_task(
active_account.id(),
request.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_json_attempt(
&request,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = match response.map_err(map_handshake_attempt_error) {
Ok(response) => response,
Err(mut failure) => {
@@ -860,7 +820,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
selector,
quota,
catalog,
agent_identity,
lease,
output_started_at,
session_affinity_key,
@@ -879,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,
@@ -895,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(
@@ -912,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,
@@ -1178,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())
@@ -1282,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())
@@ -2364,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);
@@ -1,13 +1,15 @@
/// `/codex/responses`
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
/// `/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/images/generations";
/// `/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/images/edits";
/// `/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 主动消费额度重置卡。
@@ -9,6 +9,7 @@ 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;
@@ -35,10 +36,16 @@ pub use self::{
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_PATH, WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
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,
@@ -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
@@ -5,7 +5,9 @@ use serde_json::{Map, Value};
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
/// WebSocket 握手审计快照。
@@ -12,6 +12,7 @@ use crate::transport::protocol::responses::CodexResponsesRequest;
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
"authorization",
@@ -63,18 +64,14 @@ const INSTALLATION_ID_KEYS: &[&str] = &[
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RequestAccountScope {
SameAccount,
CrossAccountFullRequest,
CrossAccountContinuationProbe,
Same,
Different,
Unknown,
}
impl RequestAccountScope {
const fn is_cross_account(self) -> bool {
!matches!(self, Self::SameAccount)
}
const fn preserves_continuation(self) -> bool {
matches!(self, Self::CrossAccountContinuationProbe)
pub(crate) const fn can_reuse_account_state(self) -> bool {
matches!(self, Self::Same)
}
}
@@ -94,7 +91,7 @@ pub fn encode_generate_request(
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
}
let mut body = payload.body().clone();
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
adapt_codex_responses_body(&mut body, upstream_model);
let mut encoded = CodexResponsesRequest::from_body(body);
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
@@ -103,6 +100,13 @@ pub fn encode_generate_request(
Ok(encoded)
}
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
}
fn extract_request_context(request: &mut CodexResponsesRequest) {
let context = ExtractedRequestContext::from_body(request.body());
request.turn_state = context.turn_state;
@@ -205,8 +209,9 @@ pub(crate) fn derive_conversation_anchor(
})
}
/// A `thread_spawn` shares its parent session but is a separate child execution.
/// Its child thread must therefore own the affinity and WebSocket pool identity.
/// `thread_spawn` 与父任务共享根会话,但它本身是独立的子任务执行。
/// 因此子线程必须拥有独立的 continuation/WebSocket 传输身份;账号亲和则从共享的
/// 根会话单独派生。
fn thread_spawn_conversation_anchor(
request: &CodexResponsesRequest,
) -> Option<(&'static str, String)> {
@@ -306,21 +311,17 @@ fn normalize_conversation_anchor_text(text: &str) -> String {
///
/// 真实 account ID 与 installation ID 由随后构造的 `CodexRequestContext` 注入
/// 请求头;正文只替换客户端原本声明过的 installation 字段,绝不接受客户端
/// 提供的 token、cookie 或账号身份。Continuation probe 保留 canonical
/// previous-response 和原始增量 input,不能把它们误当成完整请求清理。
/// 提供的 token、cookie 或账号身份。`input` 是 Responses 的可回放会话正文,
/// item ID、encrypted content 和 compaction 都必须原样保留。
pub(crate) fn scope_request_to_account(
request: &mut CodexResponsesRequest,
installation_id: &str,
account_scope: RequestAccountScope,
) {
let cross_account = account_scope.is_cross_account();
let continuation = account_scope
.preserves_continuation()
.then(|| request.previous_response_id().map(ToOwned::to_owned))
.flatten();
let reset_account_state = !account_scope.can_reuse_account_state();
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
let preserve_turn_state =
!cross_account && (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let preserve_turn_state = !reset_account_state
&& (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let turn_state = preserve_turn_state
.then(|| request.turn_state.clone())
.flatten();
@@ -329,38 +330,22 @@ pub(crate) fn scope_request_to_account(
} else {
None
};
let turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account))
})
.flatten();
let client_metadata_turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account))
})
.flatten();
let turn_metadata = request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, reset_account_state));
let client_metadata_turn_metadata = metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, reset_account_state));
if cross_account {
if reset_account_state {
request.passthrough_headers.remove("x-codex-turn-state");
request.passthrough_headers.remove("x-codex-turn-metadata");
if !account_scope.preserves_continuation() {
sanitize_cross_account_input(request);
}
for key in CROSS_ACCOUNT_IDENTITY_KEYS
.iter()
.chain(ACCOUNT_BOUND_STATE_KEYS)
{
request.body_mut().remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
request.body_mut().remove(*key);
}
}
}
for key in INSTALLATION_ID_KEYS {
@@ -371,7 +356,7 @@ pub(crate) fn scope_request_to_account(
.body()
.get(*key)
.and_then(Value::as_str)
.and_then(|value| scope_turn_metadata(value, installation_id, cross_account));
.and_then(|value| scope_turn_metadata(value, installation_id, reset_account_state));
replace_existing_body_string(request, key, scoped.as_deref());
}
@@ -383,22 +368,17 @@ pub(crate) fn scope_request_to_account(
(
key,
metadata.get(key).and_then(Value::as_str).and_then(|value| {
scope_turn_metadata(value, installation_id, cross_account)
scope_turn_metadata(value, installation_id, reset_account_state)
}),
)
});
if cross_account {
if reset_account_state {
for key in CROSS_ACCOUNT_IDENTITY_KEYS
.iter()
.chain(ACCOUNT_BOUND_STATE_KEYS)
{
metadata.remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
metadata.remove(*key);
}
}
}
metadata.insert(
"x-codex-installation-id".to_owned(),
@@ -436,36 +416,6 @@ pub(crate) fn scope_request_to_account(
request.turn_state = turn_state;
request.turn_metadata = turn_metadata;
if let Some(continuation) = continuation {
request.set_previous_response_id(Some(continuation));
}
}
fn sanitize_cross_account_input(request: &mut CodexResponsesRequest) {
if request.input().is_empty() {
return;
}
let input = request
.input()
.iter()
.cloned()
.filter_map(sanitize_cross_account_item)
.collect();
request.set_input(input);
}
pub(crate) fn sanitize_cross_account_item(mut item: Value) -> Option<Value> {
if let Value::Object(object) = &mut item {
if matches!(
object.get("type").and_then(Value::as_str),
Some("compaction" | "compaction_summary" | "context_compaction")
) {
return None;
}
object.remove("id");
object.remove("encrypted_content");
}
Some(item)
}
fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String> {
@@ -412,6 +412,20 @@ pub fn openai_billing_breakdown(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
openai_billing_breakdown_with_context(
model,
usage,
service_tier,
usage.input_tokens > LONG_CONTEXT_THRESHOLD,
)
}
fn openai_billing_breakdown_with_context(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
long_context: bool,
) -> Option<CalculatedCostBreakdown> {
if usage.cached_tokens > usage.input_tokens
|| usage.image_input_tokens > 0
@@ -421,7 +435,6 @@ pub fn openai_billing_breakdown(
}
let web_search_ticks = web_search_amount_ticks(usage)?;
let pricing = model_pricing(model)?;
let long_context = usage.input_tokens > LONG_CONTEXT_THRESHOLD;
let normalized_tier = normalize_service_tier(service_tier);
let tier = pricing_tier(normalized_tier.as_deref())?;
let standard_rates = pricing.rates(PricingTier::Standard, long_context)?;
@@ -10,13 +10,13 @@ use std::{
time::Duration,
};
use tokio::{sync::Semaphore, time::Instant};
use tokio::sync::Semaphore;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use uuid::Uuid;
use self::state::{
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, account_slot_count,
close_pooled_connection, close_pooled_connections, take_lru_idle_connection,
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, close_pooled_connection,
close_pooled_connections,
};
use super::pump::PumpKeepalive;
@@ -31,8 +31,6 @@ pub(crate) use self::{
},
};
const DEFAULT_MAX_PER_ACCOUNT: usize = 8;
const DEFAULT_MAX_TOTAL: usize = 64;
const DEFAULT_MAX_CONNECTING: usize = 16;
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
@@ -64,10 +62,6 @@ pub struct CodexWebSocketPoolConfig {
pub enabled: bool,
/// 单个 socket 的最大生命周期。
pub max_age: Duration,
/// 单个账号允许占用的最大池 slot 数。
pub max_per_account: usize,
/// 所有账号合计允许占用的最大池 slot 数。
pub max_total: usize,
/// 所有账号合计允许并发执行的 opening 数。
pub max_connecting: usize,
/// 后台维护间隔;`None` 表示不启动后台任务。
@@ -87,8 +81,6 @@ impl Default for CodexWebSocketPoolConfig {
Self {
enabled: true,
max_age: DEFAULT_MAX_AGE,
max_per_account: DEFAULT_MAX_PER_ACCOUNT,
max_total: DEFAULT_MAX_TOTAL,
max_connecting: DEFAULT_MAX_CONNECTING,
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
ping_interval: Some(DEFAULT_PING_INTERVAL),
@@ -113,10 +105,9 @@ impl CodexWebSocketPoolConfig {
}
impl CodexWebSocketPool {
/// 构造连接池策略和状态。
pub fn new(max_per_account: usize, max_age: Duration) -> Self {
/// 构造不限制累计 slot 数的连接池策略和状态。
pub fn new(max_age: Duration) -> Self {
Self::with_config(CodexWebSocketPoolConfig {
max_per_account: max_per_account.max(1),
max_age,
maintenance_interval: None,
ping_interval: None,
@@ -218,43 +209,25 @@ impl CodexWebSocketPool {
let acquire = if required_response_id.is_some() {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::ContinuationNotFound)
} else if self.config.max_per_account == 0
|| account_slot_count(&state.slots, key.account_id()) >= self.config.max_per_account
{
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
} else {
let connect_permit = self.connect_semaphore.clone().try_acquire_owned().ok();
match connect_permit {
Some(connect_permit) => {
let has_total_capacity = if self.config.max_total == 0 {
false
} else if state.slots.len() < self.config.max_total {
true
} else if let Some(connection) = take_lru_idle_connection(&mut state) {
connections_to_close.push(connection);
true
} else {
false
};
if has_total_capacity {
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
} else {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
}
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
}
None => WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap),
}
@@ -292,7 +265,6 @@ impl CodexWebSocketPool {
key.clone(),
WebSocketPoolSlot::Idle {
connection: Box::new(connection),
last_used_at: Instant::now(),
},
);
}
@@ -108,7 +108,6 @@ impl WebSocketContinuationState {
pub(super) enum WebSocketPoolSlot {
Idle {
connection: Box<PooledWebSocketConnection>,
last_used_at: Instant,
},
Busy(WebSocketPoolReservation),
Connecting(WebSocketPoolConnecting),
@@ -138,34 +137,6 @@ pub(super) struct WebSocketPoolReservation {
pub(super) latest_response_id: Option<String>,
}
pub(super) fn account_slot_count(
slots: &HashMap<CodexWebSocketPoolKey, WebSocketPoolSlot>,
account_id: &str,
) -> usize {
slots
.keys()
.filter(|key| key.account_id() == account_id)
.count()
}
pub(super) fn take_lru_idle_connection(
state: &mut WebSocketPoolState,
) -> Option<PooledWebSocketConnection> {
let key = state
.slots
.iter()
.filter_map(|(key, slot)| match slot {
WebSocketPoolSlot::Idle { last_used_at, .. } => Some((key, *last_used_at)),
WebSocketPoolSlot::Busy(_) | WebSocketPoolSlot::Connecting(_) => None,
})
.min_by_key(|(_, last_used_at)| *last_used_at)
.map(|(key, _)| key.clone())?;
let Some(WebSocketPoolSlot::Idle { connection, .. }) = state.slots.remove(&key) else {
return None;
};
Some(*connection)
}
pub(super) async fn close_pooled_connection(connection: PooledWebSocketConnection) {
connection.websocket.close().await;
}
+153 -4
View File
@@ -1,11 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use futures::{StreamExt, future::BoxFuture};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
@@ -23,7 +23,12 @@ use gateway_core::engine::credential::{
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaObservation, QuotaState,
};
use gateway_core::engine::provider::ProviderRequest;
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
@@ -32,7 +37,11 @@ use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
@@ -46,9 +55,14 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
profile, secret,
account_policy, profile, secret,
};
const COMPLETED_SESSION_SSE: &str = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
);
#[tokio::test]
async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() {
let config = valid_config();
@@ -97,6 +111,84 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
assert!(bundle.take_worker_contributions().is_empty());
}
#[tokio::test]
async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinct() {
let account_id = "acct_initialized_thread_spawn";
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret("at-initialized-thread-spawn"),
verified_account: profile("chatgpt-initialized-thread-spawn"),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_SESSION_SSE),
)
.expect(2)
.mount(&server)
.await;
let mut config = valid_config();
config.config.api.base_url = server.uri();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("initialized OpenAI provider");
let provider = bundle.core_provider();
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
let mut conversation_ids = Vec::new();
for (request_id, thread_id) in [
("req_initialized_thread_spawn_first", "child-one"),
("req_initialized_thread_spawn_second", "child-two"),
] {
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("child task")),
("session_id".to_owned(), json!("parent-session")),
("thread_id".to_owned(), json!(thread_id)),
("turnMetadata".to_owned(), json!(thread_spawn)),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let mut stream = provider
.execute(
initialized_provider_request(operation, account_id),
initialized_attempt_context(request_id, account_id),
)
.await
.expect("prepare child provider stream");
let mut conversation_id = None;
while let Some(event) = stream.next().await {
let event = event.expect("child provider response");
if let Some(update) = event.session_update() {
conversation_id = update
.payload()
.get("conversation_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
}
conversation_ids.push(conversation_id.expect("child transport conversation id"));
}
assert_ne!(conversation_ids[0], conversation_ids[1]);
}
#[tokio::test]
async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() {
let config = valid_config();
@@ -921,6 +1013,63 @@ fn reset_credit_command(account_id: ProviderAccountId) -> ConsumeProviderResetCr
}
}
fn initialized_provider_request(operation: Operation, account_id: &str) -> ProviderRequest {
let provider = ProviderKind::new("openai").expect("provider");
let upstream_model = UpstreamModelId::new("gpt-5.4").expect("upstream model");
let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model");
let account_scope = initialized_account_scope(account_id);
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
account_policy(),
vec![provider.clone()],
vec![ProviderModel::new(
provider,
upstream_model,
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
.with_upstream_feature_validation(),
)],
Vec::new(),
)
.expect("runtime snapshot");
let plan = snapshot
.plan(
&public_model,
&operation,
account_scope,
&RoutingContext::default(),
)
.expect("routing plan");
ProviderRequest::new(operation, plan.candidates()[0].clone())
}
fn initialized_attempt_context(request_id: &str, account_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_initialized").expect("client key id"),
),
NonZeroU32::new(1).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(initialized_account_scope(account_id)),
None,
CancellationToken::new(),
)
}
fn initialized_account_scope(account_id: &str) -> Arc<FrozenAccountScope> {
let provider = ProviderKind::new("openai").expect("provider");
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new(account_id).expect("account id"),
RuntimeAccount::new(provider, BTreeSet::new()),
)]))),
ClientRoutingScope::all_accounts(),
))
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -101,8 +101,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
config.api.base_url.as_str(),
config.ws_pool.enabled,
config.ws_pool.max_age_ms,
config.ws_pool.max_per_account,
config.ws_pool.max_total,
config.ws_pool.max_connecting,
config.ws_pool.initial_event_timeout_ms,
config.quota.refresh_interval_minutes,
@@ -115,8 +113,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
true,
3_300_000,
8,
64,
8,
20_000,
15,
true,
@@ -59,6 +59,48 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
assert!(account.account.next_refresh_at().is_none());
}
#[tokio::test]
async fn direct_import_accepts_snake_case_oauth_token_aliases() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accounts": [{
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": "snake-access-token",
"refresh_token": "snake-refresh-token",
"id_token": "snake-id-token"
}
}]
}))
.await
.expect("snake_case OAuth token aliases must be accepted");
let account = prepared.accounts().first().expect("one prepared account");
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(
(
oauth.access_token.expose_secret(),
oauth
.refresh_token
.as_ref()
.map(|token| token.expose_secret()),
oauth.id_token.as_ref().map(|token| token.expose_secret()),
),
(
"snake-access-token",
Some("snake-refresh-token"),
Some("snake-id-token"),
)
);
}
#[tokio::test]
async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refresh_margin() {
let expires_at = Utc
@@ -1,256 +0,0 @@
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{TimeZone, Utc};
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use ed25519_dalek::{Signature, SigningKey, Verifier as _};
use gateway_core::engine::credential::{
CredentialRevision, CredentialState, NewProviderAccount, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaState,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
CodexAgentIdentityCredentialData, CodexAgentIdentityError, CodexAgentIdentitySecret,
CodexAgentIdentityTaskRegistrar, CodexAgentIdentityTaskService, CodexCredentialCodec,
CodexRuntimeAuthentication, is_agent_identity_task_invalid_response,
};
use provider_openai::transport::CodexWebSocketPool;
use provider_openai::transport::headers::build_codex_profile_headers;
use reqwest::StatusCode;
use reqwest::header::USER_AGENT;
use secrecy::ExposeSecret as _;
use serde_json::Value;
use crate::support::MemoryAccountStore;
fn signing_fixture(seed: u8) -> (SigningKey, String) {
let signing_key = SigningKey::from_bytes(&[seed; 32]);
let der = signing_key.to_pkcs8_der().expect("encode test PKCS#8 key");
(signing_key, STANDARD.encode(der.as_bytes()))
}
fn agent_account(
account_id: &str,
runtime_id: &str,
private_key: &str,
task_id: Option<&str>,
) -> NewProviderAccount {
let account_id = ProviderAccountId::new(account_id.to_owned()).expect("account id");
let provider = ProviderKind::new("openai").expect("provider");
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: 1,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: "00000000-0000-4000-8000-000000000001".to_owned(),
agent_runtime_id: runtime_id.to_owned(),
agent_private_key: private_key.to_owned(),
task_id: task_id.map(str::to_owned),
cookies: Vec::new(),
})
.expect("encode Agent Identity credential");
let account = ProviderAccount::new(
account_id,
provider,
"Agent Identity test".to_owned(),
Some("agent-user".to_owned()),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_profile(
Some("agent@example.com".to_owned()),
Some("agent-account".to_owned()),
Some("pro".to_owned()),
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
NewProviderAccount {
account,
credential,
}
}
#[test]
fn agent_identity_secret_builds_a_verifiable_assertion_without_leaking_material() {
let (signing_key, private_key) = signing_fixture(7);
let secret = CodexAgentIdentitySecret::from_pkcs8(
"runtime-test".to_owned(),
&private_key,
Some("task-test".to_owned()),
)
.expect("parse Agent Identity secret");
let now = Utc
.with_ymd_and_hms(2026, 7, 23, 1, 2, 3)
.single()
.expect("timestamp");
let header = secret
.authorization_header(now)
.expect("build AgentAssertion");
let encoded = header
.expose_secret()
.strip_prefix("AgentAssertion ")
.expect("AgentAssertion scheme");
let envelope: Value = serde_json::from_slice(
&URL_SAFE_NO_PAD
.decode(encoded)
.expect("decode assertion envelope"),
)
.expect("parse assertion envelope");
assert_eq!(envelope["agent_runtime_id"], "runtime-test");
assert_eq!(envelope["task_id"], "task-test");
assert_eq!(envelope["timestamp"], "2026-07-23T01:02:03Z");
let signature = Signature::from_slice(
&STANDARD
.decode(envelope["signature"].as_str().expect("signature"))
.expect("decode signature"),
)
.expect("signature shape");
signing_key
.verifying_key()
.verify(b"runtime-test:task-test:2026-07-23T01:02:03Z", &signature)
.expect("verify AgentAssertion");
let debug = format!("{secret:?}");
assert!(!debug.contains(&private_key));
assert!(!debug.contains("runtime-test"));
assert!(!debug.contains("task-test"));
}
#[test]
fn agent_identity_task_error_classifier_requires_unauthorized_task_error() {
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"task expired",
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::FORBIDDEN,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"invalid access token",
));
}
#[test]
fn agent_identity_task_registration_should_use_the_shared_desktop_profile_headers() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
let headers = build_codex_profile_headers(&profile).expect("Desktop profile headers");
assert_eq!(headers.len(), 3);
assert_eq!(headers["originator"], "Codex Desktop");
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert_eq!(
headers[USER_AGENT],
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
struct SequenceTaskRegistrar {
task_ids: Mutex<VecDeque<String>>,
calls: AtomicUsize,
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for SequenceTaskRegistrar {
async fn register(
&self,
_credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.task_ids
.lock()
.expect("task registrar lock")
.pop_front()
.ok_or(CodexAgentIdentityError::TaskRegistrationUnavailable)
}
}
fn task_service(
store: &Arc<MemoryAccountStore>,
registrar: Arc<SequenceTaskRegistrar>,
) -> CodexAgentIdentityTaskService {
CodexAgentIdentityTaskService::new(
store.repository(),
registrar,
Arc::new(CodexWebSocketPool::default()),
)
}
#[tokio::test]
async fn task_registration_and_recovery_are_revision_fenced() {
let (signing_key, private_key) = signing_fixture(8);
let runtime_id = format!(
"runtime-{}",
hex::encode(signing_key.verifying_key().as_bytes())
);
let store = Arc::new(MemoryAccountStore::default());
store
.create_account(agent_account(
"acct_agent_task",
&runtime_id,
&private_key,
None,
))
.await
.expect("seed Agent Identity account");
let registrar = Arc::new(SequenceTaskRegistrar {
task_ids: Mutex::new(VecDeque::from([
"task-first".to_owned(),
"task-recovered".to_owned(),
])),
calls: AtomicUsize::new(0),
});
let service = task_service(&store, Arc::clone(&registrar));
let current = store.account("acct_agent_task").expect("current account");
let prepared = service.prepare(&current).await.expect("register task");
assert_eq!(prepared.account.revision().get(), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = prepared.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-first"));
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let current = store.account("acct_agent_task").expect("updated account");
let prepared = service
.prepare(&current)
.await
.expect("reuse registered task");
assert_eq!(prepared.account.revision().get(), 2);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let recovered = service
.recover(prepared.account.id(), "task-first")
.await
.expect("rejected task must register a replacement");
assert_eq!(recovered.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = recovered.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-recovered"));
let fenced = service
.recover(recovered.account.id(), "task-first")
.await
.expect("stale task recovery must keep the newer task");
assert_eq!(fenced.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
}
@@ -13,7 +13,7 @@ use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileStat
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::support::{MemoryAccountStore, agent_identity_service, catalog_cache, profile, secret};
use crate::support::{MemoryAccountStore, catalog_cache, profile, secret};
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/fixtures/official_models_snapshot.json");
@@ -84,7 +84,6 @@ fn service_with_catalog_cache(
.build()
.expect("client"),
base_url,
agent_identity_service(store),
catalog_cache,
)
}
@@ -37,7 +37,7 @@ use url::Url;
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service, catalog_cache, profile, secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
fn create_account(store: &Arc<MemoryAccountStore>, id: &str, token: &str) {
@@ -162,13 +162,11 @@ fn selector_with_runtime(
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
let agent_identity = agent_identity_service(store);
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -176,7 +174,6 @@ fn selector_with_runtime(
profile,
http,
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
cooldowns,
));
CodexCredentialSelector::new(
@@ -187,7 +184,6 @@ fn selector_with_runtime(
Arc::new(MemorySessionExclusions::default()),
catalog,
quota,
agent_identity,
account_feedback,
CodexCookiePolicy::official().expect("official cookie policy"),
)
@@ -445,6 +441,74 @@ fn selector_round_robin_cursor_advances_across_requests() {
);
}
#[tokio::test]
async fn selector_should_claim_the_initial_session_account_before_upstream_send() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first", "at-first");
create_account(&store, "acct_second", "at-second");
let affinity = Arc::new(MemorySessionAffinity::default());
let selector = selector_with_affinity(
&store,
Arc::new(TestLeaseCoordinator::default()),
Arc::clone(&affinity),
);
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("initial-claim").expect("affinity key");
let request_attempt = attempt(BTreeSet::new());
let selected = selector
.select(&SelectCodexCredential {
upstream_model: "gpt-5.4",
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
.expect("request URL"),
attempt: &request_attempt,
session_affinity_key: Some(&key),
})
.await
.expect("select initial account");
assert_eq!(
affinity
.load(&provider, &key)
.await
.expect("load claimed affinity"),
Some(selected.account_id().clone())
);
}
#[tokio::test]
async fn record_success_should_not_overwrite_a_newer_session_winner() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first", "at-first");
create_account(&store, "acct_second", "at-second");
let affinity = Arc::new(MemorySessionAffinity::default());
let selector = selector_with_affinity(
&store,
Arc::new(TestLeaseCoordinator::default()),
Arc::clone(&affinity),
);
let provider = ProviderKind::new("openai").expect("provider");
let key = ProviderSessionAffinityKey::try_new("late-success").expect("affinity key");
let first = store.account("acct_first").expect("first account");
let second = ProviderAccountId::new("acct_second").expect("second account");
affinity
.bind(&provider, &key, &second, Duration::from_secs(60))
.await
.expect("seed newer affinity");
selector
.record_success(&first, Some(&key), first.id())
.await;
assert_eq!(
affinity
.load(&provider, &key)
.await
.expect("load preserved affinity"),
Some(second)
);
}
#[tokio::test]
async fn selector_should_reuse_the_account_bound_to_the_same_session() {
let store = Arc::new(MemoryAccountStore::default());
@@ -469,7 +533,9 @@ async fn selector_should_reuse_the_account_bound_to_the_same_session() {
})
.await
.expect("select first account");
selector.record_success(first.account(), Some(&key)).await;
selector
.record_success(first.account(), Some(&key), first.account_id())
.await;
let first_account = first.account_id().clone();
let second_attempt = attempt(BTreeSet::new());
@@ -535,7 +601,7 @@ async fn selector_should_replace_a_busy_affinity_binding_after_the_fallback_succ
.await
.expect("select fallback account");
selector
.record_success(selected.account(), Some(&key))
.record_success(selected.account(), Some(&key), &bound)
.await;
assert_eq!(
@@ -1005,7 +1071,7 @@ fn successful_upstream_response_recovers_non_quota_terminal_states() {
let current = store.account("acct_primary").expect("stale account");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_success(&current, None));
block_on(selector.record_success(&current, None, current.id()));
assert_eq!(
store
@@ -1289,7 +1355,7 @@ fn cloudflare_challenge_does_not_change_persisted_account_facts() {
.credential_state(),
CredentialState::Ready
);
block_on(selector.record_success(lease.account(), None));
block_on(selector.record_success(lease.account(), None, lease.account_id()));
}
#[test]
@@ -1,9 +1,9 @@
mod admin;
mod agent_identity;
mod catalog;
mod contract;
mod cookie;
mod oauth;
mod profile_statistics;
mod quota;
mod refresh;
mod token_client;
@@ -0,0 +1,157 @@
use std::sync::Arc;
use chrono::{TimeZone as _, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, profile, secret};
fn wire_profile() -> CodexWireProfileState {
CodexWireProfileState::new(CodexWireProfile {
originator: "codex_cli_rs".to_owned(),
codex_version: "0.144.0".to_owned(),
desktop_version: "1.0.0".to_owned(),
desktop_build: "1".to_owned(),
os_type: "linux".to_owned(),
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
.expect("fixture time"),
})
}
async fn service(
account_id: &str,
server: &MockServer,
) -> (Arc<MemoryAccountStore>, CodexCredentialProfileService) {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret(&format!("token-{account_id}")),
verified_account: profile(&format!("chatgpt-{account_id}")),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let service = CodexCredentialProfileService::new(
store.repository(),
wire_profile(),
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
(store, service)
}
#[tokio::test]
async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wham/profiles/me"))
.and(header(
"chatgpt-account-id",
"chatgpt-acct_profile_statistics",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"profile": {
"display_name": " Ada ",
"username": " ada ",
"profile_picture_url": " https://example.test/avatar.png "
},
"stats": {
"lifetime_tokens": 409500000,
"peak_daily_tokens": 267000000,
"longest_running_turn_sec": 51900,
"current_streak_days": 16,
"longest_streak_days": 32,
"daily_usage_buckets": [
{"start_date": "2026-08-18", "tokens": 20},
{"start_date": "2026-08-17", "tokens": 10}
],
"fast_mode_usage_percentage": 0,
"most_used_reasoning_effort": "high",
"most_used_reasoning_effort_percentage": 48,
"unique_skills_used": 8,
"total_skills_used": 61,
"total_threads": 2391,
"top_invocations": [{
"type": "plugin",
"plugin_id": "plugin_1",
"plugin_name": "Example",
"usage_count": 29
}]
},
"metadata": {"stats_error": null}
})))
.expect(1)
.mount(&server)
.await;
let (store, service) = service("acct_profile_statistics", &server).await;
let account = store
.account("acct_profile_statistics")
.expect("profile account");
let statistics = service
.profile_statistics(account.id())
.await
.expect("profile statistics");
assert_eq!(statistics.display_name.as_deref(), Some("Ada"));
assert_eq!(statistics.username.as_deref(), Some("ada"));
assert_eq!(statistics.summary.total_text_tokens, Some(409_500_000));
assert_eq!(
statistics.summary.longest_task_duration_ms,
Some(51_900_000)
);
assert_eq!(
statistics.daily_usage.as_ref().expect("daily")[0].tokens,
10
);
assert_eq!(
statistics
.activity_insights
.invocations
.as_ref()
.expect("invocations")[0]
.usage_count,
Some(29)
);
assert!(!statistics.has_stats_error);
}
#[tokio::test]
async fn profile_statistics_keeps_profile_when_stats_are_unavailable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wham/profiles/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"profile": {"display_name": "Ada"},
"stats": {},
"metadata": {"stats_error": " aggregation unavailable "}
})))
.mount(&server)
.await;
let (_store, service) = service("acct_profile_partial", &server).await;
let account_id = ProviderAccountId::new("acct_profile_partial").expect("account ID");
let statistics = service
.profile_statistics(&account_id)
.await
.expect("partial profile");
assert_eq!(statistics.display_name.as_deref(), Some("Ada"));
assert!(statistics.has_stats_error);
assert_eq!(statistics.summary.total_text_tokens, None);
assert_eq!(statistics.daily_usage, None);
}
@@ -61,7 +61,6 @@ fn quota_service_with_base_url(
wire_profile(),
http,
base_url,
crate::support::agent_identity_service(store),
Arc::new(crate::support::MemoryCooldownPort::new()),
)
}
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write};
use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -48,13 +49,13 @@ use tokio::{
};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode};
use tracing_subscriber::fmt::MakeWriter;
use wiremock::matchers::{body_bytes, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service_with_pool, catalog_cache, profile,
secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
use crate::transport::accept_codex_test_websocket;
@@ -65,6 +66,61 @@ const CAPTURE_COMPLETED_SSE: &str = concat!(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scope_capture\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
);
#[derive(Clone, Default)]
struct CapturedLogs {
bytes: Arc<Mutex<Vec<u8>>>,
}
impl CapturedLogs {
fn json_events(&self) -> Vec<Value> {
let bytes = self.bytes.lock().expect("captured logs lock").clone();
String::from_utf8(bytes)
.expect("captured logs are UTF-8")
.lines()
.map(|line| serde_json::from_str(line).expect("captured log is JSON"))
.collect()
}
}
impl<'writer> MakeWriter<'writer> for CapturedLogs {
type Writer = Self;
fn make_writer(&'writer self) -> Self::Writer {
self.clone()
}
}
impl Write for CapturedLogs {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.bytes
.lock()
.map_err(|_| io::Error::other("captured logs lock poisoned"))?
.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn selected_account_log_fields<'events>(
events: &'events [Value],
request_id: &str,
) -> &'events Map<String, Value> {
events
.iter()
.find_map(|event| {
let fields = event.get("fields")?.as_object()?;
(fields.get("message").and_then(Value::as_str) == Some("OpenAI account selected")
&& fields.get("request_id").and_then(Value::as_str) == Some(request_id))
.then_some(fields)
})
.unwrap_or_else(|| {
panic!("OpenAI account selection log for {request_id}; captured events: {events:#?}")
})
}
fn wire_profile() -> CodexWireProfileState {
CodexWireProfileState::new(CodexWireProfile {
originator: "codex_cli_rs".to_owned(),
@@ -146,13 +202,11 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
.build()
.expect("client");
let websocket_pool = Arc::new(CodexWebSocketPool::default());
let agent_identity = agent_identity_service_with_pool(store, Arc::clone(&websocket_pool));
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -160,7 +214,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
Arc::new(MemoryCooldownPort::new()),
));
let account_feedback = Arc::new(AccountFeedbackStats::default());
@@ -172,7 +225,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
Arc::new(MemorySessionExclusions::default()),
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().expect("cookie policy"),
));
@@ -181,7 +233,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
selector,
catalog,
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile,
@@ -344,6 +395,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_local_affinity",
"acct_metadata_new",
"acct_metadata_old",
"acct_unknown_continuation",
"acct_unknown_turn_state",
"acct_prefetch_limit",
"acct_presentation",
"acct_provider_contract",
@@ -352,7 +405,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_scope_same",
"acct_semantic_failure",
"acct_session_affinity",
"acct_subagent",
"acct_subagent_a",
"acct_subagent_b",
"acct_success_exhausted",
"acct_thread_spawn_affinity",
"acct_truncated_stream",
@@ -456,6 +510,24 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
}
fn external_continuation_context(request_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
),
NonZeroU32::new(1).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
Some(ContinuationBinding::External(PreviousResponseId::new(
"external-previous-response",
))),
CancellationToken::new(),
)
}
async fn capture_scoped_http_request(
request_id: &str,
selected_account_id: &str,
@@ -820,13 +892,13 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
let cases = [
(
ImageRequestKind::Generation,
"/images/generations",
"/codex/images/generations",
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "background":"transparent", "future_option":{"schema":2}, "future_integer":9007199254740993 }"#.as_slice(),
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#.as_slice(),
),
(
ImageRequestKind::Edit,
"/images/edits",
"/codex/images/edits",
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
),
@@ -918,7 +990,7 @@ async fn image_endpoint_returns_the_exact_upstream_error_response() {
br#"{ "model":"gpt-image-2", "images":[], "future_invalid":9007199254740993 }"#;
let response_body = br#"{ "error":{"message":"future image validation","type":"image_error","code":"future_code"}, "future":9007199254740993 }"#;
Mock::given(method("POST"))
.and(path("/images/edits"))
.and(path("/codex/images/edits"))
.and(body_bytes(request_body.to_vec()))
.respond_with(
ResponseTemplate::new(422)
@@ -1319,6 +1391,53 @@ async fn cross_account_scope_removes_only_account_bound_body_fields() {
);
}
#[tokio::test]
async fn cross_account_full_replay_should_preserve_the_complete_transcript() {
let transcript = json!([
{
"type": "reasoning",
"id": "reasoning-item-id",
"encrypted_content": "reasoning-ciphertext",
"summary": []
},
{
"type": "compaction",
"id": "compaction-item-id",
"encrypted_content": "compaction-ciphertext"
},
{
"type": "agent_message",
"id": "agent-message-id",
"content": [
{"type": "output_text", "text": "visible"},
{"type": "encrypted_content", "encrypted_content": "nested-ciphertext"}
]
},
{
"type": "function_call",
"id": "function-call-id",
"call_id": "call-id",
"name": "tool",
"arguments": "{}",
"encrypted_function_args": "function-args-ciphertext"
}
]);
let request = capture_scoped_http_request(
"req_scope_transcript",
"acct_scope_new",
"acct_scope_old",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), transcript.clone()),
]),
Map::new(),
)
.await;
let body: Value = captured_request_body(&request);
assert_eq!(body.get("input"), Some(&transcript));
}
#[tokio::test]
async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
let request = capture_scoped_http_request(
@@ -1359,40 +1478,19 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
}
#[tokio::test]
async fn cross_account_continuation_probe_should_preserve_delta_input_and_previous_response() {
async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_scope_new").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let delta_input = json!([
{
"type": "message",
"id": "delta-message-id",
"encrypted_content": "opaque-delta-ciphertext",
"role": "user",
"content": "current turn only"
},
{
"type": "compaction",
"id": "delta-compaction-id",
"encrypted_content": "opaque-compaction-ciphertext"
}
]);
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), delta_input.clone()),
(
"input".to_owned(),
json!([{"role": "user", "content": "delta"}]),
),
(
"previous_response_id".to_owned(),
json!("client-previous-response"),
@@ -1432,41 +1530,137 @@ async fn cross_account_continuation_probe_should_preserve_delta_input_and_previo
)
.expect("provider session state"),
);
let mut stream = provider_with_base_url(&store, server.uri())
let result = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
replay_any_context("req_cross_account_probe", "acct_scope_old"),
)
.await;
let Err(error) = result else {
panic!("cross-account continuation must require a full client replay");
};
let detail = error
.client_visible_upstream_error()
.expect("client replay error detail");
let requests = server
.received_requests()
.await
.expect("prepare continuation probe stream");
.expect("captured upstream requests");
assert_eq!(
(
error.kind(),
error.send_state(),
error.continuation_failure(),
detail.code(),
detail.error_type(),
),
(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
Some(ContinuationFailure::HistoryUnavailable),
Some("previous_response_not_found"),
Some("invalid_request_error"),
)
);
assert!(requests.is_empty());
}
#[tokio::test]
async fn missing_affinity_continuation_should_require_client_replay_without_an_upstream_probe() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_unknown_continuation").await;
let server = MockServer::start().await;
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
(
"input".to_owned(),
json!([{"role": "user", "content": "delta"}]),
),
(
"previous_response_id".to_owned(),
json!("external-previous-response"),
),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("session_id".to_owned(), json!("expired-affinity-session")),
])),
);
let result = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
external_continuation_context("req_unknown_continuation"),
)
.await;
let Err(error) = result else {
panic!("unowned continuation must require a full client replay");
};
let requests = server
.received_requests()
.await
.expect("captured upstream requests");
assert_eq!(
error
.client_visible_upstream_error()
.and_then(|detail| detail.code()),
Some("previous_response_not_found")
);
assert!(requests.is_empty());
}
#[tokio::test]
async fn missing_affinity_full_request_should_clear_unowned_turn_state() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_unknown_turn_state").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("full transcript")),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("session_id".to_owned(), json!("expired-turn-state-session")),
("turn_id".to_owned(), json!("turn-after-affinity-expiry")),
("turn_state".to_owned(), json!("unowned-turn-state")),
])),
);
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
context("req_unknown_turn_state", CancellationToken::new()),
)
.await
.expect("prepare full replay stream");
while let Some(event) = stream.next().await {
event.expect("continuation probe response");
event.expect("full replay response");
}
let requests = server
.received_requests()
.await
.expect("captured continuation probe request");
let body: Value = captured_request_body(&requests[0]);
.expect("captured full replay request");
assert_eq!(body.get("input"), Some(&delta_input));
assert_eq!(
body.get("previous_response_id"),
Some(&json!("upstream-previous-response"))
);
assert!(body.get("authorization").is_none());
assert!(body.get("turnMetadata").is_none());
assert!(
body.pointer("/client_metadata/x-codex-turn-state")
.is_none()
);
assert!(
body.pointer("/client_metadata/x-codex-turn-metadata")
.is_none()
);
assert_eq!(
body.pointer("/client_metadata/future"),
Some(&json!("keep"))
);
assert!(captured_header_values(&requests[0], "x-codex-turn-state").is_empty());
}
@@ -1603,6 +1797,106 @@ async fn account_change_drops_only_account_bound_opaque_headers() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn account_selection_log_should_include_affinity_observation_fields() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_affinity").await;
let provider = provider_with_affinity(&store, Arc::new(MemorySessionAffinity::default()));
let captured = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
.json()
.without_time()
.with_ansi(false)
.with_target(false)
.with_writer(captured.clone())
.finish();
// 该 integration test binary 没有其他 subscriber;全局安装可避免并行测试切换
// thread-local dispatcher 时重建 tracing callsite interest 所产生的竞争。
tracing::subscriber::set_global_default(subscriber)
.expect("install affinity observation log subscriber");
for (request_id, prompt_cache_key, session_id) in [
(
"req_affinity_observation_session_first",
"turn-cache-first",
Some("stable-observation-session"),
),
(
"req_affinity_observation_session_second",
"turn-cache-second",
Some("stable-observation-session"),
),
(
"req_affinity_observation_conversation",
"turn-cache-with-conversation",
None,
),
] {
let mut payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("hello")),
("prompt_cache_key".to_owned(), json!(prompt_cache_key)),
]),
)
.expect("OpenAI payload");
if let Some(session_id) = session_id {
payload = payload.with_context(Map::from_iter([(
"session_id".to_owned(),
json!(session_id),
)]));
} else {
payload = payload.with_context(Map::from_iter([(
"conversation_id".to_owned(),
json!("root-observation-conversation"),
)]));
}
let generation = GenerateRequest::from_protocol_payload(payload);
let stream = provider
.execute(
planned_request("openai", Operation::Generate(generation)),
context(request_id, CancellationToken::new()),
)
.await
.expect("prepare affinity observation request");
drop(stream);
}
let events = captured.json_events();
let first = selected_account_log_fields(&events, "req_affinity_observation_session_first");
let second = selected_account_log_fields(&events, "req_affinity_observation_session_second");
for fields in [first, second] {
assert_eq!(fields["affinity_anchor_source"], "root-session");
assert_eq!(fields["affinity_anchor"], "stable-observation-session");
assert_eq!(fields["session_id"], "stable-observation-session");
assert_eq!(fields["session_id_present"], true);
let key_hash = fields["affinity_key_hash"]
.as_str()
.expect("affinity key hash");
assert_eq!(key_hash.len(), 12);
assert!(key_hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
assert_eq!(
first["affinity_key_hash"], second["affinity_key_hash"],
"the same session must emit the same affinity hash"
);
let conversation =
selected_account_log_fields(&events, "req_affinity_observation_conversation");
assert_eq!(conversation["affinity_anchor_source"], "root-conversation");
assert_eq!(
conversation["affinity_anchor"],
"root-observation-conversation"
);
assert_eq!(conversation["session_id"], "");
assert_eq!(conversation["session_id_present"], false);
assert_ne!(
first["affinity_key_hash"], conversation["affinity_key_hash"],
"different anchors must not share the same affinity hash"
);
}
#[tokio::test]
async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1637,9 +1931,10 @@ async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key()
}
#[tokio::test]
async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_session() {
async fn subagent_requests_should_share_the_root_session_account_affinity_key() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_subagent").await;
create_account(&store, "acct_subagent_a").await;
create_account(&store, "acct_subagent_b").await;
let affinity = Arc::new(MemorySessionAffinity::default());
let server = MockServer::start().await;
Mock::given(method("POST"))
@@ -1689,10 +1984,25 @@ async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_ses
let keys = affinity.lookup_keys();
assert_eq!(keys.len(), 3);
assert_ne!(keys[0], keys[1], "subagent must not inherit the root key");
assert_eq!(
keys[1], keys[2],
"the same subagent should retain its own key"
assert!(
keys.iter().all(|key| key == &keys[0]),
"root and derived subagent requests must prefer the same account"
);
let requests = server
.received_requests()
.await
.expect("captured root and subagent requests");
let selected_accounts = requests
.iter()
.filter_map(|request| request.headers.get("chatgpt-account-id"))
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert_eq!(selected_accounts.len(), 3);
assert!(
selected_accounts
.iter()
.all(|account| account == &selected_accounts[0]),
"root and subagents should route to the same preferred account"
);
}
@@ -1867,7 +2177,7 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st
}
#[tokio::test]
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
async fn thread_spawn_children_should_share_the_parent_account_affinity_key() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_thread_spawn_affinity").await;
let affinity = Arc::new(MemorySessionAffinity::default());
@@ -1912,10 +2222,10 @@ async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
let keys = affinity.lookup_keys();
assert_eq!(keys.len(), 5);
assert_ne!(keys[0], keys[1]);
assert_eq!(keys[0], keys[2]);
assert_ne!(keys[3], keys[4]);
assert_ne!(keys[0], keys[3]);
assert!(
keys.iter().all(|key| key == &keys[0]),
"child thread transport identities must not split parent account affinity"
);
}
#[tokio::test]
@@ -2539,7 +2849,7 @@ async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
}
#[tokio::test]
async fn previous_response_not_found_should_remain_client_visible_without_internal_replay_proof() {
async fn same_account_previous_response_not_found_should_remain_client_visible() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_client_history").await;
let server = MockServer::start().await;
@@ -2578,7 +2888,7 @@ async fn previous_response_not_found_should_remain_client_visible_without_intern
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context("req_history_missing", CancellationToken::new()),
context_with_state_owner("req_history_missing", "acct_client_history"),
)
.await
.expect("prepare missing-history stream");
@@ -27,11 +27,9 @@ use gateway_core::provider_ports::{
};
use gateway_core::routing::ProviderKind;
use provider_openai::credential::{
CodexAccountProfile, CodexAgentIdentityTaskService, CodexCredentialAdmin,
CodexCredentialRepository, CodexOAuthSecret, ImportCodexOAuthCredential,
OfficialCodexAgentIdentityTaskRegistrar,
CodexAccountProfile, CodexCredentialAdmin, CodexCredentialRepository, CodexOAuthSecret,
ImportCodexOAuthCredential,
};
use provider_openai::transport::CodexWebSocketPool;
use secrecy::SecretString;
#[derive(Clone)]
@@ -510,32 +508,6 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
.with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at)
}
pub(crate) fn agent_identity_service(
store: &Arc<MemoryAccountStore>,
) -> Arc<CodexAgentIdentityTaskService> {
agent_identity_service_with_pool(store, Arc::new(CodexWebSocketPool::default()))
}
pub(crate) fn agent_identity_service_with_pool(
store: &Arc<MemoryAccountStore>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Arc<CodexAgentIdentityTaskService> {
let client = reqwest::Client::builder()
.no_proxy()
.build()
.expect("agent task client");
let registrar = OfficialCodexAgentIdentityTaskRegistrar::new(
client,
provider_openai::OpenAiConfig::default().wire_profile_state(),
)
.expect("agent task registrar");
Arc::new(CodexAgentIdentityTaskService::new(
store.repository(),
Arc::new(registrar),
websocket_pool,
))
}
#[derive(Default)]
pub(crate) struct TestLeaseCoordinator {
pub(crate) requests: Mutex<Vec<ProviderSchedulingLeaseRequest>>,
@@ -678,6 +650,55 @@ impl ProviderSessionAffinityPort for MemorySessionAffinity {
})
}
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>> {
Box::pin(async move {
let mut bindings = self.bindings.lock().expect("session affinity lock");
Ok(bindings
.entry((
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
))
.or_insert_with(|| candidate_account_id.clone())
.clone())
})
}
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>> {
Box::pin(async move {
let binding_key = (
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
);
let mut bindings = self.bindings.lock().expect("session affinity lock");
if bindings
.get(&binding_key)
.is_none_or(|current| current == expected_account_id)
{
bindings.insert(binding_key, replacement_account_id.clone());
return Ok(replacement_account_id.clone());
}
bindings.get(&binding_key).cloned().ok_or_else(|| {
ProviderStoreError::new(
gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable,
"resolve in-memory provider session affinity",
)
})
})
}
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -195,7 +195,7 @@ fn bare_http_429_remains_a_temporary_rate_limit() {
}
#[test]
fn sub2api_openai_account_failure_matrix_is_preserved() {
fn openai_account_failure_matrix_is_preserved() {
let cases = [
(
400,
@@ -265,7 +265,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let response = backend
.create_response(
@@ -480,7 +480,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
client
.create_response(
@@ -932,7 +932,7 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
});
let profile = test_wire_profile();
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
@@ -300,7 +300,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -354,7 +354,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
.unwrap();
assert_eq!(read, 0);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -733,7 +733,7 @@ async fn concurrent_multi_conversation_continuations_should_select_each_exact_so
tokio::join!(first_continuation, second_continuation);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1190,7 +1190,7 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1325,7 +1325,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
write_completed_sse_response(&mut second_http).await;
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -495,8 +495,6 @@ fn websocket_pool_config_for_tests(
CodexWebSocketPoolConfig {
enabled: true,
max_age: Duration::from_mins(1),
max_per_account: 8,
max_total: 64,
max_connecting: 16,
maintenance_interval,
ping_interval,
@@ -70,7 +70,7 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
}
#[test]
fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("secret prompt")),
@@ -78,7 +78,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
"tools".to_owned(),
json!([{"type": "function", "name": "lookup", "strict": true}]),
),
("max_output_tokens".to_owned(), json!(512)),
("store".to_owned(), json!(false)),
(
"reasoning".to_owned(),
@@ -89,7 +88,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
assert!(encoded.body().get("stream").is_none());
assert_eq!(encoded.body().get("max_output_tokens"), Some(&json!(512)));
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/tools/0/strict"), Some(&json!(true)));
@@ -97,6 +95,28 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
assert!(!encoded.force_http_sse);
}
#[test]
fn encoder_should_remove_unsupported_fields_from_upstream_body() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("hello")),
("max_output_tokens".to_owned(), json!(512)),
("max_tokens".to_owned(), json!(256)),
("temperature".to_owned(), json!(0.2)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
json!({
"model": "gpt-test",
"input": "hello",
"max_tokens": 256,
})
);
}
#[test]
fn encoder_should_preserve_client_store_intent() {
let request = request(Map::from_iter([
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
websocket.send(Message::Text(payload.into())).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -932,7 +932,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
.unwrap();
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1914,7 +1914,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_string()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -35,7 +35,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
}
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -97,7 +97,7 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
format!("http://{addr}"),
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -401,7 +401,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
}
#[tokio::test]
async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
async fn codex_backend_client_stream_should_use_http_when_connecting_limit_is_exhausted() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
@@ -414,7 +414,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
write_completed_sse_response(&mut first_stream).await;
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 0,
max_connecting: 0,
initial_event_timeout: Some(Duration::from_millis(30)),
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -432,7 +432,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
request_context("req_structural_bypass", Some("chatgpt-account")),
)
.await
.expect("pool cap should select HTTP before sending payload");
.expect("exhausted connection-opening limit should select HTTP before sending payload");
assert_eq!(response.transport, CodexBackendTransport::HttpSse);
assert_eq!(
response.transport_metrics.decision,
@@ -472,7 +472,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
websocket.close(None).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -841,140 +841,32 @@ async fn websocket_pool_shutdown_should_cancel_and_join_active_stream() {
}
#[tokio::test]
async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
async fn websocket_pool_should_keep_sixty_fifth_conversation_on_same_account_on_websocket() {
const REQUEST_COUNT: usize = 65;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
let accepted_connections_for_server = Arc::clone(&accepted_connections);
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
let _first_message = first_websocket.next().await.unwrap().unwrap();
first_websocket
.send(Message::Text(
completed_websocket_response("resp_cap_first", 2, 1).into(),
))
.await
.unwrap();
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut websockets = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let (stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
write_completed_sse_response(&mut stream).await;
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
completed_websocket_response(&format!("resp_same_account_{index}"), 2, 1)
.into(),
))
.await
.unwrap();
websockets.push(websocket);
}
drop(first_websocket);
});
let pool = Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(pool);
let first_request = pooled_websocket_request("conversation-cap-one");
let second_request = pooled_websocket_request("conversation-cap-two");
let first = backend
.create_response(
&first_request,
request_context("req_cap_first", Some("chatgpt-account")),
)
.await
.expect("first capped websocket response should succeed");
let second = backend
.create_response(
&second_request,
request_context("req_cap_second", Some("chatgpt-account")),
)
.await
.expect("new key over account cap should use HTTP");
let third = backend
.create_response(
&second_request,
request_context("req_cap_third", Some("chatgpt-account")),
)
.await
.expect("capped key should keep using HTTP instead of opening one-shot sockets");
server.await.unwrap();
assert!(first.body.contains("resp_cap_first"));
assert!(second.body.contains("response.completed"));
assert!(third.body.contains("response.completed"));
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(
second.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(
third.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(accepted_connections.load(Ordering::SeqCst), 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(8, 2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_total_slots_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(3, 8).await;
assert_eq!((websockets, http), (3, 5));
}
#[tokio::test]
async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
let mut first = accept_codex_test_websocket(first_stream).await;
let _ = first.next().await.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_global_lru_first", 2, 1).into(),
))
.await
.unwrap();
let (second_stream, _) = listener.accept().await.unwrap();
let mut second = accept_codex_test_websocket(second_stream).await;
let _ = second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_global_lru_second", 2, 1).into(),
))
.await
.unwrap();
let close = timeout(Duration::from_secs(2), first.next())
.await
.expect("global LRU eviction should close the oldest idle socket")
.expect("oldest idle socket should emit a close frame")
.expect("oldest idle socket close should be valid");
std::assert_matches!(close, Message::Close(_));
let (third_stream, _) = listener.accept().await.unwrap();
let mut third = accept_codex_test_websocket(third_stream).await;
let _ = third.next().await.unwrap().unwrap();
third
.send(Message::Text(
completed_websocket_response("resp_global_lru_third", 2, 1).into(),
))
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 8,
max_total: 2,
max_connecting: 2,
max_connecting: 1,
..websocket_pool_config_for_tests(None, None, None)
}));
let backend = CodexBackendClient::new(
@@ -983,36 +875,52 @@ async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut responses = Vec::new();
for index in 0..3 {
let request = pooled_websocket_request(&format!("conversation-global-lru-{index}"));
let account_id = format!("account-global-lru-{index}");
let request_id = format!("req_global_lru_{index}");
let mut responses = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let request = pooled_websocket_request(&format!("conversation-same-account-{index}"));
responses.push(
backend
.create_response(&request, request_context(&request_id, Some(&account_id)))
.create_response(
&request,
request_context(
&format!("req_same_account_{index}"),
Some("chatgpt-account"),
),
)
.await
.expect("global LRU request should keep using websocket"),
.expect("same-account conversation should use websocket"),
);
}
server.await.unwrap();
assert!(responses[0].body.contains("resp_global_lru_first"));
assert!(responses[1].body.contains("resp_global_lru_second"));
assert!(responses[2].body.contains("resp_global_lru_third"));
assert!(
responses
.iter()
.all(|response| response.transport == CodexBackendTransport::WebSocket)
assert_eq!(
(
responses
.iter()
.filter(|response| response.transport == CodexBackendTransport::WebSocket)
.count(),
accepted_connections.load(Ordering::SeqCst),
),
(REQUEST_COUNT, REQUEST_COUNT)
);
pool.shutdown().await;
}
async fn concurrent_pool_transport_counts(
max_total: usize,
max_connecting: usize,
) -> (usize, usize) {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_allow_all_openings_when_connect_permits_are_available() {
let (websockets, http) = concurrent_pool_transport_counts(8).await;
assert_eq!((websockets, http), (8, 0));
}
async fn concurrent_pool_transport_counts(max_connecting: usize) -> (usize, usize) {
const REQUEST_COUNT: usize = 8;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1051,7 +959,7 @@ async fn concurrent_pool_transport_counts(
websocket
.send(Message::Text(
completed_websocket_response(
&format!("resp_global_cap_{max_total}_{max_connecting}_{index}"),
&format!("resp_connecting_cap_{max_connecting}_{index}"),
2,
1,
)
@@ -1063,8 +971,6 @@ async fn concurrent_pool_transport_counts(
(websocket_count, http_count)
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: REQUEST_COUNT,
max_total,
max_connecting,
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -1096,7 +1002,7 @@ async fn concurrent_pool_transport_counts(
let response = request
.await
.unwrap()
.expect("globally capped request should complete");
.expect("concurrent request should complete");
if response.transport == CodexBackendTransport::WebSocket {
websocket_responses += 1;
}
@@ -1910,7 +1816,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1987,7 +1893,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -237,19 +237,19 @@ impl GrokCredentialAdmin {
let mut secret = decode_secret(&loaded.credential)?;
let mut credentials = serde_json::Map::new();
credentials.insert(
"access_token".to_owned(),
"accessToken".to_owned(),
Value::String(std::mem::take(&mut secret.access_token)),
);
credentials.insert(
"refresh_token".to_owned(),
"refreshToken".to_owned(),
Value::String(std::mem::take(&mut secret.refresh_token)),
);
if let Some(id_token) = secret.id_token.take() {
credentials.insert("id_token".to_owned(), Value::String(id_token));
credentials.insert("idToken".to_owned(), Value::String(id_token));
}
credentials.insert("token_type".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert("tokenType".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert(
"expires_at".to_owned(),
"expiresAt".to_owned(),
Value::String(
loaded
.account
@@ -260,11 +260,11 @@ impl GrokCredentialAdmin {
),
);
credentials.insert(
"base_url".to_owned(),
"baseUrl".to_owned(),
Value::String(GROK_CLI_BASE_URL.to_owned()),
);
credentials.insert(
"client_id".to_owned(),
"clientId".to_owned(),
Value::String(OFFICIAL_CLIENT_ID.to_owned()),
);
credentials.insert(
@@ -290,7 +290,7 @@ impl GrokCredentialAdmin {
Ok(GrokAccountExport(serde_json::json!({
"version": 1,
"type": "oauth-account-bundle",
"exported_at": exported_at.to_rfc3339(),
"exportedAt": exported_at.to_rfc3339(),
"accounts": exported_accounts,
"proxies": [],
})))
+1 -1
View File
@@ -2076,7 +2076,7 @@ const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
// 缺少可信 reset 时间时按短周期探测策略恢复检查。
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
@@ -108,7 +108,7 @@ impl GrokCompactionRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -286,8 +286,7 @@ pub(crate) fn compaction_wire_events(
Value::Null,
);
let item = compaction_item(summary, encrypted_content);
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
// 摘要生成阶段的 incomplete 状态。
// compact 成功以真实密文为依据,不向客户端延续摘要生成阶段的 incomplete 状态。
let terminal_response = compaction_response(
completed,
terminal_source.or(created_source),
@@ -91,7 +91,7 @@ impl GrokResponsesRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -1928,8 +1928,8 @@ impl ToolNormalizer {
"apply_patch" => self.normalize_apply_patch_tool(tool),
"x_search" | "collections_search" | "file_search" | "code_execution"
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
// sub2api 的 xAI Responses 适配层对未获 xAI 接受的工具做静默过滤,
// 并在过滤后同步收敛 tool_choice;不要把客户端可选扩展升级成整单 400。
// 对未获 xAI 接受的工具做静默过滤,并在过滤后同步收敛 tool_choice;
// 不要把客户端可选扩展升级成整单 400。
"" | "computer_use_preview" | "image_generation" => Ok(Vec::new()),
_ => Ok(Vec::new()),
}
@@ -3517,15 +3517,15 @@ fn normalize_compaction_input(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let has_sub2api_shape = item.contains_key("summary")
let has_structured_compaction_shape = item.contains_key("summary")
|| item.contains_key("id")
|| item.contains_key("status")
|| string_field(item, "type").trim() == "compaction_summary";
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格按
// sub2api 回放为 xAI reasoning 密文 + 可见 conversation summary。
if !has_sub2api_shape {
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
// xAI reasoning 密文 + 可见 conversation summary。
if !has_structured_compaction_shape {
let Some(summary) = encrypted else {
return Ok(Vec::new());
};
@@ -363,20 +363,48 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
let value = export.into_value();
assert_eq!(value["version"], 1);
assert_eq!(value["type"], "oauth-account-bundle");
assert!(value.get("exportedAt").is_some());
assert!(value.get("exported_at").is_none());
assert_eq!(value["accounts"][0]["platform"], "grok");
assert_eq!(value["accounts"][0]["type"], "oauth");
assert_eq!(
value["accounts"][0]["credentials"]["base_url"],
value["accounts"][0]["credentials"]["baseUrl"],
provider_xai::GROK_CLI_BASE_URL
);
assert_eq!(
value["accounts"][0]["credentials"]["access_token"],
value["accounts"][0]["credentials"]["accessToken"],
"access-export"
);
assert_eq!(
value["accounts"][0]["credentials"]["refreshToken"],
"refresh-export"
);
assert_eq!(value["accounts"][0]["credentials"]["idToken"], "id-export");
assert_eq!(value["accounts"][0]["credentials"]["tokenType"], "Bearer");
assert!(
value["accounts"][0]["credentials"]["expiresAt"]
.as_str()
.is_some()
);
assert_eq!(
value["accounts"][0]["credentials"]["clientId"],
provider_xai::OFFICIAL_CLIENT_ID
);
assert_eq!(
value["accounts"][0]["credentials"]["scope"],
"openid profile email offline_access grok-cli:access api:access"
);
for field in [
"access_token",
"refresh_token",
"id_token",
"token_type",
"expires_at",
"base_url",
"client_id",
] {
assert!(value["accounts"][0]["credentials"].get(field).is_none());
}
assert_eq!(value["proxies"], serde_json::json!([]));
}
@@ -225,7 +225,7 @@ fn oauth_account_document_should_reject_api_key_credential_field() {
fn oauth_account_document_should_ignore_source_wrapper_and_non_auth_metadata() {
let mut document: serde_json::Value =
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
document["type"] = serde_json::Value::String("sub2api-data".to_owned());
document["type"] = serde_json::Value::String("external-oauth-data".to_owned());
document["version"] = serde_json::Value::from(99);
document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
document["source_extension"] = serde_json::json!({"opaque": true});
@@ -1534,7 +1534,7 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
}
#[tokio::test]
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
async fn compaction_stream_should_accept_short_summary() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
"<summary>too short</summary>",
None,
@@ -713,6 +713,27 @@ impl ProviderSessionAffinityPort for TestSessionAffinity {
Box::pin(async { Ok(()) })
}
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 { Ok(candidate_account_id.clone()) })
}
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 { Ok(replacement_account_id.clone()) })
}
fn clear<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
@@ -76,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
}
#[test]
fn encoder_should_preserve_sub2api_compaction_constraints() {
fn encoder_should_preserve_compaction_constraints() {
let request = encode(json!({
"model": "client-model",
"input": [
@@ -190,7 +190,7 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
}
#[test]
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
fn decoder_should_preserve_summary_text_after_trimming_edges() {
let raw = valid_summary("checkpoint-only-secret");
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
@@ -234,7 +234,7 @@ fn decoder_should_preserve_unclosed_leading_analysis() {
}
#[test]
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
fn decoder_should_preserve_compaction_control_tokens() {
let raw = valid_summary(
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
plus <summary_request> and </summary_request>.",
@@ -278,7 +278,7 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
}
#[test]
fn decoder_should_accept_short_summary_like_sub2api() {
fn decoder_should_accept_short_summary() {
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
assert_eq!(summary, "<summary>too short</summary>");
@@ -567,7 +567,7 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
}
#[tokio::test]
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
async fn inference_transport_should_apply_body_aware_400_failures() {
let cases = [
(
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
@@ -198,7 +198,7 @@ fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
}
#[test]
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
fn encoder_should_strip_grok_unsupported_fields() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
@@ -943,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
}
#[test]
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
fn hosted_tool_choice_should_preserve_normalized_web_search_choice() {
let request = raw_request(json!({
"model": "client",
"input": "search",
@@ -1244,7 +1244,7 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
}
#[test]
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
fn structured_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
for item_type in ["compaction", "compaction_summary"] {
let request = raw_request(json!({
"model": "client",
@@ -1264,7 +1264,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("sub2api compaction continuation");
.expect("structured compaction continuation");
let body = Value::Object(encoded.body().clone());
assert_eq!(
@@ -1290,7 +1290,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}
#[test]
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
fn malformed_compaction_history_should_be_dropped() {
for encrypted_content in [
None,
Some(json!(null)),
+1 -2
View File
@@ -1,2 +1 @@
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
+1 -1
View File
@@ -315,7 +315,7 @@ create table model_requests (
operation text not null,
endpoint text not null,
client_transport text not null,
requested_model_id text not null,
requested_model_id text,
provider_kind text,
upstream_model_id text,
service_tier text,
@@ -1,2 +0,0 @@
alter table model_requests
alter column requested_model_id drop not null;
+1 -2
View File
@@ -4,8 +4,7 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
## 冻结规则
+1 -1
View File
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa AS backend-builder
FROM rust:1.98-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+2 -20
View File
@@ -131,7 +131,7 @@ Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provide
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和 Agent Identity;
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)和 CPR 账号 bundle;
- OpenAI OAuth token 字段只识别 `accessToken`、`refreshToken`、`idToken`,可以嵌套在账号 object 内;
每项至少包含 AT 或 RT。RT-only 会在导入时换取 AT,AT-only 不具备自动续期能力;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
@@ -160,25 +160,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
`taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种
认证类型,导入时按每个账号的认证模式分别解析。
```json
{
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_...",
"accountId": "...",
"userId": "...",
"authMode": "agentIdentity",
"agentRuntimeId": "...",
"agentPrivateKey": "...",
"taskId": "..."
}]
}
```
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
OpenAI rotation 请求字段为:
+2 -2
View File
@@ -59,7 +59,7 @@ flowchart LR
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin wire、静态管理端;不直接访问 Store 或具体 Provider |
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 |
| `providers/openai` | OpenAI OAuth/Agent Identity、账号选择、目录、额度、Responses/Images transport |
| `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images transport |
| `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 |
| `frontend` | Vue 管理端,仅通过 Admin API 读写状态 |
@@ -189,7 +189,7 @@ PostgreSQL 周期对账才是正确性基础。
credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403
也不能单独证明 refresh token 永久失效。
- OpenAI 支持 OAuth、AT/RT、OAuth JSON 与 Agent Identity;RT-only 导入先换取 AT,AT-only 导入没有
- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON;RT-only 导入先换取 AT,AT-only 导入没有
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
+365
View File
@@ -0,0 +1,365 @@
<!-- prettier-ignore -->
<div align="center">
<img src="../frontend/public/favicon.svg" alt="Codex Proxy RS" width="72" height="72" />
# 管理端主题系统
**基于 Seed、Map、Alias 与 Component Token 的运行时主题架构**
[设计原则](#设计原则) · [Token 模型](#token-模型) · [运行时架构](#运行时架构) · [主题编辑器](#主题编辑器) · [扩展指南](#扩展指南)
</div>
主题系统为 Codex Proxy RS 管理端提供浅色、深色、预置配色与自定义配色能力。实现借鉴 Ant Design 的
Token 分层与十阶色板算法,但不引入 `antd`、React、`ConfigProvider` 或 CSS-in-JS;Vue 组件继续通过
CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
> [!IMPORTANT]
> 主题的目标是调整颜色、密度、圆角与层级,不改变现有页面结构和产品语义。默认主题必须保持既有视觉基线,
> 成功、警告、错误和业务数据色不会因为品牌色变化而失去原有含义。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
- **运行时可定制**:用户输入在浏览器中实时派生,因此使用 CSS Variables,不使用构建时 SCSS 变量。
- **单一事实源**:Store 只保存最小配置,所有 Map、Alias 与未覆盖的 Component Token 均由纯函数生成。
- **可访问性优先**:文字、焦点和实心控件在 Alias 层执行对比度保护,并尊重 `prefers-reduced-motion`。
## 架构概览
```mermaid
flowchart LR
A[Pinia 持久化配置] --> B[resolveTheme]
B --> C[Seed Token]
C --> D[Map Token]
D --> E[Alias Token]
E --> F[Component Token]
F --> G[#cp-runtime-theme]
G --> H[Vue / Tailwind / ECharts]
B --> I[Theme Editor 草稿]
I --> J[Shadow DOM 预览]
J --> K[首页画板 / 组件概览]
```
核心职责保持分离:
| 层 | 文件 | 职责 |
| --- | --- | --- |
| 领域模型 | [`theme/index.ts`](../frontend/src/theme/index.ts) | 类型、规范化、纯派生算法与最终 Token 输出 |
| 设计基线 | [`theme/constants.ts`](../frontend/src/theme/constants.ts) | 默认值、预置主题、中性色面、阴影与可编辑白名单 |
| 浏览器适配 | [`theme/browser.ts`](../frontend/src/theme/browser.ts) | 将解析结果提交为根作用域运行时样式表 |
| 状态 | [`stores/modules/theme.ts`](../frontend/src/stores/modules/theme.ts) | 持久化配置、系统明暗偏好、切换动作与动画 |
| 编辑器状态 | [`useThemeEditor.ts`](../frontend/src/views/theme/composables/useThemeEditor.ts) | 草稿、修改计数、恢复与保存 |
| 样式桥接 | [`styles/index.css`](../frontend/src/styles/index.css) | 将 CSS Token 暴露为 Tailwind CSS 4 utilities |
| 稳定语义 | [`styles/tokens.css`](../frontend/src/styles/tokens.css) | 不参与主题派生的业务常量 |
`theme/index.ts` 不访问 DOM,`theme/browser.ts` 不包含派生规则,Theme Store 不复制算法。类型与算法集中在
`theme/index.ts`,不额外拆分 `types.ts`。
## Token 模型
主题采用四层模型:
```text
Seed Token → Map Token → Alias Token → Component Token
```
### Seed Token
Seed 是用户配置与持久化的最小事实:
```ts
type ThemeMode = 'system' | 'light' | 'dark'
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
interface ThemeCustomization {
seed?: Partial<ThemeSeedOverrides>
component?: Partial<ThemeComponentOverrides>
tokenOverrides?: Partial<ThemeTokens>
}
```
| 类别 | Seed |
| --- | --- |
| 品牌 | `colorPrimary`,由预置 ID 或自定义 HEX 提供 |
| 功能色 | `colorSuccess`、`colorWarning`、`colorError`、`colorInfo`、`colorLink` |
| 中性色 | `colorTextBase`、`colorBgBase` |
| 尺寸 | `fontSize`、`sizeUnit`、`sizeStep`、`controlHeight` |
| 风格 | `borderRadius`、`shadowStrength` |
| 组件尺寸 | `tableRowHeight`、`cardBorderRadius` |
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
初始化时规范化并回退到默认配置。
### Map Token
品牌色和功能色使用 `@ant-design/colors` 从一个 Seed 生成十阶色板。主色派生规则为:
| 角色 | 规则 |
| --- | --- |
| `colorPrimary` | 始终使用 Seed 原色 |
| Hover | 浅色取 P5,深色取暗色算法 P8 |
| Active | 浅色取 P7,深色取暗色算法 P6 |
| 实心 Hover | Seed 向白混合 6% |
| 实心 Active | Seed 向黑混合 8% |
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
功能色的基础 Map 对齐 Ant Design:`info`、`success`、`warning`、`error` 均固定取十阶色板 P6。浅色 P6
就是用户选择的 Seed,因此 `--cp-color-info / success / warning / error` 不再经过额外对比度改写;深色 P6
继续使用暗色色板对当前 Surface 做主题适配。功能色的 Hover、Active、弱背景、文字和描边仍按各自角色派生。
背景与文本 Seed 进入独立的 Surface Map,生成:
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
- `colorFillSecondary / Tertiary / Quaternary`
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
默认中继蓝使用固定的石墨中性色基线。其他预置通过各自的浅色、深色 `colorBgBase` 与 `colorTextBase`
建立完整气质。自定义主色只向中性表面注入少量色温:浅色混入 2.8%,深色从默认暗面与暗色色板背景混合;
用户显式设置背景或文字 Seed 后,自动配套值立即让位。
### Alias Token
Alias 按视觉角色命名,使用 `--cp-` 命名空间,并优先对齐 Ant Design 的公开 Token 词汇:
| 角色 | CSS Token | 主要消费者 |
| --- | --- | --- |
| 品牌交互 | `--cp-color-primary*` | 主操作、选中态、焦点与强调 |
| 链接 | `--cp-color-link*` | 普通文本链接 |
| 表面 | `--cp-color-bg-*` | 页面、容器、浮层和遮罩 |
| 填充 | `--cp-color-fill-*` | 轨道、弱背景和静态层级 |
| 文字 | `--cp-color-text*` | 标题、正文和辅助信息 |
| 选择 | `--cp-control-item-bg-active*` | Segmented、Select 和选择控件 |
| 焦点 | `--cp-control-outline` | 键盘焦点和输入反馈 |
| 功能色 | `--cp-color-info / success / warning / error-*` | 系统反馈与状态 |
Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
### Component Token
当组件需要独立演进时,使用 `component[-part][-variant][-state]-property` 命名,不创造含义重复的全局别名。
| 组件 | 代表 Token |
| --- | --- |
| Button | `--cp-button-primary-color / bg / hover-bg / active-bg` |
| Input | `--cp-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
| Menu | `--cp-menu-item-selected-bg` |
| Table | `--cp-table-row-bg / stripe-bg / hover-bg / selected-bg / height` |
| Card | `--cp-card-bg / border-radius / shadow` |
| Layout | `--cp-layout-sider-bg / shadow` |
| Scrollbar | `--cp-scrollbar-thumb-bg / hover-bg` |
| BrandMark | `--cp-brand-mark-bg` |
应用内品牌图标使用 [`AppBrandMark.vue`](../frontend/src/components/AppBrandMark.vue):浅色模式取
`colorBgSpotlight`,深色模式取 `colorBgElevated`,保持中性暗面与白色图形;浏览器 favicon 继续使用固定黑白
图标,不随主题改变。
### 稳定业务语义
下列颜色不属于通用主题皮肤,保留在 `styles/tokens.css`:
- 账号“正常”状态 `colorStatusNormal*`
- 推理标识色 `colorReasoning`
- 账号套餐标识色 `accountPlan*`
它们可以分别拥有浅色和深色值,但不会被品牌 Seed 重写。图表数据系列同样按数据语义管理,不把所有曲线染成
主色。
## 预置主题
| ID | 名称 | 主色 | 表面方向 |
| --- | --- | --- | --- |
| `relay-blue` | 中继蓝 | `#5983F4` | 默认石墨控制面,清晰可信 |
| `deep-teal` | 深海青 | `#0E7C72` | 氧化铜与深水玻璃,冷静低饱和 |
| `signal-violet` | 古风色 | `#A0583D` | 赭石、宣纸与松烟墨,温厚克制 |
| `graphite` | 石墨 | `#525B66` | 工作室灰纸与铅笔粉末,近单色层级 |
预置主题只改变颜色画像,不改变字号、密度、圆角或布局。所有预置与自定义主题走同一条派生链路,不存在页面级
特判。
## 运行时架构
### 启动顺序
[`main.ts`](../frontend/src/main.ts) 在 Vue 挂载前初始化主题:
```text
createApp
→ app.use(pinia)
→ persistedstate 同步水合 Theme Store
→ themeStore.initializeTheme()
→ app.use(router / auth)
→ app.mount('#app')
```
这样首个 Vue 组件渲染时已经具备正确主题,不需要在 `index.html` 增加 bootstrap 脚本,也不维护第二套本地存储
解析逻辑。
### CSS Variables 提交
全局主题由 `<head>` 中唯一的运行时样式节点承载:
```html
<style id="cp-runtime-theme">
:root[data-theme='dark'][data-theme-color='relay-blue'] {
color-scheme: dark;
--cp-color-bg-layout: #0B111C;
/* 其余 Map、Alias 与 Component Token */
}
</style>
```
根元素只保留 `data-theme` 与 `data-theme-color` 状态,不写整套内联 Token。运行时样式表比逐项调用
`style.setProperty()` 更集中、可检查,也避免在 DOM 属性中形成超长变量串。全局弹窗通过 Teleport 挂到 `body`
后仍继承根变量。
> [!NOTE]
> Theme Editor 预览是例外:草稿 Token 以内联变量写在影子环境的局部根节点上,仅影响预览,不污染已保存主题。
### 切换与图表
- 用户主动切换时,以点击位置为圆心运行 View Transition;不支持时使用 180ms 颜色过渡。
- 初始化、系统主题变化或减少动态效果时直接提交,不播放扩散动画。
- 每次有效提交只增加一次 `themeRevision`,相同签名不会重复刷新。
- `useThemeColor()` 在正式页面读取根 CSS Variables,在影子预览中优先读取注入的局部 Token。
- 图表 Option 在主题 revision 或预览 Token 改变后重算,`BaseChart` 通过 `setOption` 更新现有 Canvas。
## 主题编辑器
主题编辑器位于一级路由 `/theme`,采用一屏工作台:左侧编辑,右侧预览,顶部保留全局操作。桌面端高度固定为
`100dvh - 3rem`,切换编辑层级或预览类型时不改变外框高度。窄屏头部操作允许自然换行;手机端隐藏实时预览,
只保留 Token 编辑,避免在有限宽度内渲染不可操作的缩放画板。
### 编辑能力
| 区域 | 能力 |
| --- | --- |
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
| 全局 / 风格 | 通用圆角、阴影强度 |
| 组件 | Action、Form、Selection、Surface、Data Display、Feedback、Navigation、Layout |
| 工作流 | 搜索、单项恢复、撤销草稿、恢复默认、保存并应用 |
Component Token 只开放白名单字段。未覆盖项始终继续使用全局 Seed 与 Alias 算法,避免主题配置逐渐退化成一份
无法维护的完整 CSS 快照。
### 草稿与保存
编辑器维护 `saved` 与 `draft` 两份状态:
1. 输入只更新草稿和局部预览。
2. 修改计数按模式、主题色、Seed、组件值和 Token override 分项统计。
3. “撤销草稿”恢复到最近一次已保存配置。
4. “保存并应用”先规范化草稿,再原子更新 Theme Store。
### 隔离预览
[`ThemePreviewScope.vue`](../frontend/src/views/theme/components/ThemePreviewScope.vue) 创建开放 Shadow Root,复制应用
样式,并把预览内容 Teleport 到影子根中。预览可独立切换浅色和深色,不受外层主题影响。
- **首页画板**复用真实 `DashboardContent` 和固定 fixture,不请求接口,也不启动自动刷新。
- **组件概览**展示基础组件、表格、空状态、骨架、浮层和菜单等关键状态。
- 画板固定为 `1600 × 1808`,使用 CSS `zoom` 重排,不使用 `transform: scale()` 长期缩放文字。
- 空白区域可拖拽,滚轮以指针为锚点缩放,并提供缩小、100%、放大和适应画板操作。
- 编辑面板和组件概览统一使用 `BaseScrollbar`,滚动条空闲时自动隐藏。
## 样式与命名约定
### CSS Token
- 全局 Token:`--cp-color-bg-container`、`--cp-font-size`。
- Component Token:`--cp-table-row-hover-bg`、`--cp-input-active-shadow`。
- 业务 Token:命名中明确业务域,例如 `--cp-account-plan-pro-bg`。
- 禁止继续引入 `accent`、`soft`、`current`、`subtle` 等与现有角色重叠的平行词汇。
### Vue 与 Tailwind CSS 4
组件优先使用 `bg-cp-*`、`text-cp-*`、`shadow-cp-*`、`rounded-cp-*` 等由 `@theme inline` 暴露的 utility:
```vue
<section class="rounded-cp-card bg-cp-bg-container text-cp-text shadow-cp-card">
...
</section>
```
仅当值需要在 CSS 函数、SVG 或局部派生中参与计算时,直接读取 `var(--cp-*)`。不在页面组件中重新实现色阶、
对比度或明暗算法。
### 视觉状态
- 静态填充、Hover、Active 与 Selected 必须使用不同角色,不能复用一个变量制造所有层级。
- Input 的 Hover 与 Active 只改变内部填充;外圈反馈保持同色同宽,错误状态使用独立 Error Token。
- 表格斑马纹使用 `table-row-stripe-bg`,不借用 `row-hover-bg`。
- Card 和选项默认不增加装饰性边框;键盘焦点必须保留可见反馈。
- 阴影保持中性,`shadowStrength` 只调节层级强弱,不给阴影染品牌色。
## 扩展指南
### 增加预置主题
1. 在 `ThemeColorPresetId` 增加稳定 ID。
2. 在 `THEME_COLOR_PRESETS` 声明名称、描述、主色 Seed 与可选浅暗表面画像。
3. 不新增页面特判,确认预置可通过同一 `resolveTheme()` 派生。
4. 在浅色、深色和系统模式下检查文本、浮层、Input、表格与图表。
### 增加全局 Seed 或 Alias
1. 在 `theme/index.ts` 的领域类型中声明字段。
2. 在规范化函数中定义合法输入边界。
3. 在 Map 或 Alias 层集中派生,并加入 `ThemeTokens` 完整输出。
4. 如需 Tailwind utility,在 `styles/index.css` 的 `@theme inline` 中映射。
5. 只在确实需要用户控制时加入 Theme Editor;派生细节默认只读。
### 增加 Component Token
1. 先确认全局 Alias 无法准确表达组件职责。
2. 使用 `component[-part][-variant][-state]-property` 命名。
3. 在 `ThemeComponentMap` 和 `toThemeTokens()` 中提供默认派生值。
4. 需要开放编辑时,加入对应组件目录和可编辑白名单。
5. 基础组件消费 Token,页面不得再写第二套局部常量。
> [!WARNING]
> 不要把任意 HEX、阴影或尺寸加入 `styles/tokens.css` 作为“临时修复”。可换肤值必须进入派生链;只有稳定的
> 业务语义与首帧安全 fallback 可以留在静态样式中。
## 验证
前端改动至少执行:
```bash
cd frontend
pnpm run lint
pnpm run typecheck
pnpm run build
git diff --check
```
视觉验收覆盖:
- 四个预置、自定义 HEX、浅色、深色和跟随系统模式。
- 页面、容器、浮层、输入框、表格、分页、主按钮、选中态、焦点和品牌图标。
- 草稿隔离、保存刷新恢复与 Teleport 弹窗。
- 首页画板缩放清晰度、组件概览滚动、ECharts 网格线和 Skeleton 动效。
- 键盘操作、颜色之外的选中反馈和 `prefers-reduced-motion`。
## 上游参考
- [Ant Design 色彩规范](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/colors.zh-CN.md)
- [Ant Design 暗黑模式](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/dark.zh-CN.md)
- [Ant Design 主题定制](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/react/customize-theme.zh-CN.md)
- [Seed 到 Map](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/themes/shared/genColorMapToken.ts)
- [Map 到 Alias](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/util/alias.ts)
- [`@ant-design/colors` 生成器](https://github.com/ant-design/ant-design-colors/blob/89b4a5b7e989b792610087abe855bf4a2fb1d322/src/generate.ts)
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -14,18 +14,19 @@
"format:check": "pnpm run lint"
},
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.31.0",
"@lucide/vue": "^1.33.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.13",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"echarts": "^6.1.0",
"es-toolkit": "^1.50.0",
"es-toolkit": "^1.51.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"marked": "^18.0.10",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
@@ -37,12 +38,12 @@
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.10"
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
+274 -248
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,9 +2,6 @@
import { RouterView } from 'vue-router'
import { BaseToast } from '@/components/base/BaseToast'
import { useUiStore } from '@/stores/modules/ui'
useUiStore().initializeTheme()
</script>
<template>
+50
View File
@@ -170,6 +170,48 @@ export interface AccountQuotaResponse {
account: Account
}
export interface AccountProfileStatisticsSummary {
totalTextTokens: number | null
peakTokens: number | null
longestTaskDurationMs: number | null
currentStreakDays: number | null
longestStreakDays: number | null
}
export interface AccountProfileDailyUsage {
date: string
tokens: number
}
export interface AccountProfileInvocation {
type: string
pluginId: string | null
pluginName: string | null
skillId: string | null
skillName: string | null
usageCount: number | null
}
export interface AccountProfileActivityInsights {
fastModePercent: number | null
reasoningEffort: string | null
reasoningEffortPercent: number | null
skillsExplored: number | null
totalSkillsUsed: number | null
totalThreads: number | null
invocations: AccountProfileInvocation[] | null
}
export interface AccountProfileStatisticsResponse {
displayName: string | null
username: string | null
imageUrl: string | null
hasStatsError: boolean
summary: AccountProfileStatisticsSummary
dailyUsage: AccountProfileDailyUsage[] | null
activityInsights: AccountProfileActivityInsights
}
export interface AccountResetCredit {
id: string
status: string | null
@@ -326,6 +368,14 @@ export function getAccountQuota(data: AccountIdParam) {
})
}
export function getAccountProfileStatistics(data: AccountIdParam) {
return request<AccountProfileStatisticsResponse>({
url: '/api/admin/accounts/profile-statistics',
method: 'GET',
params: data,
})
}
export function refreshAccountQuota(data: AccountIdParam) {
return request<AccountQuotaResponse>({
url: '/api/admin/accounts/quota/refresh',
@@ -32,7 +32,7 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
class="shrink-0 text-cp-xs font-emphasis text-cp-text-quaternary"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -27,5 +27,5 @@ const fullNames = computed(() => props.groups
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-cp-muted-text">—</span>
<span v-else class="text-xs text-cp-text-quaternary">—</span>
</template>
@@ -90,7 +90,7 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-cp-tertiary" />
<Search class="size-4 text-cp-text-tertiary" />
</template>
</BaseInput>
@@ -102,10 +102,10 @@ function providerSummary(group: AccountGroup) {
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="flex w-full items-start gap-3 rounded-cp border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
@@ -115,7 +115,7 @@ function providerSummary(group: AccountGroup) {
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
<strong class="min-w-0 truncate text-cp text-cp-text">
{{ group.name }}
</strong>
<span
@@ -125,7 +125,7 @@ function providerSummary(group: AccountGroup) {
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
<span class="mt-1 block text-cp-xs font-emphasis text-cp-text-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
@@ -148,15 +148,15 @@ function providerSummary(group: AccountGroup) {
<div
v-if="selectedIds.length === 0"
class="rounded-cp-control px-3.5 py-3"
class="rounded-cp px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
: 'bg-cp-fill-quaternary text-cp-text-secondary'"
>
<p class="m-0 text-[12px] font-bold">
<p class="m-0 text-cp-sm font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useId } from 'vue'
const id = useId().replaceAll(':', '')
const noiseFilterId = `${id}-brand-noise`
const wornFilterId = `${id}-brand-worn`
const mistGradientId = `${id}-brand-mist`
</script>
<template>
<svg
class="app-brand-mark"
xmlns="http://www.w3.org/2000/svg"
viewBox="48 48 416 416"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="noiseFilterId" x="0" y="0" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="1.7"
numOctaves="2"
seed="31"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 2.56 2.56 2.56 0 -4.76"
result="speckle"
/>
<feComposite operator="in" in="SourceGraphic" in2="speckle" />
</filter>
<filter :id="wornFilterId" x="-12%" y="-12%" width="124%" height="124%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.12"
numOctaves="2"
seed="41"
result="noise"
/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.2" />
</filter>
<radialGradient :id="mistGradientId" cx="0.5" cy="0.45" r="0.75">
<stop offset="0" stop-color="currentColor" stop-opacity="0.1" />
<stop offset="0.55" stop-color="currentColor" stop-opacity="0.045" />
<stop offset="1" stop-color="currentColor" stop-opacity="0.012" />
</radialGradient>
</defs>
<rect class="app-brand-mark__surface" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
</g>
<path d="m407 105-119 153-183 149 124-157Z" fill="currentColor" />
<g
:filter="`url(#${wornFilterId})`"
fill="none"
stroke="currentColor"
stroke-linecap="square"
>
<path d="M92 170 136 148" stroke-width="1" opacity="0.38" />
<path d="M98 178 142 156" stroke-width="0.5" opacity="0.3" />
<path d="M308 398 346 378" stroke-width="0.9" opacity="0.36" />
<path d="M314 406 352 386" stroke-width="0.5" opacity="0.3" />
<path d="M364 132 398 118" stroke-width="0.9" opacity="0.38" />
<path d="M402 118 410 126" stroke-width="0.5" opacity="0.28" />
<path d="M108 350 142 334" stroke-width="1" opacity="0.4" />
<path d="M158 238 190 226" stroke-width="0.8" opacity="0.34" />
<path d="M296 292 320 280" stroke-width="0.7" opacity="0.3" />
<path d="M126 118 136 110" stroke-width="0.6" opacity="0.26" />
<path d="M400 96 410 88" stroke-width="0.6" opacity="0.24" />
</g>
<g :filter="`url(#${wornFilterId})`" fill="currentColor">
<path
d="M98 176 104 173 108 175 114 170 118 173 124 167 130 170 136 164 142 167 148 161 154 164 160 158Z"
opacity="0.34"
/>
<path
d="M314 395 320 392 324 394 330 389 336 392 342 386 348 389 354 383Z"
opacity="0.3"
/>
</g>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
fill="currentColor"
:filter="`url(#${noiseFilterId})`"
opacity="0.08"
/>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
:fill="`url(#${mistGradientId})`"
/>
</svg>
</template>
<style scoped>
.app-brand-mark {
color: var(--cp-color-text-light-solid);
}
.app-brand-mark__surface {
fill: var(--cp-brand-mark-bg);
}
</style>
+5 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
import { Key, Openai, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
@@ -28,8 +28,6 @@ const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'agent_identity')
return 'Agent Identity'
if (normalizedAuthenticationKind.value === 'oauth')
return 'OAuth'
return props.authenticationKind?.trim() || '未知认证类型'
@@ -39,18 +37,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -59,12 +57,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<Robot
v-else-if="normalizedAuthenticationKind === 'agent_identity'"
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span class="sr-only">
+9 -9
View File
@@ -30,19 +30,19 @@ defineSlots<{
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
'bg-cp-fill-tertiary text-cp-text shadow-cp-tertiary hover:bg-cp-bg-text-active active:bg-cp-bg-text-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-cp-error-bg text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
md: 'h-cp-control gap-2 px-4 text-cp',
lg: 'h-cp-control-lg gap-2.5 px-5 text-cp-lg',
}
const spinnerSizes: Record<ButtonSize, number> = {
@@ -52,9 +52,9 @@ const spinnerSizes: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
+3 -3
View File
@@ -45,7 +45,7 @@ const contentClasses = computed(() => [
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
class="overflow-hidden rounded-cp-card bg-(--cp-card-bg) shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
@@ -55,7 +55,7 @@ const contentClasses = computed(() => [
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance"
>
<slot name="title">
{{ props.title }}
@@ -63,7 +63,7 @@ const contentClasses = computed(() => [
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
@@ -26,12 +26,12 @@ watchEffect(() => {
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-bg-container text-transparent shadow-[inset_0_0_0_1px_var(--cp-color-border)]',
])
</script>
@@ -39,7 +39,7 @@ const boxClasses = computed(() => [
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-cp leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
@@ -52,7 +52,7 @@ const boxClasses = computed(() => [
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<Minus
@@ -64,6 +64,6 @@ const boxClasses = computed(() => [
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -1,9 +1,9 @@
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor, normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
export interface HsvaColor {
h: number
s: number
v: number
@@ -17,8 +17,23 @@ interface RgbaColor {
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
export function normalizePickerHexColor(value: string, allowAlpha: boolean) {
if (allowAlpha) {
const rgba = normalizeRgbaHexColor(value)
if (rgba)
return rgba
const rgb = normalizeHexColor(value)
return rgb ? `${rgb}FF` : null
}
const rgb = normalizeHexColor(value)
if (rgb)
return rgb
return normalizeRgbaHexColor(value)?.slice(0, 7) ?? null
}
export function hexToHsva(value: string): HsvaColor | null {
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -48,7 +63,7 @@ export function hexToHsv(value: string): HsvColor | null {
}
}
export function hsvToHex(color: HsvColor) {
export function hsvaToHex(color: HsvaColor, allowAlpha: boolean) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
@@ -71,7 +86,9 @@ export function hsvToHex(color: HsvColor) {
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
const alpha = allowAlpha
? Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
: ''
return `#${rgb}${alpha}`.toUpperCase()
}
@@ -97,7 +114,7 @@ export function rgbaToHexColor(color: RgbaColor) {
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -3,14 +3,15 @@ import { Check, ChevronDown } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor } from '@/utils/color'
import BaseButton from '../BaseButton.vue'
import BaseInput from '../BaseInput.vue'
import BasePopover from '../BasePopover.vue'
import {
formatRgbaColor,
hexToHsv,
hsvToHex,
hexToHsva,
hsvaToHex,
normalizePickerHexColor,
parseRgbaColor,
rgbaToHexColor,
} from './color'
@@ -20,11 +21,13 @@ const props = withDefaults(
presets?: readonly string[]
disabled?: boolean
label?: string
allowAlpha?: boolean
}>(),
{
presets: () => [],
disabled: false,
label: '选择颜色',
allowAlpha: true,
},
)
@@ -39,13 +42,22 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
const draftColor = computed(() => hsvToHex({
const normalizedModel = computed(() =>
normalizePickerHexColor(model.value, props.allowAlpha)
?? (props.allowAlpha ? '#60A5FA80' : '#2563EB'),
)
const triggerSwatchStyle = computed(() => props.allowAlpha
? {
backgroundImage: `linear-gradient(${normalizedModel.value}, ${normalizedModel.value}), conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0)`,
backgroundSize: '100% 100%, 8px 8px',
}
: { backgroundColor: normalizedModel.value })
const draftColor = computed(() => hsvaToHex({
h: hue.value,
s: saturation.value,
v: brightness.value,
a: alpha.value,
}))
}, props.allowAlpha))
const hueColor = computed(() => `hsl(${hue.value} 100% 50%)`)
const svCursorStyle = computed(() => ({
left: `${saturation.value}%`,
@@ -63,8 +75,11 @@ const alphaTrackStyle = computed(() => {
backgroundSize: '100% 100%, 8px 8px',
}
})
const inputError = computed(() => customInput.value && !parseRgbaColor(customInput.value)
? '请输入 rgba(r, g, b, a)'
const inputValid = computed(() => props.allowAlpha
? Boolean(parseRgbaColor(customInput.value))
: Boolean(normalizeHexColor(customInput.value)))
const inputError = computed(() => customInput.value && !inputValid.value
? props.allowAlpha ? '请输入 rgba(r, g, b, a)' : '请输入六位 HEX 颜色,例如 #2563EB'
: undefined)
watch(open, (isOpen) => {
@@ -78,16 +93,19 @@ watch(model, (value) => {
})
function resetDraft(value: string) {
const color = hexToHsv(value) ?? { h: 213, s: 62, v: 98, a: 0.5 }
const color = hexToHsva(normalizePickerHexColor(value, props.allowAlpha) ?? '')
?? { h: 213, s: 62, v: 98, a: props.allowAlpha ? 0.5 : 1 }
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function syncInput() {
customInput.value = formatRgbaColor(draftColor.value) ?? ''
customInput.value = props.allowAlpha
? formatRgbaColor(draftColor.value) ?? ''
: normalizeHexColor(draftColor.value) ?? ''
}
function updateSaturationBrightness(event: PointerEvent) {
@@ -189,31 +207,31 @@ function handleAlphaKeydown(event: KeyboardEvent) {
}
function updateFromInput() {
const rgba = parseRgbaColor(customInput.value)
const color = rgba ? hexToHsv(rgbaToHexColor(rgba)) : null
const inputColor = normalizedInputColor()
const color = inputColor ? hexToHsva(inputColor) : null
if (!color)
return
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function choosePreset(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (normalized)
resetDraft(normalized)
}
function isPresetSelected(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
}
function presetCheckClass(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (!normalized)
return 'text-white'
@@ -227,13 +245,21 @@ function presetCheckClass(value: string) {
function confirm() {
updateFromInput()
const rgba = parseRgbaColor(customInput.value)
if (!rgba)
const normalized = normalizedInputColor()
if (!normalized)
return
model.value = rgbaToHexColor(rgba)
model.value = normalized
open.value = false
}
function normalizedInputColor() {
if (!props.allowAlpha)
return normalizeHexColor(customInput.value)
const rgba = parseRgbaColor(customInput.value)
return rgba ? rgbaToHexColor(rgba) : null
}
</script>
<template>
@@ -246,14 +272,17 @@ function confirm() {
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<span
class="relative flex size-full items-center justify-center overflow-hidden rounded-sm"
:style="triggerSwatchStyle"
aria-hidden="true"
>
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
@@ -263,7 +292,7 @@ function confirm() {
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -285,7 +314,7 @@ function confirm() {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
role="slider"
tabindex="0"
aria-label="色相"
@@ -305,10 +334,10 @@ function confirm() {
</div>
</div>
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div v-if="props.allowAlpha" class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -328,7 +357,7 @@ function confirm() {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
<span class="text-right font-mono text-cp-xs tabular-nums text-cp-text-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -343,7 +372,7 @@ function confirm() {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -364,8 +393,9 @@ function confirm() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
class="rounded-cp bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
@@ -376,13 +406,13 @@ function confirm() {
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<BaseButton size="md" variant="secondary" :disabled="!inputValid" @click="confirm">
确定
</BaseButton>
</div>
@@ -391,16 +421,6 @@ function confirm() {
</template>
<style scoped>
.base-color-picker__checker,
.base-color-picker__hue,
.base-color-picker__sv {
--cp-color-picker-checker: conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0) 0 / 8px 8px;
}
.base-color-picker__checker {
background: var(--cp-color-picker-checker);
}
.base-color-picker__sv::before,
.base-color-picker__sv::after {
position: absolute;
@@ -52,7 +52,7 @@ function handleConfirm() {
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
class="text-cp-lg leading-[1.55] font-emphasis text-cp-text-secondary"
>
<slot />
</div>
+4 -4
View File
@@ -28,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-fill-alter',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
class="inline-flex items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text-quaternary"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
<p class="m-0 text-cp leading-[1.15] font-heavy text-cp-text">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -43,10 +43,10 @@ provide(formFieldKey, {
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
class="flex min-w-0 items-center gap-1.5 text-cp leading-none font-medium text-cp-text-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="font-bold text-cp-error" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
@@ -56,7 +56,7 @@ provide(formFieldKey, {
<p
v-if="description"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-text-quaternary"
>
{{ description }}
</p>
@@ -66,7 +66,7 @@ provide(formFieldKey, {
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ error }}
@@ -32,20 +32,20 @@ defineSlots<{
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
'bg-cp-bg-container text-cp-text-secondary shadow-cp-tertiary hover:bg-cp-bg-text-hover hover:text-cp-text active:bg-cp-bg-text-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-transparent text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
md: 'size-cp-control [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
@@ -56,12 +56,12 @@ const spinnerSizes: Record<IconButtonSize, number> = {
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
props.pressed ? 'bg-cp-bg-text-active text-cp-text' : undefined,
])
</script>
+28 -17
View File
@@ -48,31 +48,31 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
md: 'h-cp-control gap-2.5 px-3.5 text-cp',
lg: 'h-cp-control-lg gap-3 px-4 text-cp-lg',
}
const containerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp border-0 text-cp-text shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
'bg-[var(--cp-input-bg)]',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-active-bg) focus-within:shadow-cp-input-active',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
? 'text-cp-error'
: 'text-cp-text-quaternary',
])
function updateModel(event: Event) {
@@ -90,7 +90,7 @@ function updateModel(event: Event) {
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-text outline-0 placeholder:text-cp-text-quaternary disabled:cursor-not-allowed disabled:text-cp-text-disabled"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
@@ -109,24 +109,35 @@ function updateModel(event: Event) {
<style scoped>
.base-input__control {
--base-input-autofill-bg: var(--cp-input-current-bg, var(--cp-input-context-bg));
--base-input-autofill-bg: var(--cp-input-bg);
}
.base-input__control:hover {
--base-input-autofill-bg: var(--cp-input-current-bg-hover, var(--cp-input-context-bg-hover));
--base-input-autofill-bg: var(--cp-input-hover-bg);
}
.base-input__control:focus-within {
--base-input-autofill-bg: var(--cp-input-soft-bg-focus);
--base-input-autofill-bg: var(--cp-input-active-bg);
}
.base-input__field:-webkit-autofill,
.base-input__field:-webkit-autofill:hover,
.base-input__field:-webkit-autofill:focus,
.base-input__field:autofill {
caret-color: var(--cp-text-primary);
-webkit-text-fill-color: var(--cp-text-primary) !important;
caret-color: var(--cp-color-text);
-webkit-text-fill-color: var(--cp-color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
}
/* 普通数字表单保留键盘步进,但不混入浏览器原生微调器外观。 */
.base-input__field[type='number'] {
appearance: textfield;
}
.base-input__field[type='number']::-webkit-inner-spin-button,
.base-input__field[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -22,12 +22,12 @@ defineSlots<{
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline',
'disabled:cursor-not-allowed disabled:text-cp-text-disabled',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
? 'text-cp-error-text hover:bg-cp-error-bg-hover'
: 'text-cp-text hover:bg-cp-bg-text-hover',
])
</script>

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