Compare commits

...
23 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
115 changed files with 4016 additions and 35804 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 -2
View File
@@ -27,7 +27,7 @@
| 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` |
@@ -90,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 {
@@ -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;
@@ -832,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",
@@ -877,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,
},
))),
@@ -933,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!({
@@ -973,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();
@@ -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)
}
@@ -887,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));
@@ -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"]),
})
}
@@ -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);
@@ -8,6 +8,8 @@ pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
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,
@@ -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,
@@ -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,
@@ -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}"),
@@ -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,
+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 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
+7 -2
View File
@@ -21,7 +21,8 @@ CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;只有 Hover、Active、弱背景和文字色使用派生值。
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
@@ -110,6 +111,10 @@ interface ThemeCustomization {
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 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`
@@ -141,7 +146,7 @@ Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改最终 Alias,不反写用户保存的 Seed。
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
### Component Token
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -18,15 +18,15 @@
"@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",
@@ -38,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"
}
}
+258 -248
View File
File diff suppressed because it is too large Load Diff
+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',
@@ -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() || '未知认证类型'
@@ -59,11 +57,6 @@ 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-text-quaternary">?</span>
</span>
@@ -393,7 +393,7 @@ function normalizedInputColor() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
class="bg-cp-fill-tertiary!"
class="rounded-cp bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
+1 -1
View File
@@ -28,7 +28,7 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-bg-elevated',
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',
]"
>
@@ -5,6 +5,7 @@ import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import BaseIconButton from '../BaseIconButton.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
import { useModalDrag } from './useModalDrag'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
@@ -16,6 +17,7 @@ const props = withDefaults(
size?: ModalSize
tone?: ModalTone
dismissible?: boolean
draggable?: boolean
role?: 'dialog' | 'alertdialog'
}>(),
{
@@ -23,6 +25,7 @@ const props = withDefaults(
size: 'md',
tone: 'neutral',
dismissible: true,
draggable: true,
role: 'dialog',
},
)
@@ -31,6 +34,10 @@ const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLElement>('panel')
const titleId = useId()
const descriptionId = useId()
const { cancelDrag, handlePointerDown, isDragging, resetPosition } = useModalDrag(
panel,
() => props.draggable,
)
let previouslyFocused: HTMLElement | null = null
let ownsScrollLock = false
@@ -161,6 +168,7 @@ watch(
return
}
cancelDrag()
releaseScrollLock()
restorePreviousFocus()
},
@@ -175,7 +183,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<Transition name="cp-modal">
<Transition name="cp-modal" @after-leave="resetPosition">
<div
v-if="open"
class="fixed inset-0 z-50 grid place-items-center overflow-hidden p-3 sm:p-6"
@@ -191,10 +199,11 @@ onBeforeUnmount(() => {
/>
<section
ref="panel"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-bg-container shadow-cp sm:max-h-[calc(100dvh-3rem)]"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-modal-bg shadow-cp sm:max-h-[calc(100dvh-3rem)]"
:class="[
sizeClasses[size],
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
isDragging ? 'cp-modal-panel--dragging' : undefined,
]"
:role="role"
aria-modal="true"
@@ -203,13 +212,15 @@ onBeforeUnmount(() => {
tabindex="-1"
>
<header
class="grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
class="cp-modal-header grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="[
tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
description ? 'items-start' : 'items-center',
draggable ? 'cp-modal-header--draggable' : undefined,
]"
@pointerdown="handlePointerDown"
>
<span
v-if="tone !== 'neutral'"
@@ -243,7 +254,9 @@ onBeforeUnmount(() => {
</BaseIconButton>
</header>
<div v-if="$slots.default" class="min-h-0 overflow-hidden px-3.25 py-3.25 sm:px-5.25 sm:py-5.25">
<BaseScrollbar class="h-full">
<BaseScrollbar
class="h-full -mr-2 pr-2 sm:-mr-3 sm:pr-3"
>
<div class="p-0.75">
<slot />
</div>
@@ -289,6 +302,16 @@ onBeforeUnmount(() => {
transform: translate3d(0, 4px, 0) scale(0.99);
}
.cp-modal-header--draggable {
cursor: move;
touch-action: none;
user-select: none;
}
.cp-modal-panel--dragging .cp-modal-header {
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.cp-modal-enter-active,
.cp-modal-leave-active,
@@ -0,0 +1,166 @@
import type { ShallowRef } from 'vue'
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
const VIEWPORT_GUTTER = 8
const interactiveSelector = 'button, a, input, select, textarea, [role="button"], [data-modal-drag-ignore]'
interface DragSession {
pointerId: number
startX: number
startY: number
originX: number
originY: number
minX: number
maxX: number
minY: number
maxY: number
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function useModalDrag(
target: Readonly<ShallowRef<HTMLElement | null>>,
enabled: () => boolean,
) {
const isDragging = shallowRef(false)
const offset = { x: 0, y: 0 }
let session: DragSession | null = null
function applyPosition() {
if (!target.value)
return
target.value.style.transform
= offset.x === 0 && offset.y === 0
? ''
: `translate3d(${offset.x}px, ${offset.y}px, 0)`
}
function removeDragListeners() {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', endDrag)
document.removeEventListener('pointercancel', endDrag)
window.removeEventListener('blur', handleWindowBlur)
}
function endDrag(event?: PointerEvent) {
if (event && session && event.pointerId !== session.pointerId)
return
session = null
isDragging.value = false
removeDragListeners()
}
function handlePointerMove(event: PointerEvent) {
if (!session || event.pointerId !== session.pointerId)
return
if (!enabled()) {
endDrag()
return
}
event.preventDefault()
offset.x = clamp(
session.originX + event.clientX - session.startX,
session.minX,
session.maxX,
)
offset.y = clamp(
session.originY + event.clientY - session.startY,
session.minY,
session.maxY,
)
applyPosition()
}
function handlePointerDown(event: PointerEvent) {
if (!enabled() || !event.isPrimary || event.button !== 0)
return
const eventTarget = event.target
if (eventTarget instanceof Element && eventTarget.closest(interactiveSelector))
return
const element = target.value
if (!element)
return
const rect = element.getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
session = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offset.x,
originY: offset.y,
minX: offset.x - rect.left + VIEWPORT_GUTTER,
maxX: offset.x + viewportWidth - rect.right - VIEWPORT_GUTTER,
minY: offset.y - rect.top + VIEWPORT_GUTTER,
maxY: offset.y + viewportHeight - rect.bottom - VIEWPORT_GUTTER,
}
isDragging.value = true
event.preventDefault()
document.addEventListener('pointermove', handlePointerMove, { passive: false })
document.addEventListener('pointerup', endDrag)
document.addEventListener('pointercancel', endDrag)
window.addEventListener('blur', handleWindowBlur)
}
function handleWindowBlur() {
endDrag()
}
function updatePosition() {
const element = target.value
if (!element)
return
const rect = element.getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
if (rect.left < VIEWPORT_GUTTER)
offset.x += VIEWPORT_GUTTER - rect.left
else if (rect.right > viewportWidth - VIEWPORT_GUTTER)
offset.x -= rect.right - viewportWidth + VIEWPORT_GUTTER
if (rect.top < VIEWPORT_GUTTER)
offset.y += VIEWPORT_GUTTER - rect.top
else if (rect.bottom > viewportHeight - VIEWPORT_GUTTER)
offset.y -= rect.bottom - viewportHeight + VIEWPORT_GUTTER
applyPosition()
}
function resetPosition() {
endDrag()
offset.x = 0
offset.y = 0
applyPosition()
}
function handleResize() {
endDrag()
updatePosition()
}
onMounted(() => window.addEventListener('resize', handleResize))
onBeforeUnmount(() => {
endDrag()
window.removeEventListener('resize', handleResize)
})
return {
cancelDrag: endDrag,
handlePointerDown,
isDragging,
resetPosition,
}
}
@@ -24,7 +24,7 @@ defineProps<{
</div>
<div
v-if="$slots.actions"
class="flex min-w-0 basis-full flex-wrap items-center justify-start gap-2 min-[640px]:mt-0.5 min-[640px]:basis-auto min-[640px]:shrink-0 min-[640px]:justify-end"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
>
<slot name="actions" />
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-container text-left text-cp-text shadow-cp',
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-elevated text-left text-cp-text shadow-cp',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
+1 -1
View File
@@ -111,7 +111,7 @@ const triggerClasses = computed(() => [
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-container p-1 shadow-cp',
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-elevated p-1 shadow-cp',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -44,6 +44,7 @@ export interface BaseTableProps<Row extends TableRow> {
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
scrollbarAlwaysVisible?: boolean
sort?: BaseTableSort
}
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
density: 'default',
loading: false,
emptyText: '暂无数据',
scrollbarAlwaysVisible: false,
sort: undefined,
})
@@ -196,6 +197,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
:class="density === 'compact'
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
:always-visible="scrollbarAlwaysVisible"
horizontal
@scroll="handleTableScroll"
>
@@ -45,7 +45,7 @@ const colorClasses = {
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-container px-3.5 py-3 shadow-cp"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-elevated px-3.5 py-3 shadow-cp"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
+7
View File
@@ -30,6 +30,7 @@
--color-cp-fill-secondary: var(--cp-color-fill-secondary);
--color-cp-fill-tertiary: var(--cp-color-fill-tertiary);
--color-cp-fill-quaternary: var(--cp-color-fill-quaternary);
--color-cp-fill-alter: var(--cp-color-fill-alter);
--color-cp-border: var(--cp-color-border);
--color-cp-border-secondary: var(--cp-color-border-secondary);
--color-cp-split: var(--cp-color-split);
@@ -59,6 +60,7 @@
--color-cp-control-outline: var(--cp-control-outline);
--color-cp-menu-item-selected-bg: var(--cp-menu-item-selected-bg);
--color-cp-progress-remaining: var(--cp-progress-remaining-color);
--color-cp-modal-bg: var(--cp-modal-bg);
--color-cp-info: var(--cp-color-info);
--color-cp-info-hover: var(--cp-color-info-hover);
@@ -66,6 +68,11 @@
--color-cp-info-bg: var(--cp-color-info-bg);
--color-cp-info-border: var(--cp-color-info-border);
--color-cp-info-text: var(--cp-color-info-text);
--color-cp-activity-level-0: var(--cp-activity-level-0);
--color-cp-activity-level-1: var(--cp-activity-level-1);
--color-cp-activity-level-2: var(--cp-activity-level-2);
--color-cp-activity-level-3: var(--cp-activity-level-3);
--color-cp-activity-level-4: var(--cp-activity-level-4);
--color-cp-status-normal: var(--cp-color-status-normal);
--color-cp-status-normal-hover: var(--cp-color-status-normal-hover);
--color-cp-status-normal-active: var(--cp-color-status-normal-active);
+3 -2
View File
@@ -98,7 +98,7 @@ export const WHITE = '#FFFFFF'
export const NEUTRAL_LIGHT_SURFACES = {
colorBgLayout: '#F6F8FB',
colorBgContainer: LIGHT_CONTAINER_BASE,
colorBgElevated: '#F8FAFC',
colorBgElevated: LIGHT_CONTAINER_BASE,
colorBgSpotlight: '#111827',
colorBgMask: '#0E17264D',
colorBgTextHover: '#F1F5F9',
@@ -106,7 +106,7 @@ export const NEUTRAL_LIGHT_SURFACES = {
colorBgContainerDisabled: '#F1F5F9',
colorFillSecondary: '#E9EEF5',
colorFillTertiary: '#F1F5F9',
colorFillQuaternary: '#F8FAFC',
colorFillQuaternary: '#F6F8FB',
colorBorder: '#D8E0EA',
colorBorderSecondary: '#E2E8F0',
colorSplit: '#D8E0EA6B',
@@ -194,6 +194,7 @@ export const EDITABLE_COLOR_TOKEN_NAMES = new Set<ThemeTokenName>([
'--cp-button-primary-hover-bg',
'--cp-button-primary-active-bg',
'--cp-card-bg',
'--cp-modal-bg',
'--cp-table-header-bg',
'--cp-table-row-bg',
'--cp-table-row-stripe-bg',
+74 -9
View File
@@ -65,6 +65,7 @@ export type ThemeTokenName
| '--cp-color-fill-secondary'
| '--cp-color-fill-tertiary'
| '--cp-color-fill-quaternary'
| '--cp-color-fill-alter'
| '--cp-color-border'
| '--cp-color-border-secondary'
| '--cp-color-split'
@@ -103,6 +104,7 @@ export type ThemeTokenName
| '--cp-button-primary-active-bg'
| '--cp-brand-mark-bg'
| '--cp-card-bg'
| '--cp-modal-bg'
| '--cp-table-header-bg'
| '--cp-table-row-bg'
| '--cp-table-row-stripe-bg'
@@ -127,6 +129,11 @@ export type ThemeTokenName
| '--cp-color-info-bg'
| '--cp-color-info-border'
| '--cp-color-info-text'
| '--cp-activity-level-0'
| '--cp-activity-level-1'
| '--cp-activity-level-2'
| '--cp-activity-level-3'
| '--cp-activity-level-4'
| '--cp-color-success'
| '--cp-color-success-bg'
| '--cp-color-success-bg-hover'
@@ -270,6 +277,11 @@ interface ThemeSurfaceMap {
colorShadow: string
}
/** Map Token 经过视觉角色映射后供通用组件消费的 Alias。 */
interface ThemeAliasMap {
colorFillAlter: string
}
interface ThemeShadowMap {
boxShadow: string
boxShadowSecondary: string
@@ -288,6 +300,7 @@ interface ThemeComponentMap {
buttonPrimaryActiveBg: string
brandMarkBg: string
cardBg: string
modalBg: string
tableHeaderBg: string
tableRowBg: string
tableRowStripeBg: string
@@ -323,6 +336,14 @@ interface ThemeSemanticMap {
error: FunctionalColorMap
}
interface ThemeDataMap {
activityLevel0: string
activityLevel1: string
activityLevel2: string
activityLevel3: string
activityLevel4: string
}
interface ThemeDimensionMap {
fontSizeXs: string
fontSizeSm: string
@@ -377,21 +398,34 @@ export function resolveTheme(
appearance,
)
const surfaces = deriveThemeSurfaceMap(name, seedTokens)
const aliases = deriveThemeAliasMap(surfaces)
const primary = deriveThemePrimaryMap(seed, name, surfaces.colorBgContainer)
const link = seedTokens.colorLink === seed
? primary
: deriveThemePrimaryMap(seedTokens.colorLink, name, surfaces.colorBgContainer)
const semantics = deriveThemeSemanticMap(name, surfaces.colorBgContainer, seedTokens)
const data = deriveThemeDataMap(aliases, semantics)
const shadows = deriveThemeShadowMap(name, seedTokens.shadowStrength)
const components = deriveThemeComponentMap(
name,
surfaces,
aliases,
primary,
semantics.error,
seedTokens.shadowStrength,
)
const dimensions = deriveThemeDimensionMap(seedTokens, normalizedCustomization.component)
const tokens = toThemeTokens(surfaces, primary, link, semantics, shadows, components, dimensions)
const tokens = toThemeTokens(
surfaces,
aliases,
primary,
link,
semantics,
data,
shadows,
components,
dimensions,
)
const mergedTokens: ThemeTokens = {
...tokens,
...normalizedCustomization.tokenOverrides,
@@ -600,9 +634,7 @@ function deriveNeutralLightSurfaceMap(seedTokens: ResolvedThemeSeedTokens): Them
? NEUTRAL_LIGHT_SURFACES.colorBgLayout
: mix(background, colorText, 0.035),
colorBgContainer: background,
colorBgElevated: baselineBackground
? NEUTRAL_LIGHT_SURFACES.colorBgElevated
: mix(background, colorText, 0.018),
colorBgElevated: baselineBackground ? NEUTRAL_LIGHT_SURFACES.colorBgElevated : background,
colorBgSpotlight: baselineBackground
? NEUTRAL_LIGHT_SURFACES.colorBgSpotlight
: mix(colorText, BLACK, 0.08),
@@ -644,6 +676,12 @@ function deriveNeutralLightSurfaceMap(seedTokens: ResolvedThemeSeedTokens): Them
}
}
function deriveThemeAliasMap(surfaces: ThemeSurfaceMap): ThemeAliasMap {
return {
colorFillAlter: surfaces.colorFillQuaternary,
}
}
function deriveThemePrimaryMap(
seed: string,
theme: ThemeName,
@@ -744,7 +782,9 @@ function deriveFunctionalColorMap(
const palette = generate(seed, theme === 'dark'
? { theme: 'dark', backgroundColor: containerBg }
: undefined).map(color => normalizeHexColor(color) ?? seed)
const color = palette[theme === 'dark' ? 6 : 5]!
// 与 Ant Design 的功能色 Map 保持一致:基础功能色固定取 P6。
// 浅色 P6 即用户选择的 Seed;深色 P6 由暗色色板适配。对比度保护只作用于具体角色 Token。
const color = palette[5]!
const hover = palette[theme === 'dark' ? 7 : 4]!
const active = palette[theme === 'dark' ? 5 : 6]!
const backgroundMix = theme === 'dark' ? 0.18 : 0.08
@@ -753,7 +793,7 @@ function deriveFunctionalColorMap(
const text = baselineText ?? palette[theme === 'dark' ? 8 : 5]!
return {
color: ensureContrast(color, containerBg, 3),
color,
hover: ensureContrast(hover, containerBg, 3),
active: ensureContrast(active, containerBg, 3),
background: mix(containerBg, color, backgroundMix),
@@ -765,6 +805,19 @@ function deriveFunctionalColorMap(
}
}
function deriveThemeDataMap(
aliases: ThemeAliasMap,
semantics: ThemeSemanticMap,
): ThemeDataMap {
return {
activityLevel0: aliases.colorFillAlter,
activityLevel1: semantics.info.background,
activityLevel2: semantics.info.backgroundHover,
activityLevel3: semantics.info.backgroundActive,
activityLevel4: semantics.info.color,
}
}
function deriveThemeDimensionMap(
seedTokens: ResolvedThemeSeedTokens,
componentOverrides: ThemeComponentOverrides | undefined,
@@ -818,6 +871,7 @@ function deriveThemeShadowMap(theme: ThemeName, shadowStrength: number): ThemeSh
function deriveThemeComponentMap(
theme: ThemeName,
surfaces: ThemeSurfaceMap,
aliases: ThemeAliasMap,
primary: ThemePrimaryMap,
error: FunctionalColorMap,
shadowStrength: number,
@@ -843,9 +897,10 @@ function deriveThemeComponentMap(
buttonPrimaryActiveBg: primary.colorPrimarySolidActive,
brandMarkBg: surfaces.colorBgElevated,
cardBg: surfaces.colorBgContainer,
modalBg: surfaces.colorBgContainer,
tableHeaderBg: surfaces.colorFillTertiary,
tableRowBg: surfaces.colorBgContainer,
tableRowStripeBg: surfaces.colorBgElevated,
tableRowStripeBg: aliases.colorFillAlter,
tableRowHoverBg: surfaces.colorBgTextHover,
tableRowSelectedBg: primary.colorPrimaryBg,
progressRemainingColor: mix(surfaces.colorBgContainer, surfaces.colorBorderSecondary, 0.92),
@@ -874,7 +929,7 @@ function deriveThemeComponentMap(
inputHoverBg: usesNeutralInput
? NEUTRAL_LIGHT_INPUT.hoverBg
: mix(surfaces.colorBgContainer, surfaces.colorFillTertiary, 0.88),
inputActiveBg: usesNeutralInput ? NEUTRAL_LIGHT_INPUT.activeBg : surfaces.colorBgElevated,
inputActiveBg: usesNeutralInput ? NEUTRAL_LIGHT_INPUT.activeBg : aliases.colorFillAlter,
inputErrorActiveBg: error.background,
buttonPrimaryColor: solidControlTextColor(primary.colorPrimarySolid),
buttonPrimaryBg: primary.colorPrimarySolid,
@@ -882,9 +937,10 @@ function deriveThemeComponentMap(
buttonPrimaryActiveBg: primary.colorPrimarySolidActive,
brandMarkBg: surfaces.colorBgSpotlight,
cardBg: surfaces.colorBgContainer,
modalBg: surfaces.colorBgContainer,
tableHeaderBg: surfaces.colorFillTertiary,
tableRowBg: surfaces.colorBgContainer,
tableRowStripeBg: surfaces.colorBgElevated,
tableRowStripeBg: aliases.colorFillAlter,
tableRowHoverBg: surfaces.colorBgTextHover,
tableRowSelectedBg: primary.colorPrimaryBg,
progressRemainingColor: surfaces.colorBorderSecondary,
@@ -905,9 +961,11 @@ function deriveThemeComponentMap(
function toThemeTokens(
surfaces: ThemeSurfaceMap,
aliases: ThemeAliasMap,
primary: ThemePrimaryMap,
link: ThemePrimaryMap,
semantics: ThemeSemanticMap,
data: ThemeDataMap,
shadows: ThemeShadowMap,
components: ThemeComponentMap,
dimensions: ThemeDimensionMap,
@@ -924,6 +982,7 @@ function toThemeTokens(
'--cp-color-fill-secondary': surfaces.colorFillSecondary,
'--cp-color-fill-tertiary': surfaces.colorFillTertiary,
'--cp-color-fill-quaternary': surfaces.colorFillQuaternary,
'--cp-color-fill-alter': aliases.colorFillAlter,
'--cp-color-border': surfaces.colorBorder,
'--cp-color-border-secondary': surfaces.colorBorderSecondary,
'--cp-color-split': surfaces.colorSplit,
@@ -962,6 +1021,7 @@ function toThemeTokens(
'--cp-button-primary-active-bg': components.buttonPrimaryActiveBg,
'--cp-brand-mark-bg': components.brandMarkBg,
'--cp-card-bg': components.cardBg,
'--cp-modal-bg': components.modalBg,
'--cp-table-header-bg': components.tableHeaderBg,
'--cp-table-row-bg': components.tableRowBg,
'--cp-table-row-stripe-bg': components.tableRowStripeBg,
@@ -986,6 +1046,11 @@ function toThemeTokens(
'--cp-color-info-bg': semantics.info.background,
'--cp-color-info-border': semantics.info.border,
'--cp-color-info-text': semantics.info.text,
'--cp-activity-level-0': data.activityLevel0,
'--cp-activity-level-1': data.activityLevel1,
'--cp-activity-level-2': data.activityLevel2,
'--cp-activity-level-3': data.activityLevel3,
'--cp-activity-level-4': data.activityLevel4,
'--cp-color-success': semantics.success.color,
'--cp-color-success-bg': semantics.success.background,
'--cp-color-success-bg-hover': semantics.success.backgroundHover,
@@ -196,7 +196,7 @@ async function copyText(value: string, successText: string) {
<BaseScrollbar
max-height="92px"
>
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-input">
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="view.oauth.authUrl"
@@ -17,7 +17,6 @@ const modeOptions = {
{ label: 'AT', value: 'access_token' },
{ label: 'RT', value: 'refresh_token' },
{ label: '账号文件', value: 'json' },
{ label: 'Agent 身份', value: 'agent_identity' },
],
xai: [
{ label: 'OAuth', value: 'oauth' },
@@ -89,8 +88,6 @@ function resolveModal(
description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期'
else if (input.form.mode === 'refresh_token')
description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token'
else if (input.form.mode === 'agent_identity')
description = '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
return {
title: '导入账号',
@@ -150,13 +147,6 @@ function resolveImportInput(
uploadable: false,
}
}
if (form.mode === 'agent_identity') {
return {
label: 'Agent 身份文件',
placeholder: '粘贴 Agent 身份文件内容',
uploadable: true,
}
}
if (provider === 'batch') {
return {
label: '批量账号文件',
@@ -13,15 +13,15 @@ const props = withDefaults(
)
const fallbackPalettes = [
'bg-cp-info-bg text-cp-info-text shadow-cp-input',
'bg-cp-success-bg text-cp-success-text shadow-cp-input',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-cp-input',
'bg-cp-warning-bg text-cp-warning-text shadow-cp-input',
'bg-cp-info-bg text-cp-info-text shadow-cp-tertiary',
'bg-cp-success-bg text-cp-success-text shadow-cp-tertiary',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-cp-tertiary',
'bg-cp-warning-bg text-cp-warning-text shadow-cp-tertiary',
]
const usageTierPalettes: Record<string, string> = {
pro: 'bg-cp-account-plan-pro-bg text-cp-account-plan-pro shadow-cp-input',
prolite: 'bg-cp-account-plan-pro-lite-bg text-cp-account-plan-pro-lite shadow-cp-input',
pro: 'bg-cp-account-plan-pro-bg text-cp-account-plan-pro shadow-cp-tertiary',
prolite: 'bg-cp-account-plan-pro-lite-bg text-cp-account-plan-pro-lite shadow-cp-tertiary',
}
const label = computed(() => props.planType?.trim() || 'Free')
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { AccountProfileActivityInsights, AccountProfileInvocation } from '@/api'
import { Package } from '@lucide/vue'
import { computed } from 'vue'
import { formatInteger } from '@/utils/number'
const props = defineProps<{
insights: AccountProfileActivityInsights
}>()
const insightRows = computed(() => [
{ label: '快速模式', value: formatPercent(props.insights.fastModePercent) },
{
label: '最常用的推理强度',
value: formatReasoning(props.insights.reasoningEffort, props.insights.reasoningEffortPercent),
},
{ label: '已探索的技能', value: formatNumber(props.insights.skillsExplored) },
{ label: '使用的技能总数', value: formatNumber(props.insights.totalSkillsUsed) },
{ label: '聊天总数', value: formatNumber(props.insights.totalThreads) },
])
const invocations = computed(() => (props.insights.invocations ?? [])
.map(invocationView)
.filter((invocation): invocation is NonNullable<typeof invocation> => invocation !== null))
function formatNumber(value: number | null) {
return value === null ? '—' : formatInteger(value)
}
function formatPercent(value: number | null) {
if (value === null)
return '—'
return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value)}%`
}
function formatReasoning(effort: string | null, percent: number | null) {
if (!effort)
return '—'
return percent === null ? effort : `${effort} · ${formatPercent(percent)}`
}
function invocationView(invocation: AccountProfileInvocation) {
const isPlugin = invocation.type === 'plugin'
const name = (isPlugin ? invocation.pluginName : invocation.skillName)?.trim()
if (!name || invocation.usageCount === null)
return null
return {
key: `${invocation.type}:${invocation.pluginId ?? invocation.skillId ?? name}`,
name,
count: invocation.usageCount,
}
}
</script>
<template>
<section class="grid gap-8 sm:grid-cols-2 sm:gap-12" aria-labelledby="profile-activity-insights-title">
<div>
<h3 id="profile-activity-insights-title" class="m-0 text-cp-lg font-heavy text-cp-text">
活动洞察
</h3>
<dl class="mt-4 mb-0 grid gap-3.5">
<div
v-for="row in insightRows"
:key="row.label"
class="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-4"
>
<dt class="min-w-0 text-cp font-semibold text-cp-text-secondary">
{{ row.label }}
</dt>
<dd class="m-0 font-mono font-heavy tabular-nums text-cp-text">
{{ row.value }}
</dd>
</div>
</dl>
</div>
<div>
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
最常用的插件与技能
</h3>
<ol v-if="invocations.length > 0" class="mt-3 mb-0 grid list-none gap-1.5 p-0">
<li
v-for="invocation in invocations"
:key="invocation.key"
class="grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2.5 py-1.5"
>
<span
class="grid size-6 place-items-center rounded-cp bg-cp-fill-quaternary text-cp-text-secondary"
aria-hidden="true"
>
<Package class="size-3.5" :stroke-width="2" />
</span>
<span class="truncate text-cp font-bold text-cp-text">{{ invocation.name }}</span>
<span class="font-mono text-cp-sm font-semibold tabular-nums text-cp-text-secondary">
{{ formatInteger(invocation.count) }} 次运行
</span>
</li>
</ol>
<p v-else class="mt-4 mb-0 text-cp-sm font-semibold text-cp-text-quaternary">
暂无插件或技能调用记录
</p>
</div>
</section>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { AccountProfileStatisticsResponse } from '@/api'
import { UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import AccountPlanBadge from '../AccountPlanBadge.vue'
const props = defineProps<{
account: AccountRow
profile: AccountProfileStatisticsResponse
}>()
const imageFailed = shallowRef(false)
const displayName = computed(() =>
props.profile.displayName
|| props.profile.username
|| props.account.email
|| props.account.name
|| 'Codex 用户',
)
const username = computed(() => props.profile.username
? `@${props.profile.username.replace(/^@/, '')}`
: null)
const accountIdentity = computed(() => props.account.email?.trim() || props.account.accountId?.trim())
const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperCase() || null)
const metrics = computed(() => [
{
label: '累计 Token 数',
value: formatMetric(props.profile.summary.totalTextTokens),
title: formatExact(props.profile.summary.totalTextTokens),
},
{
label: '峰值 Token 数',
value: formatMetric(props.profile.summary.peakTokens),
title: formatExact(props.profile.summary.peakTokens),
},
{
label: '最长聊天时长',
value: formatDuration(props.profile.summary.longestTaskDurationMs),
},
{
label: '当前连续天数',
value: formatDays(props.profile.summary.currentStreakDays),
},
{
label: '最长连续天数',
value: formatDays(props.profile.summary.longestStreakDays),
},
])
watch(() => props.profile.imageUrl, () => {
imageFailed.value = false
})
function formatMetric(value: number | null) {
return value === null ? '—' : formatCompactNumber(value)
}
function formatExact(value: number | null) {
return value === null ? undefined : `${formatInteger(value)} Tokens`
}
function formatDays(value: number | null) {
return value === null ? '—' : `${formatInteger(value)} 天`
}
function formatDuration(value: number | null) {
if (value === null)
return '—'
const totalMinutes = Math.floor(value / 60_000)
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor(totalMinutes % (24 * 60) / 60)
const minutes = totalMinutes % 60
if (days > 0)
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
if (hours > 0)
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
if (totalMinutes > 0)
return `${totalMinutes} 分`
return `${Math.max(0, Math.floor(value / 1000))} 秒`
}
</script>
<template>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
aria-labelledby="profile-display-name"
>
<div class="flex min-w-0 items-center gap-3.5">
<span
class="grid size-14 shrink-0 place-items-center overflow-hidden rounded-full bg-cp-error-bg text-xl font-heavy text-cp-error-text"
>
<img
v-if="profile.imageUrl && !imageFailed"
:src="profile.imageUrl"
:alt="`${displayName} 的头像`"
class="size-full object-cover"
referrerpolicy="no-referrer"
@error="imageFailed = true"
>
<span v-else-if="initial" aria-hidden="true">{{ initial }}</span>
<UserRound v-else class="size-6" aria-hidden="true" />
</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h3 id="profile-display-name" class="m-0 min-w-0 truncate text-cp-lg leading-tight font-heavy text-cp-text">
{{ displayName }}
</h3>
<AccountPlanBadge v-if="account.planType" :plan-type="account.planType" size="sm" />
</div>
<div
v-if="username || accountIdentity"
class="mt-1.5 grid min-w-0 gap-0.5 text-cp-xs font-semibold text-cp-text-secondary"
>
<span v-if="username" class="truncate">{{ username }}</span>
<span v-if="accountIdentity" class="truncate" :title="accountIdentity">{{ accountIdentity }}</span>
</div>
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<dl class="grid min-w-130 grid-cols-5">
<div
v-for="metric in metrics"
:key="metric.label"
class="min-w-0 px-2 text-center sm:px-3"
>
<dd
class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text"
:title="metric.title"
>
{{ metric.value }}
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</div>
</section>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</script>
<template>
<div class="grid gap-8 sm:gap-10" role="status" aria-live="polite" aria-busy="true">
<span class="sr-only">正在加载官方个人资料</span>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
aria-hidden="true"
>
<div class="flex min-w-0 items-center gap-3.5">
<BaseSkeleton shape="circle" class="size-14 shrink-0" />
<div class="grid min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-5 w-9 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-24 opacity-60" />
<BaseSkeleton shape="text" class="w-full max-w-64 opacity-60" />
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<div class="grid min-w-130 grid-cols-5">
<div
v-for="metric in 5"
:key="metric"
class="grid justify-items-center gap-2 px-2 sm:px-3"
>
<BaseSkeleton shape="text" class="h-4 w-14" />
<BaseSkeleton shape="text" class="w-20 opacity-60" />
</div>
</div>
</div>
</section>
<section class="grid gap-4" aria-hidden="true">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
</div>
<div
class="grid min-w-190 gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
>
<div v-for="week in 52" :key="week" class="grid gap-1">
<BaseSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
</div>
</div>
</section>
<section class="grid gap-10 sm:grid-cols-2" aria-hidden="true">
<div v-for="column in 2" :key="column" class="grid gap-3">
<BaseSkeleton shape="text" class="h-4 w-28" />
<BaseSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
</div>
</section>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { ProfileActivityLevel, ProfileActivityMode } from '../../utils/accountProfileStatistics'
import type { AccountProfileDailyUsage } from '@/api'
import { computed, shallowRef } from 'vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import {
buildProfileActivityGrid,
profileActivityCellLabel,
} from '../../utils/accountProfileStatistics'
const props = defineProps<{
dailyUsage: AccountProfileDailyUsage[] | null
}>()
const mode = shallowRef<ProfileActivityMode>('daily')
const modeOptions = [
{ label: '每日', value: 'daily' },
{ label: '每周', value: 'weekly' },
{ label: '累计', value: 'cumulative' },
]
const levelClasses: Record<ProfileActivityLevel, string> = {
0: 'bg-cp-activity-level-0',
1: 'bg-cp-activity-level-1',
2: 'bg-cp-activity-level-2',
3: 'bg-cp-activity-level-3',
4: 'bg-cp-activity-level-4',
}
const grid = computed(() => buildProfileActivityGrid(props.dailyUsage ?? [], mode.value))
const rangeLabel = computed(() => `${grid.value.rangeStart} 至 ${grid.value.rangeEnd}`)
</script>
<template>
<section aria-labelledby="profile-token-activity-title">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 id="profile-token-activity-title" class="m-0 text-cp-lg font-heavy text-cp-text">
Token 活动
</h3>
</div>
<BaseSegmented
v-model="mode"
label="Token 活动统计方式"
size="sm"
:options="modeOptions"
/>
</div>
<BaseEmpty
v-if="dailyUsage === null"
title="暂无 Token 活动"
description="官方个人资料没有返回每日 Token 活动。"
size="sm"
surface="none"
/>
<div
v-else
class="overflow-x-auto pb-1"
role="img"
:aria-label="`Token 活动热力图,${rangeLabel}`"
>
<div class="min-w-190">
<div
class="mb-1.5 grid gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
aria-hidden="true"
>
<span
v-for="week in grid.weeks"
:key="week.key"
class="h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
>
{{ week.monthLabel }}
</span>
</div>
<div
class="grid gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
aria-hidden="true"
>
<div
v-for="week in grid.weeks"
:key="week.key"
class="grid grid-rows-7 gap-1"
>
<span
v-for="cell in week.cells"
:key="cell.date"
class="aspect-square rounded-[3px] transition-colors motion-reduce:transition-none"
:class="cell.isFuture ? 'invisible' : levelClasses[cell.level]"
:title="profileActivityCellLabel(cell, mode)"
/>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import { RefreshCw, TriangleAlert } from '@lucide/vue'
import { toRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useAccountProfileStatistics } from '../../composables/useAccountProfileStatistics'
import AccountProfileActivityInsights from './ActivityInsights.vue'
import AccountProfileHero from './ProfileHero.vue'
import AccountProfileSkeleton from './Skeleton.vue'
import AccountProfileTokenActivity from './TokenActivity.vue'
const props = defineProps<{
account: AccountRow
}>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const { profile, loading, error, load } = useAccountProfileStatistics(accountId, open)
</script>
<template>
<BaseModal
v-model="open"
title="个人资料"
description="来自 Codex 官方个人资料的累计活动与使用洞察"
size="xl"
>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseEmpty
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="load(true)">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="profile" class="flex min-h-0 flex-col gap-8 pb-2 sm:gap-10">
<AccountProfileHero :account="account" :profile="profile" />
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="官方个人资料已加载,但本次没有返回统计数据。"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
</template>
</div>
<template #footer>
<span v-if="error && profile" class="mr-auto self-center text-cp-xs font-semibold text-cp-error-text">
{{ error }}
</span>
<BaseButton variant="ghost" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
<template #loading>
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
</template>
<template #icon>
<RefreshCw class="size-4" />
</template>
刷新资料
</BaseButton>
</template>
</BaseModal>
</template>
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
resolveAccountUsageWindowPresentation,
} from './AccountUsageWindow/presenter'
} from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
@@ -9,7 +9,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseRadio from '@/components/base/BaseRadio.vue'
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
import { useAccountResetCredits } from '../../composables/useAccountResetCredits'
const props = defineProps<{
accountId: string
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { RefreshCw } from '@lucide/vue'
import type { AccountRow } from '../../constants'
import { RefreshCw, UserRound } from '@lucide/vue'
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
import AccountResetCredits from './AccountResetCredits.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountProfileModal from '../AccountProfileModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
const props = defineProps<{
account: AccountRow
@@ -22,6 +23,7 @@ const emit = defineEmits<{
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const profileOpen = shallowRef(false)
</script>
<template>
@@ -44,6 +46,16 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看个人资料"
size="sm"
variant="ghost"
:pressed="profileOpen"
@click="profileOpen = true"
>
<UserRound class="size-3.5" />
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
:account-id="account.id"
@@ -77,4 +89,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
</section>
<AccountProfileModal
v-if="account.provider === 'openai'"
v-model="profileOpen"
:account="account"
/>
</template>
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountQuotaWindowGroup from './WindowGroup.vue'
const props = defineProps<{
label: string | null
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { AccountRow } from '../../constants'
import { computed } from 'vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants'
import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../../constants'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import AccountQuotaSummaryEntry from './Entry.vue'
const props = defineProps<{
account: AccountRow
@@ -1,15 +1,28 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
defineProps<{
const props = defineProps<{
account: AccountRow
}>()
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() =>
props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'),
)
const totalBillingDisplay = computed(() =>
totalBilling.value?.estimatedAmountDisplay ?? '—',
)
const hasUsageSummary = computed(() =>
(props.account.usage.totalTokens ?? 0) > 0 || totalBilling.value !== undefined,
)
const modelUsageColumns = defineTableColumns<AccountModelUsage>([
{ key: 'model', label: '模型', kind: 'text', size: 'lg' },
{ key: 'requestCountDisplay', label: '调用', kind: 'numeric', size: 'xs' },
@@ -71,11 +84,39 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
<div class="ml-auto flex items-center gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
<Sigma
class="size-3.5 self-center text-cp-text-tertiary"
:stroke-width="1.75"
aria-hidden="true"
/>
<span title="总 Token">
<span class="sr-only">总 Token:</span>
<span class="font-mono text-cp-sm font-emphasis tabular-nums text-cp-text">
{{ account.usage.totalTokensDisplay }}
</span>
</span>
<span
class="mx-0.5 text-[10px] leading-none font-emphasis text-cp-text-quaternary"
aria-hidden="true"
>
/
</span>
<span title="总计费">
<span class="sr-only">总计费:</span>
<span class="font-mono text-cp-sm font-heavy tabular-nums text-cp-success-text">
{{ totalBillingDisplay }}
</span>
</span>
</div>
<span class="whitespace-nowrap text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
</div>
</div>
<div class="h-52 min-w-0">

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