mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38c328975 | ||
|
|
97d95d25f1 | ||
|
|
abfd24267b | ||
|
|
a363682929 | ||
|
|
57422275bb | ||
|
|
cb7f69850b | ||
|
|
5ea3740de3 | ||
|
|
3920624862 | ||
|
|
d804bca495 | ||
|
|
ca527d5833 | ||
|
|
ae4eb49afc | ||
|
|
5d97086511 | ||
|
|
0e6da1e731 | ||
|
|
5d0922b9d1 | ||
|
|
ed3d622554 | ||
|
|
0f71cf6df2 | ||
|
|
a1d3eba874 | ||
|
|
d22c1d4ab9 | ||
|
|
cc8dfe3dd8 | ||
|
|
f25edadd5f | ||
|
|
4f50c67332 | ||
|
|
75f9d2a290 | ||
|
|
b93b8859f9 | ||
|
|
3510a45742 | ||
|
|
85eb283b40 | ||
|
|
b8e1c5077d | ||
|
|
8422240967 | ||
|
|
fbd922117e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提供统一鉴权、模型路由、额度状态、
|
||||
请求观测和管理控制面。
|
||||
|
||||
> [!NOTE]
|
||||
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
|
||||
|
||||
@@ -30,7 +27,7 @@ Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提
|
||||
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
|
||||
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
|
||||
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
|
||||
| 账号 | OAuth、OpenAI AT/RT、账号文件与 Agent Identity 导入,credential/额度刷新、账号恢复及主动额度重置卡 |
|
||||
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
|
||||
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
|
||||
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
|
||||
|
||||
@@ -93,7 +90,7 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
### 3. 初始化网关
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT、OAuth JSON 与 Agent Identity;
|
||||
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
|
||||
xAI 支持 OAuth 和账号 JSON。
|
||||
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
|
||||
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
|
||||
|
||||
Generated
+18
-197
@@ -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();
|
||||
@@ -682,8 +696,10 @@ impl AccountStore for PgAdminAccountStore {
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
match command.credential {
|
||||
AuthorizationCredentialCommit::Create(credential) => {
|
||||
let account_id = credential.account_id.clone();
|
||||
let result = self
|
||||
let CredentialImportResult {
|
||||
config_revision,
|
||||
credential_ids,
|
||||
} = self
|
||||
.commit_prepared_import(
|
||||
PreparedCredentialImport {
|
||||
provider_kind: credential.provider_kind.clone(),
|
||||
@@ -693,6 +709,14 @@ impl AccountStore for PgAdminAccountStore {
|
||||
"authorize",
|
||||
)
|
||||
.await?;
|
||||
let [account_id]: [CoreProviderAccountId; 1] =
|
||||
credential_ids.try_into().map_err(|_| {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
ENTITY,
|
||||
"authorization import returned an unexpected account count",
|
||||
)
|
||||
})?;
|
||||
let details = self
|
||||
.accounts
|
||||
.load_provider_account(account_id.as_str())
|
||||
@@ -706,7 +730,7 @@ impl AccountStore for PgAdminAccountStore {
|
||||
)
|
||||
})?;
|
||||
Ok(CredentialMutationResult {
|
||||
config_revision: result.config_revision,
|
||||
config_revision,
|
||||
account_id,
|
||||
credential_revision: Some(admin_revision(details.summary.credential_revision)?),
|
||||
})
|
||||
|
||||
@@ -339,6 +339,30 @@ pub(crate) fn admin_account_usage_window_model_cost(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn admin_account_usage_window_cost(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> AdminStoreResult<((String, String), AccountCost)> {
|
||||
let key = (
|
||||
window_usage_value(row, "account_id")?,
|
||||
window_usage_value(row, "window_key")?,
|
||||
);
|
||||
let amount = window_usage_value::<String>(row, "amount")?;
|
||||
let amount = AdminDecimalAmount::from_str(&amount).map_err(|_| {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Invalid,
|
||||
ENTITY,
|
||||
"persisted account cost is invalid",
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
key,
|
||||
AccountCost {
|
||||
currency: window_usage_value(row, "cost_currency")?,
|
||||
amount,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn window_usage_count(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
column: &'static str,
|
||||
|
||||
@@ -51,6 +51,28 @@ pub(crate) const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as
|
||||
group by requested.account_id, requested.window_key
|
||||
order by requested.account_id, requested.window_key";
|
||||
|
||||
pub(crate) const ACCOUNT_USAGE_COSTS_BY_WINDOWS_SQL: &str = "with requested_windows as (
|
||||
select *
|
||||
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
|
||||
as requested(account_id, window_key, window_start, window_end)
|
||||
)
|
||||
select requested.account_id,
|
||||
requested.window_key,
|
||||
mr.cost_currency,
|
||||
sum(mr.cost_amount)::text as amount
|
||||
from requested_windows requested
|
||||
join model_requests mr
|
||||
on mr.provider_account_ref = requested.account_id
|
||||
and mr.started_at >= requested.window_start
|
||||
and mr.started_at < requested.window_end
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and mr.cost_amount is not null
|
||||
and mr.cost_currency is not null
|
||||
group by requested.account_id, requested.window_key, mr.cost_currency
|
||||
order by requested.account_id, requested.window_key, mr.cost_currency";
|
||||
|
||||
pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_windows as (
|
||||
select *
|
||||
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
|
||||
|
||||
@@ -16,6 +16,24 @@ use super::{namespace, resource_fingerprint};
|
||||
|
||||
const MAX_SESSION_AFFINITY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
const CLAIM_OR_LOAD_SCRIPT: &str = r#"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current then
|
||||
return current
|
||||
end
|
||||
redis.call('PSETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
|
||||
return ARGV[1]
|
||||
"#;
|
||||
|
||||
const COMPARE_AND_BIND_SCRIPT: &str = r#"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current or current == ARGV[1] then
|
||||
redis.call('PSETEX', KEYS[1], tonumber(ARGV[3]), ARGV[2])
|
||||
return ARGV[2]
|
||||
end
|
||||
return current
|
||||
"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisProviderSessionAffinityRepository {
|
||||
connection: ConnectionManager,
|
||||
@@ -79,11 +97,7 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<(), ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
|
||||
return Err(provider_invalid("validate provider session affinity TTL"));
|
||||
}
|
||||
let ttl_millis = u64::try_from(ttl.as_millis())
|
||||
.map_err(|_| provider_invalid("validate provider session affinity TTL"))?;
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
redis::cmd("PSETEX")
|
||||
.arg(self.key(provider_kind, key)?)
|
||||
@@ -95,6 +109,50 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
})
|
||||
}
|
||||
|
||||
fn claim_or_load<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
candidate_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
let effective_account_id = redis::Script::new(CLAIM_OR_LOAD_SCRIPT)
|
||||
.key(self.key(provider_kind, key)?)
|
||||
.arg(candidate_account_id.as_str())
|
||||
.arg(ttl_millis)
|
||||
.invoke_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("claim provider session affinity"))?;
|
||||
decode_account_id(effective_account_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_and_bind<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
expected_account_id: &'a ProviderAccountId,
|
||||
replacement_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
let effective_account_id = redis::Script::new(COMPARE_AND_BIND_SCRIPT)
|
||||
.key(self.key(provider_kind, key)?)
|
||||
.arg(expected_account_id.as_str())
|
||||
.arg(replacement_account_id.as_str())
|
||||
.arg(ttl_millis)
|
||||
.invoke_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("compare provider session affinity"))?;
|
||||
decode_account_id(effective_account_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
@@ -112,6 +170,19 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_affinity_ttl_millis(ttl: Duration) -> Result<u64, ProviderStoreError> {
|
||||
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
|
||||
return Err(provider_invalid("validate provider session affinity TTL"));
|
||||
}
|
||||
u64::try_from(ttl.as_millis())
|
||||
.map_err(|_| provider_invalid("validate provider session affinity TTL"))
|
||||
}
|
||||
|
||||
fn decode_account_id(account_id: String) -> Result<ProviderAccountId, ProviderStoreError> {
|
||||
ProviderAccountId::new(account_id)
|
||||
.map_err(|_| provider_invalid("decode provider session affinity"))
|
||||
}
|
||||
|
||||
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
|
||||
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ use gateway_admin::{
|
||||
UpdateAccount,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{CredentialListQuery, CredentialListWindow, CredentialStateFilter},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationCredentialCommit, AuthorizationMutationTarget,
|
||||
AuthorizationOwnerBinding, CredentialListQuery, CredentialListWindow,
|
||||
CredentialStateFilter, PendingAuthorizationMutation, PreparedCredentialCreate,
|
||||
ProviderDocument,
|
||||
},
|
||||
},
|
||||
ports::store::AccountStore,
|
||||
};
|
||||
@@ -882,10 +887,16 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
|
||||
("gpt-window-recent", Some(700)),
|
||||
],
|
||||
);
|
||||
assert_eq!(usage[0].usage.costs.len(), 1);
|
||||
assert_eq!(usage[0].usage.costs[0].currency, "USD");
|
||||
assert_eq!(usage[0].usage.costs[0].amount.as_str(), "3.75");
|
||||
assert_eq!(usage[0].usage.models[0].costs[0].amount.as_str(), "2.5");
|
||||
assert_eq!(usage[0].usage.models[1].costs[0].amount.as_str(), "1.25");
|
||||
assert_eq!(usage[1].key, "short");
|
||||
assert_eq!(usage[1].usage.total_tokens, Some(700));
|
||||
assert_eq!(usage[1].usage.costs.len(), 1);
|
||||
assert_eq!(usage[1].usage.costs[0].currency, "USD");
|
||||
assert_eq!(usage[1].usage.costs[0].amount.as_str(), "1.25");
|
||||
assert_eq!(usage[1].usage.models.len(), 1);
|
||||
assert_eq!(usage[1].usage.models[0].model, "gpt-window-recent");
|
||||
assert_eq!(usage[1].usage.models[0].total_tokens, Some(700));
|
||||
@@ -1326,6 +1337,75 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_create_returns_existing_account_id_when_identity_is_upserted() {
|
||||
let Some(database) = TestDatabase::create("provider_account_authorization_upsert").await else {
|
||||
return;
|
||||
};
|
||||
PgProviderAccountRepository::new(database.pool.clone())
|
||||
.insert_provider_account(account(
|
||||
"acct_authorization_existing",
|
||||
"user-authorization-upsert",
|
||||
))
|
||||
.await
|
||||
.expect("seed existing authorized identity");
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "request_authorization_upsert".to_owned(),
|
||||
};
|
||||
let provider_kind = ProviderKind::new("openai").expect("OpenAI Provider kind");
|
||||
let serde_json::Value::Object(provider_material) =
|
||||
json!({ "access_token": "authorized-secret" })
|
||||
else {
|
||||
unreachable!("credential fixture must be an object");
|
||||
};
|
||||
|
||||
let result = PgAdminAccountStore::new(database.pool.clone(), None)
|
||||
.commit_authorization(
|
||||
AuthorizationCommit {
|
||||
pending: PendingAuthorizationMutation::new(
|
||||
provider_kind.clone(),
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: "authorized account".to_owned(),
|
||||
},
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
),
|
||||
credential: AuthorizationCredentialCommit::Create(PreparedCredentialCreate {
|
||||
account_id: ProviderAccountId::new("acct_authorization_candidate")
|
||||
.expect("candidate account ID"),
|
||||
provider_kind,
|
||||
name: "authorized account".to_owned(),
|
||||
email: Some("authorized@example.invalid".to_owned()),
|
||||
upstream_user_id: Some("user-authorization-upsert".to_owned()),
|
||||
upstream_account_id: None,
|
||||
plan_type: Some("free".to_owned()),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(
|
||||
provider_material,
|
||||
)),
|
||||
has_refresh_token: true,
|
||||
access_token_expires_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
credential_observed_at: Utc::now(),
|
||||
}),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("authorize existing identity");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
result.account_id.as_str(),
|
||||
result.credential_revision.map(|revision| revision.get()),
|
||||
),
|
||||
("acct_authorization_existing", Some(2)),
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
|
||||
|
||||
@@ -65,6 +65,55 @@ async fn session_affinity_should_overwrite_the_previous_account() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_claim_should_keep_the_first_account() {
|
||||
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
|
||||
return;
|
||||
};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("claim-session").expect("affinity key");
|
||||
let first = ProviderAccountId::new("acct_first").expect("first account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("second account");
|
||||
|
||||
let first_winner = repository
|
||||
.claim_or_load(&provider, &key, &first, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("claim first affinity");
|
||||
let second_winner = repository
|
||||
.claim_or_load(&provider, &key, &second, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("load existing affinity");
|
||||
|
||||
assert_eq!((first_winner, second_winner), (first.clone(), first));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_compare_should_not_replace_a_newer_winner() {
|
||||
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
|
||||
return;
|
||||
};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("compare-session").expect("affinity key");
|
||||
let first = ProviderAccountId::new("acct_first").expect("first account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("second account");
|
||||
let stale = ProviderAccountId::new("acct_stale").expect("stale account");
|
||||
repository
|
||||
.bind(&provider, &key, &first, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("seed affinity");
|
||||
|
||||
let migrated = repository
|
||||
.compare_and_bind(&provider, &key, &first, &second, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("migrate expected affinity");
|
||||
let stale_result = repository
|
||||
.compare_and_bind(&provider, &key, &first, &stale, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("reject stale affinity migration");
|
||||
|
||||
assert_eq!((migrated, stale_result), (second.clone(), second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_should_apply_ttl_and_support_explicit_clear() {
|
||||
let Some((repository, mut connection, namespace)) = affinity_repository().await else {
|
||||
|
||||
@@ -14,8 +14,6 @@ base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["clock", "serde"] }
|
||||
cookie = "0.18.1"
|
||||
crypto_box = { version = "0.9.1", features = ["seal"] }
|
||||
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
|
||||
futures = "0.3.32"
|
||||
flate2 = "1.1.9"
|
||||
gateway-admin = { path = "../../gateway-admin" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: ×tamp,
|
||||
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) = ¤t.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(¤t.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(¤t.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("a),
|
||||
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("a),
|
||||
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("a),
|
||||
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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(®istrar));
|
||||
let current = store.account("acct_agent_task").expect("current account");
|
||||
let prepared = service.prepare(¤t).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(¤t)
|
||||
.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(¤t, None));
|
||||
block_on(selector.record_success(¤t, 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("a),
|
||||
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("a),
|
||||
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 +1 @@
|
||||
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
|
||||
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
|
||||
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
|
||||
|
||||
@@ -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;
|
||||
@@ -4,8 +4,7 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
|
||||
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
|
||||
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
|
||||
|
||||
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
|
||||
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
|
||||
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
|
||||
|
||||
## 冻结规则
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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 请求字段为:
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ flowchart LR
|
||||
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin wire、静态管理端;不直接访问 Store 或具体 Provider |
|
||||
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
|
||||
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 |
|
||||
| `providers/openai` | OpenAI OAuth/Agent Identity、账号选择、目录、额度、Responses/Images transport |
|
||||
| `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images transport |
|
||||
| `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 |
|
||||
| `frontend` | Vue 管理端,仅通过 Admin API 读写状态 |
|
||||
|
||||
@@ -189,7 +189,7 @@ PostgreSQL 周期对账才是正确性基础。
|
||||
credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403
|
||||
也不能单独证明 refresh token 永久失效。
|
||||
|
||||
- OpenAI 支持 OAuth、AT/RT、OAuth JSON 与 Agent Identity;RT-only 导入先换取 AT,AT-only 导入没有
|
||||
- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON;RT-only 导入先换取 AT,AT-only 导入没有
|
||||
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
|
||||
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
|
||||
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
|
||||
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
<!-- prettier-ignore -->
|
||||
<div align="center">
|
||||
|
||||
<img src="../frontend/public/favicon.svg" alt="Codex Proxy RS" width="72" height="72" />
|
||||
|
||||
# 管理端主题系统
|
||||
|
||||
**基于 Seed、Map、Alias 与 Component Token 的运行时主题架构**
|
||||
|
||||
[设计原则](#设计原则) · [Token 模型](#token-模型) · [运行时架构](#运行时架构) · [主题编辑器](#主题编辑器) · [扩展指南](#扩展指南)
|
||||
|
||||
</div>
|
||||
|
||||
主题系统为 Codex Proxy RS 管理端提供浅色、深色、预置配色与自定义配色能力。实现借鉴 Ant Design 的
|
||||
Token 分层与十阶色板算法,但不引入 `antd`、React、`ConfigProvider` 或 CSS-in-JS;Vue 组件继续通过
|
||||
CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 主题的目标是调整颜色、密度、圆角与层级,不改变现有页面结构和产品语义。默认主题必须保持既有视觉基线,
|
||||
> 成功、警告、错误和业务数据色不会因为品牌色变化而失去原有含义。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
|
||||
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
|
||||
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
|
||||
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
|
||||
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
|
||||
- **运行时可定制**:用户输入在浏览器中实时派生,因此使用 CSS Variables,不使用构建时 SCSS 变量。
|
||||
- **单一事实源**:Store 只保存最小配置,所有 Map、Alias 与未覆盖的 Component Token 均由纯函数生成。
|
||||
- **可访问性优先**:文字、焦点和实心控件在 Alias 层执行对比度保护,并尊重 `prefers-reduced-motion`。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Pinia 持久化配置] --> B[resolveTheme]
|
||||
B --> C[Seed Token]
|
||||
C --> D[Map Token]
|
||||
D --> E[Alias Token]
|
||||
E --> F[Component Token]
|
||||
F --> G[#cp-runtime-theme]
|
||||
G --> H[Vue / Tailwind / ECharts]
|
||||
|
||||
B --> I[Theme Editor 草稿]
|
||||
I --> J[Shadow DOM 预览]
|
||||
J --> K[首页画板 / 组件概览]
|
||||
```
|
||||
|
||||
核心职责保持分离:
|
||||
|
||||
| 层 | 文件 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| 领域模型 | [`theme/index.ts`](../frontend/src/theme/index.ts) | 类型、规范化、纯派生算法与最终 Token 输出 |
|
||||
| 设计基线 | [`theme/constants.ts`](../frontend/src/theme/constants.ts) | 默认值、预置主题、中性色面、阴影与可编辑白名单 |
|
||||
| 浏览器适配 | [`theme/browser.ts`](../frontend/src/theme/browser.ts) | 将解析结果提交为根作用域运行时样式表 |
|
||||
| 状态 | [`stores/modules/theme.ts`](../frontend/src/stores/modules/theme.ts) | 持久化配置、系统明暗偏好、切换动作与动画 |
|
||||
| 编辑器状态 | [`useThemeEditor.ts`](../frontend/src/views/theme/composables/useThemeEditor.ts) | 草稿、修改计数、恢复与保存 |
|
||||
| 样式桥接 | [`styles/index.css`](../frontend/src/styles/index.css) | 将 CSS Token 暴露为 Tailwind CSS 4 utilities |
|
||||
| 稳定语义 | [`styles/tokens.css`](../frontend/src/styles/tokens.css) | 不参与主题派生的业务常量 |
|
||||
|
||||
`theme/index.ts` 不访问 DOM,`theme/browser.ts` 不包含派生规则,Theme Store 不复制算法。类型与算法集中在
|
||||
`theme/index.ts`,不额外拆分 `types.ts`。
|
||||
|
||||
## Token 模型
|
||||
|
||||
主题采用四层模型:
|
||||
|
||||
```text
|
||||
Seed Token → Map Token → Alias Token → Component Token
|
||||
```
|
||||
|
||||
### Seed Token
|
||||
|
||||
Seed 是用户配置与持久化的最小事实:
|
||||
|
||||
```ts
|
||||
type ThemeMode = 'system' | 'light' | 'dark'
|
||||
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
|
||||
|
||||
interface ThemeCustomization {
|
||||
seed?: Partial<ThemeSeedOverrides>
|
||||
component?: Partial<ThemeComponentOverrides>
|
||||
tokenOverrides?: Partial<ThemeTokens>
|
||||
}
|
||||
```
|
||||
|
||||
| 类别 | Seed |
|
||||
| --- | --- |
|
||||
| 品牌 | `colorPrimary`,由预置 ID 或自定义 HEX 提供 |
|
||||
| 功能色 | `colorSuccess`、`colorWarning`、`colorError`、`colorInfo`、`colorLink` |
|
||||
| 中性色 | `colorTextBase`、`colorBgBase` |
|
||||
| 尺寸 | `fontSize`、`sizeUnit`、`sizeStep`、`controlHeight` |
|
||||
| 风格 | `borderRadius`、`shadowStrength` |
|
||||
| 组件尺寸 | `tableRowHeight`、`cardBorderRadius` |
|
||||
|
||||
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
|
||||
初始化时规范化并回退到默认配置。
|
||||
|
||||
### Map Token
|
||||
|
||||
品牌色和功能色使用 `@ant-design/colors` 从一个 Seed 生成十阶色板。主色派生规则为:
|
||||
|
||||
| 角色 | 规则 |
|
||||
| --- | --- |
|
||||
| `colorPrimary` | 始终使用 Seed 原色 |
|
||||
| Hover | 浅色取 P5,深色取暗色算法 P8 |
|
||||
| Active | 浅色取 P7,深色取暗色算法 P6 |
|
||||
| 实心 Hover | Seed 向白混合 6% |
|
||||
| 实心 Active | Seed 向黑混合 8% |
|
||||
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
|
||||
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
|
||||
|
||||
功能色的基础 Map 对齐 Ant Design:`info`、`success`、`warning`、`error` 均固定取十阶色板 P6。浅色 P6
|
||||
就是用户选择的 Seed,因此 `--cp-color-info / success / warning / error` 不再经过额外对比度改写;深色 P6
|
||||
继续使用暗色色板对当前 Surface 做主题适配。功能色的 Hover、Active、弱背景、文字和描边仍按各自角色派生。
|
||||
|
||||
背景与文本 Seed 进入独立的 Surface Map,生成:
|
||||
|
||||
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
|
||||
- `colorFillSecondary / Tertiary / Quaternary`
|
||||
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
|
||||
- `colorBorder / BorderSecondary / Split / Shadow`
|
||||
|
||||
默认中继蓝使用固定的石墨中性色基线。其他预置通过各自的浅色、深色 `colorBgBase` 与 `colorTextBase`
|
||||
建立完整气质。自定义主色只向中性表面注入少量色温:浅色混入 2.8%,深色从默认暗面与暗色色板背景混合;
|
||||
用户显式设置背景或文字 Seed 后,自动配套值立即让位。
|
||||
|
||||
### Alias Token
|
||||
|
||||
Alias 按视觉角色命名,使用 `--cp-` 命名空间,并优先对齐 Ant Design 的公开 Token 词汇:
|
||||
|
||||
| 角色 | CSS Token | 主要消费者 |
|
||||
| --- | --- | --- |
|
||||
| 品牌交互 | `--cp-color-primary*` | 主操作、选中态、焦点与强调 |
|
||||
| 链接 | `--cp-color-link*` | 普通文本链接 |
|
||||
| 表面 | `--cp-color-bg-*` | 页面、容器、浮层和遮罩 |
|
||||
| 填充 | `--cp-color-fill-*` | 轨道、弱背景和静态层级 |
|
||||
| 文字 | `--cp-color-text*` | 标题、正文和辅助信息 |
|
||||
| 选择 | `--cp-control-item-bg-active*` | Segmented、Select 和选择控件 |
|
||||
| 焦点 | `--cp-control-outline` | 键盘焦点和输入反馈 |
|
||||
| 功能色 | `--cp-color-info / success / warning / error-*` | 系统反馈与状态 |
|
||||
|
||||
Alias 层执行以下保护:
|
||||
|
||||
- 普通文字与背景的目标对比度为 4.5:1。
|
||||
- 控件边界与相邻 Surface 的目标对比度为 3:1。
|
||||
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
|
||||
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
|
||||
|
||||
### Component Token
|
||||
|
||||
当组件需要独立演进时,使用 `component[-part][-variant][-state]-property` 命名,不创造含义重复的全局别名。
|
||||
|
||||
| 组件 | 代表 Token |
|
||||
| --- | --- |
|
||||
| Button | `--cp-button-primary-color / bg / hover-bg / active-bg` |
|
||||
| Input | `--cp-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
|
||||
| Menu | `--cp-menu-item-selected-bg` |
|
||||
| Table | `--cp-table-row-bg / stripe-bg / hover-bg / selected-bg / height` |
|
||||
| Card | `--cp-card-bg / border-radius / shadow` |
|
||||
| Layout | `--cp-layout-sider-bg / shadow` |
|
||||
| Scrollbar | `--cp-scrollbar-thumb-bg / hover-bg` |
|
||||
| BrandMark | `--cp-brand-mark-bg` |
|
||||
|
||||
应用内品牌图标使用 [`AppBrandMark.vue`](../frontend/src/components/AppBrandMark.vue):浅色模式取
|
||||
`colorBgSpotlight`,深色模式取 `colorBgElevated`,保持中性暗面与白色图形;浏览器 favicon 继续使用固定黑白
|
||||
图标,不随主题改变。
|
||||
|
||||
### 稳定业务语义
|
||||
|
||||
下列颜色不属于通用主题皮肤,保留在 `styles/tokens.css`:
|
||||
|
||||
- 账号“正常”状态 `colorStatusNormal*`
|
||||
- 推理标识色 `colorReasoning`
|
||||
- 账号套餐标识色 `accountPlan*`
|
||||
|
||||
它们可以分别拥有浅色和深色值,但不会被品牌 Seed 重写。图表数据系列同样按数据语义管理,不把所有曲线染成
|
||||
主色。
|
||||
|
||||
## 预置主题
|
||||
|
||||
| ID | 名称 | 主色 | 表面方向 |
|
||||
| --- | --- | --- | --- |
|
||||
| `relay-blue` | 中继蓝 | `#5983F4` | 默认石墨控制面,清晰可信 |
|
||||
| `deep-teal` | 深海青 | `#0E7C72` | 氧化铜与深水玻璃,冷静低饱和 |
|
||||
| `signal-violet` | 古风色 | `#A0583D` | 赭石、宣纸与松烟墨,温厚克制 |
|
||||
| `graphite` | 石墨 | `#525B66` | 工作室灰纸与铅笔粉末,近单色层级 |
|
||||
|
||||
预置主题只改变颜色画像,不改变字号、密度、圆角或布局。所有预置与自定义主题走同一条派生链路,不存在页面级
|
||||
特判。
|
||||
|
||||
## 运行时架构
|
||||
|
||||
### 启动顺序
|
||||
|
||||
[`main.ts`](../frontend/src/main.ts) 在 Vue 挂载前初始化主题:
|
||||
|
||||
```text
|
||||
createApp
|
||||
→ app.use(pinia)
|
||||
→ persistedstate 同步水合 Theme Store
|
||||
→ themeStore.initializeTheme()
|
||||
→ app.use(router / auth)
|
||||
→ app.mount('#app')
|
||||
```
|
||||
|
||||
这样首个 Vue 组件渲染时已经具备正确主题,不需要在 `index.html` 增加 bootstrap 脚本,也不维护第二套本地存储
|
||||
解析逻辑。
|
||||
|
||||
### CSS Variables 提交
|
||||
|
||||
全局主题由 `<head>` 中唯一的运行时样式节点承载:
|
||||
|
||||
```html
|
||||
<style id="cp-runtime-theme">
|
||||
:root[data-theme='dark'][data-theme-color='relay-blue'] {
|
||||
color-scheme: dark;
|
||||
--cp-color-bg-layout: #0B111C;
|
||||
/* 其余 Map、Alias 与 Component Token */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
根元素只保留 `data-theme` 与 `data-theme-color` 状态,不写整套内联 Token。运行时样式表比逐项调用
|
||||
`style.setProperty()` 更集中、可检查,也避免在 DOM 属性中形成超长变量串。全局弹窗通过 Teleport 挂到 `body`
|
||||
后仍继承根变量。
|
||||
|
||||
> [!NOTE]
|
||||
> Theme Editor 预览是例外:草稿 Token 以内联变量写在影子环境的局部根节点上,仅影响预览,不污染已保存主题。
|
||||
|
||||
### 切换与图表
|
||||
|
||||
- 用户主动切换时,以点击位置为圆心运行 View Transition;不支持时使用 180ms 颜色过渡。
|
||||
- 初始化、系统主题变化或减少动态效果时直接提交,不播放扩散动画。
|
||||
- 每次有效提交只增加一次 `themeRevision`,相同签名不会重复刷新。
|
||||
- `useThemeColor()` 在正式页面读取根 CSS Variables,在影子预览中优先读取注入的局部 Token。
|
||||
- 图表 Option 在主题 revision 或预览 Token 改变后重算,`BaseChart` 通过 `setOption` 更新现有 Canvas。
|
||||
|
||||
## 主题编辑器
|
||||
|
||||
主题编辑器位于一级路由 `/theme`,采用一屏工作台:左侧编辑,右侧预览,顶部保留全局操作。桌面端高度固定为
|
||||
`100dvh - 3rem`,切换编辑层级或预览类型时不改变外框高度。窄屏头部操作允许自然换行;手机端隐藏实时预览,
|
||||
只保留 Token 编辑,避免在有限宽度内渲染不可操作的缩放画板。
|
||||
|
||||
### 编辑能力
|
||||
|
||||
| 区域 | 能力 |
|
||||
| --- | --- |
|
||||
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
|
||||
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
|
||||
| 全局 / 风格 | 通用圆角、阴影强度 |
|
||||
| 组件 | Action、Form、Selection、Surface、Data Display、Feedback、Navigation、Layout |
|
||||
| 工作流 | 搜索、单项恢复、撤销草稿、恢复默认、保存并应用 |
|
||||
|
||||
Component Token 只开放白名单字段。未覆盖项始终继续使用全局 Seed 与 Alias 算法,避免主题配置逐渐退化成一份
|
||||
无法维护的完整 CSS 快照。
|
||||
|
||||
### 草稿与保存
|
||||
|
||||
编辑器维护 `saved` 与 `draft` 两份状态:
|
||||
|
||||
1. 输入只更新草稿和局部预览。
|
||||
2. 修改计数按模式、主题色、Seed、组件值和 Token override 分项统计。
|
||||
3. “撤销草稿”恢复到最近一次已保存配置。
|
||||
4. “保存并应用”先规范化草稿,再原子更新 Theme Store。
|
||||
|
||||
### 隔离预览
|
||||
|
||||
[`ThemePreviewScope.vue`](../frontend/src/views/theme/components/ThemePreviewScope.vue) 创建开放 Shadow Root,复制应用
|
||||
样式,并把预览内容 Teleport 到影子根中。预览可独立切换浅色和深色,不受外层主题影响。
|
||||
|
||||
- **首页画板**复用真实 `DashboardContent` 和固定 fixture,不请求接口,也不启动自动刷新。
|
||||
- **组件概览**展示基础组件、表格、空状态、骨架、浮层和菜单等关键状态。
|
||||
- 画板固定为 `1600 × 1808`,使用 CSS `zoom` 重排,不使用 `transform: scale()` 长期缩放文字。
|
||||
- 空白区域可拖拽,滚轮以指针为锚点缩放,并提供缩小、100%、放大和适应画板操作。
|
||||
- 编辑面板和组件概览统一使用 `BaseScrollbar`,滚动条空闲时自动隐藏。
|
||||
|
||||
## 样式与命名约定
|
||||
|
||||
### CSS Token
|
||||
|
||||
- 全局 Token:`--cp-color-bg-container`、`--cp-font-size`。
|
||||
- Component Token:`--cp-table-row-hover-bg`、`--cp-input-active-shadow`。
|
||||
- 业务 Token:命名中明确业务域,例如 `--cp-account-plan-pro-bg`。
|
||||
- 禁止继续引入 `accent`、`soft`、`current`、`subtle` 等与现有角色重叠的平行词汇。
|
||||
|
||||
### Vue 与 Tailwind CSS 4
|
||||
|
||||
组件优先使用 `bg-cp-*`、`text-cp-*`、`shadow-cp-*`、`rounded-cp-*` 等由 `@theme inline` 暴露的 utility:
|
||||
|
||||
```vue
|
||||
<section class="rounded-cp-card bg-cp-bg-container text-cp-text shadow-cp-card">
|
||||
...
|
||||
</section>
|
||||
```
|
||||
|
||||
仅当值需要在 CSS 函数、SVG 或局部派生中参与计算时,直接读取 `var(--cp-*)`。不在页面组件中重新实现色阶、
|
||||
对比度或明暗算法。
|
||||
|
||||
### 视觉状态
|
||||
|
||||
- 静态填充、Hover、Active 与 Selected 必须使用不同角色,不能复用一个变量制造所有层级。
|
||||
- Input 的 Hover 与 Active 只改变内部填充;外圈反馈保持同色同宽,错误状态使用独立 Error Token。
|
||||
- 表格斑马纹使用 `table-row-stripe-bg`,不借用 `row-hover-bg`。
|
||||
- Card 和选项默认不增加装饰性边框;键盘焦点必须保留可见反馈。
|
||||
- 阴影保持中性,`shadowStrength` 只调节层级强弱,不给阴影染品牌色。
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 增加预置主题
|
||||
|
||||
1. 在 `ThemeColorPresetId` 增加稳定 ID。
|
||||
2. 在 `THEME_COLOR_PRESETS` 声明名称、描述、主色 Seed 与可选浅暗表面画像。
|
||||
3. 不新增页面特判,确认预置可通过同一 `resolveTheme()` 派生。
|
||||
4. 在浅色、深色和系统模式下检查文本、浮层、Input、表格与图表。
|
||||
|
||||
### 增加全局 Seed 或 Alias
|
||||
|
||||
1. 在 `theme/index.ts` 的领域类型中声明字段。
|
||||
2. 在规范化函数中定义合法输入边界。
|
||||
3. 在 Map 或 Alias 层集中派生,并加入 `ThemeTokens` 完整输出。
|
||||
4. 如需 Tailwind utility,在 `styles/index.css` 的 `@theme inline` 中映射。
|
||||
5. 只在确实需要用户控制时加入 Theme Editor;派生细节默认只读。
|
||||
|
||||
### 增加 Component Token
|
||||
|
||||
1. 先确认全局 Alias 无法准确表达组件职责。
|
||||
2. 使用 `component[-part][-variant][-state]-property` 命名。
|
||||
3. 在 `ThemeComponentMap` 和 `toThemeTokens()` 中提供默认派生值。
|
||||
4. 需要开放编辑时,加入对应组件目录和可编辑白名单。
|
||||
5. 基础组件消费 Token,页面不得再写第二套局部常量。
|
||||
|
||||
> [!WARNING]
|
||||
> 不要把任意 HEX、阴影或尺寸加入 `styles/tokens.css` 作为“临时修复”。可换肤值必须进入派生链;只有稳定的
|
||||
> 业务语义与首帧安全 fallback 可以留在静态样式中。
|
||||
|
||||
## 验证
|
||||
|
||||
前端改动至少执行:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm run lint
|
||||
pnpm run typecheck
|
||||
pnpm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
视觉验收覆盖:
|
||||
|
||||
- 四个预置、自定义 HEX、浅色、深色和跟随系统模式。
|
||||
- 页面、容器、浮层、输入框、表格、分页、主按钮、选中态、焦点和品牌图标。
|
||||
- 草稿隔离、保存刷新恢复与 Teleport 弹窗。
|
||||
- 首页画板缩放清晰度、组件概览滚动、ECharts 网格线和 Skeleton 动效。
|
||||
- 键盘操作、颜色之外的选中反馈和 `prefers-reduced-motion`。
|
||||
|
||||
## 上游参考
|
||||
|
||||
- [Ant Design 色彩规范](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/colors.zh-CN.md)
|
||||
- [Ant Design 暗黑模式](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/dark.zh-CN.md)
|
||||
- [Ant Design 主题定制](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/react/customize-theme.zh-CN.md)
|
||||
- [Seed 到 Map](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/themes/shared/genColorMapToken.ts)
|
||||
- [Map 到 Alias](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/util/alias.ts)
|
||||
- [`@ant-design/colors` 生成器](https://github.com/ant-design/ant-design-colors/blob/89b4a5b7e989b792610087abe855bf4a2fb1d322/src/generate.ts)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,19 @@
|
||||
"format:check": "pnpm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.1",
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.31.0",
|
||||
"@lucide/vue": "^1.33.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.13",
|
||||
"dayjs": "^1.11.23",
|
||||
"dompurify": "^3.4.14",
|
||||
"echarts": "^6.1.0",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"es-toolkit": "^1.51.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.9",
|
||||
"marked": "^18.0.10",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.41",
|
||||
@@ -37,12 +38,12 @@
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.10"
|
||||
"vite": "^8.2.2",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+274
-248
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,6 @@
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import { BaseToast } from '@/components/base/BaseToast'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
useUiStore().initializeTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -170,6 +170,48 @@ export interface AccountQuotaResponse {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export interface AccountProfileStatisticsSummary {
|
||||
totalTextTokens: number | null
|
||||
peakTokens: number | null
|
||||
longestTaskDurationMs: number | null
|
||||
currentStreakDays: number | null
|
||||
longestStreakDays: number | null
|
||||
}
|
||||
|
||||
export interface AccountProfileDailyUsage {
|
||||
date: string
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export interface AccountProfileInvocation {
|
||||
type: string
|
||||
pluginId: string | null
|
||||
pluginName: string | null
|
||||
skillId: string | null
|
||||
skillName: string | null
|
||||
usageCount: number | null
|
||||
}
|
||||
|
||||
export interface AccountProfileActivityInsights {
|
||||
fastModePercent: number | null
|
||||
reasoningEffort: string | null
|
||||
reasoningEffortPercent: number | null
|
||||
skillsExplored: number | null
|
||||
totalSkillsUsed: number | null
|
||||
totalThreads: number | null
|
||||
invocations: AccountProfileInvocation[] | null
|
||||
}
|
||||
|
||||
export interface AccountProfileStatisticsResponse {
|
||||
displayName: string | null
|
||||
username: string | null
|
||||
imageUrl: string | null
|
||||
hasStatsError: boolean
|
||||
summary: AccountProfileStatisticsSummary
|
||||
dailyUsage: AccountProfileDailyUsage[] | null
|
||||
activityInsights: AccountProfileActivityInsights
|
||||
}
|
||||
|
||||
export interface AccountResetCredit {
|
||||
id: string
|
||||
status: string | null
|
||||
@@ -326,6 +368,14 @@ export function getAccountQuota(data: AccountIdParam) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAccountProfileStatistics(data: AccountIdParam) {
|
||||
return request<AccountProfileStatisticsResponse>({
|
||||
url: '/api/admin/accounts/profile-statistics',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(data: AccountIdParam) {
|
||||
return request<AccountQuotaResponse>({
|
||||
url: '/api/admin/accounts/quota/refresh',
|
||||
|
||||
@@ -32,7 +32,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3.5 py-2.5"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</span>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
|
||||
class="shrink-0 text-cp-xs font-emphasis text-cp-text-quaternary"
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary"
|
||||
>
|
||||
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
|
||||
</p>
|
||||
|
||||
@@ -27,5 +27,5 @@ const fullNames = computed(() => props.groups
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-xs text-cp-muted-text">—</span>
|
||||
<span v-else class="text-xs text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
@@ -90,7 +90,7 @@ function providerSummary(group: AccountGroup) {
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4 text-cp-tertiary" />
|
||||
<Search class="size-4 text-cp-text-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
@@ -102,10 +102,10 @@ function providerSummary(group: AccountGroup) {
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id"
|
||||
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
class="flex w-full items-start gap-3 rounded-cp border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline"
|
||||
:class="selectedIds.includes(group.id)
|
||||
? 'bg-cp-info-bg'
|
||||
: 'bg-cp-subtle hover:bg-cp-default-hover'"
|
||||
? 'bg-cp-control-item-bg-active'
|
||||
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover'"
|
||||
>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.includes(group.id)"
|
||||
@@ -115,7 +115,7 @@ function providerSummary(group: AccountGroup) {
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
|
||||
<strong class="min-w-0 truncate text-cp text-cp-text">
|
||||
{{ group.name }}
|
||||
</strong>
|
||||
<span
|
||||
@@ -125,7 +125,7 @@ function providerSummary(group: AccountGroup) {
|
||||
已禁用
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
|
||||
<span class="mt-1 block text-cp-xs font-emphasis text-cp-text-secondary">
|
||||
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
|
||||
</span>
|
||||
</span>
|
||||
@@ -148,15 +148,15 @@ function providerSummary(group: AccountGroup) {
|
||||
|
||||
<div
|
||||
v-if="selectedIds.length === 0"
|
||||
class="rounded-cp-control px-3.5 py-3"
|
||||
class="rounded-cp px-3.5 py-3"
|
||||
:class="emptyCopy.tone === 'warning'
|
||||
? 'bg-cp-warning-bg text-cp-warning-text'
|
||||
: 'bg-cp-subtle text-cp-secondary'"
|
||||
: 'bg-cp-fill-quaternary text-cp-text-secondary'"
|
||||
>
|
||||
<p class="m-0 text-[12px] font-bold">
|
||||
<p class="m-0 text-cp-sm font-bold">
|
||||
{{ emptyCopy.title }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
|
||||
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis">
|
||||
{{ emptyCopy.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
const id = useId().replaceAll(':', '')
|
||||
const noiseFilterId = `${id}-brand-noise`
|
||||
const wornFilterId = `${id}-brand-worn`
|
||||
const mistGradientId = `${id}-brand-mist`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="app-brand-mark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="48 48 416 416"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<filter :id="noiseFilterId" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="1.7"
|
||||
numOctaves="2"
|
||||
seed="31"
|
||||
result="noise"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="noise"
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 2.56 2.56 2.56 0 -4.76"
|
||||
result="speckle"
|
||||
/>
|
||||
<feComposite operator="in" in="SourceGraphic" in2="speckle" />
|
||||
</filter>
|
||||
<filter :id="wornFilterId" x="-12%" y="-12%" width="124%" height="124%">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.12"
|
||||
numOctaves="2"
|
||||
seed="41"
|
||||
result="noise"
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.2" />
|
||||
</filter>
|
||||
<radialGradient :id="mistGradientId" cx="0.5" cy="0.45" r="0.75">
|
||||
<stop offset="0" stop-color="currentColor" stop-opacity="0.1" />
|
||||
<stop offset="0.55" stop-color="currentColor" stop-opacity="0.045" />
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0.012" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<rect class="app-brand-mark__surface" x="48" y="48" width="416" height="416" rx="88" />
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
|
||||
<path d="M156 307A112 112 0 0 1 307 156" />
|
||||
<path d="M356 205A112 112 0 0 1 205 356" />
|
||||
</g>
|
||||
<path d="m407 105-119 153-183 149 124-157Z" fill="currentColor" />
|
||||
|
||||
<g
|
||||
:filter="`url(#${wornFilterId})`"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
>
|
||||
<path d="M92 170 136 148" stroke-width="1" opacity="0.38" />
|
||||
<path d="M98 178 142 156" stroke-width="0.5" opacity="0.3" />
|
||||
<path d="M308 398 346 378" stroke-width="0.9" opacity="0.36" />
|
||||
<path d="M314 406 352 386" stroke-width="0.5" opacity="0.3" />
|
||||
<path d="M364 132 398 118" stroke-width="0.9" opacity="0.38" />
|
||||
<path d="M402 118 410 126" stroke-width="0.5" opacity="0.28" />
|
||||
<path d="M108 350 142 334" stroke-width="1" opacity="0.4" />
|
||||
<path d="M158 238 190 226" stroke-width="0.8" opacity="0.34" />
|
||||
<path d="M296 292 320 280" stroke-width="0.7" opacity="0.3" />
|
||||
<path d="M126 118 136 110" stroke-width="0.6" opacity="0.26" />
|
||||
<path d="M400 96 410 88" stroke-width="0.6" opacity="0.24" />
|
||||
</g>
|
||||
<g :filter="`url(#${wornFilterId})`" fill="currentColor">
|
||||
<path
|
||||
d="M98 176 104 173 108 175 114 170 118 173 124 167 130 170 136 164 142 167 148 161 154 164 160 158Z"
|
||||
opacity="0.34"
|
||||
/>
|
||||
<path
|
||||
d="M314 395 320 392 324 394 330 389 336 392 342 386 348 389 354 383Z"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<rect
|
||||
x="48"
|
||||
y="48"
|
||||
width="416"
|
||||
height="416"
|
||||
rx="88"
|
||||
fill="currentColor"
|
||||
:filter="`url(#${noiseFilterId})`"
|
||||
opacity="0.08"
|
||||
/>
|
||||
<rect
|
||||
x="48"
|
||||
y="48"
|
||||
width="416"
|
||||
height="416"
|
||||
rx="88"
|
||||
:fill="`url(#${mistGradientId})`"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-brand-mark {
|
||||
color: var(--cp-color-text-light-solid);
|
||||
}
|
||||
|
||||
.app-brand-mark__surface {
|
||||
fill: var(--cp-brand-mark-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
|
||||
import { Key, Openai, Xai } from '@boxicons/vue'
|
||||
import { computed } from 'vue'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
@@ -28,8 +28,6 @@ const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
|
||||
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
|
||||
|
||||
const authenticationLabel = computed(() => {
|
||||
if (normalizedAuthenticationKind.value === 'agent_identity')
|
||||
return 'Agent Identity'
|
||||
if (normalizedAuthenticationKind.value === 'oauth')
|
||||
return 'OAuth'
|
||||
return props.authenticationKind?.trim() || '未知认证类型'
|
||||
@@ -39,18 +37,18 @@ const authenticationLabel = computed(() => {
|
||||
<template>
|
||||
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
|
||||
:class="iconContainerClass"
|
||||
:title="providerLabel"
|
||||
>
|
||||
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
|
||||
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showAuthenticationKind"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
|
||||
:class="iconContainerClass"
|
||||
:title="authenticationLabel"
|
||||
>
|
||||
@@ -59,12 +57,7 @@ const authenticationLabel = computed(() => {
|
||||
:class="iconClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Robot
|
||||
v-else-if="normalizedAuthenticationKind === 'agent_identity'"
|
||||
:class="iconClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
|
||||
</span>
|
||||
|
||||
<span class="sr-only">
|
||||
|
||||
@@ -30,19 +30,19 @@ defineSlots<{
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
|
||||
secondary:
|
||||
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
|
||||
'bg-cp-fill-tertiary text-cp-text shadow-cp-tertiary hover:bg-cp-bg-text-active active:bg-cp-bg-text-active',
|
||||
ghost:
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
|
||||
destructive:
|
||||
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
'bg-cp-error-bg text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
|
||||
md: 'h-cp-control gap-2 px-4 text-cp',
|
||||
lg: 'h-cp-control-lg gap-2.5 px-5 text-cp-lg',
|
||||
}
|
||||
|
||||
const spinnerSizes: Record<ButtonSize, number> = {
|
||||
@@ -52,9 +52,9 @@ const spinnerSizes: Record<ButtonSize, number> = {
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
])
|
||||
|
||||
@@ -45,7 +45,7 @@ const contentClasses = computed(() => [
|
||||
<template>
|
||||
<component
|
||||
:is="props.as"
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
|
||||
class="overflow-hidden rounded-cp-card bg-(--cp-card-bg) shadow-cp-card"
|
||||
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
|
||||
>
|
||||
<template v-if="$slots.header || hasManagedHeader || $slots.body">
|
||||
@@ -55,7 +55,7 @@ const contentClasses = computed(() => [
|
||||
<div class="min-w-0 pt-0.5">
|
||||
<h2
|
||||
v-if="props.title || $slots.title"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ props.title }}
|
||||
@@ -63,7 +63,7 @@ const contentClasses = computed(() => [
|
||||
</h2>
|
||||
<p
|
||||
v-if="props.description || $slots.description"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
|
||||
class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ props.description }}
|
||||
|
||||
@@ -26,12 +26,12 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
const boxClasses = computed(() => [
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-cp-disabled text-cp-disabled-icon'
|
||||
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
|
||||
: checked.value
|
||||
? 'bg-cp-accent text-cp-accent-on'
|
||||
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
|
||||
? 'bg-cp-primary text-(--cp-button-primary-color)'
|
||||
: 'bg-cp-bg-container text-transparent shadow-[inset_0_0_0_1px_var(--cp-color-border)]',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@ const boxClasses = computed(() => [
|
||||
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
|
||||
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-cp leading-none font-emphasis"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
|
||||
>
|
||||
<input
|
||||
@@ -52,7 +52,7 @@ const boxClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
:class="boxClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Minus
|
||||
@@ -64,6 +64,6 @@ const boxClasses = computed(() => [
|
||||
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
|
||||
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import { normalizeHexColor, normalizeRgbaHexColor } from '@/utils/color'
|
||||
|
||||
const RGBA_COLOR_PATTERN
|
||||
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
|
||||
|
||||
interface HsvColor {
|
||||
export interface HsvaColor {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
@@ -17,8 +17,23 @@ interface RgbaColor {
|
||||
a: number
|
||||
}
|
||||
|
||||
export function hexToHsv(value: string): HsvColor | null {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
export function normalizePickerHexColor(value: string, allowAlpha: boolean) {
|
||||
if (allowAlpha) {
|
||||
const rgba = normalizeRgbaHexColor(value)
|
||||
if (rgba)
|
||||
return rgba
|
||||
const rgb = normalizeHexColor(value)
|
||||
return rgb ? `${rgb}FF` : null
|
||||
}
|
||||
|
||||
const rgb = normalizeHexColor(value)
|
||||
if (rgb)
|
||||
return rgb
|
||||
return normalizeRgbaHexColor(value)?.slice(0, 7) ?? null
|
||||
}
|
||||
|
||||
export function hexToHsva(value: string): HsvaColor | null {
|
||||
const normalized = normalizePickerHexColor(value, true)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
@@ -48,7 +63,7 @@ export function hexToHsv(value: string): HsvColor | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function hsvToHex(color: HsvColor) {
|
||||
export function hsvaToHex(color: HsvaColor, allowAlpha: boolean) {
|
||||
const hue = ((color.h % 360) + 360) % 360
|
||||
const saturation = clamp(color.s, 0, 100) / 100
|
||||
const brightness = clamp(color.v, 0, 100) / 100
|
||||
@@ -71,7 +86,9 @@ export function hsvToHex(color: HsvColor) {
|
||||
const rgb = [red, green, blue]
|
||||
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
|
||||
const alpha = allowAlpha
|
||||
? Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
|
||||
: ''
|
||||
return `#${rgb}${alpha}`.toUpperCase()
|
||||
}
|
||||
|
||||
@@ -97,7 +114,7 @@ export function rgbaToHexColor(color: RgbaColor) {
|
||||
}
|
||||
|
||||
export function formatRgbaColor(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
const normalized = normalizePickerHexColor(value, true)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import { normalizeHexColor } from '@/utils/color'
|
||||
import BaseButton from '../BaseButton.vue'
|
||||
import BaseInput from '../BaseInput.vue'
|
||||
import BasePopover from '../BasePopover.vue'
|
||||
import {
|
||||
formatRgbaColor,
|
||||
hexToHsv,
|
||||
hsvToHex,
|
||||
hexToHsva,
|
||||
hsvaToHex,
|
||||
normalizePickerHexColor,
|
||||
parseRgbaColor,
|
||||
rgbaToHexColor,
|
||||
} from './color'
|
||||
@@ -20,11 +21,13 @@ const props = withDefaults(
|
||||
presets?: readonly string[]
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
allowAlpha?: boolean
|
||||
}>(),
|
||||
{
|
||||
presets: () => [],
|
||||
disabled: false,
|
||||
label: '选择颜色',
|
||||
allowAlpha: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -39,13 +42,22 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
|
||||
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
|
||||
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
|
||||
|
||||
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
|
||||
const draftColor = computed(() => hsvToHex({
|
||||
const normalizedModel = computed(() =>
|
||||
normalizePickerHexColor(model.value, props.allowAlpha)
|
||||
?? (props.allowAlpha ? '#60A5FA80' : '#2563EB'),
|
||||
)
|
||||
const triggerSwatchStyle = computed(() => props.allowAlpha
|
||||
? {
|
||||
backgroundImage: `linear-gradient(${normalizedModel.value}, ${normalizedModel.value}), conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0)`,
|
||||
backgroundSize: '100% 100%, 8px 8px',
|
||||
}
|
||||
: { backgroundColor: normalizedModel.value })
|
||||
const draftColor = computed(() => hsvaToHex({
|
||||
h: hue.value,
|
||||
s: saturation.value,
|
||||
v: brightness.value,
|
||||
a: alpha.value,
|
||||
}))
|
||||
}, props.allowAlpha))
|
||||
const hueColor = computed(() => `hsl(${hue.value} 100% 50%)`)
|
||||
const svCursorStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
@@ -63,8 +75,11 @@ const alphaTrackStyle = computed(() => {
|
||||
backgroundSize: '100% 100%, 8px 8px',
|
||||
}
|
||||
})
|
||||
const inputError = computed(() => customInput.value && !parseRgbaColor(customInput.value)
|
||||
? '请输入 rgba(r, g, b, a)'
|
||||
const inputValid = computed(() => props.allowAlpha
|
||||
? Boolean(parseRgbaColor(customInput.value))
|
||||
: Boolean(normalizeHexColor(customInput.value)))
|
||||
const inputError = computed(() => customInput.value && !inputValid.value
|
||||
? props.allowAlpha ? '请输入 rgba(r, g, b, a)' : '请输入六位 HEX 颜色,例如 #2563EB'
|
||||
: undefined)
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
@@ -78,16 +93,19 @@ watch(model, (value) => {
|
||||
})
|
||||
|
||||
function resetDraft(value: string) {
|
||||
const color = hexToHsv(value) ?? { h: 213, s: 62, v: 98, a: 0.5 }
|
||||
const color = hexToHsva(normalizePickerHexColor(value, props.allowAlpha) ?? '')
|
||||
?? { h: 213, s: 62, v: 98, a: props.allowAlpha ? 0.5 : 1 }
|
||||
hue.value = color.h
|
||||
saturation.value = color.s
|
||||
brightness.value = color.v
|
||||
alpha.value = color.a
|
||||
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
|
||||
alpha.value = props.allowAlpha ? color.a : 1
|
||||
syncInput()
|
||||
}
|
||||
|
||||
function syncInput() {
|
||||
customInput.value = formatRgbaColor(draftColor.value) ?? ''
|
||||
customInput.value = props.allowAlpha
|
||||
? formatRgbaColor(draftColor.value) ?? ''
|
||||
: normalizeHexColor(draftColor.value) ?? ''
|
||||
}
|
||||
|
||||
function updateSaturationBrightness(event: PointerEvent) {
|
||||
@@ -189,31 +207,31 @@ function handleAlphaKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function updateFromInput() {
|
||||
const rgba = parseRgbaColor(customInput.value)
|
||||
const color = rgba ? hexToHsv(rgbaToHexColor(rgba)) : null
|
||||
const inputColor = normalizedInputColor()
|
||||
const color = inputColor ? hexToHsva(inputColor) : null
|
||||
if (!color)
|
||||
return
|
||||
|
||||
hue.value = color.h
|
||||
saturation.value = color.s
|
||||
brightness.value = color.v
|
||||
alpha.value = color.a
|
||||
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
|
||||
alpha.value = props.allowAlpha ? color.a : 1
|
||||
syncInput()
|
||||
}
|
||||
|
||||
function choosePreset(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
const normalized = normalizePickerHexColor(value, props.allowAlpha)
|
||||
if (normalized)
|
||||
resetDraft(normalized)
|
||||
}
|
||||
|
||||
function isPresetSelected(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
const normalized = normalizePickerHexColor(value, props.allowAlpha)
|
||||
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
|
||||
}
|
||||
|
||||
function presetCheckClass(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
const normalized = normalizePickerHexColor(value, props.allowAlpha)
|
||||
if (!normalized)
|
||||
return 'text-white'
|
||||
|
||||
@@ -227,13 +245,21 @@ function presetCheckClass(value: string) {
|
||||
|
||||
function confirm() {
|
||||
updateFromInput()
|
||||
const rgba = parseRgbaColor(customInput.value)
|
||||
if (!rgba)
|
||||
const normalized = normalizedInputColor()
|
||||
if (!normalized)
|
||||
return
|
||||
|
||||
model.value = rgbaToHexColor(rgba)
|
||||
model.value = normalized
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function normalizedInputColor() {
|
||||
if (!props.allowAlpha)
|
||||
return normalizeHexColor(customInput.value)
|
||||
|
||||
const rgba = parseRgbaColor(customInput.value)
|
||||
return rgba ? rgbaToHexColor(rgba) : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -246,14 +272,17 @@ function confirm() {
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="props.disabled"
|
||||
:aria-label="props.label"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
|
||||
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
|
||||
<span
|
||||
class="relative flex size-full items-center justify-center overflow-hidden rounded-sm"
|
||||
:style="triggerSwatchStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -263,7 +292,7 @@ function confirm() {
|
||||
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
|
||||
<div
|
||||
ref="svPanel"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
|
||||
:style="{ backgroundColor: hueColor }"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -285,7 +314,7 @@ function confirm() {
|
||||
|
||||
<div
|
||||
ref="hueSlider"
|
||||
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="色相"
|
||||
@@ -305,10 +334,10 @@ function confirm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
|
||||
<div v-if="props.allowAlpha" class="grid grid-cols-[1fr_36px] items-center gap-2">
|
||||
<div
|
||||
ref="alphaSlider"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
|
||||
:style="alphaTrackStyle"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -328,7 +357,7 @@ function confirm() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
|
||||
<span class="text-right font-mono text-cp-xs tabular-nums text-cp-text-secondary">
|
||||
{{ Math.round(alpha * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -343,7 +372,7 @@ function confirm() {
|
||||
v-for="color in props.presets"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
|
||||
:style="{ backgroundColor: color }"
|
||||
role="radio"
|
||||
:aria-label="color"
|
||||
@@ -364,8 +393,9 @@ function confirm() {
|
||||
<div class="min-w-0 flex-1">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
aria-label="RGBA 颜色"
|
||||
placeholder="rgba(96, 165, 250, 1)"
|
||||
class="rounded-cp bg-cp-fill-tertiary!"
|
||||
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
|
||||
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:aria-invalid="Boolean(inputError) || undefined"
|
||||
@@ -376,13 +406,13 @@ function confirm() {
|
||||
<p
|
||||
v-if="inputError"
|
||||
id="color-input-error"
|
||||
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
|
||||
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-error-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ inputError }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
|
||||
<BaseButton size="md" variant="secondary" :disabled="!inputValid" @click="confirm">
|
||||
确定
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -391,16 +421,6 @@ function confirm() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.base-color-picker__checker,
|
||||
.base-color-picker__hue,
|
||||
.base-color-picker__sv {
|
||||
--cp-color-picker-checker: conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0) 0 / 8px 8px;
|
||||
}
|
||||
|
||||
.base-color-picker__checker {
|
||||
background: var(--cp-color-picker-checker);
|
||||
}
|
||||
|
||||
.base-color-picker__sv::before,
|
||||
.base-color-picker__sv::after {
|
||||
position: absolute;
|
||||
|
||||
@@ -52,7 +52,7 @@ function handleConfirm() {
|
||||
>
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
|
||||
class="text-cp-lg leading-[1.55] font-emphasis text-cp-text-secondary"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -28,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
|
||||
<div
|
||||
class="grid justify-items-center text-center"
|
||||
:class="[
|
||||
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
|
||||
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-fill-alter',
|
||||
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
|
||||
class="inline-flex items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text-quaternary"
|
||||
:class="size === 'sm' ? 'size-8' : 'size-10'"
|
||||
>
|
||||
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
|
||||
</span>
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
|
||||
<p class="m-0 text-cp leading-[1.15] font-heavy text-cp-text">
|
||||
{{ resolvedTitle }}
|
||||
</p>
|
||||
<p
|
||||
v-if="description"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-text-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
@@ -43,10 +43,10 @@ provide(formFieldKey, {
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
:for="resolvedControlId"
|
||||
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
|
||||
class="flex min-w-0 items-center gap-1.5 text-cp leading-none font-medium text-cp-text-secondary"
|
||||
>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
|
||||
<span v-if="required" class="font-bold text-cp-error" aria-hidden="true">*</span>
|
||||
<span v-if="required" class="sr-only">必填</span>
|
||||
</label>
|
||||
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
|
||||
@@ -56,7 +56,7 @@ provide(formFieldKey, {
|
||||
<p
|
||||
v-if="description"
|
||||
:id="descriptionId"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-text-quaternary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -66,7 +66,7 @@ provide(formFieldKey, {
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
|
||||
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-error-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ error }}
|
||||
|
||||
@@ -32,20 +32,20 @@ defineSlots<{
|
||||
|
||||
const variantClasses: Record<IconButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
|
||||
secondary:
|
||||
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
|
||||
'bg-cp-bg-container text-cp-text-secondary shadow-cp-tertiary hover:bg-cp-bg-text-hover hover:text-cp-text active:bg-cp-bg-text-active',
|
||||
success:
|
||||
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
|
||||
ghost:
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
|
||||
destructive:
|
||||
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
'bg-transparent text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
|
||||
}
|
||||
|
||||
const sizeClasses: Record<IconButtonSize, string> = {
|
||||
sm: 'size-cp-control-sm [&>svg]:size-3.5',
|
||||
md: 'size-cp-control-md [&>svg]:size-4',
|
||||
md: 'size-cp-control [&>svg]:size-4',
|
||||
lg: 'size-cp-control-lg [&>svg]:size-4.5',
|
||||
}
|
||||
|
||||
@@ -56,12 +56,12 @@ const spinnerSizes: Record<IconButtonSize, number> = {
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
|
||||
props.pressed ? 'bg-cp-bg-text-active text-cp-text' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,31 +48,31 @@ const controlAttrs = computed(() => Object.fromEntries(
|
||||
|
||||
const sizeClasses: Record<InputSize, string> = {
|
||||
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
|
||||
md: 'h-cp-control gap-2.5 px-3.5 text-cp',
|
||||
lg: 'h-cp-control-lg gap-3 px-4 text-cp-lg',
|
||||
}
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
|
||||
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp border-0 text-cp-text shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
|
||||
sizeClasses[props.size],
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
|
||||
: invalid.value
|
||||
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
|
||||
? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active'
|
||||
: [
|
||||
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
|
||||
'bg-[var(--cp-input-bg)]',
|
||||
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
|
||||
'focus-within:bg-(--cp-input-active-bg) focus-within:shadow-cp-input-active',
|
||||
],
|
||||
])
|
||||
|
||||
const iconClasses = computed(() => [
|
||||
'inline-flex shrink-0',
|
||||
props.disabled
|
||||
? 'text-cp-disabled-icon'
|
||||
? 'text-cp-text-disabled'
|
||||
: invalid.value
|
||||
? 'text-cp-danger'
|
||||
: 'text-cp-muted-text',
|
||||
? 'text-cp-error'
|
||||
: 'text-cp-text-quaternary',
|
||||
])
|
||||
|
||||
function updateModel(event: Event) {
|
||||
@@ -90,7 +90,7 @@ function updateModel(event: Event) {
|
||||
v-bind="controlAttrs"
|
||||
:id="controlId"
|
||||
:value="model"
|
||||
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
|
||||
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-text outline-0 placeholder:text-cp-text-quaternary disabled:cursor-not-allowed disabled:text-cp-text-disabled"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
@@ -109,24 +109,35 @@ function updateModel(event: Event) {
|
||||
|
||||
<style scoped>
|
||||
.base-input__control {
|
||||
--base-input-autofill-bg: var(--cp-input-current-bg, var(--cp-input-context-bg));
|
||||
--base-input-autofill-bg: var(--cp-input-bg);
|
||||
}
|
||||
|
||||
.base-input__control:hover {
|
||||
--base-input-autofill-bg: var(--cp-input-current-bg-hover, var(--cp-input-context-bg-hover));
|
||||
--base-input-autofill-bg: var(--cp-input-hover-bg);
|
||||
}
|
||||
|
||||
.base-input__control:focus-within {
|
||||
--base-input-autofill-bg: var(--cp-input-soft-bg-focus);
|
||||
--base-input-autofill-bg: var(--cp-input-active-bg);
|
||||
}
|
||||
|
||||
.base-input__field:-webkit-autofill,
|
||||
.base-input__field:-webkit-autofill:hover,
|
||||
.base-input__field:-webkit-autofill:focus,
|
||||
.base-input__field:autofill {
|
||||
caret-color: var(--cp-text-primary);
|
||||
-webkit-text-fill-color: var(--cp-text-primary) !important;
|
||||
caret-color: var(--cp-color-text);
|
||||
-webkit-text-fill-color: var(--cp-color-text) !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
|
||||
box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
|
||||
}
|
||||
|
||||
/* 普通数字表单保留键盘步进,但不混入浏览器原生微调器外观。 */
|
||||
.base-input__field[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.base-input__field[type='number']::-webkit-inner-spin-button,
|
||||
.base-input__field[type='number']::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,12 +22,12 @@ defineSlots<{
|
||||
}>()
|
||||
|
||||
const classes = computed(() => [
|
||||
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
|
||||
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
|
||||
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline',
|
||||
'disabled:cursor-not-allowed disabled:text-cp-text-disabled',
|
||||
props.tone === 'destructive'
|
||||
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
|
||||
: 'text-cp-primary hover:bg-cp-default-hover',
|
||||
? 'text-cp-error-text hover:bg-cp-error-bg-hover'
|
||||
: 'text-cp-text hover:bg-cp-bg-text-hover',
|
||||
])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,8 +79,8 @@ const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
|
||||
icon: 'text-cp-warning',
|
||||
},
|
||||
danger: {
|
||||
iconBg: 'bg-cp-danger-bg',
|
||||
icon: 'text-cp-danger',
|
||||
iconBg: 'bg-cp-error-bg',
|
||||
icon: 'text-cp-error',
|
||||
},
|
||||
success: {
|
||||
iconBg: 'bg-cp-success-bg',
|
||||
@@ -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"
|
||||
@@ -186,15 +194,16 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
|
||||
class="absolute inset-0 cursor-default border-0 bg-cp-bg-mask p-0"
|
||||
@click="closeModal"
|
||||
/>
|
||||
<section
|
||||
ref="panel"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover 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,31 +212,33 @@ 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'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-cp-control"
|
||||
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
|
||||
class="inline-flex size-11 items-center justify-center rounded-cp"
|
||||
:class="$slots.icon ? 'bg-cp-fill-quaternary' : toneClasses[tone].iconBg"
|
||||
>
|
||||
<slot name="icon">
|
||||
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
|
||||
</slot>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
|
||||
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-text">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
:id="descriptionId"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
|
||||
class="mt-2 mb-0 text-cp leading-[1.45] font-semibold text-cp-text-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { Minus, Plus } from '@lucide/vue'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
step: 1,
|
||||
unit: undefined,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<number>({ required: true })
|
||||
const attrs = useAttrs()
|
||||
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const controlAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => !['class', 'style'].includes(key)),
|
||||
))
|
||||
const canDecrease = computed(() => !props.disabled && (props.min === undefined || model.value > props.min))
|
||||
const canIncrease = computed(() => !props.disabled && (props.max === undefined || model.value < props.max))
|
||||
|
||||
function clampValue(value: number) {
|
||||
return Math.min(props.max ?? Number.POSITIVE_INFINITY, Math.max(props.min ?? Number.NEGATIVE_INFINITY, value))
|
||||
}
|
||||
|
||||
function stepPrecision() {
|
||||
const decimal = String(props.step).split('.')[1]
|
||||
return decimal?.length ?? 0
|
||||
}
|
||||
|
||||
function updateModel(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber
|
||||
if (Number.isFinite(value))
|
||||
model.value = clampValue(value)
|
||||
}
|
||||
|
||||
function restoreValue(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.value = String(model.value)
|
||||
}
|
||||
|
||||
function stepBy(direction: -1 | 1) {
|
||||
const next = clampValue(model.value + props.step * direction)
|
||||
model.value = Number(next.toFixed(stepPrecision()))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="rootAttrs"
|
||||
class="inline-flex h-8 items-center rounded-cp bg-cp-fill-tertiary p-0.5 text-cp-text transition-[background-color,box-shadow] duration-150 focus-within:bg-cp-bg-container focus-within:shadow-cp-input-active motion-reduce:transition-none"
|
||||
>
|
||||
<BaseIconButton
|
||||
:label="`减少${label}`"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="size-7!"
|
||||
:disabled="!canDecrease"
|
||||
@click="stepBy(-1)"
|
||||
>
|
||||
<Minus class="size-3" />
|
||||
</BaseIconButton>
|
||||
|
||||
<span class="inline-flex min-w-0 items-baseline justify-center gap-1 px-1">
|
||||
<input
|
||||
v-bind="controlAttrs"
|
||||
:value="model"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:aria-label="label"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
class="base-number-input__field w-9 min-w-0 border-0 bg-transparent p-0 text-right font-mono text-cp-xs font-bold tabular-nums text-cp-text outline-none disabled:text-cp-text-disabled"
|
||||
@input="updateModel"
|
||||
@blur="restoreValue"
|
||||
>
|
||||
<span
|
||||
v-if="unit"
|
||||
class="shrink-0 text-[9px] font-emphasis text-cp-text-quaternary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ unit }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<BaseIconButton
|
||||
:label="`增加${label}`"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="size-7!"
|
||||
:disabled="!canIncrease"
|
||||
@click="stepBy(1)"
|
||||
>
|
||||
<Plus class="size-3" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 隐藏浏览器原生微调器,统一使用两侧的减号和加号。 */
|
||||
.base-number-input__field {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.base-number-input__field::-webkit-inner-spin-button,
|
||||
.base-number-input__field::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
@@ -10,12 +10,12 @@ defineProps<{
|
||||
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1 basis-64">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-text">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="description || $slots.description"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-cp-xl leading-[1.15] font-semibold text-cp-text-secondary"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
|
||||
@@ -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-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
|
||||
'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,
|
||||
|
||||
@@ -24,10 +24,10 @@ const checked = computed(() => model.value === props.value)
|
||||
const indicatorClasses = computed(() => [
|
||||
'relative inline-grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-cp-disabled text-cp-disabled-icon'
|
||||
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
|
||||
: checked.value
|
||||
? 'bg-cp-accent text-cp-accent-on'
|
||||
: 'bg-cp-muted text-transparent group-hover:bg-cp-default-active',
|
||||
? 'bg-cp-primary text-(--cp-button-primary-color)'
|
||||
: 'bg-cp-fill-tertiary text-transparent group-hover:bg-cp-bg-text-active',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -49,7 +49,7 @@ const indicatorClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
:class="indicatorClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-current" />
|
||||
@@ -57,7 +57,7 @@ const indicatorClasses = computed(() => [
|
||||
<span v-if="$slots.default" class="min-w-0 flex-1">
|
||||
<slot />
|
||||
</span>
|
||||
<span v-else-if="showLabel" class="text-[13px] leading-none font-emphasis">
|
||||
<span v-else-if="showLabel" class="text-cp leading-none font-emphasis">
|
||||
{{ label }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: undefined,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<number>({ required: true })
|
||||
|
||||
const rangeStyle = computed<CSSProperties>(() => {
|
||||
const span = props.max - props.min
|
||||
const progress = span > 0 ? ((model.value - props.min) / span) * 100 : 0
|
||||
return {
|
||||
'--base-range-progress': `${Math.min(100, Math.max(0, progress))}%`,
|
||||
}
|
||||
})
|
||||
|
||||
const valueText = computed(() => `${model.value}${props.unit ?? ''}`)
|
||||
|
||||
function updateModel(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber
|
||||
if (Number.isFinite(value))
|
||||
model.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="model"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:aria-label="label"
|
||||
:aria-valuetext="valueText"
|
||||
class="base-range h-5 w-full"
|
||||
:style="rangeStyle"
|
||||
@input="updateModel"
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.base-range {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.base-range::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--cp-color-primary) 0 var(--base-range-progress),
|
||||
var(--cp-color-fill-secondary) var(--base-range-progress) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.base-range::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
appearance: none;
|
||||
border: 2px solid var(--cp-color-bg-container);
|
||||
border-radius: 999px;
|
||||
background: var(--cp-color-primary);
|
||||
transition:
|
||||
box-shadow 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.base-range::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--cp-color-fill-secondary);
|
||||
}
|
||||
|
||||
.base-range::-moz-range-progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--cp-color-primary);
|
||||
}
|
||||
|
||||
.base-range::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid var(--cp-color-bg-container);
|
||||
border-radius: 999px;
|
||||
background: var(--cp-color-primary);
|
||||
transition:
|
||||
box-shadow 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.base-range:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.base-range:hover::-moz-range-thumb {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.base-range:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
|
||||
}
|
||||
|
||||
.base-range:focus-visible::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
|
||||
}
|
||||
|
||||
.base-range:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.base-range::-webkit-slider-thumb {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.base-range::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,12 +9,14 @@ const props = withDefaults(
|
||||
height?: string
|
||||
horizontal?: boolean
|
||||
vertical?: boolean
|
||||
alwaysVisible?: boolean
|
||||
}>(),
|
||||
{
|
||||
maxHeight: undefined,
|
||||
height: undefined,
|
||||
horizontal: false,
|
||||
vertical: true,
|
||||
alwaysVisible: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -64,10 +66,10 @@ const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollba
|
||||
const canScrollY = computed(() => thumbHeight.value > 0)
|
||||
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
|
||||
const verticalScrollbarVisible = computed(
|
||||
() => dragging.value || verticalTrackHovering.value || visible.value,
|
||||
() => props.alwaysVisible || dragging.value || verticalTrackHovering.value || visible.value,
|
||||
)
|
||||
const horizontalScrollbarVisible = computed(
|
||||
() => horizontalDragging.value || horizontalTrackHovering.value || visible.value,
|
||||
() => props.alwaysVisible || horizontalDragging.value || horizontalTrackHovering.value || visible.value,
|
||||
)
|
||||
const thumbStyle = computed(() => ({
|
||||
height: `${thumbHeight.value}px`,
|
||||
@@ -510,8 +512,8 @@ defineExpose({
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
|
||||
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
|
||||
:style="thumbStyle"
|
||||
@pointerdown="handleThumbPointerDown"
|
||||
/>
|
||||
@@ -528,8 +530,8 @@ defineExpose({
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
|
||||
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
|
||||
:style="horizontalThumbStyle"
|
||||
@pointerdown="handleHorizontalThumbPointerDown"
|
||||
/>
|
||||
@@ -549,6 +551,6 @@ defineExpose({
|
||||
}
|
||||
|
||||
.base-scrollbar-track-y {
|
||||
top: var(--cp-scrollbar-track-top, 0.25rem);
|
||||
top: var(--cp-scrollbar-track-inset-block-start, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,13 +45,13 @@ const indicatorStyle = computed(() => ({
|
||||
|
||||
const rootClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-cp-control-sm',
|
||||
md: 'h-cp-control-md',
|
||||
md: 'h-cp-control',
|
||||
lg: 'h-cp-control-lg',
|
||||
}
|
||||
const optionClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-6.5 px-2 text-[11px]',
|
||||
sm: 'h-6.5 px-2 text-cp-xs',
|
||||
md: 'h-8 px-3 text-xs',
|
||||
lg: 'h-9.5 px-4 text-[13px]',
|
||||
lg: 'h-9.5 px-4 text-cp',
|
||||
}
|
||||
|
||||
function enabledIndexes() {
|
||||
@@ -97,7 +97,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
|
||||
class="relative inline-grid items-center rounded-cp bg-cp-fill-tertiary p-0.75"
|
||||
:class="rootClasses[size]"
|
||||
:style="gridStyle"
|
||||
role="radiogroup"
|
||||
@@ -106,20 +106,20 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
>
|
||||
<span
|
||||
v-if="options.length > 0"
|
||||
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp bg-cp-bg-container shadow-cp-tertiary transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:style="indicatorStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
|
||||
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
|
||||
:class="[
|
||||
optionClasses[size],
|
||||
model === option.value
|
||||
? 'text-cp-primary'
|
||||
: 'text-cp-secondary hover:text-cp-primary',
|
||||
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
|
||||
? 'text-cp-text'
|
||||
: 'text-cp-text-secondary hover:text-cp-text',
|
||||
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-text-secondary' : undefined,
|
||||
display === 'icon' ? 'px-0' : undefined,
|
||||
]"
|
||||
type="button"
|
||||
|
||||
@@ -76,18 +76,18 @@ const sizeConfig: Record<
|
||||
> = {
|
||||
md: {
|
||||
trigger:
|
||||
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
|
||||
option: 'h-8.5 px-3 text-[13px]',
|
||||
'h-cp-control px-3.5 pr-9 text-cp rounded-cp',
|
||||
option: 'h-8.5 px-3 text-cp',
|
||||
icon: 16,
|
||||
},
|
||||
sm: {
|
||||
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
|
||||
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp',
|
||||
option: 'h-8 px-2.5 text-xs',
|
||||
icon: 14,
|
||||
},
|
||||
lg: {
|
||||
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
|
||||
option: 'h-10 px-3.5 text-[14px]',
|
||||
trigger: 'h-cp-control-lg px-4 pr-10 text-cp-lg rounded-cp',
|
||||
option: 'h-10 px-3.5 text-cp-lg',
|
||||
icon: 17,
|
||||
},
|
||||
}
|
||||
@@ -98,20 +98,20 @@ const triggerClasses = computed(() => [
|
||||
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
sizeConfig[props.size].trigger,
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
|
||||
: invalid.value
|
||||
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
|
||||
? 'cursor-pointer bg-(--cp-input-error-active-bg) text-cp-error-text shadow-cp-input-error-active'
|
||||
: open.value
|
||||
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
|
||||
? 'cursor-pointer bg-(--cp-input-active-bg) text-cp-text shadow-cp-input-active'
|
||||
: [
|
||||
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
|
||||
'cursor-pointer bg-[var(--cp-input-bg)] text-cp-text',
|
||||
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
|
||||
'focus-visible:bg-(--cp-input-active-bg) focus-visible:shadow-cp-input-active',
|
||||
],
|
||||
])
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
|
||||
'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',
|
||||
])
|
||||
|
||||
@@ -255,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
|
||||
function optionClasses(option: SelectOption, index: number) {
|
||||
return [
|
||||
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
|
||||
'flex w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
|
||||
sizeConfig[props.size].option,
|
||||
option.disabled
|
||||
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
|
||||
? 'cursor-not-allowed bg-transparent text-cp-text-disabled'
|
||||
: option.value === model.value
|
||||
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
|
||||
? 'cursor-pointer bg-cp-control-item-bg-active text-cp-primary-text'
|
||||
: activeIndex.value === index
|
||||
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
|
||||
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
|
||||
? 'cursor-pointer bg-cp-bg-text-hover text-cp-text'
|
||||
: 'cursor-pointer bg-transparent text-cp-text hover:bg-cp-bg-text-hover',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -316,10 +316,10 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
|
||||
:class="
|
||||
disabled
|
||||
? 'text-cp-disabled-icon'
|
||||
? 'text-cp-text-disabled'
|
||||
: open
|
||||
? 'rotate-180 text-cp-info'
|
||||
: 'text-cp-muted-text'
|
||||
? 'rotate-180 text-cp-primary-text'
|
||||
: 'text-cp-text-quaternary'
|
||||
"
|
||||
:size="sizeConfig[size].icon"
|
||||
aria-hidden="true"
|
||||
@@ -346,7 +346,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
|
||||
class="flex h-8.5 items-center rounded-cp-sm px-3 text-cp leading-none font-emphasis text-cp-text-quaternary"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
@@ -369,7 +369,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-cp-info"
|
||||
class="shrink-0 text-cp-primary-text"
|
||||
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type SkeletonShape = 'text' | 'block' | 'circle'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
shape?: SkeletonShape
|
||||
animated?: boolean
|
||||
}>(),
|
||||
{
|
||||
as: 'span',
|
||||
shape: 'block',
|
||||
animated: true,
|
||||
},
|
||||
)
|
||||
|
||||
const shapeClasses: Record<SkeletonShape, string> = {
|
||||
text: 'h-3 rounded-full',
|
||||
block: 'rounded-cp',
|
||||
circle: 'rounded-full',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'block bg-cp-fill-secondary',
|
||||
shapeClasses[props.shape],
|
||||
props.animated ? 'motion-safe:animate-cp-skeleton motion-reduce:animate-none' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
aria-hidden="true"
|
||||
:class="classes"
|
||||
/>
|
||||
</template>
|
||||
@@ -18,10 +18,10 @@ const model = defineModel<boolean>({ default: false })
|
||||
const trackClasses = computed(() => [
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-cp-disabled shadow-none'
|
||||
? 'bg-cp-bg-container-disabled shadow-none'
|
||||
: model.value
|
||||
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
|
||||
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
|
||||
? 'bg-cp-primary shadow-cp-tertiary group-hover:bg-cp-primary-hover'
|
||||
: 'bg-cp-fill-tertiary shadow-cp-tertiary group-hover:bg-cp-bg-text-active',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -42,14 +42,14 @@ const trackClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
:class="trackClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
|
||||
class="size-5 rounded-full bg-cp-bg-container shadow-cp-tertiary transition-transform duration-180 ease-out motion-reduce:transition-none"
|
||||
:class="model ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
|
||||
<span v-if="showLabel" class="text-cp font-emphasis text-cp-text">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user