Compare commits

...
21 Commits
Author SHA1 Message Date
ZyyandCodex 3920624862 chore: bump version to 3.3.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 01:53:05 +08:00
Zyy d804bca495 fix(openai): strip unsupported Codex request fields 2026-08-27 01:51:30 +08:00
ZyyandCodex ca527d5833 fix(accounts): use personal usage reports only
Remove the workspace token-breakdown probe and its dedicated statistics mode so usage aggregation always uses the personal model-credit and token-total reports.

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

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

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

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

Remove the staged Pen design source.

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:22 +08:00
ZyyandCodex fbd922117e fix(accounts): return persisted OAuth account ID
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 19:37:03 +08:00
215 changed files with 11081 additions and 35479 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -79,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2 -2
View File
@@ -448,7 +448,7 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -519,7 +519,7 @@ jobs:
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
-3
View File
@@ -16,9 +16,6 @@
</div>
Codex Proxy RS 在一个网关中管理 OpenAI 与 xAI 账号,为客户端提供统一鉴权、模型路由、额度状态、
请求观测和管理控制面。
> [!NOTE]
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
+18 -18
View File
@@ -216,9 +216,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 +308,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 +341,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 +385,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 +411,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",
@@ -1246,7 +1246,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]]
@@ -2714,7 +2714,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2813,9 +2813,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 +2984,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3628,7 +3628,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4100,9 +4100,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},
@@ -13,6 +13,7 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
observability::CurrencyCost,
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -627,6 +628,104 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方用量统计的目标额度周期。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
/// Provider 官方用量统计查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderUsageStatisticsRequest {
pub account_id: ProviderAccountId,
/// `0` 为当前周期,负数为历史周期。
pub cycle_offset: i8,
/// 客户端相对 UTC 的分钟偏移,例如 UTC+8 为 `480`。
pub utc_offset_minutes: i16,
}
/// 官方用量统计中的 Token 分类。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProviderUsageStatisticsTokens {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
/// 官方模型报表中的服务档位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderUsageStatisticsServiceTier {
Standard,
Fast,
}
impl ProviderUsageStatisticsServiceTier {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
}
/// 一个模型与服务档位在目标周期内的统计行。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsModel {
pub key: String,
pub model: String,
pub service_tier: ProviderUsageStatisticsServiceTier,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
/// 目标周期内的一天;官方日报只能提供本地日粒度。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsDay {
pub date: NaiveDate,
pub credit_share: Option<f64>,
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
/// 目标周期汇总。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsSummary {
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<CurrencyCost>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
/// Provider 已解释、计算并排序的官方用量统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatistics {
pub cycle: ProviderUsageStatisticsCycle,
pub summary: ProviderUsageStatisticsSummary,
pub models: Vec<ProviderUsageStatisticsModel>,
pub daily: Vec<ProviderUsageStatisticsDay>,
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
@@ -18,6 +18,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderUsageStatistics, ProviderUsageStatisticsRequest,
};
/// Provider 管理失败的稳定分类。
@@ -146,6 +147,14 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方用量统计;不支持该能力的 Provider 使用默认拒绝。
async fn usage_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
@@ -26,7 +26,8 @@ use crate::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsRequest,
QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -80,6 +81,15 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn usage_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
Err(AdminError::invalid(
"Provider usage statistics are not supported",
))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -538,6 +548,17 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn usage_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
let (_, provider) = self.provider_for_account(&request.account_id).await?;
provider
.usage_statistics(request)
.await
.map_err(|error| map_provider_error(error, "provider usage statistics"))
}
async fn reset_credits(
&self,
context: &MutationContext,
@@ -29,7 +29,10 @@ use gateway_admin::model::{
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsRequest,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, RotateCredential,
StartAuthorization,
},
};
use gateway_core::{
@@ -58,6 +61,8 @@ const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_USAGE_CYCLE_OFFSET: i8 = 8;
const MAX_UTC_OFFSET_MINUTES: i16 = 14 * 60;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
@@ -420,6 +425,35 @@ impl AccountIdQuery {
}
}
/// 官方每日用量统计查询。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountUsageStatisticsQuery {
pub account_id: String,
pub cycle_offset: Option<i8>,
pub utc_offset_minutes: Option<i16>,
}
impl AccountUsageStatisticsQuery {
pub fn into_request(self) -> Result<ProviderUsageStatisticsRequest, WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
let cycle_offset = self.cycle_offset.unwrap_or_default();
if !(-MAX_USAGE_CYCLE_OFFSET..=0).contains(&cycle_offset) {
return Err(WireValidationError::new("cycleOffset"));
}
let utc_offset_minutes = self.utc_offset_minutes.unwrap_or_default();
if !(-MAX_UTC_OFFSET_MINUTES..=MAX_UTC_OFFSET_MINUTES).contains(&utc_offset_minutes) {
return Err(WireValidationError::new("utcOffsetMinutes"));
}
Ok(ProviderUsageStatisticsRequest {
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
cycle_offset,
utc_offset_minutes,
})
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -587,6 +621,183 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方每日用量统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsData {
pub cycle: AccountUsageStatisticsCycleView,
pub summary: AccountUsageStatisticsSummaryView,
pub models: Vec<AccountUsageStatisticsModelView>,
pub daily: Vec<AccountUsageStatisticsDayView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsCycleView {
pub offset: i8,
pub start_at: String,
pub end_at: String,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsTokensView {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsCostView {
pub currency: String,
pub amount: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsSummaryView {
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<AccountUsageStatisticsCostView>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsModelView {
pub key: String,
pub model: String,
pub service_tier: String,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsDayView {
pub date: String,
pub credit_share: Option<f64>,
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
impl From<ProviderUsageStatistics> for AccountUsageStatisticsData {
fn from(statistics: ProviderUsageStatistics) -> Self {
Self {
cycle: AccountUsageStatisticsCycleView {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at.to_rfc3339(),
end_at: statistics.cycle.end_at.to_rfc3339(),
window_seconds: statistics.cycle.window_seconds,
used_percent: statistics.cycle.used_percent,
is_current: statistics.cycle.is_current,
can_go_previous: statistics.cycle.can_go_previous,
can_go_next: statistics.cycle.can_go_next,
},
summary: account_usage_statistics_summary_view(statistics.summary),
models: statistics
.models
.into_iter()
.map(account_usage_statistics_model_view)
.collect(),
daily: statistics
.daily
.into_iter()
.map(account_usage_statistics_day_view)
.collect(),
}
}
}
fn account_usage_statistics_tokens_view(
tokens: ProviderUsageStatisticsTokens,
) -> AccountUsageStatisticsTokensView {
AccountUsageStatisticsTokensView {
uncached_input: tokens.uncached_input,
cached_input: tokens.cached_input,
output: tokens.output,
total: tokens.total,
}
}
fn account_usage_statistics_cost_view(
cost: gateway_admin::model::observability::CurrencyCost,
) -> AccountUsageStatisticsCostView {
AccountUsageStatisticsCostView {
currency: cost.currency,
amount: cost.amount.to_string(),
}
}
fn account_usage_statistics_summary_view(
summary: ProviderUsageStatisticsSummary,
) -> AccountUsageStatisticsSummaryView {
AccountUsageStatisticsSummaryView {
tokens: account_usage_statistics_tokens_view(summary.tokens),
estimated_cost: summary
.estimated_cost
.map(account_usage_statistics_cost_view),
projected_tokens: summary.projected_tokens,
projected_cost: summary
.projected_cost
.map(account_usage_statistics_cost_view),
day_count: summary.day_count,
has_unknown_pricing: summary.has_unknown_pricing,
has_missing_token_data: summary.has_missing_token_data,
}
}
fn account_usage_statistics_model_view(
model: ProviderUsageStatisticsModel,
) -> AccountUsageStatisticsModelView {
AccountUsageStatisticsModelView {
key: model.key,
model: model.model,
service_tier: model.service_tier.as_str().to_owned(),
credit_share: model.credit_share,
quota_share: model.quota_share,
tokens: account_usage_statistics_tokens_view(model.tokens),
estimated_cost: model.estimated_cost.map(account_usage_statistics_cost_view),
has_unknown_pricing: model.has_unknown_pricing,
has_estimated_allocation: model.has_estimated_allocation,
has_rate_fallback: model.has_rate_fallback,
has_missing_token_data: model.has_missing_token_data,
}
}
fn account_usage_statistics_day_view(
day: ProviderUsageStatisticsDay,
) -> AccountUsageStatisticsDayView {
AccountUsageStatisticsDayView {
date: day.date.to_string(),
credit_share: day.credit_share,
tokens: account_usage_statistics_tokens_view(day.tokens),
estimated_cost: day.estimated_cost.map(account_usage_statistics_cost_view),
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
}
}
/// 主动额度重置卡列表响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -1160,6 +1371,10 @@ where
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/usage-statistics",
get(account_usage_statistics::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -1494,6 +1709,27 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_usage_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountUsageStatisticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let request = query.into_request().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.usage_statistics(request)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountUsageStatisticsData::from(result)),
))
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -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,147 @@ mod query {
}
}
mod usage_statistics {
use std::str::FromStr as _;
use chrono::{NaiveDate, TimeZone as _, Utc};
use gateway_admin::model::{
observability::{CurrencyCost, DecimalAmount},
provider_credentials::{
ProviderUsageStatistics, ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens,
},
};
use gateway_api::admin::accounts::{AccountUsageStatisticsData, AccountUsageStatisticsQuery};
use serde_json::json;
fn tokens(total: u64) -> ProviderUsageStatisticsTokens {
ProviderUsageStatisticsTokens {
uncached_input: total / 4,
cached_input: total / 2,
output: total / 4,
total,
}
}
fn usd(amount: &str) -> CurrencyCost {
CurrencyCost {
currency: "USD".to_owned(),
amount: DecimalAmount::from_str(amount).expect("USD amount"),
}
}
#[test]
fn usage_statistics_query_accepts_only_current_or_bounded_history_offsets() {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": -3,
"utcOffsetMinutes": 480
}))
.expect("decode usage-statistics query");
let request = query.into_request().expect("valid cycle offset");
assert_eq!(request.cycle_offset, -3);
assert_eq!(request.utc_offset_minutes, 480);
for (cycle_offset, field) in [(-9, "cycleOffset"), (1, "cycleOffset")] {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": cycle_offset
}))
.expect("decode bounded integer");
assert_eq!(
query
.into_request()
.expect_err("reject cycle offset")
.field(),
field
);
}
assert!(
serde_json::from_value::<AccountUsageStatisticsQuery>(json!({
"accountId": "acct_openai",
"historyCycles": 8
}))
.is_err()
);
}
#[test]
fn usage_statistics_response_serializes_calculated_rows_without_raw_documents() {
let start_at = Utc
.with_ymd_and_hms(2026, 8, 24, 7, 50, 0)
.single()
.expect("cycle start");
let end_at = Utc
.with_ymd_and_hms(2026, 8, 31, 7, 50, 0)
.single()
.expect("cycle end");
let response = AccountUsageStatisticsData::from(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: 0,
start_at,
end_at,
window_seconds: 604_800,
used_percent: Some(20.0),
is_current: true,
can_go_previous: true,
can_go_next: false,
},
summary: ProviderUsageStatisticsSummary {
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
projected_tokens: Some(500),
projected_cost: Some(usd("6.25")),
day_count: 1,
has_unknown_pricing: false,
has_missing_token_data: false,
},
models: vec![ProviderUsageStatisticsModel {
key: "gpt-5.6-sol::standard".to_owned(),
model: "gpt-5.6-sol".to_owned(),
service_tier: ProviderUsageStatisticsServiceTier::Standard,
credit_share: Some(1.0),
quota_share: Some(0.2),
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
has_unknown_pricing: false,
has_estimated_allocation: true,
has_rate_fallback: false,
has_missing_token_data: false,
}],
daily: vec![ProviderUsageStatisticsDay {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("report date"),
credit_share: Some(1.0),
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
has_unknown_pricing: false,
has_missing_token_data: false,
is_boundary_day: true,
}],
});
let value = serde_json::to_value(response).expect("serialize usage statistics");
assert!(value.get("mode").is_none());
assert_eq!(value["cycle"]["offset"], 0);
assert_eq!(
value["summary"]["estimatedCost"],
json!({
"currency": "USD",
"amount": "1.25"
})
);
assert_eq!(value["models"][0]["serviceTier"], "standard");
assert!(value["models"][0].get("mode").is_none());
assert_eq!(value["daily"][0]["tokens"]["total"], 100);
assert_eq!(value["daily"][0]["isBoundaryDay"], true);
assert!(value["cycle"].get("deltaPercent").is_none());
assert!(value.get("modelBreakdown").is_none());
assert!(value.get("dailyTotals").is_none());
assert!(value.get("usage").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,
@@ -682,8 +682,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 +695,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 +716,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)?),
})
@@ -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,
};
@@ -1326,6 +1331,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 {
+152 -3
View File
@@ -20,7 +20,10 @@ use gateway_admin::model::provider_credentials::{
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderResetCreditResult, ProviderResetCredits, ProviderUsageStatistics,
ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay, ProviderUsageStatisticsModel,
ProviderUsageStatisticsRequest, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -48,8 +51,10 @@ use crate::credential::{
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexResetCreditsError, CodexUsageStatistics, CodexUsageStatisticsError,
CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens, CompleteCodexOAuthAuthorization,
CompletedCodexOAuthCredential, ExportManagedCodexCredential, StartCodexOAuthAuthorization,
StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -443,6 +448,23 @@ impl ProviderAdmin for OpenAiAdminProvider {
Ok(project_quota(snapshot, &account))
}
async fn usage_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
self.account(&request.account_id).await?;
let statistics = self
.quota
.usage_statistics(
&request.account_id,
request.cycle_offset,
request.utc_offset_minutes,
)
.await
.map_err(map_usage_statistics_error)?;
project_usage_statistics(statistics)
}
async fn reset_credits(
&self,
account_id: &ProviderAccountId,
@@ -800,6 +822,94 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
}
}
fn project_usage_statistics(
statistics: CodexUsageStatistics,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
let models = statistics
.models
.into_iter()
.map(|model| {
Ok(ProviderUsageStatisticsModel {
key: model.key,
model: model.model,
service_tier: project_usage_service_tier(model.service_tier),
credit_share: model.credit_share,
quota_share: model.quota_share,
tokens: project_usage_tokens(model.tokens),
estimated_cost: model.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: model.has_unknown_pricing,
has_estimated_allocation: model.has_estimated_allocation,
has_rate_fallback: model.has_rate_fallback,
has_missing_token_data: model.has_missing_token_data,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
let daily = statistics
.daily
.into_iter()
.map(|day| {
Ok(ProviderUsageStatisticsDay {
date: day.date,
credit_share: day.credit_share,
tokens: project_usage_tokens(day.tokens),
estimated_cost: day.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
Ok(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at,
end_at: statistics.cycle.end_at,
window_seconds: statistics.cycle.window_seconds,
used_percent: statistics.cycle.used_percent,
is_current: statistics.cycle.is_current,
can_go_previous: statistics.cycle.can_go_previous,
can_go_next: statistics.cycle.can_go_next,
},
summary: ProviderUsageStatisticsSummary {
tokens: project_usage_tokens(statistics.summary.tokens),
estimated_cost: statistics
.summary
.estimated_cost
.map(currency_cost)
.transpose()?,
projected_tokens: statistics.summary.projected_tokens,
projected_cost: statistics
.summary
.projected_cost
.map(currency_cost)
.transpose()?,
day_count: statistics.summary.day_count,
has_unknown_pricing: statistics.summary.has_unknown_pricing,
has_missing_token_data: statistics.summary.has_missing_token_data,
},
models,
daily,
})
}
const fn project_usage_tokens(tokens: CodexUsageStatisticsTokens) -> ProviderUsageStatisticsTokens {
ProviderUsageStatisticsTokens {
uncached_input: tokens.uncached_input,
cached_input: tokens.cached_input,
output: tokens.output,
total: tokens.total,
}
}
const fn project_usage_service_tier(
service_tier: CodexUsageStatisticsServiceTier,
) -> ProviderUsageStatisticsServiceTier {
match service_tier {
CodexUsageStatisticsServiceTier::Standard => ProviderUsageStatisticsServiceTier::Standard,
CodexUsageStatisticsServiceTier::Fast => ProviderUsageStatisticsServiceTier::Fast,
}
}
fn project_reset_credits(
credits: crate::transport::CodexRateLimitResetCredits,
) -> ProviderResetCredits {
@@ -1397,6 +1507,45 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
})
}
fn map_usage_statistics_error(error: CodexUsageStatisticsError) -> ProviderAdminError {
use CodexUsageStatisticsError as Error;
match error {
Error::InvalidRequest | Error::InvalidCredentialData => {
provider_admin_error(ProviderAdminErrorKind::Invalid)
}
Error::NotFound => provider_admin_error(ProviderAdminErrorKind::NotFound),
Error::Store { .. } | Error::TransportUnavailable => {
provider_admin_error(ProviderAdminErrorKind::Unavailable)
}
Error::QuotaWindowUnavailable => provider_admin_error(ProviderAdminErrorKind::Conflict)
.with_message("OpenAI did not expose a resettable quota window for usage statistics"),
Error::CredentialRefreshRequired { upstream_body } => {
let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
if let Some(body) = upstream_body {
error = error.with_message(format!(
"OpenAI usage-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 usage-statistics upstream returned HTTP {status}{retry_after}: {}",
bounded_upstream_body(&body)
))
}
}
}
fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError {
use CodexResetCreditsError as Error;
+1 -12
View File
@@ -155,8 +155,6 @@ impl CodexApiConfig {
pub struct CodexWebSocketPoolSettings {
pub enabled: bool,
pub max_age_ms: u64,
pub max_per_account: usize,
pub max_total: usize,
pub max_connecting: usize,
pub initial_event_timeout_ms: u64,
}
@@ -166,8 +164,6 @@ impl Default for CodexWebSocketPoolSettings {
Self {
enabled: true,
max_age_ms: 55 * 60 * 1000,
max_per_account: 8,
max_total: 64,
max_connecting: 8,
initial_event_timeout_ms: 20_000,
}
@@ -176,12 +172,7 @@ impl Default for CodexWebSocketPoolSettings {
impl CodexWebSocketPoolSettings {
fn validate(&self) -> Result<(), OpenAiConfigError> {
if self.max_age_ms == 0
|| self.max_per_account == 0
|| self.max_total == 0
|| self.max_connecting == 0
|| self.max_connecting > self.max_total
{
if self.max_age_ms == 0 || self.max_connecting == 0 {
return Err(OpenAiConfigError::InvalidField("openai.ws_pool"));
}
Ok(())
@@ -191,8 +182,6 @@ impl CodexWebSocketPoolSettings {
CodexWebSocketPoolConfig {
enabled: self.enabled,
max_age: Duration::from_millis(self.max_age_ms),
max_per_account: self.max_per_account,
max_total: self.max_total,
max_connecting: self.max_connecting,
initial_event_timeout: (self.initial_event_timeout_ms != 0)
.then(|| Duration::from_millis(self.initial_event_timeout_ms)),
@@ -1,4 +1,4 @@
//! OpenAI 会话锚点到 Store 不透明亲和键的单向派生。
//! OpenAI 根会话锚点到 Store 不透明账号亲和键的单向派生。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
@@ -11,34 +11,8 @@ pub(crate) fn derive_codex_session_affinity_key(
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()),
)
}
})?;
let (domain, value) = derive_account_affinity_anchor(request)?;
let session_key = opaque_affinity_key(domain, &value)?;
opaque_affinity_key(
"client-session",
&format!(
@@ -49,6 +23,33 @@ pub(crate) fn derive_codex_session_affinity_key(
)
}
/// 账号选择优先使用根会话事实;`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> {
let mut hasher = Sha256::new();
hasher.update(b"codex-session-affinity-v1\0");
@@ -47,7 +47,10 @@ pub use oauth::{
pub use quota::{
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, parse_codex_quota_usage,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, CodexUsageStatistics,
CodexUsageStatisticsCycle, CodexUsageStatisticsDay, CodexUsageStatisticsError,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens, parse_codex_quota_usage,
};
pub use refresh::{
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
@@ -7,17 +7,23 @@
mod document;
pub(crate) mod evidence;
pub(crate) mod snapshot;
mod usage_statistics;
pub use snapshot::{
CodexAccountQuotaSnapshot, CodexQuotaFact, CodexQuotaWindow, CodexQuotaWindowKind,
CodexQuotaWindowRole, parse_codex_quota_usage,
};
pub use usage_statistics::{
CodexUsageStatistics, CodexUsageStatisticsCycle, CodexUsageStatisticsDay,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant, SystemTime};
use chrono::Utc;
use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
@@ -36,6 +42,7 @@ use tokio::sync::Mutex;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::usage_statistics::CodexDailyUsageReport;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexRateLimitResetCredits,
CodexRateLimitResetCreditsConsumeResult, CodexRequestContext,
@@ -43,6 +50,7 @@ use crate::transport::{
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::types::CODEX_AUTHENTICATION_KIND_OAUTH;
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
@@ -144,6 +152,57 @@ pub enum CodexResetCreditsError {
ConsumeResultUnknown,
}
/// 官方每日用量统计查询失败。
#[derive(Error)]
pub enum CodexUsageStatisticsError {
#[error("Codex usage-statistics request is invalid")]
InvalidRequest,
#[error("Codex usage-statistics credential data is invalid")]
InvalidCredentialData,
#[error("Codex OAuth access token must be refreshed before querying usage statistics")]
CredentialRefreshRequired { upstream_body: Option<String> },
#[error("Codex usage-statistics account was not found")]
NotFound,
#[error("provider account store is unavailable: {detail}")]
Store { detail: String },
#[error("Codex usage-statistics quota window is unavailable")]
QuotaWindowUnavailable,
#[error("Codex usage-statistics upstream returned HTTP {status}")]
Upstream {
status: u16,
body: String,
retry_after_seconds: Option<u64>,
},
#[error("Codex usage-statistics query transport is unavailable")]
TransportUnavailable,
}
impl std::fmt::Debug for CodexUsageStatisticsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequest => formatter.write_str("InvalidRequest"),
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::QuotaWindowUnavailable => formatter.write_str("QuotaWindowUnavailable"),
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"),
}
}
}
impl std::fmt::Debug for CodexResetCreditsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -246,6 +305,25 @@ enum ResetCreditAttemptError {
Upstream(CodexClientError),
}
enum DailyUsageAttemptError {
InvalidCredential,
Upstream(CodexClientError),
}
enum DailyUsageFetchError {
InvalidCredential,
Recovery,
Upstream {
prepared: Box<PreparedCodexRuntimeCredential>,
error: CodexClientError,
},
}
struct FetchedDailyUsage {
model_breakdown: Value,
daily_totals: Value,
}
impl CodexQuotaSchedulingProjection {
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
let mut state = self
@@ -509,6 +587,113 @@ impl CodexCredentialQuotaService {
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
}
/// 查询当前账号的官方每日用量报表,并把报表范围锚定到最接近七天的额度窗口。
pub async fn usage_statistics(
&self,
account_id: &ProviderAccountId,
cycle_offset: i8,
utc_offset_minutes: i16,
) -> Result<CodexUsageStatistics, CodexUsageStatisticsError> {
const MAX_CYCLE_OFFSET: u8 = 8;
const MAX_UTC_OFFSET_MINUTES: i16 = 14 * 60;
const TARGET_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60;
if !(-(MAX_CYCLE_OFFSET as i8)..=0).contains(&cycle_offset)
|| !(-MAX_UTC_OFFSET_MINUTES..=MAX_UTC_OFFSET_MINUTES).contains(&utc_offset_minutes)
{
return Err(CodexUsageStatisticsError::InvalidRequest);
}
let account = self
.store
.get_account(account_id)
.await
.map_err(|error| CodexUsageStatisticsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexUsageStatisticsError::NotFound)?;
let observed_at = SystemTime::now();
if !access_token_is_current(&account, observed_at) {
return Err(CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: None,
});
}
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } = self
.fetch_usage_with_recovery(&client, &account)
.await
.map_err(map_statistics_quota_fetch_error)?;
let snapshot = parse_account_quota_snapshot(
account.id().clone(),
account.revision(),
observed_at,
&value,
)
.map_err(|_| CodexUsageStatisticsError::InvalidCredentialData)?;
let window = snapshot
.windows()
.iter()
.filter(|window| window.is_account_wide())
.filter_map(|window| {
Some((
window,
window.window_seconds()?.abs_diff(TARGET_WINDOW_SECONDS),
))
})
.min_by_key(|(_, distance)| *distance)
.map(|(window, _)| window)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_seconds = window
.window_seconds()
.filter(|seconds| *seconds > 0)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let end_at = window
.reset_at()
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_delta = chrono::TimeDelta::seconds(
i64::try_from(window_seconds)
.map_err(|_| CodexUsageStatisticsError::QuotaWindowUnavailable)?,
);
let current_start_at = end_at - window_delta;
let timezone = FixedOffset::east_opt(i32::from(utc_offset_minutes) * 60)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let now = DateTime::<Utc>::from(observed_at);
let period = usage_statistics::statistics_period(
current_start_at,
end_at,
window_seconds,
cycle_offset,
timezone,
now,
)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let fetched = self
.fetch_daily_usage_with_recovery(
&client,
&account,
period.query_start_date,
period.query_end_date,
)
.await
.map_err(map_daily_usage_fetch_error)?;
Ok(usage_statistics::build_usage_statistics(
usage_statistics::BuildUsageStatistics {
period,
timezone,
current_used_percent: window.used_percent(),
now,
model_breakdown: &fetched.model_breakdown,
daily_totals: &fetched.daily_totals,
max_cycle_offset: MAX_CYCLE_OFFSET,
},
))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
pub async fn consume_reset_credit(
&self,
@@ -1262,6 +1447,135 @@ impl CodexCredentialQuotaService {
}
}
}
async fn fetch_daily_usage_with_recovery(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
.await
.map_err(|_| DailyUsageFetchError::InvalidCredential)?;
let mut result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
if let Err(DailyUsageAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| DailyUsageFetchError::Recovery)?
{
prepared = recovered;
result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
}
match result {
Ok(value) => Ok(value),
Err(DailyUsageAttemptError::InvalidCredential) => {
Err(DailyUsageFetchError::InvalidCredential)
}
Err(DailyUsageAttemptError::Upstream(error)) => Err(DailyUsageFetchError::Upstream {
prepared: Box::new(prepared),
error,
}),
}
}
}
async fn fetch_daily_usage_once(
client: &CodexBackendClient,
prepared: &PreparedCodexRuntimeCredential,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageAttemptError> {
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.map_err(|_| DailyUsageAttemptError::InvalidCredential)?;
let model_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let totals_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let model_breakdown = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&model_request_id,
None,
),
CodexDailyUsageReport::PersonalModelCredits,
start_date,
end_date,
);
let daily_totals = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&totals_request_id,
None,
),
CodexDailyUsageReport::PersonalTokenTotals,
start_date,
end_date,
);
let (model_breakdown, daily_totals) = tokio::try_join!(model_breakdown, daily_totals)
.map_err(DailyUsageAttemptError::Upstream)?;
Ok(FetchedDailyUsage {
model_breakdown,
daily_totals,
})
}
fn map_statistics_quota_fetch_error(error: CodexQuotaFetchError) -> CodexUsageStatisticsError {
match error {
CodexQuotaFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
CodexQuotaFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
CodexQuotaFetchError::Upstream { account, error } => map_statistics_client_error(
error,
account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH,
),
}
}
fn map_daily_usage_fetch_error(error: DailyUsageFetchError) -> CodexUsageStatisticsError {
match error {
DailyUsageFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
DailyUsageFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
DailyUsageFetchError::Upstream { prepared, error } => {
map_statistics_client_error(error, prepared.credential.authentication.oauth().is_some())
}
}
}
fn map_statistics_client_error(
error: CodexClientError,
credential_can_refresh: bool,
) -> CodexUsageStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED && credential_can_refresh =>
{
CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
..
} => CodexUsageStatisticsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
},
_ => CodexUsageStatisticsError::TransportUnavailable,
}
}
async fn list_reset_credits_once(
@@ -0,0 +1,874 @@
use std::{
cmp::{Ordering, Reverse},
collections::BTreeMap,
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime, TimeDelta, Utc};
use gateway_core::accounting::{Decimal, Money};
use serde_json::Value;
use crate::transport::{OpenAiBillingUsage, openai_aggregate_billing_breakdown};
const EPSILON: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CodexUsageStatisticsServiceTier {
Standard,
Fast,
}
impl CodexUsageStatisticsServiceTier {
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
const fn billing_service_tier(self) -> &'static str {
match self {
Self::Standard => "default",
Self::Fast => "fast",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CodexUsageStatisticsTokens {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
impl CodexUsageStatisticsTokens {
fn checked_add(self, other: Self) -> Option<Self> {
Some(Self {
uncached_input: self.uncached_input.checked_add(other.uncached_input)?,
cached_input: self.cached_input.checked_add(other.cached_input)?,
output: self.output.checked_add(other.output)?,
total: self.total.checked_add(other.total)?,
})
}
const fn is_empty(self) -> bool {
self.total == 0 && self.uncached_input == 0 && self.cached_input == 0 && self.output == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsModel {
pub key: String,
pub model: String,
pub service_tier: CodexUsageStatisticsServiceTier,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsDay {
pub date: NaiveDate,
pub credit_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsSummary {
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<Money>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatistics {
pub cycle: CodexUsageStatisticsCycle,
pub summary: CodexUsageStatisticsSummary,
pub models: Vec<CodexUsageStatisticsModel>,
pub daily: Vec<CodexUsageStatisticsDay>,
}
#[derive(Debug, Clone, Copy)]
pub struct CodexUsageStatisticsPeriod {
pub cycle_offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub query_start_date: NaiveDate,
pub query_end_date: NaiveDate,
}
pub fn statistics_period(
current_start_at: DateTime<Utc>,
current_end_at: DateTime<Utc>,
window_seconds: u64,
cycle_offset: i8,
timezone: FixedOffset,
now: DateTime<Utc>,
) -> Option<CodexUsageStatisticsPeriod> {
let window_delta = TimeDelta::seconds(i64::try_from(window_seconds).ok()?);
let shift = window_delta.checked_mul(i32::from(cycle_offset))?;
let start_at = current_start_at.checked_add_signed(shift)?;
let end_at = current_end_at.checked_add_signed(shift)?;
let query_start_date = start_at
.checked_sub_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
let effective_end = end_at.min(now);
let query_end_date = effective_end
.checked_add_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
Some(CodexUsageStatisticsPeriod {
cycle_offset,
start_at,
end_at,
window_seconds,
query_start_date,
query_end_date,
})
}
pub(super) struct BuildUsageStatistics<'a> {
pub period: CodexUsageStatisticsPeriod,
pub timezone: FixedOffset,
pub current_used_percent: Option<f64>,
pub now: DateTime<Utc>,
pub model_breakdown: &'a Value,
pub daily_totals: &'a Value,
pub max_cycle_offset: u8,
}
pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUsageStatistics {
let BuildUsageStatistics {
period,
timezone,
current_used_percent,
now,
model_breakdown,
daily_totals,
max_cycle_offset,
} = input;
let all_days = build_personal_days(daily_totals, model_breakdown);
let selected_days = select_cycle_days(
&all_days,
period.start_at,
period.end_at,
timezone,
now,
true,
);
let is_current = period.cycle_offset == 0;
let used_percent = is_current
.then_some(current_used_percent)
.flatten()
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0));
let used_ratio = used_percent
.map(|value| value.clamp(0.0, 100.0) / 100.0)
.unwrap_or_default();
let summary = summarize(&selected_days, used_ratio, is_current);
let models = aggregate_models(&selected_days, used_ratio, is_current);
let total_credits = selected_days
.iter()
.map(|day| day.credit_weight)
.sum::<f64>();
let mut daily = selected_days
.into_iter()
.map(|day| CodexUsageStatisticsDay {
date: day.date,
credit_share: (total_credits > EPSILON).then_some(day.credit_weight / total_credits),
tokens: day.tokens,
estimated_cost: day.estimated_cost,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
.collect::<Vec<_>>();
daily.sort_by_key(|day| Reverse(day.date));
CodexUsageStatistics {
cycle: CodexUsageStatisticsCycle {
offset: period.cycle_offset,
start_at: period.start_at,
end_at: period.end_at,
window_seconds: period.window_seconds,
used_percent,
is_current,
can_go_previous: period.cycle_offset > -(max_cycle_offset as i8),
can_go_next: period.cycle_offset < 0,
},
summary,
models,
daily,
}
}
#[derive(Debug, Clone)]
struct DailyModelUsage {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
credits: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
#[derive(Debug, Clone)]
struct DailyUsage {
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_missing_token_data: bool,
is_boundary_day: bool,
models: Vec<DailyModelUsage>,
}
#[derive(Default)]
struct MoneySum {
value: Option<Money>,
overflowed: bool,
}
impl MoneySum {
fn push(&mut self, value: Option<Money>) {
let Some(value) = value else {
return;
};
if self.overflowed {
return;
}
self.value = match self.value {
Some(current) => match current.checked_add(value) {
Some(total) => Some(total),
None => {
self.overflowed = true;
None
}
},
None => Some(value),
};
}
fn finish(self) -> Option<Money> {
(!self.overflowed).then_some(self.value).flatten()
}
}
fn build_personal_days(totals_payload: &Value, breakdown_payload: &Value) -> Vec<DailyUsage> {
let totals_by_date = rows_by_date(totals_payload);
let breakdown_by_date = rows_by_date(breakdown_payload);
let mut dates = totals_by_date
.keys()
.chain(breakdown_by_date.keys())
.copied()
.collect::<Vec<_>>();
dates.sort_unstable();
dates.dedup();
dates
.into_iter()
.filter_map(|date| {
let totals_row = totals_by_date.get(&date).unwrap_or(&Value::Null);
let breakdown_row = breakdown_by_date.get(&date).unwrap_or(&Value::Null);
let totals = token_parts(totals_row.get("totals").unwrap_or(totals_row));
let credit_weight = day_credit_weight(breakdown_row);
let weighted = weighted_models(breakdown_row);
if totals.is_empty() {
if credit_weight <= EPSILON {
return None;
}
let models = weighted
.into_iter()
.map(|model| DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: model.weight,
tokens: CodexUsageStatisticsTokens::default(),
estimated_cost: None,
has_unknown_pricing: false,
has_estimated_allocation: false,
has_rate_fallback: false,
has_missing_token_data: true,
})
.collect();
return Some(finalize_day(date, credit_weight, totals, models, true));
}
let has_model_breakdown = !weighted.is_empty();
let weighted = if has_model_breakdown {
weighted
} else {
vec![WeightedModel {
model: "unknown".to_owned(),
service_tier: CodexUsageStatisticsServiceTier::Standard,
weight: 1.0,
}]
};
let allocation = rate_adjusted_shares(&weighted, totals);
let uncached = allocate_integer_total(totals.uncached_input, &allocation.token_shares);
let cached = allocate_integer_total(totals.cached_input, &allocation.token_shares);
let output = allocate_integer_total(totals.output, &allocation.token_shares);
let total = allocate_integer_total(totals.total, &allocation.token_shares);
let estimated_allocation = weighted.len() > 1 || !has_model_breakdown;
let rate_fallback = allocation.has_rate_fallback || !has_model_breakdown;
let models = weighted
.into_iter()
.enumerate()
.map(|(index, model)| {
let tokens = CodexUsageStatisticsTokens {
uncached_input: uncached.get(index).copied().unwrap_or_default(),
cached_input: cached.get(index).copied().unwrap_or_default(),
output: output.get(index).copied().unwrap_or_default(),
total: total.get(index).copied().unwrap_or_default(),
};
let estimated_cost = aggregate_cost(&model.model, model.service_tier, tokens);
DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: if has_model_breakdown {
model.weight
} else {
0.0
},
tokens,
has_unknown_pricing: estimated_cost.is_none() && !tokens.is_empty(),
estimated_cost,
has_estimated_allocation: estimated_allocation,
has_rate_fallback: rate_fallback,
has_missing_token_data: false,
}
})
.collect();
Some(finalize_day(date, credit_weight, totals, models, false))
})
.collect()
}
fn finalize_day(
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
models: Vec<DailyModelUsage>,
has_missing_token_data: bool,
) -> DailyUsage {
let mut costs = MoneySum::default();
for model in &models {
costs.push(model.estimated_cost);
}
DailyUsage {
date,
credit_weight,
tokens,
estimated_cost: costs.finish(),
has_unknown_pricing: models.iter().any(|model| model.has_unknown_pricing),
has_missing_token_data,
is_boundary_day: false,
models,
}
}
#[derive(Debug)]
struct WeightedModel {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
weight: f64,
}
struct Allocation {
token_shares: Vec<f64>,
has_rate_fallback: bool,
}
fn weighted_models(row: &Value) -> Vec<WeightedModel> {
value_array(row.get("models"))
.iter()
.filter_map(|value| {
let weight = non_negative_f64(value.get("credits"));
(weight > EPSILON).then(|| WeightedModel {
model: model_name(value),
service_tier: model_service_tier(value.get("speed").or_else(|| value.get("mode"))),
weight,
})
})
.collect()
}
fn rate_adjusted_shares(
models: &[WeightedModel],
totals: CodexUsageStatisticsTokens,
) -> Allocation {
let total_weight = models.iter().map(|model| model.weight).sum::<f64>();
let credit_shares = models
.iter()
.map(|model| model.weight / total_weight)
.collect::<Vec<_>>();
if models.len() == 1 {
return Allocation {
token_shares: vec![1.0],
has_rate_fallback: false,
};
}
let classified_tokens = totals
.uncached_input
.saturating_add(totals.cached_input)
.saturating_add(totals.output);
if classified_tokens == 0 {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let rates = models
.iter()
.map(|model| blended_rate(&model.model, model.service_tier, totals, classified_tokens))
.collect::<Vec<_>>();
let known = rates
.iter()
.enumerate()
.filter_map(|(index, rate)| rate.map(|rate| (index, rate)))
.collect::<Vec<_>>();
if known.is_empty() {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let known_weight = known
.iter()
.map(|(index, _)| models[*index].weight)
.sum::<f64>();
let fallback_rate = known
.iter()
.map(|(index, rate)| rate * models[*index].weight / known_weight)
.sum::<f64>();
let inverse = models
.iter()
.enumerate()
.map(|(index, model)| model.weight / rates[index].unwrap_or(fallback_rate))
.collect::<Vec<_>>();
let inverse_total = inverse.iter().sum::<f64>();
Allocation {
token_shares: inverse
.into_iter()
.map(|value| value / inverse_total)
.collect(),
has_rate_fallback: known.len() != models.len(),
}
}
#[allow(clippy::cast_precision_loss)]
fn blended_rate(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
totals: CodexUsageStatisticsTokens,
classified_tokens: u64,
) -> Option<f64> {
let cost = aggregate_cost(model, service_tier, totals)?;
let rate = cost.amount().scaled() as f64 / classified_tokens as f64;
(rate > EPSILON).then_some(rate)
}
fn aggregate_cost(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
tokens: CodexUsageStatisticsTokens,
) -> Option<Money> {
let input = tokens.uncached_input.checked_add(tokens.cached_input)?;
openai_aggregate_billing_breakdown(
model,
OpenAiBillingUsage::new(input, tokens.output, tokens.cached_input, 0),
Some(service_tier.billing_service_tier()),
)
.map(|breakdown| breakdown.total_amount())
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn allocate_integer_total(total: u64, shares: &[f64]) -> Vec<u64> {
if shares.is_empty() {
return Vec::new();
}
let share_total = shares.iter().copied().sum::<f64>();
if !share_total.is_finite() || share_total <= EPSILON {
return vec![0; shares.len()];
}
let raw = shares
.iter()
.map(|share| total as f64 * (share.max(0.0) / share_total))
.collect::<Vec<_>>();
let mut allocated = raw
.iter()
.map(|value| value.floor() as u64)
.collect::<Vec<_>>();
let allocated_total = allocated.iter().copied().sum::<u64>();
let mut remainder = total.saturating_sub(allocated_total);
let mut order = raw
.iter()
.enumerate()
.map(|(index, value)| (index, value - value.floor()))
.collect::<Vec<_>>();
order.sort_by(|left, right| {
right
.1
.total_cmp(&left.1)
.then_with(|| left.0.cmp(&right.0))
});
let mut index = 0_usize;
while remainder > 0 {
let target = order[index % order.len()].0;
allocated[target] = allocated[target].saturating_add(1);
remainder -= 1;
index += 1;
}
allocated
}
fn select_cycle_days(
days: &[DailyUsage],
start_at: DateTime<Utc>,
end_at: DateTime<Utc>,
timezone: FixedOffset,
now: DateTime<Utc>,
mark_boundary: bool,
) -> Vec<DailyUsage> {
let local_start = start_at.with_timezone(&timezone);
let start_date = local_start.date_naive();
let effective_end = end_at.min(now);
let end_date = effective_end.with_timezone(&timezone).date_naive();
let exclude_end_date = end_at <= now && end_date > start_date;
let is_boundary = local_start.time() != NaiveTime::MIN;
days.iter()
.filter(|day| {
day.date >= start_date
&& if exclude_end_date {
day.date < end_date
} else {
day.date <= end_date
}
})
.cloned()
.map(|mut day| {
day.is_boundary_day = mark_boundary && is_boundary && day.date == start_date;
day
})
.collect()
}
fn summarize(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> CodexUsageStatisticsSummary {
let tokens = sum_tokens(days.iter().map(|day| day.tokens));
let mut costs = MoneySum::default();
for day in days {
costs.push(day.estimated_cost);
}
let estimated_cost = costs.finish();
let has_unknown_pricing = days.iter().any(|day| day.has_unknown_pricing);
let projected_tokens = (is_current && used_ratio > EPSILON && tokens.total > 0)
.then(|| project_u64(tokens.total, used_ratio))
.flatten();
let projected_cost = (is_current && !has_unknown_pricing && used_ratio > EPSILON)
.then(|| estimated_cost.and_then(|cost| project_money(cost, used_ratio)))
.flatten();
CodexUsageStatisticsSummary {
tokens,
estimated_cost,
projected_tokens,
projected_cost,
day_count: u32::try_from(days.len()).unwrap_or(u32::MAX),
has_unknown_pricing,
has_missing_token_data: days.iter().any(|day| day.has_missing_token_data),
}
}
#[derive(Default)]
struct ModelAggregate {
credits: f64,
tokens: CodexUsageStatisticsTokens,
costs: MoneySum,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
fn aggregate_models(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> Vec<CodexUsageStatisticsModel> {
let mut aggregates =
BTreeMap::<(String, CodexUsageStatisticsServiceTier), ModelAggregate>::new();
for model in days.iter().flat_map(|day| &day.models) {
if model.tokens.is_empty() && model.credits <= EPSILON {
continue;
}
let aggregate = aggregates
.entry((model.model.clone(), model.service_tier))
.or_default();
aggregate.credits += model.credits;
aggregate.tokens =
aggregate
.tokens
.checked_add(model.tokens)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
});
aggregate.costs.push(model.estimated_cost);
aggregate.has_unknown_pricing |= model.has_unknown_pricing;
aggregate.has_estimated_allocation |= model.has_estimated_allocation;
aggregate.has_rate_fallback |= model.has_rate_fallback;
aggregate.has_missing_token_data |= model.has_missing_token_data;
}
let total_credits = aggregates
.values()
.map(|aggregate| aggregate.credits)
.sum::<f64>();
let mut rows = aggregates
.into_iter()
.map(|((model, service_tier), aggregate)| {
let credit_share =
(total_credits > EPSILON).then_some(aggregate.credits / total_credits);
let quota_share = (is_current && used_ratio > EPSILON)
.then(|| credit_share.map(|share| share * used_ratio))
.flatten();
CodexUsageStatisticsModel {
key: format!("{model}::{}", service_tier.as_str()),
model,
service_tier,
credit_share,
quota_share,
tokens: aggregate.tokens,
estimated_cost: aggregate.costs.finish(),
has_unknown_pricing: aggregate.has_unknown_pricing,
has_estimated_allocation: aggregate.has_estimated_allocation,
has_rate_fallback: aggregate.has_rate_fallback,
has_missing_token_data: aggregate.has_missing_token_data,
}
})
.collect::<Vec<_>>();
rows.sort_by(compare_model_rows);
rows
}
fn compare_model_rows(
left: &CodexUsageStatisticsModel,
right: &CodexUsageStatisticsModel,
) -> Ordering {
right
.credit_share
.unwrap_or_default()
.total_cmp(&left.credit_share.unwrap_or_default())
.then_with(|| cost_ticks(right.estimated_cost).cmp(&cost_ticks(left.estimated_cost)))
.then_with(|| right.tokens.total.cmp(&left.tokens.total))
.then_with(|| left.model.cmp(&right.model))
.then_with(|| left.service_tier.cmp(&right.service_tier))
}
fn cost_ticks(cost: Option<Money>) -> u128 {
cost.map(|value| value.amount().scaled())
.unwrap_or_default()
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_u64(value: u64, used_ratio: f64) -> Option<u64> {
let projected = value as f64 / used_ratio;
(projected.is_finite() && projected >= 0.0 && projected <= u64::MAX as f64)
.then(|| projected.round() as u64)
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_money(cost: Money, used_ratio: f64) -> Option<Money> {
let projected = cost.amount().scaled() as f64 / used_ratio;
if !projected.is_finite() || projected < 0.0 || projected > u128::MAX as f64 {
return None;
}
let amount = Decimal::from_scaled(projected.round() as u128).ok()?;
Some(Money::new(amount, cost.currency()))
}
fn sum_tokens(
values: impl IntoIterator<Item = CodexUsageStatisticsTokens>,
) -> CodexUsageStatisticsTokens {
values
.into_iter()
.fold(CodexUsageStatisticsTokens::default(), |total, value| {
total
.checked_add(value)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
})
})
}
fn rows_by_date(payload: &Value) -> BTreeMap<NaiveDate, Value> {
extract_daily_list(payload)
.into_iter()
.filter_map(|row| Some((date_value(row.get("date")?)?, row.clone())))
.collect()
}
fn extract_daily_list(payload: &Value) -> Vec<&Value> {
if let Some(rows) = payload.as_array() {
return rows.iter().collect();
}
[
"data",
"items",
"results",
"daily",
"daily_usage",
"dailyWorkspaceUsageCounts",
"daily_workspace_usage_counts",
"workspace_usage_counts",
]
.into_iter()
.find_map(|key| payload.get(key).and_then(Value::as_array))
.map(|rows| rows.iter().collect())
.unwrap_or_default()
}
fn value_array(value: Option<&Value>) -> &[Value] {
value.and_then(Value::as_array).map_or(&[], Vec::as_slice)
}
fn token_parts(value: &Value) -> CodexUsageStatisticsTokens {
let uncached_input = non_negative_u64(value.get("uncached_text_input_tokens"));
let cached_input = non_negative_u64(value.get("cached_text_input_tokens"));
let output = non_negative_u64(value.get("text_output_tokens"));
let derived = uncached_input
.saturating_add(cached_input)
.saturating_add(output);
let reported = non_negative_u64(value.get("text_total_tokens"));
CodexUsageStatisticsTokens {
uncached_input,
cached_input,
output,
total: if reported > 0 { reported } else { derived },
}
}
fn day_credit_weight(value: &Value) -> f64 {
let model_credits = value_array(value.get("models"))
.iter()
.map(|model| non_negative_f64(model.get("credits")))
.sum::<f64>();
if model_credits > EPSILON {
return model_credits;
}
value
.get("product_surface_usage_values")
.and_then(Value::as_object)
.map(|values| {
values
.values()
.map(|value| non_negative_f64(Some(value)))
.sum()
})
.unwrap_or_default()
}
fn model_name(value: &Value) -> String {
["model", "model_id", "model_name", "name", "id"]
.into_iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_ascii_lowercase()
}
fn model_service_tier(value: Option<&Value>) -> CodexUsageStatisticsServiceTier {
match value
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("fast" | "priority") => CodexUsageStatisticsServiceTier::Fast,
Some(_) | None => CodexUsageStatisticsServiceTier::Standard,
}
}
fn date_value(value: &Value) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value.as_str()?.get(..10)?, "%Y-%m-%d").ok()
}
fn non_negative_u64(value: Option<&Value>) -> u64 {
let Some(value) = value else {
return 0;
};
if let Some(number) = value.as_u64() {
return number;
}
let normalized = value
.as_str()
.map(|raw| raw.replace(',', ""))
.unwrap_or_else(|| value.to_string());
normalized
.trim()
.parse::<u64>()
.ok()
.or_else(|| {
normalized
.trim()
.parse::<f64>()
.ok()
.filter(|number| number.is_finite() && *number >= 0.0)
.map(|number| number.round() as u64)
})
.unwrap_or_default()
}
fn non_negative_f64(value: Option<&Value>) -> f64 {
let Some(value) = value else {
return 0.0;
};
let parsed = value.as_f64().or_else(|| {
value
.as_str()
.map(|raw| raw.replace(',', ""))
.and_then(|raw| raw.trim().parse::<f64>().ok())
});
parsed
.filter(|number| number.is_finite() && *number >= 0.0)
.unwrap_or_default()
}
@@ -476,14 +476,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 +513,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,6 +533,40 @@ 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());
tracing::info!(
request_id = %request.attempt.request_id(),
@@ -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 {
+39 -11
View File
@@ -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,
};
@@ -318,8 +321,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 +375,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(),
@@ -1178,7 +1185,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 +1293,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 +2379,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,11 @@ 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";
/// 个人账号每日模型额度权重。
pub const WHAM_DAILY_TOKEN_USAGE_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
/// 个人账号每日 Token 总量。
pub const WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH: &str =
"/wham/analytics/daily-workspace-usage-counts";
/// Codex Desktop 主动额度重置卡列表。
pub const WHAM_RATE_LIMIT_RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits";
/// Codex Desktop 主动消费额度重置卡。
@@ -17,6 +17,7 @@ pub(crate) mod session;
mod time;
pub mod tls;
pub mod usage;
pub(crate) mod usage_statistics;
pub mod websocket;
pub use self::{
@@ -35,8 +36,9 @@ 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_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_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,
request::{CodexRequestEncodeError, encode_generate_request},
@@ -50,3 +52,5 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
};
pub(crate) use self::usage::openai_aggregate_billing_breakdown;
@@ -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,30 @@ 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,
)
}
/// 官方日报没有单次请求边界,不能把整日输入量套用长上下文阶梯。
/// 聚合统计固定使用公开的短上下文价格,其余价格规则与请求级计费共用。
pub(crate) fn openai_aggregate_billing_breakdown(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
openai_billing_breakdown_with_context(model, usage, service_tier, false)
}
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 +445,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)?;
@@ -0,0 +1,119 @@
//! Codex 官方每日用量报表 transport。
use chrono::NaiveDate;
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use reqwest::StatusCode;
use serde_json::Value;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
client::{
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
retry_after_seconds, truncate_for_error,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{
WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH, endpoint_url,
},
response_meta,
};
const MAX_CODEX_DAILY_USAGE_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CodexDailyUsageReport {
PersonalModelCredits,
PersonalTokenTotals,
}
impl CodexDailyUsageReport {
const fn path(self) -> &'static str {
match self {
Self::PersonalModelCredits => WHAM_DAILY_TOKEN_USAGE_PATH,
Self::PersonalTokenTotals => WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
}
}
const fn workspace_user(self) -> bool {
matches!(self, Self::PersonalTokenTotals)
}
}
impl CodexBackendClient {
pub(crate) async fn fetch_daily_usage(
&self,
context: CodexRequestContext<'_>,
report: CodexDailyUsageReport,
start_date: NaiveDate,
end_date: NaiveDate,
) -> CodexClientResult<Value> {
let headers = self.usage_request_headers(context)?;
let workspace_user = if report.workspace_user() {
"&workspace_user=true"
} else {
""
};
let url = format!(
"{}?start_date={start_date}&end_date={end_date}&group_by=day{workspace_user}",
endpoint_url(&self.base_url, report.path()),
);
let response = self.client.get(url).headers(headers).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_DAILY_USAGE_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(upstream_error(
if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
"upstream daily usage 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,
));
}
serde_json::from_str::<Value>(&body).map_err(|_| {
upstream_error(
StatusCode::BAD_GATEWAY,
format!(
"invalid daily usage response: {}",
truncate_for_error(&body)
),
None,
diagnostics,
)
})
}
}
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,
}
}
@@ -10,13 +10,13 @@ use std::{
time::Duration,
};
use tokio::{sync::Semaphore, time::Instant};
use tokio::sync::Semaphore;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use uuid::Uuid;
use self::state::{
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, account_slot_count,
close_pooled_connection, close_pooled_connections, take_lru_idle_connection,
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, close_pooled_connection,
close_pooled_connections,
};
use super::pump::PumpKeepalive;
@@ -31,8 +31,6 @@ pub(crate) use self::{
},
};
const DEFAULT_MAX_PER_ACCOUNT: usize = 8;
const DEFAULT_MAX_TOTAL: usize = 64;
const DEFAULT_MAX_CONNECTING: usize = 16;
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
@@ -64,10 +62,6 @@ pub struct CodexWebSocketPoolConfig {
pub enabled: bool,
/// 单个 socket 的最大生命周期。
pub max_age: Duration,
/// 单个账号允许占用的最大池 slot 数。
pub max_per_account: usize,
/// 所有账号合计允许占用的最大池 slot 数。
pub max_total: usize,
/// 所有账号合计允许并发执行的 opening 数。
pub max_connecting: usize,
/// 后台维护间隔;`None` 表示不启动后台任务。
@@ -87,8 +81,6 @@ impl Default for CodexWebSocketPoolConfig {
Self {
enabled: true,
max_age: DEFAULT_MAX_AGE,
max_per_account: DEFAULT_MAX_PER_ACCOUNT,
max_total: DEFAULT_MAX_TOTAL,
max_connecting: DEFAULT_MAX_CONNECTING,
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
ping_interval: Some(DEFAULT_PING_INTERVAL),
@@ -113,10 +105,9 @@ impl CodexWebSocketPoolConfig {
}
impl CodexWebSocketPool {
/// 构造连接池策略和状态。
pub fn new(max_per_account: usize, max_age: Duration) -> Self {
/// 构造不限制累计 slot 数的连接池策略和状态。
pub fn new(max_age: Duration) -> Self {
Self::with_config(CodexWebSocketPoolConfig {
max_per_account: max_per_account.max(1),
max_age,
maintenance_interval: None,
ping_interval: None,
@@ -218,43 +209,25 @@ impl CodexWebSocketPool {
let acquire = if required_response_id.is_some() {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::ContinuationNotFound)
} else if self.config.max_per_account == 0
|| account_slot_count(&state.slots, key.account_id()) >= self.config.max_per_account
{
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
} else {
let connect_permit = self.connect_semaphore.clone().try_acquire_owned().ok();
match connect_permit {
Some(connect_permit) => {
let has_total_capacity = if self.config.max_total == 0 {
false
} else if state.slots.len() < self.config.max_total {
true
} else if let Some(connection) = take_lru_idle_connection(&mut state) {
connections_to_close.push(connection);
true
} else {
false
};
if has_total_capacity {
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
} else {
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
}
let lease = WebSocketPoolConnectLease::reserve(
self.clone(),
key.clone(),
connect_permit,
);
state.slots.insert(
key,
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
id: lease.id,
started_at: lease.started_at,
outcome: lease.outcome.clone(),
cancellation: lease.cancellation.clone(),
}),
);
WebSocketPoolAcquire::Connect(lease)
}
None => WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap),
}
@@ -292,7 +265,6 @@ impl CodexWebSocketPool {
key.clone(),
WebSocketPoolSlot::Idle {
connection: Box::new(connection),
last_used_at: Instant::now(),
},
);
}
@@ -108,7 +108,6 @@ impl WebSocketContinuationState {
pub(super) enum WebSocketPoolSlot {
Idle {
connection: Box<PooledWebSocketConnection>,
last_used_at: Instant,
},
Busy(WebSocketPoolReservation),
Connecting(WebSocketPoolConnecting),
@@ -138,34 +137,6 @@ pub(super) struct WebSocketPoolReservation {
pub(super) latest_response_id: Option<String>,
}
pub(super) fn account_slot_count(
slots: &HashMap<CodexWebSocketPoolKey, WebSocketPoolSlot>,
account_id: &str,
) -> usize {
slots
.keys()
.filter(|key| key.account_id() == account_id)
.count()
}
pub(super) fn take_lru_idle_connection(
state: &mut WebSocketPoolState,
) -> Option<PooledWebSocketConnection> {
let key = state
.slots
.iter()
.filter_map(|(key, slot)| match slot {
WebSocketPoolSlot::Idle { last_used_at, .. } => Some((key, *last_used_at)),
WebSocketPoolSlot::Busy(_) | WebSocketPoolSlot::Connecting(_) => None,
})
.min_by_key(|(_, last_used_at)| *last_used_at)
.map(|(key, _)| key.clone())?;
let Some(WebSocketPoolSlot::Idle { connection, .. }) = state.slots.remove(&key) else {
return None;
};
Some(*connection)
}
pub(super) async fn close_pooled_connection(connection: PooledWebSocketConnection) {
connection.websocket.close().await;
}
+153 -4
View File
@@ -1,11 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use futures::{StreamExt, future::BoxFuture};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
@@ -23,7 +23,12 @@ use gateway_core::engine::credential::{
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaObservation, QuotaState,
};
use gateway_core::engine::provider::ProviderRequest;
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
@@ -32,7 +37,11 @@ use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
RuntimeSnapshot, UpstreamModelId,
};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
@@ -46,9 +55,14 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
profile, secret,
account_policy, profile, secret,
};
const COMPLETED_SESSION_SSE: &str = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
);
#[tokio::test]
async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() {
let config = valid_config();
@@ -97,6 +111,84 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
assert!(bundle.take_worker_contributions().is_empty());
}
#[tokio::test]
async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinct() {
let account_id = "acct_initialized_thread_spawn";
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret("at-initialized-thread-spawn"),
verified_account: profile("chatgpt-initialized-thread-spawn"),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_SESSION_SSE),
)
.expect(2)
.mount(&server)
.await;
let mut config = valid_config();
config.config.api.base_url = server.uri();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("initialized OpenAI provider");
let provider = bundle.core_provider();
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
let mut conversation_ids = Vec::new();
for (request_id, thread_id) in [
("req_initialized_thread_spawn_first", "child-one"),
("req_initialized_thread_spawn_second", "child-two"),
] {
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("child task")),
("session_id".to_owned(), json!("parent-session")),
("thread_id".to_owned(), json!(thread_id)),
("turnMetadata".to_owned(), json!(thread_spawn)),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let mut stream = provider
.execute(
initialized_provider_request(operation, account_id),
initialized_attempt_context(request_id, account_id),
)
.await
.expect("prepare child provider stream");
let mut conversation_id = None;
while let Some(event) = stream.next().await {
let event = event.expect("child provider response");
if let Some(update) = event.session_update() {
conversation_id = update
.payload()
.get("conversation_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
}
conversation_ids.push(conversation_id.expect("child transport conversation id"));
}
assert_ne!(conversation_ids[0], conversation_ids[1]);
}
#[tokio::test]
async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() {
let config = valid_config();
@@ -921,6 +1013,63 @@ fn reset_credit_command(account_id: ProviderAccountId) -> ConsumeProviderResetCr
}
}
fn initialized_provider_request(operation: Operation, account_id: &str) -> ProviderRequest {
let provider = ProviderKind::new("openai").expect("provider");
let upstream_model = UpstreamModelId::new("gpt-5.4").expect("upstream model");
let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model");
let account_scope = initialized_account_scope(account_id);
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
account_policy(),
vec![provider.clone()],
vec![ProviderModel::new(
provider,
upstream_model,
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
.with_upstream_feature_validation(),
)],
Vec::new(),
)
.expect("runtime snapshot");
let plan = snapshot
.plan(
&public_model,
&operation,
account_scope,
&RoutingContext::default(),
)
.expect("routing plan");
ProviderRequest::new(operation, plan.candidates()[0].clone())
}
fn initialized_attempt_context(request_id: &str, account_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_initialized").expect("client key id"),
),
NonZeroU32::new(1).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(initialized_account_scope(account_id)),
None,
CancellationToken::new(),
)
}
fn initialized_account_scope(account_id: &str) -> Arc<FrozenAccountScope> {
let provider = ProviderKind::new("openai").expect("provider");
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
ProviderAccountId::new(account_id).expect("account id"),
RuntimeAccount::new(provider, BTreeSet::new()),
)]))),
ClientRoutingScope::all_accounts(),
))
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -101,8 +101,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
config.api.base_url.as_str(),
config.ws_pool.enabled,
config.ws_pool.max_age_ms,
config.ws_pool.max_per_account,
config.ws_pool.max_total,
config.ws_pool.max_connecting,
config.ws_pool.initial_event_timeout_ms,
config.quota.refresh_interval_minutes,
@@ -115,8 +113,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
true,
3_300_000,
8,
64,
8,
20_000,
15,
true,
@@ -445,6 +445,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 +537,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 +605,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 +1075,7 @@ fn successful_upstream_response_recovers_non_quota_terminal_states() {
let current = store.account("acct_primary").expect("stale account");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_success(&current, None));
block_on(selector.record_success(&current, None, current.id()));
assert_eq!(
store
@@ -1289,7 +1359,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]
@@ -2,6 +2,7 @@
mod recovery;
mod snapshot;
mod usage_statistics;
use std::sync::Arc;
use std::time::SystemTime;
@@ -0,0 +1,217 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Days, NaiveTime, TimeDelta, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexUsageStatisticsError, CodexUsageStatisticsServiceTier};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{create_account, quota_service_with_base_url};
use crate::support::MemoryAccountStore;
const PERSONAL_MODEL_REPORT_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
const PERSONAL_TOTALS_REPORT_PATH: &str = "/wham/analytics/daily-workspace-usage-counts";
fn cycle_anchor() -> (DateTime<Utc>, DateTime<Utc>) {
let start_date = Utc::now()
.date_naive()
.checked_sub_days(Days::new(1))
.expect("cycle start date");
let start_at = DateTime::<Utc>::from_naive_utc_and_offset(
start_date.and_time(NaiveTime::from_hms_opt(15, 50, 0).expect("cycle time")),
Utc,
);
(
start_at,
start_at
.checked_add_signed(TimeDelta::days(7))
.expect("cycle end"),
)
}
async fn mount_quota(server: &MockServer, reset_at: DateTime<Utc>) {
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": 20,
"reset_at": reset_at.timestamp(),
"limit_window_seconds": 604800
}
}
})))
.mount(server)
.await;
}
async fn service(
account_id: &str,
server: &MockServer,
reset_at: DateTime<Utc>,
) -> (
Arc<MemoryAccountStore>,
provider_openai::credential::CodexCredentialQuotaService,
) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, account_id).await;
mount_quota(server, reset_at).await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
(store, service)
}
#[tokio::test]
async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_totals() {
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = Utc::now().date_naive().to_string();
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"models": [
{"model": "gpt-5.6-sol", "speed": "fast", "credits": 75},
{"model": "gpt-5.6-terra", "speed": "standard", "credits": 25}
]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"uncached_text_input_tokens": 101,
"cached_text_input_tokens": 203,
"text_output_tokens": 17,
"text_total_tokens": 321
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_personal_statistics", &server, end_at).await;
let account = store
.account("acct_personal_statistics")
.expect("personal account");
let statistics = service
.usage_statistics(account.id(), 0, 0)
.await
.expect("personal statistics");
assert_eq!(statistics.cycle.start_at, start_at);
assert_eq!(statistics.summary.tokens.uncached_input, 101);
assert_eq!(statistics.summary.tokens.cached_input, 203);
assert_eq!(statistics.summary.tokens.output, 17);
assert_eq!(statistics.summary.tokens.total, 321);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.uncached_input)
.sum::<u64>(),
101
);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.total)
.sum::<u64>(),
321
);
assert!(
statistics
.models
.iter()
.all(|model| model.has_estimated_allocation)
);
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-sol" && model.service_tier == CodexUsageStatisticsServiceTier::Fast
}));
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-terra"
&& model.service_tier == CodexUsageStatisticsServiceTier::Standard
}));
}
#[tokio::test]
async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
let server = MockServer::start().await;
let (current_start_at, current_end_at) = cycle_anchor();
let selected_date = current_start_at
.date_naive()
.checked_sub_days(Days::new(14))
.expect("selected date");
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"models": [{
"model": "gpt-5.6-terra",
"speed": "standard",
"credits": 10
}]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_offset_statistics", &server, current_end_at).await;
let account = store
.account("acct_offset_statistics")
.expect("offset account");
let statistics = service
.usage_statistics(account.id(), -2, 0)
.await
.expect("historical statistics");
assert_eq!(statistics.cycle.offset, -2);
assert!(!statistics.cycle.is_current);
assert!(statistics.cycle.can_go_next);
assert!(statistics.cycle.used_percent.is_none());
assert_eq!(statistics.daily[0].date, selected_date);
let requests = server.received_requests().await.expect("recorded requests");
let report = requests
.iter()
.find(|request| request.url.path() == PERSONAL_TOTALS_REPORT_PATH)
.expect("personal totals report request");
let query = report
.url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<BTreeMap<_, _>>();
let expected_start = current_start_at
.date_naive()
.checked_sub_days(Days::new(15))
.expect("query start");
let expected_end = current_start_at
.date_naive()
.checked_sub_days(Days::new(6))
.expect("query end");
assert_eq!(query.get("start_date"), Some(&expected_start.to_string()));
assert_eq!(query.get("end_date"), Some(&expected_end.to_string()));
assert_eq!(query.get("group_by").map(String::as_str), Some("day"));
let invalid_account = ProviderAccountId::new("acct_invalid_offset").expect("account ID");
assert!(matches!(
service.usage_statistics(&invalid_account, -9, 0).await,
Err(CodexUsageStatisticsError::InvalidRequest)
));
}
@@ -344,6 +344,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 +354,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 +459,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 +1340,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 +1427,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 +1479,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());
}
@@ -1637,9 +1780,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 +1833,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 +2026,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 +2071,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 +2698,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 +2737,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");
@@ -678,6 +678,55 @@ impl ProviderSessionAffinityPort for MemorySessionAffinity {
})
}
fn claim_or_load<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let mut bindings = self.bindings.lock().expect("session affinity lock");
Ok(bindings
.entry((
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
))
.or_insert_with(|| candidate_account_id.clone())
.clone())
})
}
fn compare_and_bind<'a>(
&'a self,
provider_kind: &'a ProviderKind,
key: &'a ProviderSessionAffinityKey,
expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move {
let binding_key = (
provider_kind.as_str().to_owned(),
key.expose_to_store().to_owned(),
);
let mut bindings = self.bindings.lock().expect("session affinity lock");
if bindings
.get(&binding_key)
.is_none_or(|current| current == expected_account_id)
{
bindings.insert(binding_key, replacement_account_id.clone());
return Ok(replacement_account_id.clone());
}
bindings.get(&binding_key).cloned().ok_or_else(|| {
ProviderStoreError::new(
gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable,
"resolve in-memory provider session affinity",
)
})
})
}
fn clear<'a>(
&'a self,
provider_kind: &'a ProviderKind,
@@ -265,7 +265,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let response = backend
.create_response(
@@ -480,7 +480,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
format!("http://{address}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
client
.create_response(
@@ -932,7 +932,7 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
});
let profile = test_wire_profile();
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
@@ -300,7 +300,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -354,7 +354,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
.unwrap();
assert_eq!(read, 0);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -733,7 +733,7 @@ async fn concurrent_multi_conversation_continuations_should_select_each_exact_so
tokio::join!(first_continuation, second_continuation);
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1190,7 +1190,7 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1325,7 +1325,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
write_completed_sse_response(&mut second_http).await;
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -495,8 +495,6 @@ fn websocket_pool_config_for_tests(
CodexWebSocketPoolConfig {
enabled: true,
max_age: Duration::from_mins(1),
max_per_account: 8,
max_total: 64,
max_connecting: 16,
maintenance_interval,
ping_interval,
@@ -70,7 +70,7 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
}
#[test]
fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("secret prompt")),
@@ -78,7 +78,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
"tools".to_owned(),
json!([{"type": "function", "name": "lookup", "strict": true}]),
),
("max_output_tokens".to_owned(), json!(512)),
("store".to_owned(), json!(false)),
(
"reasoning".to_owned(),
@@ -89,7 +88,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
assert!(encoded.body().get("stream").is_none());
assert_eq!(encoded.body().get("max_output_tokens"), Some(&json!(512)));
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/tools/0/strict"), Some(&json!(true)));
@@ -97,6 +95,28 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
assert!(!encoded.force_http_sse);
}
#[test]
fn encoder_should_remove_unsupported_fields_from_upstream_body() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("hello")),
("max_output_tokens".to_owned(), json!(512)),
("max_tokens".to_owned(), json!(256)),
("temperature".to_owned(), json!(0.2)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
json!({
"model": "gpt-test",
"input": "hello",
"max_tokens": 256,
})
);
}
#[test]
fn encoder_should_preserve_client_store_intent() {
let request = request(Map::from_iter([
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
websocket.send(Message::Text(payload.into())).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -932,7 +932,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
.unwrap();
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1914,7 +1914,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_string()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -35,7 +35,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
}
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -97,7 +97,7 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
format!("http://{addr}"),
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1))));
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -401,7 +401,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
}
#[tokio::test]
async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
async fn codex_backend_client_stream_should_use_http_when_connecting_limit_is_exhausted() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
@@ -414,7 +414,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
write_completed_sse_response(&mut first_stream).await;
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 0,
max_connecting: 0,
initial_event_timeout: Some(Duration::from_millis(30)),
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -432,7 +432,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
request_context("req_structural_bypass", Some("chatgpt-account")),
)
.await
.expect("pool cap should select HTTP before sending payload");
.expect("exhausted connection-opening limit should select HTTP before sending payload");
assert_eq!(response.transport, CodexBackendTransport::HttpSse);
assert_eq!(
response.transport_metrics.decision,
@@ -472,7 +472,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
websocket.close(None).await.unwrap();
}
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -841,140 +841,32 @@ async fn websocket_pool_shutdown_should_cancel_and_join_active_stream() {
}
#[tokio::test]
async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
async fn websocket_pool_should_keep_sixty_fifth_conversation_on_same_account_on_websocket() {
const REQUEST_COUNT: usize = 65;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted_connections = Arc::new(AtomicUsize::new(0));
let accepted_connections_for_server = Arc::clone(&accepted_connections);
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
let _first_message = first_websocket.next().await.unwrap().unwrap();
first_websocket
.send(Message::Text(
completed_websocket_response("resp_cap_first", 2, 1).into(),
))
.await
.unwrap();
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut websockets = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let (stream, _) = listener.accept().await.unwrap();
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
write_completed_sse_response(&mut stream).await;
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
completed_websocket_response(&format!("resp_same_account_{index}"), 2, 1)
.into(),
))
.await
.unwrap();
websockets.push(websocket);
}
drop(first_websocket);
});
let pool = Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(pool);
let first_request = pooled_websocket_request("conversation-cap-one");
let second_request = pooled_websocket_request("conversation-cap-two");
let first = backend
.create_response(
&first_request,
request_context("req_cap_first", Some("chatgpt-account")),
)
.await
.expect("first capped websocket response should succeed");
let second = backend
.create_response(
&second_request,
request_context("req_cap_second", Some("chatgpt-account")),
)
.await
.expect("new key over account cap should use HTTP");
let third = backend
.create_response(
&second_request,
request_context("req_cap_third", Some("chatgpt-account")),
)
.await
.expect("capped key should keep using HTTP instead of opening one-shot sockets");
server.await.unwrap();
assert!(first.body.contains("resp_cap_first"));
assert!(second.body.contains("response.completed"));
assert!(third.body.contains("response.completed"));
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(
second.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(
third.transport_metrics.decision,
Some(CodexTransportDecision::Http2PoolUnavailable)
);
assert_eq!(accepted_connections.load(Ordering::SeqCst), 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(8, 2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_total_slots_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(3, 8).await;
assert_eq!((websockets, http), (3, 5));
}
#[tokio::test]
async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (first_stream, _) = listener.accept().await.unwrap();
let mut first = accept_codex_test_websocket(first_stream).await;
let _ = first.next().await.unwrap().unwrap();
first
.send(Message::Text(
completed_websocket_response("resp_global_lru_first", 2, 1).into(),
))
.await
.unwrap();
let (second_stream, _) = listener.accept().await.unwrap();
let mut second = accept_codex_test_websocket(second_stream).await;
let _ = second.next().await.unwrap().unwrap();
second
.send(Message::Text(
completed_websocket_response("resp_global_lru_second", 2, 1).into(),
))
.await
.unwrap();
let close = timeout(Duration::from_secs(2), first.next())
.await
.expect("global LRU eviction should close the oldest idle socket")
.expect("oldest idle socket should emit a close frame")
.expect("oldest idle socket close should be valid");
std::assert_matches!(close, Message::Close(_));
let (third_stream, _) = listener.accept().await.unwrap();
let mut third = accept_codex_test_websocket(third_stream).await;
let _ = third.next().await.unwrap().unwrap();
third
.send(Message::Text(
completed_websocket_response("resp_global_lru_third", 2, 1).into(),
))
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: 8,
max_total: 2,
max_connecting: 2,
max_connecting: 1,
..websocket_pool_config_for_tests(None, None, None)
}));
let backend = CodexBackendClient::new(
@@ -983,36 +875,52 @@ async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let mut responses = Vec::new();
for index in 0..3 {
let request = pooled_websocket_request(&format!("conversation-global-lru-{index}"));
let account_id = format!("account-global-lru-{index}");
let request_id = format!("req_global_lru_{index}");
let mut responses = Vec::with_capacity(REQUEST_COUNT);
for index in 0..REQUEST_COUNT {
let request = pooled_websocket_request(&format!("conversation-same-account-{index}"));
responses.push(
backend
.create_response(&request, request_context(&request_id, Some(&account_id)))
.create_response(
&request,
request_context(
&format!("req_same_account_{index}"),
Some("chatgpt-account"),
),
)
.await
.expect("global LRU request should keep using websocket"),
.expect("same-account conversation should use websocket"),
);
}
server.await.unwrap();
assert!(responses[0].body.contains("resp_global_lru_first"));
assert!(responses[1].body.contains("resp_global_lru_second"));
assert!(responses[2].body.contains("resp_global_lru_third"));
assert!(
responses
.iter()
.all(|response| response.transport == CodexBackendTransport::WebSocket)
assert_eq!(
(
responses
.iter()
.filter(|response| response.transport == CodexBackendTransport::WebSocket)
.count(),
accepted_connections.load(Ordering::SeqCst),
),
(REQUEST_COUNT, REQUEST_COUNT)
);
pool.shutdown().await;
}
async fn concurrent_pool_transport_counts(
max_total: usize,
max_connecting: usize,
) -> (usize, usize) {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
let (websockets, http) = concurrent_pool_transport_counts(2).await;
assert_eq!((websockets, http), (2, 6));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn websocket_pool_should_allow_all_openings_when_connect_permits_are_available() {
let (websockets, http) = concurrent_pool_transport_counts(8).await;
assert_eq!((websockets, http), (8, 0));
}
async fn concurrent_pool_transport_counts(max_connecting: usize) -> (usize, usize) {
const REQUEST_COUNT: usize = 8;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1051,7 +959,7 @@ async fn concurrent_pool_transport_counts(
websocket
.send(Message::Text(
completed_websocket_response(
&format!("resp_global_cap_{max_total}_{max_connecting}_{index}"),
&format!("resp_connecting_cap_{max_connecting}_{index}"),
2,
1,
)
@@ -1063,8 +971,6 @@ async fn concurrent_pool_transport_counts(
(websocket_count, http_count)
});
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
max_per_account: REQUEST_COUNT,
max_total,
max_connecting,
..websocket_pool_config_for_tests(None, None, None)
}));
@@ -1096,7 +1002,7 @@ async fn concurrent_pool_transport_counts(
let response = request
.await
.unwrap()
.expect("globally capped request should complete");
.expect("concurrent request should complete");
if response.transport == CodexBackendTransport::WebSocket {
websocket_responses += 1;
}
@@ -1910,7 +1816,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -1987,7 +1893,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
.unwrap();
second_websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
@@ -713,6 +713,27 @@ impl ProviderSessionAffinityPort for TestSessionAffinity {
Box::pin(async { Ok(()) })
}
fn claim_or_load<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
_key: &'a ProviderSessionAffinityKey,
candidate_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move { Ok(candidate_account_id.clone()) })
}
fn compare_and_bind<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
_key: &'a ProviderSessionAffinityKey,
_expected_account_id: &'a ProviderAccountId,
replacement_account_id: &'a ProviderAccountId,
_ttl: Duration,
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
Box::pin(async move { Ok(replacement_account_id.clone()) })
}
fn clear<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
+1 -2
View File
@@ -1,2 +1 @@
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
+1 -1
View File
@@ -315,7 +315,7 @@ create table model_requests (
operation text not null,
endpoint text not null,
client_transport text not null,
requested_model_id text not null,
requested_model_id text,
provider_kind text,
upstream_model_id text,
service_tier text,
@@ -1,2 +0,0 @@
alter table model_requests
alter column requested_model_id drop not null;
+1 -2
View File
@@ -4,8 +4,7 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
## 冻结规则
+1 -1
View File
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa AS backend-builder
FROM rust:1.98-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
+365
View File
@@ -0,0 +1,365 @@
<!-- prettier-ignore -->
<div align="center">
<img src="../frontend/public/favicon.svg" alt="Codex Proxy RS" width="72" height="72" />
# 管理端主题系统
**基于 Seed、Map、Alias 与 Component Token 的运行时主题架构**
[设计原则](#设计原则) · [Token 模型](#token-模型) · [运行时架构](#运行时架构) · [主题编辑器](#主题编辑器) · [扩展指南](#扩展指南)
</div>
主题系统为 Codex Proxy RS 管理端提供浅色、深色、预置配色与自定义配色能力。实现借鉴 Ant Design 的
Token 分层与十阶色板算法,但不引入 `antd`、React、`ConfigProvider` 或 CSS-in-JS;Vue 组件继续通过
CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
> [!IMPORTANT]
> 主题的目标是调整颜色、密度、圆角与层级,不改变现有页面结构和产品语义。默认主题必须保持既有视觉基线,
> 成功、警告、错误和业务数据色不会因为品牌色变化而失去原有含义。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
- **运行时可定制**:用户输入在浏览器中实时派生,因此使用 CSS Variables,不使用构建时 SCSS 变量。
- **单一事实源**:Store 只保存最小配置,所有 Map、Alias 与未覆盖的 Component Token 均由纯函数生成。
- **可访问性优先**:文字、焦点和实心控件在 Alias 层执行对比度保护,并尊重 `prefers-reduced-motion`。
## 架构概览
```mermaid
flowchart LR
A[Pinia 持久化配置] --> B[resolveTheme]
B --> C[Seed Token]
C --> D[Map Token]
D --> E[Alias Token]
E --> F[Component Token]
F --> G[#cp-runtime-theme]
G --> H[Vue / Tailwind / ECharts]
B --> I[Theme Editor 草稿]
I --> J[Shadow DOM 预览]
J --> K[首页画板 / 组件概览]
```
核心职责保持分离:
| 层 | 文件 | 职责 |
| --- | --- | --- |
| 领域模型 | [`theme/index.ts`](../frontend/src/theme/index.ts) | 类型、规范化、纯派生算法与最终 Token 输出 |
| 设计基线 | [`theme/constants.ts`](../frontend/src/theme/constants.ts) | 默认值、预置主题、中性色面、阴影与可编辑白名单 |
| 浏览器适配 | [`theme/browser.ts`](../frontend/src/theme/browser.ts) | 将解析结果提交为根作用域运行时样式表 |
| 状态 | [`stores/modules/theme.ts`](../frontend/src/stores/modules/theme.ts) | 持久化配置、系统明暗偏好、切换动作与动画 |
| 编辑器状态 | [`useThemeEditor.ts`](../frontend/src/views/theme/composables/useThemeEditor.ts) | 草稿、修改计数、恢复与保存 |
| 样式桥接 | [`styles/index.css`](../frontend/src/styles/index.css) | 将 CSS Token 暴露为 Tailwind CSS 4 utilities |
| 稳定语义 | [`styles/tokens.css`](../frontend/src/styles/tokens.css) | 不参与主题派生的业务常量 |
`theme/index.ts` 不访问 DOM,`theme/browser.ts` 不包含派生规则,Theme Store 不复制算法。类型与算法集中在
`theme/index.ts`,不额外拆分 `types.ts`。
## Token 模型
主题采用四层模型:
```text
Seed Token → Map Token → Alias Token → Component Token
```
### Seed Token
Seed 是用户配置与持久化的最小事实:
```ts
type ThemeMode = 'system' | 'light' | 'dark'
type ThemeColorId = 'relay-blue' | 'deep-teal' | 'signal-violet' | 'graphite' | 'custom'
interface ThemeCustomization {
seed?: Partial<ThemeSeedOverrides>
component?: Partial<ThemeComponentOverrides>
tokenOverrides?: Partial<ThemeTokens>
}
```
| 类别 | Seed |
| --- | --- |
| 品牌 | `colorPrimary`,由预置 ID 或自定义 HEX 提供 |
| 功能色 | `colorSuccess`、`colorWarning`、`colorError`、`colorInfo`、`colorLink` |
| 中性色 | `colorTextBase`、`colorBgBase` |
| 尺寸 | `fontSize`、`sizeUnit`、`sizeStep`、`controlHeight` |
| 风格 | `borderRadius`、`shadowStrength` |
| 组件尺寸 | `tableRowHeight`、`cardBorderRadius` |
持久化键为 `codex-proxy-rs-theme`。Store 不保存完整色板或 CSS Variables,损坏的模式、颜色与自定义值会在
初始化时规范化并回退到默认配置。
### Map Token
品牌色和功能色使用 `@ant-design/colors` 从一个 Seed 生成十阶色板。主色派生规则为:
| 角色 | 规则 |
| --- | --- |
| `colorPrimary` | 始终使用 Seed 原色 |
| Hover | 浅色取 P5,深色取暗色算法 P8 |
| Active | 浅色取 P7,深色取暗色算法 P6 |
| 实心 Hover | Seed 向白混合 6% |
| 实心 Active | Seed 向黑混合 8% |
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
功能色的基础 Map 对齐 Ant Design:`info`、`success`、`warning`、`error` 均固定取十阶色板 P6。浅色 P6
就是用户选择的 Seed,因此 `--cp-color-info / success / warning / error` 不再经过额外对比度改写;深色 P6
继续使用暗色色板对当前 Surface 做主题适配。功能色的 Hover、Active、弱背景、文字和描边仍按各自角色派生。
背景与文本 Seed 进入独立的 Surface Map,生成:
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
- `colorFillSecondary / Tertiary / Quaternary`
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
默认中继蓝使用固定的石墨中性色基线。其他预置通过各自的浅色、深色 `colorBgBase` 与 `colorTextBase`
建立完整气质。自定义主色只向中性表面注入少量色温:浅色混入 2.8%,深色从默认暗面与暗色色板背景混合;
用户显式设置背景或文字 Seed 后,自动配套值立即让位。
### Alias Token
Alias 按视觉角色命名,使用 `--cp-` 命名空间,并优先对齐 Ant Design 的公开 Token 词汇:
| 角色 | CSS Token | 主要消费者 |
| --- | --- | --- |
| 品牌交互 | `--cp-color-primary*` | 主操作、选中态、焦点与强调 |
| 链接 | `--cp-color-link*` | 普通文本链接 |
| 表面 | `--cp-color-bg-*` | 页面、容器、浮层和遮罩 |
| 填充 | `--cp-color-fill-*` | 轨道、弱背景和静态层级 |
| 文字 | `--cp-color-text*` | 标题、正文和辅助信息 |
| 选择 | `--cp-control-item-bg-active*` | Segmented、Select 和选择控件 |
| 焦点 | `--cp-control-outline` | 键盘焦点和输入反馈 |
| 功能色 | `--cp-color-info / success / warning / error-*` | 系统反馈与状态 |
Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
### Component Token
当组件需要独立演进时,使用 `component[-part][-variant][-state]-property` 命名,不创造含义重复的全局别名。
| 组件 | 代表 Token |
| --- | --- |
| Button | `--cp-button-primary-color / bg / hover-bg / active-bg` |
| Input | `--cp-input-bg / hover-bg / active-bg / error-active-bg` 与对应 Shadow |
| Menu | `--cp-menu-item-selected-bg` |
| Table | `--cp-table-row-bg / stripe-bg / hover-bg / selected-bg / height` |
| Card | `--cp-card-bg / border-radius / shadow` |
| Layout | `--cp-layout-sider-bg / shadow` |
| Scrollbar | `--cp-scrollbar-thumb-bg / hover-bg` |
| BrandMark | `--cp-brand-mark-bg` |
应用内品牌图标使用 [`AppBrandMark.vue`](../frontend/src/components/AppBrandMark.vue):浅色模式取
`colorBgSpotlight`,深色模式取 `colorBgElevated`,保持中性暗面与白色图形;浏览器 favicon 继续使用固定黑白
图标,不随主题改变。
### 稳定业务语义
下列颜色不属于通用主题皮肤,保留在 `styles/tokens.css`:
- 账号“正常”状态 `colorStatusNormal*`
- 推理标识色 `colorReasoning`
- 账号套餐标识色 `accountPlan*`
它们可以分别拥有浅色和深色值,但不会被品牌 Seed 重写。图表数据系列同样按数据语义管理,不把所有曲线染成
主色。
## 预置主题
| ID | 名称 | 主色 | 表面方向 |
| --- | --- | --- | --- |
| `relay-blue` | 中继蓝 | `#5983F4` | 默认石墨控制面,清晰可信 |
| `deep-teal` | 深海青 | `#0E7C72` | 氧化铜与深水玻璃,冷静低饱和 |
| `signal-violet` | 古风色 | `#A0583D` | 赭石、宣纸与松烟墨,温厚克制 |
| `graphite` | 石墨 | `#525B66` | 工作室灰纸与铅笔粉末,近单色层级 |
预置主题只改变颜色画像,不改变字号、密度、圆角或布局。所有预置与自定义主题走同一条派生链路,不存在页面级
特判。
## 运行时架构
### 启动顺序
[`main.ts`](../frontend/src/main.ts) 在 Vue 挂载前初始化主题:
```text
createApp
→ app.use(pinia)
→ persistedstate 同步水合 Theme Store
→ themeStore.initializeTheme()
→ app.use(router / auth)
→ app.mount('#app')
```
这样首个 Vue 组件渲染时已经具备正确主题,不需要在 `index.html` 增加 bootstrap 脚本,也不维护第二套本地存储
解析逻辑。
### CSS Variables 提交
全局主题由 `<head>` 中唯一的运行时样式节点承载:
```html
<style id="cp-runtime-theme">
:root[data-theme='dark'][data-theme-color='relay-blue'] {
color-scheme: dark;
--cp-color-bg-layout: #0B111C;
/* 其余 Map、Alias 与 Component Token */
}
</style>
```
根元素只保留 `data-theme` 与 `data-theme-color` 状态,不写整套内联 Token。运行时样式表比逐项调用
`style.setProperty()` 更集中、可检查,也避免在 DOM 属性中形成超长变量串。全局弹窗通过 Teleport 挂到 `body`
后仍继承根变量。
> [!NOTE]
> Theme Editor 预览是例外:草稿 Token 以内联变量写在影子环境的局部根节点上,仅影响预览,不污染已保存主题。
### 切换与图表
- 用户主动切换时,以点击位置为圆心运行 View Transition;不支持时使用 180ms 颜色过渡。
- 初始化、系统主题变化或减少动态效果时直接提交,不播放扩散动画。
- 每次有效提交只增加一次 `themeRevision`,相同签名不会重复刷新。
- `useThemeColor()` 在正式页面读取根 CSS Variables,在影子预览中优先读取注入的局部 Token。
- 图表 Option 在主题 revision 或预览 Token 改变后重算,`BaseChart` 通过 `setOption` 更新现有 Canvas。
## 主题编辑器
主题编辑器位于一级路由 `/theme`,采用一屏工作台:左侧编辑,右侧预览,顶部保留全局操作。桌面端高度固定为
`100dvh - 3rem`,切换编辑层级或预览类型时不改变外框高度。窄屏头部操作允许自然换行;手机端隐藏实时预览,
只保留 Token 编辑,避免在有限宽度内渲染不可操作的缩放画板。
### 编辑能力
| 区域 | 能力 |
| --- | --- |
| 全局 / 颜色 | 模式、预置、自定义主色、功能色、链接、基础文本与背景、派生变量查看 |
| 全局 / 尺寸 | 字号、基础间距、尺寸步长、控件高度 |
| 全局 / 风格 | 通用圆角、阴影强度 |
| 组件 | Action、Form、Selection、Surface、Data Display、Feedback、Navigation、Layout |
| 工作流 | 搜索、单项恢复、撤销草稿、恢复默认、保存并应用 |
Component Token 只开放白名单字段。未覆盖项始终继续使用全局 Seed 与 Alias 算法,避免主题配置逐渐退化成一份
无法维护的完整 CSS 快照。
### 草稿与保存
编辑器维护 `saved` 与 `draft` 两份状态:
1. 输入只更新草稿和局部预览。
2. 修改计数按模式、主题色、Seed、组件值和 Token override 分项统计。
3. “撤销草稿”恢复到最近一次已保存配置。
4. “保存并应用”先规范化草稿,再原子更新 Theme Store。
### 隔离预览
[`ThemePreviewScope.vue`](../frontend/src/views/theme/components/ThemePreviewScope.vue) 创建开放 Shadow Root,复制应用
样式,并把预览内容 Teleport 到影子根中。预览可独立切换浅色和深色,不受外层主题影响。
- **首页画板**复用真实 `DashboardContent` 和固定 fixture,不请求接口,也不启动自动刷新。
- **组件概览**展示基础组件、表格、空状态、骨架、浮层和菜单等关键状态。
- 画板固定为 `1600 × 1808`,使用 CSS `zoom` 重排,不使用 `transform: scale()` 长期缩放文字。
- 空白区域可拖拽,滚轮以指针为锚点缩放,并提供缩小、100%、放大和适应画板操作。
- 编辑面板和组件概览统一使用 `BaseScrollbar`,滚动条空闲时自动隐藏。
## 样式与命名约定
### CSS Token
- 全局 Token:`--cp-color-bg-container`、`--cp-font-size`。
- Component Token:`--cp-table-row-hover-bg`、`--cp-input-active-shadow`。
- 业务 Token:命名中明确业务域,例如 `--cp-account-plan-pro-bg`。
- 禁止继续引入 `accent`、`soft`、`current`、`subtle` 等与现有角色重叠的平行词汇。
### Vue 与 Tailwind CSS 4
组件优先使用 `bg-cp-*`、`text-cp-*`、`shadow-cp-*`、`rounded-cp-*` 等由 `@theme inline` 暴露的 utility:
```vue
<section class="rounded-cp-card bg-cp-bg-container text-cp-text shadow-cp-card">
...
</section>
```
仅当值需要在 CSS 函数、SVG 或局部派生中参与计算时,直接读取 `var(--cp-*)`。不在页面组件中重新实现色阶、
对比度或明暗算法。
### 视觉状态
- 静态填充、Hover、Active 与 Selected 必须使用不同角色,不能复用一个变量制造所有层级。
- Input 的 Hover 与 Active 只改变内部填充;外圈反馈保持同色同宽,错误状态使用独立 Error Token。
- 表格斑马纹使用 `table-row-stripe-bg`,不借用 `row-hover-bg`。
- Card 和选项默认不增加装饰性边框;键盘焦点必须保留可见反馈。
- 阴影保持中性,`shadowStrength` 只调节层级强弱,不给阴影染品牌色。
## 扩展指南
### 增加预置主题
1. 在 `ThemeColorPresetId` 增加稳定 ID。
2. 在 `THEME_COLOR_PRESETS` 声明名称、描述、主色 Seed 与可选浅暗表面画像。
3. 不新增页面特判,确认预置可通过同一 `resolveTheme()` 派生。
4. 在浅色、深色和系统模式下检查文本、浮层、Input、表格与图表。
### 增加全局 Seed 或 Alias
1. 在 `theme/index.ts` 的领域类型中声明字段。
2. 在规范化函数中定义合法输入边界。
3. 在 Map 或 Alias 层集中派生,并加入 `ThemeTokens` 完整输出。
4. 如需 Tailwind utility,在 `styles/index.css` 的 `@theme inline` 中映射。
5. 只在确实需要用户控制时加入 Theme Editor;派生细节默认只读。
### 增加 Component Token
1. 先确认全局 Alias 无法准确表达组件职责。
2. 使用 `component[-part][-variant][-state]-property` 命名。
3. 在 `ThemeComponentMap` 和 `toThemeTokens()` 中提供默认派生值。
4. 需要开放编辑时,加入对应组件目录和可编辑白名单。
5. 基础组件消费 Token,页面不得再写第二套局部常量。
> [!WARNING]
> 不要把任意 HEX、阴影或尺寸加入 `styles/tokens.css` 作为“临时修复”。可换肤值必须进入派生链;只有稳定的
> 业务语义与首帧安全 fallback 可以留在静态样式中。
## 验证
前端改动至少执行:
```bash
cd frontend
pnpm run lint
pnpm run typecheck
pnpm run build
git diff --check
```
视觉验收覆盖:
- 四个预置、自定义 HEX、浅色、深色和跟随系统模式。
- 页面、容器、浮层、输入框、表格、分页、主按钮、选中态、焦点和品牌图标。
- 草稿隔离、保存刷新恢复与 Teleport 弹窗。
- 首页画板缩放清晰度、组件概览滚动、ECharts 网格线和 Skeleton 动效。
- 键盘操作、颜色之外的选中反馈和 `prefers-reduced-motion`。
## 上游参考
- [Ant Design 色彩规范](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/colors.zh-CN.md)
- [Ant Design 暗黑模式](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/spec/dark.zh-CN.md)
- [Ant Design 主题定制](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/docs/react/customize-theme.zh-CN.md)
- [Seed 到 Map](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/themes/shared/genColorMapToken.ts)
- [Map 到 Alias](https://github.com/ant-design/ant-design/blob/977d8e037a4841bb847b8a40ffd1f79b23264826/components/theme/util/alias.ts)
- [`@ant-design/colors` 生成器](https://github.com/ant-design/ant-design-colors/blob/89b4a5b7e989b792610087abe855bf4a2fb1d322/src/generate.ts)
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -14,18 +14,19 @@
"format:check": "pnpm run lint"
},
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.31.0",
"@lucide/vue": "^1.33.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.13",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"echarts": "^6.1.0",
"es-toolkit": "^1.50.0",
"es-toolkit": "^1.51.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"marked": "^18.0.10",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
@@ -37,12 +38,12 @@
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.10"
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
+274 -248
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,9 +2,6 @@
import { RouterView } from 'vue-router'
import { BaseToast } from '@/components/base/BaseToast'
import { useUiStore } from '@/stores/modules/ui'
useUiStore().initializeTheme()
</script>
<template>
+73
View File
@@ -170,6 +170,66 @@ export interface AccountQuotaResponse {
account: Account
}
export interface AccountUsageStatisticsTokens {
uncachedInput: number
cachedInput: number
output: number
total: number
}
export interface AccountUsageStatisticsCost {
currency: string
amount: string
}
export interface AccountUsageStatisticsModel {
key: string
model: string
serviceTier: 'standard' | 'fast'
creditShare: number | null
quotaShare: number | null
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
hasUnknownPricing: boolean
hasEstimatedAllocation: boolean
hasRateFallback: boolean
hasMissingTokenData: boolean
}
export interface AccountUsageStatisticsDay {
date: string
creditShare: number | null
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
hasUnknownPricing: boolean
hasMissingTokenData: boolean
isBoundaryDay: boolean
}
export interface AccountUsageStatisticsResponse {
cycle: {
offset: number
startAt: string
endAt: string
windowSeconds: number
usedPercent: number | null
isCurrent: boolean
canGoPrevious: boolean
canGoNext: boolean
}
summary: {
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
projectedTokens: number | null
projectedCost: AccountUsageStatisticsCost | null
dayCount: number
hasUnknownPricing: boolean
hasMissingTokenData: boolean
}
models: AccountUsageStatisticsModel[]
daily: AccountUsageStatisticsDay[]
}
export interface AccountResetCredit {
id: string
status: string | null
@@ -238,6 +298,11 @@ interface AccountIdParam {
accountId: string
}
interface AccountUsageStatisticsParam extends AccountIdParam {
cycleOffset?: number
utcOffsetMinutes?: number
}
interface AccountResetCreditConsumeParam extends AccountIdParam {
creditId?: string
redeemRequestId: string
@@ -326,6 +391,14 @@ export function getAccountQuota(data: AccountIdParam) {
})
}
export function getAccountUsageStatistics(data: AccountUsageStatisticsParam) {
return request<AccountUsageStatisticsResponse>({
url: '/api/admin/accounts/usage-statistics',
method: 'GET',
params: data,
})
}
export function refreshAccountQuota(data: AccountIdParam) {
return request<AccountQuotaResponse>({
url: '/api/admin/accounts/quota/refresh',
@@ -32,7 +32,7 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
class="shrink-0 text-cp-xs font-emphasis text-cp-text-quaternary"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -27,5 +27,5 @@ const fullNames = computed(() => props.groups
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-cp-muted-text">—</span>
<span v-else class="text-xs text-cp-text-quaternary">—</span>
</template>
@@ -90,7 +90,7 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-cp-tertiary" />
<Search class="size-4 text-cp-text-tertiary" />
</template>
</BaseInput>
@@ -102,10 +102,10 @@ function providerSummary(group: AccountGroup) {
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="flex w-full items-start gap-3 rounded-cp border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
@@ -115,7 +115,7 @@ function providerSummary(group: AccountGroup) {
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
<strong class="min-w-0 truncate text-cp text-cp-text">
{{ group.name }}
</strong>
<span
@@ -125,7 +125,7 @@ function providerSummary(group: AccountGroup) {
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
<span class="mt-1 block text-cp-xs font-emphasis text-cp-text-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
@@ -148,15 +148,15 @@ function providerSummary(group: AccountGroup) {
<div
v-if="selectedIds.length === 0"
class="rounded-cp-control px-3.5 py-3"
class="rounded-cp px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
: 'bg-cp-fill-quaternary text-cp-text-secondary'"
>
<p class="m-0 text-[12px] font-bold">
<p class="m-0 text-cp-sm font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useId } from 'vue'
const id = useId().replaceAll(':', '')
const noiseFilterId = `${id}-brand-noise`
const wornFilterId = `${id}-brand-worn`
const mistGradientId = `${id}-brand-mist`
</script>
<template>
<svg
class="app-brand-mark"
xmlns="http://www.w3.org/2000/svg"
viewBox="48 48 416 416"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="noiseFilterId" x="0" y="0" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="1.7"
numOctaves="2"
seed="31"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 2.56 2.56 2.56 0 -4.76"
result="speckle"
/>
<feComposite operator="in" in="SourceGraphic" in2="speckle" />
</filter>
<filter :id="wornFilterId" x="-12%" y="-12%" width="124%" height="124%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.12"
numOctaves="2"
seed="41"
result="noise"
/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.2" />
</filter>
<radialGradient :id="mistGradientId" cx="0.5" cy="0.45" r="0.75">
<stop offset="0" stop-color="currentColor" stop-opacity="0.1" />
<stop offset="0.55" stop-color="currentColor" stop-opacity="0.045" />
<stop offset="1" stop-color="currentColor" stop-opacity="0.012" />
</radialGradient>
</defs>
<rect class="app-brand-mark__surface" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
</g>
<path d="m407 105-119 153-183 149 124-157Z" fill="currentColor" />
<g
:filter="`url(#${wornFilterId})`"
fill="none"
stroke="currentColor"
stroke-linecap="square"
>
<path d="M92 170 136 148" stroke-width="1" opacity="0.38" />
<path d="M98 178 142 156" stroke-width="0.5" opacity="0.3" />
<path d="M308 398 346 378" stroke-width="0.9" opacity="0.36" />
<path d="M314 406 352 386" stroke-width="0.5" opacity="0.3" />
<path d="M364 132 398 118" stroke-width="0.9" opacity="0.38" />
<path d="M402 118 410 126" stroke-width="0.5" opacity="0.28" />
<path d="M108 350 142 334" stroke-width="1" opacity="0.4" />
<path d="M158 238 190 226" stroke-width="0.8" opacity="0.34" />
<path d="M296 292 320 280" stroke-width="0.7" opacity="0.3" />
<path d="M126 118 136 110" stroke-width="0.6" opacity="0.26" />
<path d="M400 96 410 88" stroke-width="0.6" opacity="0.24" />
</g>
<g :filter="`url(#${wornFilterId})`" fill="currentColor">
<path
d="M98 176 104 173 108 175 114 170 118 173 124 167 130 170 136 164 142 167 148 161 154 164 160 158Z"
opacity="0.34"
/>
<path
d="M314 395 320 392 324 394 330 389 336 392 342 386 348 389 354 383Z"
opacity="0.3"
/>
</g>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
fill="currentColor"
:filter="`url(#${noiseFilterId})`"
opacity="0.08"
/>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
:fill="`url(#${mistGradientId})`"
/>
</svg>
</template>
<style scoped>
.app-brand-mark {
color: var(--cp-color-text-light-solid);
}
.app-brand-mark__surface {
fill: var(--cp-brand-mark-bg);
}
</style>
@@ -39,18 +39,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"
>
@@ -64,7 +64,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span class="sr-only">
+9 -9
View File
@@ -30,19 +30,19 @@ defineSlots<{
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
'bg-cp-fill-tertiary text-cp-text shadow-cp-tertiary hover:bg-cp-bg-text-active active:bg-cp-bg-text-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-cp-error-bg text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
md: 'h-cp-control gap-2 px-4 text-cp',
lg: 'h-cp-control-lg gap-2.5 px-5 text-cp-lg',
}
const spinnerSizes: Record<ButtonSize, number> = {
@@ -52,9 +52,9 @@ const spinnerSizes: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
+3 -3
View File
@@ -45,7 +45,7 @@ const contentClasses = computed(() => [
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
class="overflow-hidden rounded-cp-card bg-(--cp-card-bg) shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
@@ -55,7 +55,7 @@ const contentClasses = computed(() => [
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance"
>
<slot name="title">
{{ props.title }}
@@ -63,7 +63,7 @@ const contentClasses = computed(() => [
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
@@ -26,12 +26,12 @@ watchEffect(() => {
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-bg-container text-transparent shadow-[inset_0_0_0_1px_var(--cp-color-border)]',
])
</script>
@@ -39,7 +39,7 @@ const boxClasses = computed(() => [
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-cp leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
@@ -52,7 +52,7 @@ const boxClasses = computed(() => [
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<Minus
@@ -64,6 +64,6 @@ const boxClasses = computed(() => [
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -1,9 +1,9 @@
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor, normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
export interface HsvaColor {
h: number
s: number
v: number
@@ -17,8 +17,23 @@ interface RgbaColor {
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
export function normalizePickerHexColor(value: string, allowAlpha: boolean) {
if (allowAlpha) {
const rgba = normalizeRgbaHexColor(value)
if (rgba)
return rgba
const rgb = normalizeHexColor(value)
return rgb ? `${rgb}FF` : null
}
const rgb = normalizeHexColor(value)
if (rgb)
return rgb
return normalizeRgbaHexColor(value)?.slice(0, 7) ?? null
}
export function hexToHsva(value: string): HsvaColor | null {
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -48,7 +63,7 @@ export function hexToHsv(value: string): HsvColor | null {
}
}
export function hsvToHex(color: HsvColor) {
export function hsvaToHex(color: HsvaColor, allowAlpha: boolean) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
@@ -71,7 +86,9 @@ export function hsvToHex(color: HsvColor) {
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
const alpha = allowAlpha
? Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
: ''
return `#${rgb}${alpha}`.toUpperCase()
}
@@ -97,7 +114,7 @@ export function rgbaToHexColor(color: RgbaColor) {
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -3,14 +3,15 @@ import { Check, ChevronDown } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor } from '@/utils/color'
import BaseButton from '../BaseButton.vue'
import BaseInput from '../BaseInput.vue'
import BasePopover from '../BasePopover.vue'
import {
formatRgbaColor,
hexToHsv,
hsvToHex,
hexToHsva,
hsvaToHex,
normalizePickerHexColor,
parseRgbaColor,
rgbaToHexColor,
} from './color'
@@ -20,11 +21,13 @@ const props = withDefaults(
presets?: readonly string[]
disabled?: boolean
label?: string
allowAlpha?: boolean
}>(),
{
presets: () => [],
disabled: false,
label: '选择颜色',
allowAlpha: true,
},
)
@@ -39,13 +42,22 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
const draftColor = computed(() => hsvToHex({
const normalizedModel = computed(() =>
normalizePickerHexColor(model.value, props.allowAlpha)
?? (props.allowAlpha ? '#60A5FA80' : '#2563EB'),
)
const triggerSwatchStyle = computed(() => props.allowAlpha
? {
backgroundImage: `linear-gradient(${normalizedModel.value}, ${normalizedModel.value}), conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0)`,
backgroundSize: '100% 100%, 8px 8px',
}
: { backgroundColor: normalizedModel.value })
const draftColor = computed(() => hsvaToHex({
h: hue.value,
s: saturation.value,
v: brightness.value,
a: alpha.value,
}))
}, props.allowAlpha))
const hueColor = computed(() => `hsl(${hue.value} 100% 50%)`)
const svCursorStyle = computed(() => ({
left: `${saturation.value}%`,
@@ -63,8 +75,11 @@ const alphaTrackStyle = computed(() => {
backgroundSize: '100% 100%, 8px 8px',
}
})
const inputError = computed(() => customInput.value && !parseRgbaColor(customInput.value)
? '请输入 rgba(r, g, b, a)'
const inputValid = computed(() => props.allowAlpha
? Boolean(parseRgbaColor(customInput.value))
: Boolean(normalizeHexColor(customInput.value)))
const inputError = computed(() => customInput.value && !inputValid.value
? props.allowAlpha ? '请输入 rgba(r, g, b, a)' : '请输入六位 HEX 颜色,例如 #2563EB'
: undefined)
watch(open, (isOpen) => {
@@ -78,16 +93,19 @@ watch(model, (value) => {
})
function resetDraft(value: string) {
const color = hexToHsv(value) ?? { h: 213, s: 62, v: 98, a: 0.5 }
const color = hexToHsva(normalizePickerHexColor(value, props.allowAlpha) ?? '')
?? { h: 213, s: 62, v: 98, a: props.allowAlpha ? 0.5 : 1 }
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function syncInput() {
customInput.value = formatRgbaColor(draftColor.value) ?? ''
customInput.value = props.allowAlpha
? formatRgbaColor(draftColor.value) ?? ''
: normalizeHexColor(draftColor.value) ?? ''
}
function updateSaturationBrightness(event: PointerEvent) {
@@ -189,31 +207,31 @@ function handleAlphaKeydown(event: KeyboardEvent) {
}
function updateFromInput() {
const rgba = parseRgbaColor(customInput.value)
const color = rgba ? hexToHsv(rgbaToHexColor(rgba)) : null
const inputColor = normalizedInputColor()
const color = inputColor ? hexToHsva(inputColor) : null
if (!color)
return
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function choosePreset(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (normalized)
resetDraft(normalized)
}
function isPresetSelected(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
}
function presetCheckClass(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (!normalized)
return 'text-white'
@@ -227,13 +245,21 @@ function presetCheckClass(value: string) {
function confirm() {
updateFromInput()
const rgba = parseRgbaColor(customInput.value)
if (!rgba)
const normalized = normalizedInputColor()
if (!normalized)
return
model.value = rgbaToHexColor(rgba)
model.value = normalized
open.value = false
}
function normalizedInputColor() {
if (!props.allowAlpha)
return normalizeHexColor(customInput.value)
const rgba = parseRgbaColor(customInput.value)
return rgba ? rgbaToHexColor(rgba) : null
}
</script>
<template>
@@ -246,14 +272,17 @@ function confirm() {
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<span
class="relative flex size-full items-center justify-center overflow-hidden rounded-sm"
:style="triggerSwatchStyle"
aria-hidden="true"
>
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
@@ -263,7 +292,7 @@ function confirm() {
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -285,7 +314,7 @@ function confirm() {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
role="slider"
tabindex="0"
aria-label="色相"
@@ -305,10 +334,10 @@ function confirm() {
</div>
</div>
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div v-if="props.allowAlpha" class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -328,7 +357,7 @@ function confirm() {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
<span class="text-right font-mono text-cp-xs tabular-nums text-cp-text-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -343,7 +372,7 @@ function confirm() {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -364,8 +393,9 @@ function confirm() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
class="rounded-cp bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
@@ -376,13 +406,13 @@ function confirm() {
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<BaseButton size="md" variant="secondary" :disabled="!inputValid" @click="confirm">
确定
</BaseButton>
</div>
@@ -391,16 +421,6 @@ function confirm() {
</template>
<style scoped>
.base-color-picker__checker,
.base-color-picker__hue,
.base-color-picker__sv {
--cp-color-picker-checker: conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0) 0 / 8px 8px;
}
.base-color-picker__checker {
background: var(--cp-color-picker-checker);
}
.base-color-picker__sv::before,
.base-color-picker__sv::after {
position: absolute;
@@ -52,7 +52,7 @@ function handleConfirm() {
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
class="text-cp-lg leading-[1.55] font-emphasis text-cp-text-secondary"
>
<slot />
</div>
+4 -4
View File
@@ -28,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-bg-elevated',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
class="inline-flex items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text-quaternary"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
<p class="m-0 text-cp leading-[1.15] font-heavy text-cp-text">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -43,10 +43,10 @@ provide(formFieldKey, {
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
class="flex min-w-0 items-center gap-1.5 text-cp leading-none font-medium text-cp-text-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="font-bold text-cp-error" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
@@ -56,7 +56,7 @@ provide(formFieldKey, {
<p
v-if="description"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-text-quaternary"
>
{{ description }}
</p>
@@ -66,7 +66,7 @@ provide(formFieldKey, {
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ error }}
@@ -32,20 +32,20 @@ defineSlots<{
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
'bg-cp-bg-container text-cp-text-secondary shadow-cp-tertiary hover:bg-cp-bg-text-hover hover:text-cp-text active:bg-cp-bg-text-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-transparent text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
md: 'size-cp-control [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
@@ -56,12 +56,12 @@ const spinnerSizes: Record<IconButtonSize, number> = {
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
props.pressed ? 'bg-cp-bg-text-active text-cp-text' : undefined,
])
</script>
+28 -17
View File
@@ -48,31 +48,31 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
md: 'h-cp-control gap-2.5 px-3.5 text-cp',
lg: 'h-cp-control-lg gap-3 px-4 text-cp-lg',
}
const containerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp border-0 text-cp-text shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
'bg-[var(--cp-input-bg)]',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-active-bg) focus-within:shadow-cp-input-active',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
? 'text-cp-error'
: 'text-cp-text-quaternary',
])
function updateModel(event: Event) {
@@ -90,7 +90,7 @@ function updateModel(event: Event) {
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-text outline-0 placeholder:text-cp-text-quaternary disabled:cursor-not-allowed disabled:text-cp-text-disabled"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
@@ -109,24 +109,35 @@ function updateModel(event: Event) {
<style scoped>
.base-input__control {
--base-input-autofill-bg: var(--cp-input-current-bg, var(--cp-input-context-bg));
--base-input-autofill-bg: var(--cp-input-bg);
}
.base-input__control:hover {
--base-input-autofill-bg: var(--cp-input-current-bg-hover, var(--cp-input-context-bg-hover));
--base-input-autofill-bg: var(--cp-input-hover-bg);
}
.base-input__control:focus-within {
--base-input-autofill-bg: var(--cp-input-soft-bg-focus);
--base-input-autofill-bg: var(--cp-input-active-bg);
}
.base-input__field:-webkit-autofill,
.base-input__field:-webkit-autofill:hover,
.base-input__field:-webkit-autofill:focus,
.base-input__field:autofill {
caret-color: var(--cp-text-primary);
-webkit-text-fill-color: var(--cp-text-primary) !important;
caret-color: var(--cp-color-text);
-webkit-text-fill-color: var(--cp-color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
}
/* 普通数字表单保留键盘步进,但不混入浏览器原生微调器外观。 */
.base-input__field[type='number'] {
appearance: textfield;
}
.base-input__field[type='number']::-webkit-inner-spin-button,
.base-input__field[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -22,12 +22,12 @@ defineSlots<{
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline',
'disabled:cursor-not-allowed disabled:text-cp-text-disabled',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
? 'text-cp-error-text hover:bg-cp-error-bg-hover'
: 'text-cp-text hover:bg-cp-bg-text-hover',
])
</script>
@@ -72,8 +72,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',
@@ -186,12 +186,12 @@ 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-bg-container shadow-cp sm:max-h-[calc(100dvh-3rem)]"
:class="[
sizeClasses[size],
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
@@ -213,21 +213,21 @@ onBeforeUnmount(() => {
>
<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 +243,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>
@@ -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 }}
+1 -1
View File
@@ -56,7 +56,7 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-cp-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-container text-left text-cp-text shadow-cp',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
+5 -5
View File
@@ -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>
+142
View File
@@ -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"
+22 -22
View File
@@ -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-container 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>
+6 -6
View File
@@ -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>
@@ -51,10 +51,10 @@ function goToPage(page: number) {
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
'inline-flex size-8 items-center justify-center rounded-cp border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-cp-accent text-cp-accent-on'
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
? 'cursor-default bg-(--cp-button-primary-bg) text-(--cp-button-primary-color)'
: 'cursor-pointer bg-cp-fill-quaternary text-cp-text hover:bg-cp-bg-text-hover focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
]
}
</script>
@@ -64,7 +64,7 @@ function paginationPageClass(page: number) {
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
class="flex min-w-0 items-center gap-2.5 text-cp-sm font-emphasis text-cp-text-secondary"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
@@ -93,7 +93,7 @@ function paginationPageClass(page: number) {
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-text-quaternary"
>
…
</span>
@@ -44,6 +44,7 @@ export interface BaseTableProps<Row extends TableRow> {
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
scrollbarAlwaysVisible?: boolean
sort?: BaseTableSort
}
@@ -83,7 +84,7 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'lg',
align: 'left',
truncate: true,
contentClass: 'text-cp-secondary',
contentClass: 'text-cp-text-secondary',
},
status: {
size: 'md',
@@ -94,25 +95,25 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'md',
align: 'right',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
datetime: {
size: 'xl',
align: 'left',
truncate: false,
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
contentClass: 'whitespace-nowrap font-mono text-cp-sm tabular-nums text-cp-text-secondary',
},
mono: {
size: 'xl',
align: 'left',
truncate: true,
contentClass: 'font-mono text-[12px] font-emphasis',
contentClass: 'font-mono text-cp-sm font-emphasis',
},
index: {
size: 'xs',
align: 'center',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
selection: {
size: 'xs',
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
density: 'default',
loading: false,
emptyText: '暂无数据',
scrollbarAlwaysVisible: false,
sort: undefined,
})
@@ -85,14 +86,14 @@ watch([() => displayRows.value.length, () => props.columns], async () => {
})
const headerRowClass = computed(() => [
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
'font-bold text-cp-secondary',
props.density === 'compact' ? 'h-8 text-cp-xs' : 'h-10 text-cp-sm',
'font-bold text-cp-text-secondary',
])
const bodyRowClass = computed(() =>
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
props.density === 'compact' ? 'h-cp-table-row-sm' : 'h-cp-table-row',
)
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-cp-sm' : 'text-cp')
const bodyCellFrameClass = computed(() => props.density === 'compact'
? 'border-y-2 border-transparent bg-clip-padding'
: 'border-y-[3px] border-transparent bg-clip-padding')
@@ -100,8 +101,8 @@ const firstRowTopGapClass = computed(() =>
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
)
const bodyCellContentClass = computed(() => props.density === 'compact'
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
? 'min-h-[calc(var(--cp-table-row-height-sm)-4px)]'
: 'min-h-[calc(var(--cp-table-row-height)-6px)]')
function getRowKey(row: Row, index: number) {
if (typeof props.rowKey === 'function')
@@ -120,16 +121,16 @@ function isRowExpanded(row: Row, index: number) {
function rowBackgroundClass(row: Row, index: number) {
if (isRowSelected(row, index))
return 'bg-cp-tertiary-bg'
return 'bg-(--cp-table-row-selected-bg)'
if (index % 2 === 1)
return 'bg-cp-subtle'
return 'bg-cp-surface'
return 'bg-(--cp-table-row-stripe-bg)'
return 'bg-(--cp-table-row-bg)'
}
function rowClass() {
return [
bodyRowClass.value,
'hover:[&>td]:bg-cp-default-hover',
'hover:[&>td]:bg-(--cp-table-row-hover-bg)',
]
}
@@ -142,8 +143,8 @@ function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
header ? 'z-40' : 'z-20',
showShadow
? column.sticky === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
? 'shadow-[8px_0_14px_-14px_var(--cp-color-shadow)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-color-shadow)]'
: undefined,
]
}
@@ -194,8 +195,9 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
ref="scrollbar"
class="min-h-0 flex-1"
:class="density === 'compact'
? '[--cp-scrollbar-track-top:2.25rem]'
: '[--cp-scrollbar-track-top:2.75rem]'"
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
:always-visible="scrollbarAlwaysVisible"
horizontal
@scroll="handleTableScroll"
>
@@ -216,12 +218,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<th
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
class="sticky top-0 z-30 whitespace-nowrap bg-(--cp-table-header-bg) shadow-[0_10px_16px_-18px_var(--cp-color-shadow)]"
:class="[
column.paddingClass ?? cellPaddingClass,
alignClass(column),
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
stickyClass(column, true),
]"
@@ -233,7 +235,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<button
v-if="column.sortable"
type="button"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-text focus-visible:text-cp-primary-text motion-reduce:transition-none"
:aria-label="sortButtonLabel(column)"
:title="sortButtonLabel(column)"
@click="toggleColumnSort(column)"
@@ -244,12 +246,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
<Triangle
class="size-1.25 fill-current"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
<Triangle
class="size-1.25 rotate-180 fill-current"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
</span>
@@ -275,8 +277,8 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
column.contentClass,
alignClass(column),
index === 0 ? firstRowTopGapClass : undefined,
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp pr-6' : undefined,
stickyClass(column),
rowBackgroundClass(row, index),
]"
@@ -300,7 +302,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<tr v-if="isRowExpanded(row, index)">
<td
:colspan="computedColumns.length"
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
class="rounded-cp border-y-transparent bg-cp-fill-quaternary bg-clip-padding p-0"
:class="bodyCellFrameClass"
>
<slot name="expanded" :row="row" :index="index" />
@@ -47,17 +47,17 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<TextareaSize, string> = {
sm: 'px-3 py-2.5 text-xs',
md: 'px-3.5 py-3 text-[13px]',
lg: 'px-4 py-3.5 text-[14px]',
md: 'px-3.5 py-3 text-cp',
lg: 'px-4 py-3.5 text-cp-lg',
}
const textareaClasses = computed(() => [
'cp-scrollbar w-full resize-none rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'cp-scrollbar w-full resize-none rounded-cp border-0 bg-[var(--cp-input-bg)] text-cp-text shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-text-quaternary motion-reduce:transition-none',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover focus:bg-(--cp-input-active-bg) focus:shadow-cp-input-active',
'disabled:cursor-not-allowed disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
'leading-[1.55] font-emphasis',
sizeClasses[props.size],
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
invalid.value ? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active' : undefined,
])
</script>
@@ -24,8 +24,8 @@ const colorClasses = {
icon: 'text-cp-success',
},
error: {
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
iconBg: 'bg-cp-error-bg',
icon: 'text-cp-error',
},
warning: {
iconBg: 'bg-cp-warning-bg',
@@ -45,12 +45,12 @@ const colorClasses = {
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-container px-3.5 py-3 shadow-cp"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<div
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp"
:class="colorClasses[message.type].iconBg"
>
<component
@@ -61,11 +61,11 @@ const colorClasses = {
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
<p class="m-0 truncate text-cp leading-[1.15] font-bold text-cp-text">
{{ message.title ?? titleMap[message.type] }}
</p>
<p
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-text-secondary"
>
{{ message.message }}
</p>
@@ -77,7 +77,7 @@ const colorClasses = {
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
@click="toast.remove(message.id)"
>
<X :size="16" class="text-cp-muted-text" />
<X :size="16" class="text-cp-text-quaternary" />
</BaseIconButton>
</article>
</TransitionGroup>
+1 -24
View File
@@ -3,10 +3,8 @@ import type { EChartsOption } from 'echarts'
import type { EChartsType } from 'echarts/core'
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { init } from 'echarts/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import '@/plugins/echarts'
const props = withDefaults(
@@ -23,7 +21,6 @@ const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const pendingInitElement = shallowRef<HTMLElement>()
const { themeRevision } = storeToRefs(useUiStore())
const style = computed(() => ({
height: `${props.height}px`,
@@ -71,18 +68,6 @@ function applyOption(option: EChartsOption) {
chart.value.setOption(option, true)
}
async function recreateChartAfterThemeChange() {
await nextTick()
requestAnimationFrame(() => {
const element = chartElement.value
if (!element)
return
dispose()
ensureChart(element)
resize()
})
}
function dispose() {
cancelPendingInit()
chart.value?.dispose()
@@ -104,14 +89,6 @@ watch(
{ flush: 'post' },
)
watch(
themeRevision,
() => {
recreateChartAfterThemeChange()
},
{ flush: 'post' },
)
watch(
chartElement,
(element) => {
+1 -1
View File
@@ -30,7 +30,7 @@ export function chartTooltipStyle(
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: options.fontWeight ?? 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-box-shadow);',
}
if (!options.axisPointer)
+15 -15
View File
@@ -5,21 +5,21 @@ import { useThemeColor } from '@/composables/useThemeColor'
export function useChartPalette() {
const color = useThemeColor()
const palette = computed(() => ({
textPrimary: color('--cp-text-primary', '#0E1726'),
textSecondary: color('--cp-text-secondary', '#64748B'),
textMuted: color('--cp-text-muted', '#94A3B8'),
surface: color('--cp-bg-surface', '#FFFFFF'),
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
grid: color('--cp-bg-muted', '#F1F5F9'),
divider: color('--cp-divider-subtle', '#E2E8F0'),
border: color('--cp-default-border', '#E2E8F0'),
pointer: color('--cp-default-border-hover', '#CBD5E1'),
info: color('--cp-info', '#2563EB'),
success: color('--cp-success', '#10B981'),
warning: color('--cp-warning', '#F59E0B'),
danger: color('--cp-danger', '#EF4444'),
normal: color('--cp-normal', '#0F9F9A'),
reasoning: color('--cp-reasoning', '#8B5CF6'),
textPrimary: color('--cp-color-text', '#0E1726'),
textSecondary: color('--cp-color-text-secondary', '#64748B'),
textMuted: color('--cp-color-text-quaternary', '#94A3B8'),
surface: color('--cp-color-bg-container', '#FFFFFF'),
surfaceMuted: color('--cp-color-fill-tertiary', '#F1F5F9'),
grid: color('--cp-color-fill-tertiary', '#F1F5F9'),
divider: color('--cp-color-split', '#E2E8F0'),
border: color('--cp-color-border-secondary', '#E2E8F0'),
pointer: color('--cp-color-border', '#CBD5E1'),
info: color('--cp-color-info', '#2563EB'),
success: color('--cp-color-success', '#10B981'),
warning: color('--cp-color-warning', '#F59E0B'),
danger: color('--cp-color-error', '#EF4444'),
normal: color('--cp-color-status-normal', '#0F9F9A'),
reasoning: color('--cp-color-reasoning', '#8B5CF6'),
}))
return { color, palette }
+25 -4
View File
@@ -1,12 +1,33 @@
import { storeToRefs } from 'pinia'
import type { InjectionKey, Ref } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import { storeToRefs } from 'pinia'
import { inject, provide } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
export type ThemeColorTokens = Readonly<Record<string, string | number | undefined>>
const themeColorTokensKey: InjectionKey<Readonly<Ref<ThemeColorTokens>>> = Symbol('theme-color-tokens')
export function provideThemeColorTokens(tokens: Readonly<Ref<ThemeColorTokens>>) {
provide(themeColorTokensKey, tokens)
}
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
// 引用该函数的 computed 会重算并读到新变量值。
// 引用该函数的 computed 会重算并读到新变量值;影子预览优先读取其局部 Token。
export function useThemeColor() {
const { themeRevision } = storeToRefs(useUiStore())
const scopedTokens = inject(themeColorTokensKey, null)
const { themeRevision } = storeToRefs(useThemeStore())
return (name: string, fallback: string) => {
if (scopedTokens) {
const scopedValue = scopedTokens.value[name]
if (typeof scopedValue === 'string' && scopedValue.trim())
return scopedValue.trim()
if (typeof scopedValue === 'number')
return String(scopedValue)
return fallback
}
void themeRevision.value
return readCssVariable(name, fallback)
}
+3 -3
View File
@@ -28,7 +28,7 @@ const OVERLAY_BASE_CLASS = [
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-cp-surface/82 backdrop-blur-[4px]'
const OVERLAY_MASK_CLASS = 'bg-cp-bg-container/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
@@ -41,14 +41,14 @@ const PANEL_CLASS = [
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-cp-secondary',
'text-cp-text-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-bg-surface)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-normal)_0deg_105deg,var(--cp-info)_105deg_200deg,var(--cp-bg-muted)_200deg_360deg)]',
'bg-[radial-gradient(circle_at_center,var(--cp-color-bg-container)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-color-status-normal)_0deg_105deg,var(--cp-color-info)_105deg_200deg,var(--cp-color-fill-tertiary)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
@@ -53,15 +53,15 @@ const linkItems = [
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-subtle font-mono text-[15px] leading-none font-extrabold text-cp-primary"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp bg-cp-fill-quaternary font-mono text-cp-xl leading-none font-extrabold text-cp-text"
>
Z
</span>
<div class="min-w-0">
<p class="m-0 truncate text-[15px] leading-none font-heavy text-cp-primary">
<p class="m-0 truncate text-cp-xl leading-none font-heavy text-cp-text">
{{ author }}
</p>
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-cp-secondary">
<p class="mt-1.5 mb-0 text-cp-sm leading-none font-emphasis text-cp-text-secondary">
Built by Zyy · Codex Proxy RS
</p>
</div>
@@ -71,20 +71,20 @@ const linkItems = [
<div
v-for="item in linkItems"
:key="item.label"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp-control px-1 py-2.5 transition-colors hover:bg-cp-subtle"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp px-1 py-2.5 transition-colors hover:bg-cp-fill-quaternary"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-muted text-cp-primary"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text"
>
<component :is="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<p
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp-sm leading-none font-bold text-cp-text"
:title="item.value"
>
{{ item.value }}
@@ -96,7 +96,7 @@ const linkItems = [
:href="item.href"
target="_blank"
rel="noreferrer"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp-control text-cp-muted-text transition-colors hover:bg-cp-muted hover:text-cp-info"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp text-cp-text-quaternary transition-colors hover:bg-cp-fill-tertiary hover:text-cp-link"
:aria-label="`打开 ${item.label}`"
>
<ExternalLink class="size-3.5" />
@@ -104,7 +104,7 @@ const linkItems = [
</div>
</section>
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-cp-muted-text">
<p class="m-0 font-mono text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
{{ versionLine }}
</p>
</div>
+29 -26
View File
@@ -8,6 +8,7 @@ import {
LayoutDashboard,
LogOut,
Moon,
Palette,
PanelLeftClose,
PanelLeftOpen,
Settings,
@@ -29,11 +30,12 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useUiStore } from '@/stores/modules/ui'
import { useThemeStore } from '@/stores/modules/theme'
const props = withDefaults(
defineProps<{
@@ -56,10 +58,10 @@ const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemUpdateStore = useSystemUpdateStore()
const uiStore = useUiStore()
const themeStore = useThemeStore()
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const navItems = [
@@ -68,6 +70,7 @@ const navItems = [
{ label: '分组管理', icon: FolderTree, path: '/account-groups' },
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
{ label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' },
{ label: '主题设置', icon: Palette, path: '/theme' },
{ label: '系统设置', icon: Settings, path: '/settings' },
]
@@ -328,7 +331,7 @@ onBeforeUnmount(() => {
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-cp-surface px-4 shadow-cp-sidebar"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-layout-sider-bg) px-4 shadow-cp-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
@@ -341,35 +344,30 @@ onBeforeUnmount(() => {
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp"
>
<img
src="/favicon.svg"
alt=""
class="block size-11 select-none"
draggable="false"
>
<AppBrandMark class="block size-11 select-none" />
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-base leading-[1.1] font-heavy text-cp-primary">
<strong class="text-base leading-[1.1] font-heavy text-cp-text">
Codex Proxy
</strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-secondary">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-text-secondary">
Rust build
</span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-control-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
hasUpdate
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
: 'bg-cp-fill-quaternary text-cp-text-quaternary hover:bg-cp-fill-tertiary hover:text-cp-text-secondary',
]"
:title="updateButtonLabel"
@click="openSystemUpdate"
@@ -388,7 +386,7 @@ onBeforeUnmount(() => {
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp bg-cp-menu-item-selected-bg transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
@@ -397,16 +395,16 @@ onBeforeUnmount(() => {
v-for="item in navItems"
:key="item.label"
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-cp-primary transition-none'
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
? 'bg-transparent font-bold text-cp-text transition-none'
: 'bg-transparent font-bold text-cp-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-cp-secondary transition-none'
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
? 'bg-transparent font-semibold text-cp-text-secondary transition-none'
: 'bg-transparent font-semibold text-cp-text-secondary transition-colors duration-200 hover:bg-cp-fill-quaternary hover:text-cp-text',
]"
@click="navigate(item.path)"
>
@@ -422,11 +420,11 @@ onBeforeUnmount(() => {
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
<div
class="bg-cp-subtle"
class="bg-cp-fill-quaternary"
:class="
isCollapsed
? 'grid gap-1 rounded-cp-control p-1'
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
? 'grid gap-1 rounded-cp p-1'
: 'flex h-11 items-center justify-between rounded-cp-lg px-2'
"
>
<span
@@ -505,6 +503,11 @@ onBeforeUnmount(() => {
<style scoped>
.sidebar-active-signal {
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--cp-info) 9%, transparent), transparent);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--cp-color-info) 9%, transparent),
transparent
);
}
</style>
@@ -220,10 +220,10 @@ onScopeDispose(() => {
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-cp-control border-0 bg-cp-surface text-cp-primary shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-default-hover hover:text-cp-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-page"
class="inline-flex size-11.5 items-center justify-center rounded-cp border-0 bg-cp-bg-container text-cp-text shadow-[0_18px_34px_-18px_var(--cp-color-shadow)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-bg-text-hover hover:text-cp-status-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-layout"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-color-shadow)]'
: 'cursor-grab'
"
aria-label="打开侧边栏"
@@ -198,18 +198,18 @@ watch(
:dismissible="!updating && !restarting"
>
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-cp-surface bg-cp-subtle px-4 py-4">
<section class="grid gap-4 rounded-cp-card bg-cp-fill-quaternary px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-primary">
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-text">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-cp-sm font-heavy"
:class="presentation.status.badge"
>
<component
@@ -225,10 +225,10 @@ watch(
<div
v-for="item in presentation.summaryItems"
:key="item.key"
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
class="min-w-0 rounded-cp bg-cp-bg-container px-3 py-2.5"
>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 truncate text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<a
@@ -236,14 +236,14 @@ watch(
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-cp-info-text transition-colors hover:text-cp-info"
class="inline-flex shrink-0 items-center gap-1 text-cp-xs leading-none font-bold text-cp-link transition-colors hover:text-cp-link-hover"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
{{ item.value }}
@@ -253,7 +253,7 @@ watch(
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-cp-control bg-cp-danger-bg px-3 py-2 text-[12px] leading-normal font-bold text-cp-danger-text"
class="m-0 rounded-cp bg-cp-error-bg px-3 py-2 text-cp-sm leading-normal font-bold text-cp-error-text"
>
{{ updateError || updateInfo?.warning }}
</p>
@@ -261,13 +261,13 @@ watch(
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-heavy text-cp-primary">
<p class="m-0 text-cp font-heavy text-cp-text">
发布说明
</p>
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
{{ presentation.releaseVersion }}
</span>
</div>
@@ -280,22 +280,22 @@ watch(
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-cp-surface bg-cp-subtle"
class="overflow-hidden rounded-cp-card bg-cp-fill-quaternary"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-cp-success" />
<p class="m-0 text-[13px] leading-none font-heavy text-cp-primary">
<p class="m-0 text-cp leading-none font-heavy text-cp-text">
更新进度
</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-fill-quaternary px-2 text-cp-xs leading-none font-bold text-cp-text-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-text-quaternary'"
/>
{{ presentation.streamStatusLabel }}
</span>
@@ -310,16 +310,16 @@ watch(
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp-control bg-cp-surface px-3 py-2 font-mono text-[11px] leading-[1.55]"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp bg-cp-bg-container px-3 py-2 font-mono text-cp-xs leading-[1.55]"
>
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
<span class="tabular-nums text-cp-text-quaternary">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="log.classes.marker"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
<span v-if="log.step" class="mr-1 text-cp-text-quaternary">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
@@ -383,23 +383,23 @@ watch(
@confirm="handleConfirmUpdate"
>
<div class="grid gap-3">
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
<div class="grid gap-2 rounded-cp bg-cp-fill-quaternary p-3">
<div
v-for="item in presentation.confirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
<span class="text-[12px] leading-none font-bold text-cp-muted-text">
<span class="text-cp-sm leading-none font-bold text-cp-text-quaternary">
{{ item.label }}
</span>
<span
class="truncate font-mono text-[13px] leading-none font-heavy text-cp-primary"
class="truncate font-mono text-cp leading-none font-heavy text-cp-text"
>
{{ item.value }}
</span>
</div>
</div>
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-cp-muted-text">
<p class="m-0 text-cp-sm leading-relaxed font-emphasis text-cp-text-quaternary">
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
@@ -408,7 +408,7 @@ watch(
<style scoped>
.release-notes {
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-emphasis);
line-height: 1.65;
@@ -432,7 +432,7 @@ watch(
.release-notes :deep(h3),
.release-notes :deep(h4) {
margin: 12px 0 6px;
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-heavy);
line-height: 1.4;
@@ -453,19 +453,19 @@ watch(
}
.release-notes :deep(a) {
color: var(--cp-info-text);
color: var(--cp-color-link);
font-weight: 700;
text-decoration: none;
}
.release-notes :deep(a:hover) {
color: var(--cp-info);
color: var(--cp-color-link-hover);
}
.release-notes :deep(code) {
border-radius: 5px;
background: var(--cp-bg-muted);
color: var(--cp-text-primary);
background: var(--cp-color-fill-tertiary);
color: var(--cp-color-text);
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
@@ -474,8 +474,8 @@ watch(
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-radius-control);
background: var(--cp-bg-surface);
border-radius: var(--cp-border-radius);
background: var(--cp-color-bg-container);
padding: 8px 10px;
}
@@ -486,8 +486,8 @@ watch(
.release-notes :deep(blockquote) {
margin: 8px 0;
border-left: 3px solid var(--cp-divider-subtle);
color: var(--cp-text-secondary);
border-left: 3px solid var(--cp-color-split);
color: var(--cp-color-text-secondary);
padding-left: 10px;
}
</style>
@@ -36,9 +36,9 @@ export function resolveSystemUpdateLogClasses(level: string) {
case 'warning':
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
case 'error':
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
return { marker: 'text-cp-error', text: 'text-cp-error' }
default:
return { marker: 'text-cp-info', text: 'text-cp-primary' }
return { marker: 'text-cp-info', text: 'text-cp-text' }
}
}
@@ -55,8 +55,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
badge: 'bg-cp-error-bg text-cp-error-text',
iconClass: 'text-cp-error',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
@@ -70,8 +70,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
badge: 'bg-cp-fill-tertiary text-cp-text-secondary',
iconClass: 'text-cp-text-quaternary',
}
}

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