Compare commits

..
13 Commits
Author SHA1 Message Date
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
79 changed files with 4603 additions and 33848 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
+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,122 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方用量统计的账号形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderUsageStatisticsMode {
Workspace,
Personal,
}
impl ProviderUsageStatisticsMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Personal => "personal",
}
}
}
/// 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 mode: ProviderUsageStatisticsMode,
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,185 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方每日用量统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsData {
pub mode: String,
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 {
mode: statistics.mode.as_str().to_owned(),
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 +1373,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 +1711,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,148 @@ 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,
ProviderUsageStatisticsMode, 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 {
mode: ProviderUsageStatisticsMode::Personal,
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_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,
@@ -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)
}
@@ -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 {
+157 -3
View File
@@ -20,7 +20,11 @@ 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, ProviderUsageStatisticsMode,
ProviderUsageStatisticsModel, ProviderUsageStatisticsRequest,
ProviderUsageStatisticsServiceTier, ProviderUsageStatisticsSummary,
ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -48,8 +52,10 @@ use crate::credential::{
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexResetCreditsError, CodexUsageStatistics, CodexUsageStatisticsError,
CodexUsageStatisticsMode, CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -443,6 +449,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 +823,98 @@ 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 {
mode: match statistics.mode {
CodexUsageStatisticsMode::Workspace => ProviderUsageStatisticsMode::Workspace,
CodexUsageStatisticsMode::Personal => ProviderUsageStatisticsMode::Personal,
},
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 +1512,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,
CodexUsageStatisticsMode, 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,
CodexUsageStatisticsMode, 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,26 @@ enum ResetCreditAttemptError {
Upstream(CodexClientError),
}
enum DailyUsageAttemptError {
InvalidCredential,
Upstream(CodexClientError),
}
enum DailyUsageFetchError {
InvalidCredential,
Recovery,
Upstream {
prepared: Box<PreparedCodexRuntimeCredential>,
error: CodexClientError,
},
}
struct FetchedDailyUsage {
mode: CodexUsageStatisticsMode,
model_breakdown: Value,
daily_totals: Option<Value>,
}
impl CodexQuotaSchedulingProjection {
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
let mut state = self
@@ -509,6 +588,114 @@ 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 {
mode: fetched.mode,
period,
timezone,
current_used_percent: window.used_percent(),
now,
model_breakdown: &fetched.model_breakdown,
daily_totals: fetched.daily_totals.as_ref(),
max_cycle_offset: MAX_CYCLE_OFFSET,
},
))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
pub async fn consume_reset_credit(
&self,
@@ -1262,6 +1449,171 @@ 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 workspace_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let workspace = client
.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&workspace_request_id,
None,
),
CodexDailyUsageReport::WorkspaceModelTokens,
start_date,
end_date,
)
.await;
match workspace {
Ok(model_breakdown) => {
return Ok(FetchedDailyUsage {
mode: CodexUsageStatisticsMode::Workspace,
model_breakdown,
daily_totals: None,
});
}
Err(error) if workspace_is_unavailable_for_account(&error) => {}
Err(error) => return Err(DailyUsageAttemptError::Upstream(error)),
}
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 {
mode: CodexUsageStatisticsMode::Personal,
model_breakdown,
daily_totals: Some(daily_totals),
})
}
fn workspace_is_unavailable_for_account(error: &CodexClientError) -> bool {
matches!(
error,
CodexClientError::Upstream { status, body, .. }
if *status == reqwest::StatusCode::BAD_REQUEST
&& body.to_ascii_lowercase().contains("no active workspace")
)
}
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,970 @@
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)]
pub enum CodexUsageStatisticsMode {
Workspace,
Personal,
}
#[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 mode: CodexUsageStatisticsMode,
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 mode: CodexUsageStatisticsMode,
pub period: CodexUsageStatisticsPeriod,
pub timezone: FixedOffset,
pub current_used_percent: Option<f64>,
pub now: DateTime<Utc>,
pub model_breakdown: &'a Value,
pub daily_totals: Option<&'a Value>,
pub max_cycle_offset: u8,
}
pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUsageStatistics {
let BuildUsageStatistics {
mode,
period,
timezone,
current_used_percent,
now,
model_breakdown,
daily_totals,
max_cycle_offset,
} = input;
let all_days = match mode {
CodexUsageStatisticsMode::Workspace => build_workspace_days(model_breakdown),
CodexUsageStatisticsMode::Personal => {
build_personal_days(daily_totals.unwrap_or(&Value::Null), 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 {
mode,
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_workspace_days(payload: &Value) -> Vec<DailyUsage> {
extract_daily_list(payload)
.into_iter()
.filter_map(|row| {
let date = date_value(row.get("date")?)?;
let mut models = value_array(row.get("models"))
.iter()
.filter_map(direct_model_usage)
.collect::<Vec<_>>();
let model_tokens = sum_tokens(models.iter().map(|model| model.tokens));
let fallback_tokens = token_parts(row.get("totals").unwrap_or(row));
let tokens = if model_tokens.is_empty() {
fallback_tokens
} else {
model_tokens
};
let credit_weight = day_credit_weight(row);
if tokens.is_empty() && credit_weight <= EPSILON {
return None;
}
let has_missing_token_data = tokens.is_empty() && credit_weight > EPSILON;
if models.is_empty() && !tokens.is_empty() {
models.push(unknown_model(tokens, false));
}
if has_missing_token_data {
for model in &mut models {
model.has_missing_token_data = true;
}
}
Some(finalize_day(
date,
credit_weight,
tokens,
models,
has_missing_token_data,
))
})
.collect()
}
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 direct_model_usage(value: &Value) -> Option<DailyModelUsage> {
let tokens = token_parts(value);
let credits = non_negative_f64(value.get("credits"));
if tokens.is_empty() && credits <= EPSILON {
return None;
}
let model = model_name(value);
let service_tier = model_service_tier(value.get("speed").or_else(|| value.get("mode")));
let estimated_cost = (!tokens.is_empty())
.then(|| aggregate_cost(&model, service_tier, tokens))
.flatten();
Some(DailyModelUsage {
model,
service_tier,
credits,
tokens,
has_unknown_pricing: estimated_cost.is_none() && !tokens.is_empty(),
estimated_cost,
has_estimated_allocation: false,
has_rate_fallback: false,
has_missing_token_data: tokens.is_empty() && credits > EPSILON,
})
}
fn unknown_model(
tokens: CodexUsageStatisticsTokens,
estimated_allocation: bool,
) -> DailyModelUsage {
DailyModelUsage {
model: "unknown".to_owned(),
service_tier: CodexUsageStatisticsServiceTier::Standard,
credits: 0.0,
tokens,
estimated_cost: None,
has_unknown_pricing: !tokens.is_empty(),
has_estimated_allocation: estimated_allocation,
has_rate_fallback: estimated_allocation,
has_missing_token_data: false,
}
}
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,14 @@ 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";
/// Workspace 账号每日模型 Token 明细。
pub const WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH: &str =
"/wham/usage/daily-workspace-user-token-usage-breakdown";
/// 个人账号每日模型额度权重。
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,7 +36,8 @@ 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,
CODEX_USAGE_API_PATH, WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_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,
@@ -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 握手审计快照。
@@ -63,18 +63,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)
}
}
@@ -205,8 +201,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 +303,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 +322,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 +348,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 +360,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 +408,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,122 @@
//! 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,
WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_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 {
WorkspaceModelTokens,
PersonalModelCredits,
PersonalTokenTotals,
}
impl CodexDailyUsageReport {
const fn path(self) -> &'static str {
match self {
Self::WorkspaceModelTokens => WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH,
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,307 @@
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, CodexUsageStatisticsMode, 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 WORKSPACE_REPORT_PATH: &str = "/wham/usage/daily-workspace-user-token-usage-breakdown";
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(WORKSPACE_REPORT_PATH))
.respond_with(ResponseTemplate::new(400).set_body_string("no active workspace"))
.mount(&server)
.await;
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.mode, CodexUsageStatisticsMode::Personal);
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 workspace_report_keeps_direct_rows_marks_boundary_and_never_prices_unknown_models() {
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = start_at.date_naive().to_string();
Mock::given(method("GET"))
.and(path(WORKSPACE_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"models": [
{
"model": "gpt-5.6-sol",
"speed": "fast",
"uncached_text_input_tokens": 1000000,
"cached_text_input_tokens": 1000000,
"text_output_tokens": 1000000,
"text_total_tokens": 3000000
},
{
"model": "gpt-5.6-terra",
"speed": "standard",
"uncached_text_input_tokens": 1000000,
"text_total_tokens": 1000000
},
{
"model": "future-private-model",
"speed": "fast",
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
}
]
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_workspace_statistics", &server, end_at).await;
let account = store
.account("acct_workspace_statistics")
.expect("workspace account");
let statistics = service
.usage_statistics(account.id(), 0, 0)
.await
.expect("workspace statistics");
assert_eq!(statistics.mode, CodexUsageStatisticsMode::Workspace);
assert!(statistics.daily[0].is_boundary_day);
let sol = statistics
.models
.iter()
.find(|model| model.model == "gpt-5.6-sol")
.expect("Sol row");
assert_eq!(sol.service_tier, CodexUsageStatisticsServiceTier::Fast);
assert_eq!(
sol.estimated_cost
.expect("published Sol price")
.amount()
.canonical(),
"71"
);
let terra = statistics
.models
.iter()
.find(|model| model.model == "gpt-5.6-terra")
.expect("Terra row");
assert_eq!(
terra.service_tier,
CodexUsageStatisticsServiceTier::Standard
);
assert_eq!(
terra
.estimated_cost
.expect("published Terra price")
.amount()
.canonical(),
"2"
);
let unknown = statistics
.models
.iter()
.find(|model| model.model == "future-private-model")
.expect("unknown row");
assert_eq!(unknown.tokens.total, 10);
assert!(unknown.estimated_cost.is_none());
assert!(unknown.has_unknown_pricing);
assert!(statistics.summary.estimated_cost.is_some());
assert!(statistics.summary.has_unknown_pricing);
assert!(statistics.summary.projected_cost.is_none());
}
#[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(WORKSPACE_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",
"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() == WORKSPACE_REPORT_PATH)
.expect("workspace 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,
@@ -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
+7 -2
View File
@@ -21,7 +21,8 @@ CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;只有 Hover、Active、弱背景和文字色使用派生值。
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
@@ -110,6 +111,10 @@ interface ThemeCustomization {
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
功能色的基础 Map 对齐 Ant Design:`info`、`success`、`warning`、`error` 均固定取十阶色板 P6。浅色 P6
就是用户选择的 Seed,因此 `--cp-color-info / success / warning / error` 不再经过额外对比度改写;深色 P6
继续使用暗色色板对当前 Surface 做主题适配。功能色的 Hover、Active、弱背景、文字和描边仍按各自角色派生。
背景与文本 Seed 进入独立的 Surface Map,生成:
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
@@ -141,7 +146,7 @@ Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改最终 Alias,不反写用户保存的 Seed。
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
### Component Token
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -18,15 +18,15 @@
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.31.0",
"@lucide/vue": "^1.33.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.13",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"echarts": "^6.1.0",
"es-toolkit": "^1.50.0",
"es-toolkit": "^1.51.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"marked": "^18.0.10",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
@@ -38,12 +38,12 @@
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.10"
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
+258 -248
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -170,6 +170,69 @@ export interface AccountQuotaResponse {
account: Account
}
export type AccountUsageStatisticsMode = 'workspace' | 'personal'
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 {
mode: AccountUsageStatisticsMode
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 +301,11 @@ interface AccountIdParam {
accountId: string
}
interface AccountUsageStatisticsParam extends AccountIdParam {
cycleOffset?: number
utcOffsetMinutes?: number
}
interface AccountResetCreditConsumeParam extends AccountIdParam {
creditId?: string
redeemRequestId: string
@@ -326,6 +394,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',
@@ -393,7 +393,7 @@ function normalizedInputColor() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
class="bg-cp-fill-tertiary!"
class="rounded-cp bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
@@ -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>
@@ -24,7 +24,7 @@ defineProps<{
</div>
<div
v-if="$slots.actions"
class="flex min-w-0 basis-full flex-wrap items-center justify-start gap-2 min-[640px]:mt-0.5 min-[640px]:basis-auto min-[640px]:shrink-0 min-[640px]:justify-end"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
>
<slot name="actions" />
</div>
@@ -44,6 +44,7 @@ export interface BaseTableProps<Row extends TableRow> {
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
scrollbarAlwaysVisible?: boolean
sort?: BaseTableSort
}
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
density: 'default',
loading: false,
emptyText: '暂无数据',
scrollbarAlwaysVisible: false,
sort: undefined,
})
@@ -196,6 +197,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
:class="density === 'compact'
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
:always-visible="scrollbarAlwaysVisible"
horizontal
@scroll="handleTableScroll"
>
+4 -2
View File
@@ -744,7 +744,9 @@ function deriveFunctionalColorMap(
const palette = generate(seed, theme === 'dark'
? { theme: 'dark', backgroundColor: containerBg }
: undefined).map(color => normalizeHexColor(color) ?? seed)
const color = palette[theme === 'dark' ? 6 : 5]!
// 与 Ant Design 的功能色 Map 保持一致:基础功能色固定取 P6。
// 浅色 P6 即用户选择的 Seed;深色 P6 由暗色色板适配。对比度保护只作用于具体角色 Token。
const color = palette[5]!
const hover = palette[theme === 'dark' ? 7 : 4]!
const active = palette[theme === 'dark' ? 5 : 6]!
const backgroundMix = theme === 'dark' ? 0.18 : 0.08
@@ -753,7 +755,7 @@ function deriveFunctionalColorMap(
const text = baselineText ?? palette[theme === 'dark' ? 8 : 5]!
return {
color: ensureContrast(color, containerBg, 3),
color,
hover: ensureContrast(hover, containerBg, 3),
active: ensureContrast(active, containerBg, 3),
background: mix(containerBg, color, backgroundMix),
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
resolveAccountUsageWindowPresentation,
} from './AccountUsageWindow/presenter'
} from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
@@ -9,7 +9,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseRadio from '@/components/base/BaseRadio.vue'
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
import { useAccountResetCredits } from '../../composables/useAccountResetCredits'
const props = defineProps<{
accountId: string
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { RefreshCw } from '@lucide/vue'
import type { AccountRow } from '../../constants'
import { ChartNoAxesColumnIncreasing, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
import AccountResetCredits from './AccountResetCredits.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountUsageStatisticsModal from '../AccountUsageStatisticsModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
const props = defineProps<{
account: AccountRow
@@ -22,6 +23,7 @@ const emit = defineEmits<{
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const statisticsOpen = shallowRef(false)
</script>
<template>
@@ -44,6 +46,16 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看官方用量统计"
size="sm"
variant="ghost"
:pressed="statisticsOpen"
@click="statisticsOpen = true"
>
<ChartNoAxesColumnIncreasing class="size-3.5" />
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
:account-id="account.id"
@@ -77,4 +89,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
</section>
<AccountUsageStatisticsModal
v-if="account.provider === 'openai'"
v-model="statisticsOpen"
:account="account"
/>
</template>
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountQuotaWindowGroup from './WindowGroup.vue'
const props = defineProps<{
label: string | null
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { AccountRow } from '../../constants'
import { computed } from 'vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants'
import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../../constants'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import AccountQuotaSummaryEntry from './Entry.vue'
const props = defineProps<{
account: AccountRow
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { AccountUsageStatisticsDay } from '@/api'
import { computed } from 'vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatUsageCost, formatUsagePercent } from '../../utils/accountUsageStatistics'
const props = defineProps<{
rows: AccountUsageStatisticsDay[]
isCurrentCycle: boolean
usedPercent: number | null
}>()
const sectionHeightClass = computed(() => props.rows.length > 4
? 'h-[min(15rem,24dvh)]'
: undefined)
const columns = defineTableColumns<AccountUsageStatisticsDay>([
{ key: 'date', label: '日期', kind: 'custom', size: '2xl' },
{ key: 'creditShare', label: '额度占比', kind: 'numeric', size: 'sm' },
{ key: 'estimatedCost', label: '估算金额', kind: 'numeric', size: 'sm' },
{ key: 'total', label: '总 Tokens', kind: 'numeric', size: 'lg' },
{ key: 'uncachedInput', label: '未缓存输入', kind: 'numeric', size: 'lg' },
{ key: 'cachedInput', label: '缓存输入', kind: 'numeric', size: 'lg' },
{ key: 'output', label: '输出', kind: 'numeric', size: 'md' },
])
</script>
<template>
<section
class="flex min-h-0 flex-col overflow-hidden"
:class="sectionHeightClass"
aria-labelledby="account-usage-statistics-daily-title"
>
<div class="mb-2.5 flex shrink-0 items-center justify-between gap-3">
<h3
id="account-usage-statistics-daily-title"
class="m-0 text-cp-lg font-heavy text-cp-text"
>
每日用量
</h3>
<span class="text-cp-xs font-semibold text-cp-text-quaternary">官方接口为日粒度</span>
</div>
<BaseTable
v-if="rows.length"
class="min-h-0 min-w-0 flex-[1_1_auto]"
:columns="columns"
:rows="rows"
row-key="date"
density="compact"
scrollbar-always-visible
>
<template #date="{ row }">
<div class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 font-mono font-heavy text-cp-text">{{ row.date }}</span>
<span
v-if="row.isBoundaryDay"
class="shrink-0 rounded-full bg-cp-info-bg px-1.5 py-0.5 font-sans text-[10px] font-bold text-cp-info-text"
>
跨周期起始日
</span>
<span
v-if="row.hasMissingTokenData"
class="shrink-0 rounded-full bg-cp-warning-bg px-1.5 py-0.5 font-sans text-[10px] font-bold text-cp-warning-text"
>
无 Token 明细
</span>
</div>
</template>
<template #creditShare="{ row }">
{{ formatUsagePercent(row.creditShare, true) }}
</template>
<template #estimatedCost="{ row }">
<span class="font-heavy text-cp-success-text">
{{ row.hasMissingTokenData ? '—' : formatUsageCost(row.estimatedCost, row.hasUnknownPricing) }}
</span>
</template>
<template #total="{ row }">
<span class="font-heavy text-cp-text" :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.total)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.total) }}
</span>
</template>
<template #uncachedInput="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.uncachedInput)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.uncachedInput) }}
</span>
</template>
<template #cachedInput="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.cachedInput)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.cachedInput) }}
</span>
</template>
<template #output="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.output)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.output) }}
</span>
</template>
</BaseTable>
<BaseEmpty
v-else
:title="isCurrentCycle && usedPercent === 0 ? '本周期刚重置' : '该周期没有用量记录'"
:description="isCurrentCycle && usedPercent === 0 ? '尚未产生任何官方日报数据' : undefined"
size="sm"
surface="none"
class="min-h-0 flex-1"
/>
</section>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { AccountUsageStatisticsModel } from '@/api'
import { Info } from '@lucide/vue'
import { computed } from 'vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatUsageCost, formatUsagePercent } from '../../utils/accountUsageStatistics'
const props = defineProps<{
rows: AccountUsageStatisticsModel[]
}>()
const sectionHeightClass = computed(() => props.rows.length > 4
? 'h-[min(15rem,24dvh)]'
: undefined)
const allocationLabel = computed(() => {
const estimatedRows = props.rows.filter(row => row.hasEstimatedAllocation)
if (estimatedRows.length === 0)
return null
const hasRateFallback = estimatedRows.some(row => row.hasRateFallback)
const hasRateAllocation = estimatedRows.some(row => !row.hasRateFallback)
if (hasRateFallback && hasRateAllocation)
return 'Token 为估算值'
return hasRateFallback ? 'Token 按额度估算' : 'Token 按费率估算'
})
const columns = defineTableColumns<AccountUsageStatisticsModel>([
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'serviceTier', label: '服务档位', kind: 'custom', size: 'sm' },
{ key: 'creditShare', label: '额度占比', kind: 'numeric', size: 'sm' },
{ key: 'quotaShare', label: '占周限额', kind: 'numeric', size: 'sm' },
{ key: 'uncachedInput', label: '未缓存输入', kind: 'numeric', size: 'md' },
{ key: 'cachedInput', label: '缓存输入', kind: 'numeric', size: 'md' },
{ key: 'output', label: '输出', kind: 'numeric', size: 'sm' },
{ key: 'total', label: '总 Tokens', kind: 'numeric', size: 'md' },
{ key: 'estimatedCost', label: '估算金额', kind: 'numeric', size: 'sm' },
])
</script>
<template>
<section
class="flex min-h-0 flex-col overflow-hidden"
:class="sectionHeightClass"
aria-labelledby="account-usage-statistics-models-title"
>
<div class="mb-2.5 flex shrink-0 items-center gap-1.5">
<h3
id="account-usage-statistics-models-title"
class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text"
>
模型明细
</h3>
<span
v-if="allocationLabel"
class="inline-flex shrink-0 cursor-help items-center justify-center text-cp-text-quaternary"
role="img"
:aria-label="allocationLabel"
:title="allocationLabel"
>
<Info class="size-3.5" aria-hidden="true" />
</span>
</div>
<BaseTable
class="min-h-0 min-w-0 flex-[1_1_auto]"
:columns="columns"
:rows="rows"
row-key="key"
density="compact"
empty-text="该周期没有模型用量"
scrollbar-always-visible
>
<template #model="{ row }">
<span class="block truncate font-mono font-heavy text-cp-text">{{ row.model }}</span>
</template>
<template #serviceTier="{ row }">
<span class="rounded-full bg-cp-fill-tertiary px-2 py-1 font-mono text-cp-xs font-bold capitalize">
{{ row.serviceTier }}
</span>
</template>
<template #creditShare="{ row }">
{{ formatUsagePercent(row.creditShare, true) }}
</template>
<template #quotaShare="{ row }">
{{ formatUsagePercent(row.quotaShare, true) }}
</template>
<template #uncachedInput="{ row }">
<span :title="formatInteger(row.tokens.uncachedInput)">
{{ row.hasMissingTokenData && row.tokens.total === 0 ? '无明细' : formatCompactNumber(row.tokens.uncachedInput) }}
</span>
</template>
<template #cachedInput="{ row }">
<span :title="formatInteger(row.tokens.cachedInput)">{{ formatCompactNumber(row.tokens.cachedInput) }}</span>
</template>
<template #output="{ row }">
<span :title="formatInteger(row.tokens.output)">{{ formatCompactNumber(row.tokens.output) }}</span>
</template>
<template #total="{ row }">
<span class="font-heavy text-cp-text" :title="formatInteger(row.tokens.total)">
{{ formatCompactNumber(row.tokens.total) }}
</span>
</template>
<template #estimatedCost="{ row }">
<span class="font-heavy text-cp-success-text">
{{ formatUsageCost(row.estimatedCost, row.hasUnknownPricing) }}
</span>
</template>
</BaseTable>
</section>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</script>
<template>
<div
class="grid gap-5"
role="status"
aria-live="polite"
aria-busy="true"
>
<span class="sr-only">正在加载官方用量统计</span>
<section
class="grid gap-2 rounded-cp-lg bg-cp-bg-elevated px-3 py-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center"
aria-hidden="true"
>
<div class="flex items-center gap-1">
<BaseSkeleton shape="circle" class="size-8 opacity-70" />
<BaseSkeleton shape="circle" class="size-8 opacity-70" />
</div>
<div class="flex min-w-0 flex-wrap items-center gap-1.5 sm:justify-center">
<BaseSkeleton shape="text" class="w-36" />
<BaseSkeleton class="h-5 w-12 rounded-full opacity-70" />
<BaseSkeleton class="h-5 w-14 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-36 opacity-60 sm:justify-self-end" />
</section>
<section class="overflow-hidden rounded-cp-lg bg-cp-bg-elevated" aria-hidden="true">
<div class="grid gap-5 p-4 sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] sm:p-5">
<div class="min-w-0">
<BaseSkeleton shape="text" class="w-20 opacity-60" />
<BaseSkeleton class="mt-2 h-7 w-24" />
<BaseSkeleton class="mt-4 h-1.5 w-full rounded-full opacity-70" />
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-cp-split pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5">
<div v-for="metric in 4" :key="metric" class="grid gap-2">
<BaseSkeleton shape="text" class="w-16 opacity-60" />
<BaseSkeleton shape="text" class="h-3.5 w-14" />
</div>
</div>
</div>
<div class="grid grid-cols-2 border-t border-cp-split sm:grid-cols-4">
<div
v-for="metric in 4"
:key="metric"
class="grid gap-2 px-4 py-3 sm:border-l sm:border-cp-split sm:px-5 sm:first:border-l-0"
>
<BaseSkeleton shape="text" class="w-16 opacity-60" />
<BaseSkeleton shape="text" class="h-3.5 w-12" />
</div>
</div>
</section>
<section
v-for="section in 2"
:key="section"
class="min-w-0"
aria-hidden="true"
>
<div class="mb-2.5 flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-24 opacity-50" />
</div>
<div class="overflow-hidden rounded-cp">
<div class="grid grid-cols-[1.4fr_repeat(6,minmax(0,1fr))] items-center gap-3 bg-cp-fill-quaternary px-3 py-2">
<BaseSkeleton
v-for="column in 7"
:key="column"
shape="text"
class="h-2.5 opacity-60"
:class="column === 1 ? 'w-3/4' : 'ml-auto w-2/3'"
/>
</div>
<div
v-for="row in 2"
:key="row"
class="grid grid-cols-[1.4fr_repeat(6,minmax(0,1fr))] items-center gap-3 px-3 py-2.5"
:class="row % 2 === 0 ? 'bg-cp-fill-quaternary/55' : undefined"
>
<BaseSkeleton
v-for="column in 7"
:key="column"
shape="text"
class="opacity-70"
:class="column === 1 ? 'w-4/5' : 'ml-auto w-3/5'"
/>
</div>
</div>
</section>
</div>
</template>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import { ArrowLeft, ArrowRight, ChartNoAxesColumnIncreasing, TriangleAlert } from '@lucide/vue'
import { computed, toRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { formatCompactNumber } from '@/utils/number'
import { useAccountUsageStatistics } from '../../composables/useAccountUsageStatistics'
import { formatUsageCost, formatUsagePercent } from '../../utils/accountUsageStatistics'
import AccountUsageStatisticsDailyTable from './DailyTable.vue'
import AccountUsageStatisticsModelTable from './ModelTable.vue'
import AccountUsageStatisticsSkeleton from './Skeleton.vue'
const props = defineProps<{
account: AccountRow
}>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const {
loading,
error,
view,
load,
previousCycle,
nextCycle,
} = useAccountUsageStatistics(accountId, open)
const accountIdentity = computed(() =>
props.account.email?.trim()
|| props.account.accountId?.trim()
|| props.account.name.trim()
|| props.account.id,
)
const title = computed(() => `用量统计 · ${accountIdentity.value}`)
const modeLabel = computed(() => view.value?.mode === 'personal' ? '个人模式' : '工作区模式')
const quotaRailStyle = computed(() => ({
width: `${view.value?.cycle.usedPercent ?? 0}%`,
}))
const summaryLayoutClass = computed(() => view.value?.cycle.isCurrent
? 'sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]'
: undefined)
const summaryMetricsClass = computed(() => view.value?.cycle.isCurrent
? 'border-t border-cp-split pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'
: undefined)
const cycleLabel = computed(() => view.value?.cycle.isCurrent
? '本周期'
: `前第 ${Math.abs(view.value?.cycle.offset ?? 0)} 个周期 · 推算`)
const usageNotice = '每日数据可能延迟 1–2 天;周期在一天中途重置时,边界日只能整日归入一个周期。历史周期边界为按当前窗口长度推算,金额不等同于账单。'
const cycleRange = computed(() => {
const cycle = view.value?.cycle
if (!cycle)
return ''
const formatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return `${formatter.format(new Date(cycle.startAt))} → ${formatter.format(new Date(cycle.endAt))}`
})
function formatProjectedTokens(value: number | null) {
if (value === null)
return '未知'
return formatCompactNumber(value)
}
</script>
<template>
<BaseModal
v-model="open"
:title="title"
description="按官方额度周期查看模型、Token 与估算金额"
size="xl"
>
<AccountUsageStatisticsSkeleton v-if="loading && !view" />
<BaseEmpty
v-else-if="error && !view"
title="统计加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="load(true)">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="view" class="flex min-h-0 flex-col gap-5">
<section class="grid shrink-0 gap-2 rounded-cp-lg bg-cp-bg-elevated px-3 py-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
<div class="flex items-center gap-1">
<BaseIconButton
label="查看上一个额度周期"
size="sm"
:disabled="loading || !view.cycle.canGoPrevious"
@click="previousCycle"
>
<ArrowLeft class="size-3.5" />
</BaseIconButton>
<BaseIconButton
label="查看下一个额度周期"
size="sm"
:disabled="loading || !view.cycle.canGoNext"
@click="nextCycle"
>
<ArrowRight class="size-3.5" />
</BaseIconButton>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-left sm:justify-center">
<span class="font-mono text-cp-sm font-bold tabular-nums text-cp-text">
{{ cycleRange }}
</span>
<span class="rounded-full bg-cp-info-bg px-2 py-0.75 text-cp-xs font-bold text-cp-info-text">
{{ cycleLabel }}
</span>
<span class="rounded-full bg-cp-fill-tertiary px-2 py-0.75 text-cp-xs font-bold text-cp-text-secondary">
{{ modeLabel }}
</span>
</div>
<div
class="flex min-w-0 items-center gap-1.5 text-cp-xs font-semibold text-cp-warning-text sm:justify-self-end"
role="note"
:aria-label="usageNotice"
:title="usageNotice"
>
<TriangleAlert class="size-3.5 shrink-0" aria-hidden="true" />
<span class="truncate">数据延迟 1–2 天,金额仅供估算</span>
</div>
</section>
<section class="shrink-0 overflow-hidden rounded-cp-lg bg-cp-bg-elevated">
<div class="grid gap-5 p-4 sm:p-5" :class="summaryLayoutClass">
<div v-if="view.cycle.isCurrent" class="min-w-0">
<div class="flex items-end justify-between gap-3">
<div>
<p class="m-0 text-cp-xs font-bold tracking-wide text-cp-text-quaternary uppercase">
当前窗口已用
</p>
<p class="mt-1 mb-0 font-mono text-3xl leading-none font-heavy tabular-nums text-cp-text">
{{ formatUsagePercent(view.cycle.usedPercent) }}
</p>
</div>
</div>
<div class="mt-4 h-1.5 overflow-hidden rounded-full bg-cp-fill-secondary" aria-hidden="true">
<div
class="h-full rounded-full bg-cp-info transition-[width] duration-300 motion-reduce:transition-none"
:style="quotaRailStyle"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-3" :class="summaryMetricsClass">
<div>
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
估算已用金额
</p>
<p class="mt-1 mb-0 font-mono text-cp-lg font-heavy tabular-nums text-cp-success-text">
{{ formatUsageCost(view.summary.estimatedCost, view.summary.hasUnknownPricing) }}
</p>
</div>
<div>
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
{{ view.cycle.isCurrent ? '预计周期总额' : '该周期总额' }}
</p>
<p class="mt-1 mb-0 font-mono text-cp-lg font-heavy tabular-nums text-cp-text">
{{ view.cycle.isCurrent
? formatUsageCost(view.summary.projectedCost)
: formatUsageCost(view.summary.estimatedCost, view.summary.hasUnknownPricing) }}
</p>
</div>
<div>
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
{{ view.cycle.isCurrent ? '本周期 Tokens' : '该周期 Tokens' }}
</p>
<p class="mt-1 mb-0 font-mono text-cp-lg font-heavy tabular-nums text-cp-text">
{{ formatCompactNumber(view.summary.tokens.total) }}
</p>
</div>
<div>
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
{{ view.cycle.isCurrent ? '预计周期 Tokens' : '历史周期不预测' }}
</p>
<p class="mt-1 mb-0 font-mono text-cp-lg font-heavy tabular-nums text-cp-text">
{{ view.cycle.isCurrent ? formatProjectedTokens(view.summary.projectedTokens) : '—' }}
</p>
</div>
</div>
</div>
<div class="grid grid-cols-2 border-t border-cp-split sm:grid-cols-4">
<div class="px-4 py-3 sm:px-5">
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
未缓存输入
</p>
<p class="mt-1 mb-0 font-mono font-heavy tabular-nums text-cp-text">
{{ formatCompactNumber(view.summary.tokens.uncachedInput) }}
</p>
</div>
<div class="border-l border-cp-split px-4 py-3 sm:px-5">
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
缓存输入
</p>
<p class="mt-1 mb-0 font-mono font-heavy tabular-nums text-cp-text">
{{ formatCompactNumber(view.summary.tokens.cachedInput) }}
</p>
</div>
<div class="border-t border-cp-split px-4 py-3 sm:border-t-0 sm:border-l sm:px-5">
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
输出 Tokens
</p>
<p class="mt-1 mb-0 font-mono font-heavy tabular-nums text-cp-text">
{{ formatCompactNumber(view.summary.tokens.output) }}
</p>
</div>
<div class="border-t border-l border-cp-split px-4 py-3 sm:border-t-0 sm:px-5">
<p class="m-0 text-cp-xs font-semibold text-cp-text-quaternary">
统计天数
</p>
<p class="mt-1 mb-0 font-mono font-heavy tabular-nums text-cp-text">
{{ view.summary.dayCount }}
</p>
</div>
</div>
</section>
<p
v-if="view.summary.hasMissingTokenData"
class="m-0 shrink-0 rounded-cp-lg bg-cp-warning-bg px-3.5 py-3 text-cp-sm leading-[1.55] font-semibold text-cp-warning-text"
>
当前周期部分日期暂无 Token 明细,相关 Token 与金额未计入统计。
</p>
<div class="flex min-h-56 flex-col gap-5">
<AccountUsageStatisticsModelTable :rows="view.models" />
<AccountUsageStatisticsDailyTable
:rows="view.daily"
:is-current-cycle="view.cycle.isCurrent"
:used-percent="view.cycle.usedPercent"
/>
</div>
</div>
<template #footer>
<span v-if="error && view" class="mr-auto self-center text-cp-xs font-semibold text-cp-error-text">
{{ error }}
</span>
<BaseButton variant="ghost" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
<template #icon>
<ChartNoAxesColumnIncreasing class="size-4" />
</template>
重新统计
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,89 @@
import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountUsageStatistics } from '@/api'
import { errorMessage } from '@/utils/async'
export function useAccountUsageStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const view = shallowRef<Awaited<ReturnType<typeof getAccountUsageStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const loading = shallowRef(false)
const error = shallowRef('')
let requestVersion = 0
async function loadCycle(cycleOffset: number, force = false) {
const targetAccountId = accountId.value
if (!targetAccountId)
return
if (
!force
&& view.value
&& loadedAccountId.value === targetAccountId
&& view.value.cycle.offset === cycleOffset
) {
return
}
const version = ++requestVersion
loading.value = true
error.value = ''
try {
const result = await getAccountUsageStatistics({
accountId: targetAccountId,
cycleOffset,
utcOffsetMinutes: -new Date().getTimezoneOffset(),
})
if (version !== requestVersion)
return
view.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
if (version !== requestVersion)
return
error.value = errorMessage(cause, '官方用量统计加载失败')
}
finally {
if (version === requestVersion)
loading.value = false
}
}
function load(force = false) {
return loadCycle(view.value?.cycle.offset ?? 0, force)
}
function previousCycle() {
const cycle = view.value?.cycle
if (cycle?.canGoPrevious)
void loadCycle(cycle.offset - 1)
}
function nextCycle() {
const cycle = view.value?.cycle
if (cycle?.canGoNext)
void loadCycle(cycle.offset + 1)
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
requestVersion += 1
view.value = null
loadedAccountId.value = ''
loading.value = false
error.value = ''
}
if (isOpen)
void load()
}, { immediate: true })
return {
loading,
error,
view,
load,
previousCycle,
nextCycle,
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ import AccountFilters from './components/AccountFilters.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel/index.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell/index.vue'
import AccountStatusBadge from './components/AccountStatusBadge/index.vue'
import AccountTableActions from './components/AccountTableActions.vue'
import AccountUsagePanel from './components/AccountUsagePanel.vue'
@@ -0,0 +1,21 @@
import type { AccountUsageStatisticsCost } from '@/api'
export function formatUsagePercent(value: number | null, ratio = false) {
if (value === null)
return '—'
const percent = ratio ? value * 100 : value
return `${percent >= 10 ? percent.toFixed(1) : percent.toFixed(2)}%`
}
export function formatUsageCost(
cost: AccountUsageStatisticsCost | null,
hasUnknownPricing = false,
) {
if (!cost)
return hasUnknownPricing ? '未公开' : '—'
const amount = Number(cost.amount)
const display = cost.currency === 'USD' && Number.isFinite(amount)
? `$${amount.toFixed(2)}`
: `${cost.currency} ${cost.amount}`
return `${display}${hasUnknownPricing ? '*' : ''}`
}
@@ -1,11 +1,9 @@
<script setup lang="ts">
import {
Check,
Moon,
RotateCcw,
Save,
Search,
Sun,
Undo2,
} from '@lucide/vue'
@@ -52,10 +50,6 @@ const scopeOptions = [
{ label: '全局', value: 'global' },
{ label: '组件', value: 'component' },
]
const previewThemeOptions = [
{ label: '浅色预览', value: 'light', icon: Sun },
{ label: '深色预览', value: 'dark', icon: Moon },
]
function switchScope(value: string) {
scope.value = value === 'component' ? 'component' : 'global'
@@ -95,13 +89,6 @@ function saveTheme(event: MouseEvent) {
size="sm"
@update:model-value="switchScope"
/>
<BaseSegmented
v-model="previewTheme"
label="预览明暗模式"
display="icon"
:options="previewThemeOptions"
size="sm"
/>
<BaseButton size="sm" variant="ghost" :disabled="!dirty" @click="resetDraft">
<template #icon>
<Undo2 class="size-3.5" />
@@ -190,7 +190,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
outputPriceDisplay: '$10.00 / 1M',
cacheReadPriceDisplay: '$0.125 / 1M',
cacheWritePriceDisplay: '$0.00 / 1M',
serviceTierDisplay: options.provider === 'openai' ? 'Priority' : 'Standard',
serviceTierDisplay: options.provider === 'openai' ? 'Fast' : 'Standard',
multiplierDisplay: '1.0x',
},
costs: [{ currency: 'USD', estimatedAmount: options.estimatedCost }],
@@ -102,7 +102,7 @@ const detailGroups = computed(() => [
{ label: '传输方式', value: props.record?.transport, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: finalAttemptIndex.value },
{ label: '服务层级', value: props.record?.serviceTier, mono: true },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
],
},
@@ -189,7 +189,7 @@ const billingItems = computed(() => {
{ label: '输出单价', value: value.outputPriceDisplay, mono: true },
{ label: '缓存单价', value: value.cacheReadPriceDisplay, mono: true },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, mono: true },
{ label: '服务层级', value: value.serviceTierDisplay },
{ label: '服务档位', value: value.serviceTierDisplay },
{ label: '倍率', value: value.multiplierDisplay, mono: true },
]
})
+1 -1
View File
@@ -1 +1 @@
version: "3.3.0"
version: "3.3.2"
+1 -2
View File
@@ -52,8 +52,7 @@ README 保持用户导向;不要把上游 URL、重试常量、数据库字段
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
或直接改 quota reset 时间。
- credential 与 quota 是独立事实;刷新 token 不等于刷新额度,quota 401/403 也不等于 RT 永久失效。
- 当前业务 schema 是 `0001_initial.sql` 的十二张表,加 `0002_nullable_requested_model.sql` 的无模型请求
兼容变更。
- 当前业务 schema 由 `0001_initial.sql` 建立。
- 运行拓扑是单副本;worker lease 不是完整多副本 leader election,自更新也只替换当前进程。
## 前端约束
@@ -114,8 +114,7 @@ OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户
## 存储与迁移
PostgreSQL 的十二张业务表全部在 `0001_initial.sql` 建立;`0002_nullable_requested_model.sql` 让
`model_requests.requested_model_id` 可空,以记录 Images 等无模型端点。`.frozen-sha256` 覆盖全部迁移,
PostgreSQL 业务 schema 由 `0001_initial.sql` 建立。`.frozen-sha256` 覆盖全部迁移,
已应用文件不可回改。
主要状态边界: