Compare commits

..
7 Commits
Author SHA1 Message Date
ZyyandCodex a38c328975 style(frontend): format account usage summary
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:57:06 +08:00
ZyyandCodex 97d95d25f1 chore: bump version to 3.3.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:49 +08:00
ZyyandCodex abfd24267b fix(frontend): refine usage visual hierarchy
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:05 +08:00
ZyyandCodex a363682929 feat(frontend): refine surfaces and draggable modals
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:25:40 +08:00
ZyyandCodex 57422275bb feat(accounts): add official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 02:36:45 +08:00
Zyy cb7f69850b feat(openai): add session affinity observability 2026-08-28 02:20:55 +08:00
ZyyandCodex 5ea3740de3 refactor(openai): remove agent identity support
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 13:27:10 +08:00
80 changed files with 2495 additions and 4803 deletions
+2 -2
View File
@@ -27,7 +27,7 @@
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT、账号文件与 Agent Identity 导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
@@ -90,7 +90,7 @@ curl -i http://127.0.0.1:8080/healthz
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT、OAuth JSON 与 Agent Identity;
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
-179
View File
@@ -8,16 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -721,17 +711,6 @@ dependencies = [
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -961,7 +940,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -976,36 +954,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek 4.1.3",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1016,48 +964,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1169,31 +1075,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1295,18 +1176,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1631,7 +1500,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -2073,15 +1941,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2318,12 +2177,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2498,17 +2351,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2579,8 +2421,6 @@ dependencies = [
"bytes",
"chrono",
"cookie",
"crypto_box",
"ed25519-dalek",
"flate2",
"futures",
"gateway-admin",
@@ -3049,15 +2889,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -4046,16 +3877,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -13,7 +13,6 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
observability::CurrencyCost,
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -628,102 +627,56 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方用量统计的目标额度周期。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
/// Provider 官方用量统计查询。
/// 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,
pub struct ProviderProfileStatisticsSummary {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
/// 官方用量统计中的 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 {
/// Provider 官方个人资料中的单日 Token bucket。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileDailyUsage {
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,
pub tokens: u64,
}
/// 目标周期汇总。
#[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 官方个人资料中的插件或 Skill 调用排行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileInvocation {
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
/// Provider 已解释、计算并排序的官方用量统计。
/// Provider 官方个人资料中的活动洞察。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatistics {
pub cycle: ProviderUsageStatisticsCycle,
pub summary: ProviderUsageStatisticsSummary,
pub models: Vec<ProviderUsageStatisticsModel>,
pub daily: Vec<ProviderUsageStatisticsDay>,
pub struct ProviderProfileActivityInsights {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<ProviderProfileInvocation>>,
}
/// Provider 已解释的官方个人资料统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: ProviderProfileStatisticsSummary,
pub daily_usage: Option<Vec<ProviderProfileDailyUsage>>,
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 返回的一张安全主动额度重置卡。
@@ -17,8 +17,8 @@ use crate::model::provider_credentials::{
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderUsageStatistics, ProviderUsageStatisticsRequest,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -147,11 +147,11 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方用量统计;不支持该能力的 Provider 使用默认拒绝。
async fn usage_statistics(
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
async fn profile_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
@@ -24,10 +24,9 @@ use crate::{
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsRequest,
QuotaLocalUsageAttribution,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -81,12 +80,12 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn usage_statistics(
async fn profile_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid(
"Provider usage statistics are not supported",
"Provider profile statistics are not supported",
))
}
@@ -548,15 +547,15 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn usage_statistics(
async fn profile_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
let (_, provider) = self.provider_for_account(&request.account_id).await?;
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.usage_statistics(request)
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider usage statistics"))
.map_err(|error| map_provider_error(error, "provider profile statistics"))
}
async fn reset_credits(
+101 -191
View File
@@ -28,11 +28,10 @@ use gateway_admin::model::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsRequest,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, RotateCredential,
StartAuthorization,
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -61,8 +60,6 @@ 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)]
@@ -425,35 +422,6 @@ 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)]
@@ -621,180 +589,122 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方每日用量统计响应。
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsData {
pub cycle: AccountUsageStatisticsCycleView,
pub summary: AccountUsageStatisticsSummaryView,
pub models: Vec<AccountUsageStatisticsModelView>,
pub daily: Vec<AccountUsageStatisticsDayView>,
pub struct AccountProfileStatisticsData {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: AccountProfileStatisticsSummaryView,
pub daily_usage: Option<Vec<AccountProfileDailyUsageView>>,
pub activity_insights: AccountProfileActivityInsightsView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct 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,
pub struct AccountProfileStatisticsSummaryView {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct 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 struct AccountProfileDailyUsageView {
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,
pub tokens: u64,
}
impl From<ProviderUsageStatistics> for AccountUsageStatisticsData {
fn from(statistics: ProviderUsageStatistics) -> Self {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileActivityInsightsView {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<AccountProfileInvocationView>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileInvocationView {
#[serde(rename = "type")]
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
fn from(statistics: ProviderProfileStatistics) -> Self {
Self {
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(),
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: profile_statistics_summary_view(statistics.summary),
daily_usage: statistics
.daily_usage
.map(|daily| daily.into_iter().map(profile_daily_usage_view).collect()),
activity_insights: profile_activity_insights_view(statistics.activity_insights),
}
}
}
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,
const fn profile_statistics_summary_view(
summary: ProviderProfileStatisticsSummary,
) -> AccountProfileStatisticsSummaryView {
AccountProfileStatisticsSummaryView {
total_text_tokens: summary.total_text_tokens,
peak_tokens: summary.peak_tokens,
longest_task_duration_ms: summary.longest_task_duration_ms,
current_streak_days: summary.current_streak_days,
longest_streak_days: summary.longest_streak_days,
}
}
fn account_usage_statistics_cost_view(
cost: gateway_admin::model::observability::CurrencyCost,
) -> AccountUsageStatisticsCostView {
AccountUsageStatisticsCostView {
currency: cost.currency,
amount: cost.amount.to_string(),
fn profile_daily_usage_view(daily: ProviderProfileDailyUsage) -> AccountProfileDailyUsageView {
AccountProfileDailyUsageView {
date: daily.date.to_string(),
tokens: daily.tokens,
}
}
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 profile_activity_insights_view(
insights: ProviderProfileActivityInsights,
) -> AccountProfileActivityInsightsView {
AccountProfileActivityInsightsView {
fast_mode_percent: insights.fast_mode_percent,
reasoning_effort: insights.reasoning_effort,
reasoning_effort_percent: insights.reasoning_effort_percent,
skills_explored: insights.skills_explored,
total_skills_used: insights.total_skills_used,
total_threads: insights.total_threads,
invocations: insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(profile_invocation_view)
.collect()
}),
}
}
fn 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,
fn profile_invocation_view(invocation: ProviderProfileInvocation) -> AccountProfileInvocationView {
AccountProfileInvocationView {
invocation_type: invocation.invocation_type,
plugin_id: invocation.plugin_id,
plugin_name: invocation.plugin_name,
skill_id: invocation.skill_id,
skill_name: invocation.skill_name,
usage_count: invocation.usage_count,
}
}
@@ -1372,8 +1282,8 @@ where
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/usage-statistics",
get(account_usage_statistics::<S>),
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
@@ -1709,24 +1619,24 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_usage_statistics<S>(
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountUsageStatisticsQuery>,
Query(query): Query<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let request = query.into_request().map_err(map_wire_error)?;
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.usage_statistics(request)
.profile_statistics(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountUsageStatisticsData::from(result)),
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
))
}
@@ -2172,7 +2082,7 @@ fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView {
CurrencyCostView {
currency: cost.currency.clone(),
estimated_amount: cost.amount.as_str().to_owned(),
estimated_amount_display: format!("{} {}", cost.currency, cost.amount.as_str()),
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
}
}
@@ -70,144 +70,69 @@ mod query {
}
}
mod usage_statistics {
use std::str::FromStr as _;
use chrono::{NaiveDate, TimeZone as _, Utc};
use gateway_admin::model::{
observability::{CurrencyCost, DecimalAmount},
provider_credentials::{
ProviderUsageStatistics, ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens,
},
mod profile_statistics {
use chrono::NaiveDate;
use gateway_admin::model::provider_credentials::{
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::{AccountUsageStatisticsData, AccountUsageStatisticsQuery};
use gateway_api::admin::accounts::AccountProfileStatisticsData;
use serde_json::json;
fn tokens(total: u64) -> ProviderUsageStatisticsTokens {
ProviderUsageStatisticsTokens {
uncached_input: total / 4,
cached_input: total / 2,
output: total / 4,
total,
}
}
fn usd(amount: &str) -> CurrencyCost {
CurrencyCost {
currency: "USD".to_owned(),
amount: DecimalAmount::from_str(amount).expect("USD amount"),
}
}
#[test]
fn usage_statistics_query_accepts_only_current_or_bounded_history_offsets() {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": -3,
"utcOffsetMinutes": 480
}))
.expect("decode usage-statistics query");
let request = query.into_request().expect("valid cycle offset");
assert_eq!(request.cycle_offset, -3);
assert_eq!(request.utc_offset_minutes, 480);
for (cycle_offset, field) in [(-9, "cycleOffset"), (1, "cycleOffset")] {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": cycle_offset
}))
.expect("decode bounded integer");
assert_eq!(
query
.into_request()
.expect_err("reject cycle offset")
.field(),
field
);
}
assert!(
serde_json::from_value::<AccountUsageStatisticsQuery>(json!({
"accountId": "acct_openai",
"historyCycles": 8
}))
.is_err()
);
}
#[test]
fn usage_statistics_response_serializes_calculated_rows_without_raw_documents() {
let start_at = Utc
.with_ymd_and_hms(2026, 8, 24, 7, 50, 0)
.single()
.expect("cycle start");
let end_at = Utc
.with_ymd_and_hms(2026, 8, 31, 7, 50, 0)
.single()
.expect("cycle end");
let response = AccountUsageStatisticsData::from(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: 0,
start_at,
end_at,
window_seconds: 604_800,
used_percent: Some(20.0),
is_current: true,
can_go_previous: true,
can_go_next: false,
fn profile_statistics_response_preserves_nullable_official_fields() {
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
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,
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
}]),
},
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");
let value = serde_json::to_value(response).expect("serialize profile statistics");
assert!(value.get("mode").is_none());
assert_eq!(value["cycle"]["offset"], 0);
assert_eq!(value["displayName"], "Ada");
assert_eq!(value["summary"]["totalTextTokens"], 409_500_000);
assert_eq!(
value["summary"]["estimatedCost"],
json!({
"currency": "USD",
"amount": "1.25"
})
value["dailyUsage"][0],
json!({"date": "2026-08-25", "tokens": 42})
);
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());
assert_eq!(
value["activityInsights"]["invocations"][0]["type"],
"plugin"
);
assert_eq!(
value["activityInsights"]["invocations"][0]["usageCount"],
29
);
assert!(value.get("cycle").is_none());
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
}
}
@@ -97,13 +97,19 @@ impl PgAdminAccountStore {
.iter()
.map(|window| window.account_id.clone())
.collect::<Vec<_>>();
let (usage_rows, model_rows, model_cost_rows) = futures::try_join!(
let (usage_rows, cost_rows, model_rows, model_cost_rows) = futures::try_join!(
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_COSTS_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
@@ -123,6 +129,11 @@ impl PgAdminAccountStore {
postgres_unavailable("load provider account quota window usage"),
)
})?;
let mut costs_by_window = BTreeMap::<(String, String), Vec<AccountCost>>::new();
for row in &cost_rows {
let (key, cost) = admin_account_usage_window_cost(row)?;
costs_by_window.entry(key).or_default().push(cost);
}
let mut model_costs = BTreeMap::<(String, String, String), Vec<AccountCost>>::new();
for row in &model_cost_rows {
let (key, cost) = admin_account_usage_window_model_cost(row)?;
@@ -145,6 +156,9 @@ impl PgAdminAccountStore {
.map(admin_account_usage_window)
.collect::<AdminStoreResult<Vec<_>>>()?;
for result in &mut results {
result.usage.costs = costs_by_window
.remove(&(result.account_id.clone(), result.key.clone()))
.unwrap_or_default();
result.usage.models = models_by_window
.remove(&(result.account_id.clone(), result.key.clone()))
.unwrap_or_default();
@@ -339,6 +339,30 @@ pub(crate) fn admin_account_usage_window_model_cost(
))
}
pub(crate) fn admin_account_usage_window_cost(
row: &sqlx::postgres::PgRow,
) -> AdminStoreResult<((String, String), AccountCost)> {
let key = (
window_usage_value(row, "account_id")?,
window_usage_value(row, "window_key")?,
);
let amount = window_usage_value::<String>(row, "amount")?;
let amount = AdminDecimalAmount::from_str(&amount).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted account cost is invalid",
)
})?;
Ok((
key,
AccountCost {
currency: window_usage_value(row, "cost_currency")?,
amount,
},
))
}
pub(crate) fn window_usage_count(
row: &sqlx::postgres::PgRow,
column: &'static str,
@@ -51,6 +51,28 @@ pub(crate) const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as
group by requested.account_id, requested.window_key
order by requested.account_id, requested.window_key";
pub(crate) const ACCOUNT_USAGE_COSTS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
as requested(account_id, window_key, window_start, window_end)
)
select requested.account_id,
requested.window_key,
mr.cost_currency,
sum(mr.cost_amount)::text as amount
from requested_windows requested
join model_requests mr
on mr.provider_account_ref = requested.account_id
and mr.started_at >= requested.window_start
and mr.started_at < requested.window_end
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null
and mr.cost_currency is not null
group by requested.account_id, requested.window_key, mr.cost_currency
order by requested.account_id, requested.window_key, mr.cost_currency";
pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
@@ -887,10 +887,16 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
("gpt-window-recent", Some(700)),
],
);
assert_eq!(usage[0].usage.costs.len(), 1);
assert_eq!(usage[0].usage.costs[0].currency, "USD");
assert_eq!(usage[0].usage.costs[0].amount.as_str(), "3.75");
assert_eq!(usage[0].usage.models[0].costs[0].amount.as_str(), "2.5");
assert_eq!(usage[0].usage.models[1].costs[0].amount.as_str(), "1.25");
assert_eq!(usage[1].key, "short");
assert_eq!(usage[1].usage.total_tokens, Some(700));
assert_eq!(usage[1].usage.costs.len(), 1);
assert_eq!(usage[1].usage.costs[0].currency, "USD");
assert_eq!(usage[1].usage.costs[0].amount.as_str(), "1.25");
assert_eq!(usage[1].usage.models.len(), 1);
assert_eq!(usage[1].usage.models[0].model, "gpt-window-recent");
assert_eq!(usage[1].usage.models[0].total_tokens, Some(700));
@@ -14,8 +14,6 @@ base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
cookie = "0.18.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
futures = "0.3.32"
flate2 = "1.1.9"
gateway-admin = { path = "../../gateway-admin" }
+69 -118
View File
@@ -18,12 +18,11 @@ use gateway_admin::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential,
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, ProviderUsageStatistics,
ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay, ProviderUsageStatisticsModel,
ProviderUsageStatisticsRequest, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderProfileActivityInsights,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -48,13 +47,12 @@ use serde_json::{Map, Number, Value};
use crate::credential::{
CodexAccountQuotaSnapshot, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CodexUsageStatistics, CodexUsageStatisticsError,
CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens, CompleteCodexOAuthAuthorization,
CompletedCodexOAuthCredential, ExportManagedCodexCredential, StartCodexOAuthAuthorization,
StoredCodexPendingAuthorization,
CodexCredentialProfileService, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
CodexOAuthPendingStoreError, CodexPendingAuthorization, CodexProfileStatisticsError,
CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -63,7 +61,7 @@ use crate::transport::CodexWebSocketPool;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::{OpenAiBillingUsage, openai_billing_breakdown};
use crate::transport::{CodexProfileStatistics, OpenAiBillingUsage, openai_billing_breakdown};
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
@@ -75,6 +73,7 @@ pub(crate) struct OpenAiAdminProvider {
accounts: Arc<dyn ProviderAccountStore>,
credentials: Arc<CodexCredentialAdminService>,
oauth: Arc<dyn CodexOAuthAdmin>,
profile_statistics: Arc<CodexCredentialProfileService>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
websocket_pool: Arc<CodexWebSocketPool>,
@@ -84,6 +83,7 @@ pub(crate) struct OpenAiAdminProvider {
pub(crate) struct OpenAiAdminServices {
pub(crate) credentials: Arc<CodexCredentialAdminService>,
pub(crate) oauth: Arc<dyn CodexOAuthAdmin>,
pub(crate) profile_statistics: Arc<CodexCredentialProfileService>,
pub(crate) quota: Arc<CodexCredentialQuotaService>,
pub(crate) catalog: Arc<CodexCredentialCatalogService>,
}
@@ -104,6 +104,7 @@ impl OpenAiAdminProvider {
accounts,
credentials: services.credentials,
oauth: services.oauth,
profile_statistics: services.profile_statistics,
quota: services.quota,
catalog: services.catalog,
websocket_pool,
@@ -448,21 +449,17 @@ impl ProviderAdmin for OpenAiAdminProvider {
Ok(project_quota(snapshot, &account))
}
async fn usage_statistics(
async fn profile_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
self.account(&request.account_id).await?;
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
self.account(account_id).await?;
let statistics = self
.quota
.usage_statistics(
&request.account_id,
request.cycle_offset,
request.utc_offset_minutes,
)
.profile_statistics
.profile_statistics(account_id)
.await
.map_err(map_usage_statistics_error)?;
project_usage_statistics(statistics)
.map_err(map_profile_statistics_error)?;
Ok(project_profile_statistics(statistics))
}
async fn reset_credits(
@@ -822,91 +819,49 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
}
}
fn project_usage_statistics(
statistics: CodexUsageStatistics,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
let models = statistics
.models
.into_iter()
.map(|model| {
Ok(ProviderUsageStatisticsModel {
key: model.key,
model: model.model,
service_tier: project_usage_service_tier(model.service_tier),
credit_share: model.credit_share,
quota_share: model.quota_share,
tokens: project_usage_tokens(model.tokens),
estimated_cost: model.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: model.has_unknown_pricing,
has_estimated_allocation: model.has_estimated_allocation,
has_rate_fallback: model.has_rate_fallback,
has_missing_token_data: model.has_missing_token_data,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
let daily = statistics
.daily
.into_iter()
.map(|day| {
Ok(ProviderUsageStatisticsDay {
date: day.date,
credit_share: day.credit_share,
tokens: project_usage_tokens(day.tokens),
estimated_cost: day.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
Ok(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at,
end_at: statistics.cycle.end_at,
window_seconds: statistics.cycle.window_seconds,
used_percent: statistics.cycle.used_percent,
is_current: statistics.cycle.is_current,
can_go_previous: statistics.cycle.can_go_previous,
can_go_next: statistics.cycle.can_go_next,
fn project_profile_statistics(statistics: CodexProfileStatistics) -> ProviderProfileStatistics {
ProviderProfileStatistics {
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: statistics.summary.total_text_tokens,
peak_tokens: statistics.summary.peak_tokens,
longest_task_duration_ms: statistics.summary.longest_task_duration_ms,
current_streak_days: statistics.summary.current_streak_days,
longest_streak_days: statistics.summary.longest_streak_days,
},
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,
daily_usage: statistics.daily_usage.map(|daily| {
daily
.into_iter()
.map(|entry| ProviderProfileDailyUsage {
date: entry.date,
tokens: entry.tokens,
})
.collect()
}),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: statistics.activity_insights.fast_mode_percent,
reasoning_effort: statistics.activity_insights.reasoning_effort,
reasoning_effort_percent: statistics.activity_insights.reasoning_effort_percent,
skills_explored: statistics.activity_insights.skills_explored,
total_skills_used: statistics.activity_insights.total_skills_used,
total_threads: statistics.activity_insights.total_threads,
invocations: statistics.activity_insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(|invocation| ProviderProfileInvocation {
invocation_type: invocation.invocation_type,
plugin_id: invocation.plugin_id,
plugin_name: invocation.plugin_name,
skill_id: invocation.skill_id,
skill_name: invocation.skill_name,
usage_count: invocation.usage_count,
})
.collect()
}),
},
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,
}
}
@@ -1507,24 +1462,20 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
})
}
fn map_usage_statistics_error(error: CodexUsageStatisticsError) -> ProviderAdminError {
use CodexUsageStatisticsError as Error;
fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderAdminError {
use CodexProfileStatisticsError as Error;
match error {
Error::InvalidRequest | Error::InvalidCredentialData => {
provider_admin_error(ProviderAdminErrorKind::Invalid)
}
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: {}",
"OpenAI profile-statistics upstream returned HTTP 401: {}",
bounded_upstream_body(&body)
));
}
@@ -1539,7 +1490,7 @@ fn map_usage_statistics_error(error: CodexUsageStatisticsError) -> ProviderAdmin
.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}: {}",
"OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}",
bounded_upstream_body(&body)
))
}
@@ -12,7 +12,7 @@ use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, CredentialCasUpdate, CredentialRevision, CredentialState, LoadedCredential,
NewProviderAccount, ProviderAccount, ProviderAccountId, ProviderAccountIdentity,
ProviderAccountUpdate, QuotaEvidence, QuotaState,
ProviderAccountUpdate, QuotaState,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
@@ -28,14 +28,12 @@ use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::security::CodexCredentialCodec;
use super::token_client::{RefreshFailure, TokenRefresher};
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration,
parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_BATCH: usize = 200;
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
@@ -121,56 +119,31 @@ impl fmt::Debug for PreparedCodexAccountImport {
}
struct ParsedCodexImportAccount {
id: Option<String>,
name: Option<String>,
email: Option<String>,
plan_type: Option<String>,
chatgpt_account_id: Option<String>,
chatgpt_user_id: Option<String>,
authentication: ParsedCodexAuthentication,
status: Option<String>,
}
enum ParsedCodexAuthentication {
OAuth {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
},
AgentIdentity {
runtime_id: String,
private_key: String,
task_id: Option<String>,
},
struct ParsedCodexAuthentication {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
}
impl fmt::Debug for ParsedCodexAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth {
access_token,
refresh_token,
id_token,
} => formatter
.debug_struct("OAuth")
.field("access_token", &access_token.as_ref().map(|_| "<redacted>"))
.field(
"refresh_token",
&refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &id_token.as_ref().map(|_| "<redacted>"))
.finish(),
Self::AgentIdentity {
runtime_id: _,
private_key: _,
task_id,
} => formatter
.debug_struct("AgentIdentity")
.field("runtime_id", &"<redacted>")
.field("private_key", &"<redacted>")
.field("task_id", &task_id.as_ref().map(|_| "<redacted>"))
.finish(),
}
formatter
.debug_struct("OAuth")
.field(
"access_token",
&self.access_token.as_ref().map(|_| "<redacted>"),
)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
@@ -178,20 +151,9 @@ impl fmt::Debug for ParsedCodexImportAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ParsedCodexImportAccount")
.field("id", &self.id)
.field("name", &self.name)
.field("email", &self.email.as_ref().map(|_| "<redacted>"))
.field("plan_type", &self.plan_type)
.field(
"chatgpt_account_id",
&self.chatgpt_account_id.as_ref().map(|_| "<redacted>"),
)
.field(
"chatgpt_user_id",
&self.chatgpt_user_id.as_ref().map(|_| "<redacted>"),
)
.field("authentication", &self.authentication)
.field("status", &self.status)
.finish()
}
}
@@ -222,7 +184,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
source_format: &'static str,
accounts: Vec<CodexCprExportAccount>,
accounts: Vec<CodexCprOAuthExportAccount>,
}
impl CodexCprExportDocument {
@@ -277,25 +239,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprAgentIdentityExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
auth_mode: &'static str,
agent_runtime_id: String,
agent_private_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
AgentIdentity(CodexCprAgentIdentityExportAccount),
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON。
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
@@ -567,7 +510,7 @@ impl CodexCredentialAdmin {
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
CodexCprOAuthExportAccount {
common,
access_token: data.access_token,
refresh_token: data.refresh_token,
@@ -576,22 +519,7 @@ impl CodexCredentialAdmin {
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
})
}
CodexCredentialData::AgentIdentity(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
|| account.has_refresh_token()
|| account.access_token_expires_at().is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::AgentIdentity(CodexCprAgentIdentityExportAccount {
common,
auth_mode: "agentIdentity",
agent_runtime_id: data.agent_runtime_id,
agent_private_key: data.agent_private_key,
task_id: data.task_id,
})
}
};
accounts.push(exported);
@@ -861,7 +789,7 @@ impl CodexCredentialAdminService {
Ok(prepared)
}
/// 归一导入 OAuth/agent-identity 凭据到唯一 `NewProviderAccount` 写入路径。
/// 归一导入 OAuth 凭据到唯一 `NewProviderAccount` 写入路径。
///
/// OAuth 导入先取得 access token(直接提供或 RT exchange),再按官方
/// `parse_chatgpt_jwt_claims` 从 ID token/access token 本地投影账号资料。
@@ -880,101 +808,57 @@ impl CodexCredentialAdminService {
if candidates.is_empty() || candidates.len() > MAX_BATCH {
return Err(CodexCredentialAdminError::InvalidInput);
}
let mut account_ids = BTreeSet::new();
let mut upstream_identities = BTreeSet::new();
let mut accounts = Vec::with_capacity(candidates.len());
for candidate in candidates {
let prepared = match &candidate.authentication {
ParsedCodexAuthentication::OAuth {
access_token,
refresh_token,
id_token,
} => {
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
access_token.clone(),
refresh_token.clone(),
id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
CodexCredentialAdmin.prepare_unresolved_oauth(
UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
},
)?
}
ParsedCodexAuthentication::AgentIdentity { .. } => {
let account_id = candidate
.id
.clone()
.filter(|id| ProviderAccountId::new(id.clone()).is_ok())
.unwrap_or_else(|| format!("acct_{}", uuid::Uuid::now_v7().simple()));
if !account_ids.insert(account_id.clone()) {
return Err(CodexCredentialAdminError::InvalidInput);
}
let facts =
parse_cpr_account_facts(candidate.status.as_deref(), SystemTime::now())?;
let mut account =
self.prepare_agent_identity_import(&candidate, account_id, facts.enabled)?;
let upstream_user_id = account
.account
.upstream_user_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
let upstream_account_id = account
.account
.upstream_account_id()
.ok_or(CodexCredentialAdminError::InvalidInput)?
.to_owned();
if !upstream_identities.insert((upstream_user_id, upstream_account_id)) {
return Err(CodexCredentialAdminError::InvalidInput);
}
account.account = facts.apply(account.account);
account
}
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (secret, access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
candidate.authentication.access_token.clone(),
candidate.authentication.refresh_token.clone(),
candidate.authentication.id_token.clone(),
)
.await?;
// 与官方 `parse_chatgpt_jwt_claims` 一致:先读 ID token,缺失字段
// 再由 access token 补齐。解析失败不阻塞 token 入库。
let id_metadata = secret
.id_token
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata =
parse_chatgpt_jwt_claims(secret.access_token.expose_secret()).unwrap_or_default();
let metadata = CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
account_id,
name: candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
next_refresh_at: None,
enabled: true,
})?;
accounts.push(prepared);
}
Ok(PreparedCodexAccountImport { accounts })
@@ -1044,79 +928,6 @@ impl CodexCredentialAdminService {
.map(ExposeSecret::expose_secret),
);
}
fn prepare_agent_identity_import(
&self,
candidate: &ParsedCodexImportAccount,
account_id: String,
enabled: bool,
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
let ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id,
} = &candidate.authentication
else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
let upstream_account_id = candidate
.chatgpt_account_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let upstream_user_id = candidate
.chatgpt_user_id
.clone()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
let account_id = ProviderAccountId::new(account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: uuid::Uuid::new_v4().to_string(),
agent_runtime_id: runtime_id.clone(),
agent_private_key: private_key.clone(),
task_id: task_id.clone(),
cookies: Vec::new(),
})
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let name = candidate
.name
.clone()
.or_else(|| candidate.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "OpenAI Agent Identity".to_owned());
let account = ProviderAccount::new(
account_id,
provider,
name,
Some(upstream_user_id),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
revision,
None,
)
.with_profile(
candidate.email.clone(),
Some(upstream_account_id),
candidate.plan_type.clone(),
)
.with_account_facts(
enabled,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
Ok(NewProviderAccount {
account,
credential,
})
}
}
fn optional_time(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<SystemTime> {
@@ -1177,10 +988,6 @@ fn parse_import_document(
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
let mut accounts = Vec::new();
for value in import_account_values(payload)? {
if looks_like_agent_identity_account(value) {
accounts.push(parse_agent_identity_account(value)?);
continue;
}
if !is_openai_oauth_candidate(value) {
continue;
}
@@ -1194,72 +1001,9 @@ fn parse_oauth_import_account(
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let credentials = value.get("credentials").unwrap_or(value);
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
plan_type: first_string(value, &["plan_type", "planType"])
.or_else(|| first_string(credentials, &["plan_type", "planType"])),
chatgpt_account_id: None,
chatgpt_user_id: None,
authentication: parse_oauth_import_tokens(value)?,
status: first_string(value, &["status"]),
})
}
fn parse_agent_identity_account(
value: &Value,
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let account = value
.as_object()
.ok_or(CodexCredentialAdminError::InvalidInput)?;
if account
.get("platform")
.and_then(Value::as_str)
.is_some_and(|platform| !platform.eq_ignore_ascii_case("openai"))
|| account
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| !kind.eq_ignore_ascii_case("oauth"))
{
return Err(CodexCredentialAdminError::InvalidInput);
}
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
let auth_mode = first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]));
if auth_mode.as_deref() != Some("agentIdentity") {
return Err(CodexCredentialAdminError::InvalidCredential);
}
let runtime_id = first_string(identity, &["agent_runtime_id", "agentRuntimeId"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let private_key = first_string(identity, &["agent_private_key", "agentPrivateKey"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
Ok(ParsedCodexImportAccount {
id: first_string(value, &["id"]),
name: first_string(value, &["name", "label"]),
email: first_string(identity, &["email"]),
plan_type: first_string(identity, &["plan_type", "planType"]),
chatgpt_account_id: first_string(
identity,
&[
"chatgpt_account_id",
"chatgptAccountId",
"account_id",
"accountId",
],
),
chatgpt_user_id: first_string(
identity,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
authentication: ParsedCodexAuthentication::AgentIdentity {
runtime_id,
private_key,
task_id: first_string(identity, &["task_id", "taskId"]),
},
status: first_string(value, &["status"]),
})
}
@@ -1308,24 +1052,13 @@ fn parse_oauth_import_tokens(
if access_token.is_none() && refresh_token.is_none() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(ParsedCodexAuthentication::OAuth {
Ok(ParsedCodexAuthentication {
access_token,
refresh_token,
id_token,
})
}
fn looks_like_agent_identity_account(value: &Value) -> bool {
let identity = value
.get("agent_identity")
.or_else(|| value.get("credentials"))
.unwrap_or(value);
first_string(value, &["auth_mode", "authMode"])
.or_else(|| first_string(identity, &["auth_mode", "authMode"]))
.is_some_and(|mode| mode == "agentIdentity")
|| first_string(identity, &["agent_runtime_id", "agentRuntimeId"]).is_some()
}
fn is_openai_oauth_candidate(value: &Value) -> bool {
let Some(account) = value.as_object() else {
return false;
@@ -1354,66 +1087,3 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
struct ImportedCprAccountFacts {
enabled: bool,
credential_state: CredentialState,
quota: QuotaState,
error_reason: Option<AccountErrorReason>,
}
impl ImportedCprAccountFacts {
fn apply(self, account: ProviderAccount) -> ProviderAccount {
account.with_account_facts(
self.enabled,
self.credential_state,
self.quota,
self.error_reason,
None,
)
}
}
fn parse_cpr_account_facts(
status: Option<&str>,
observed_at: SystemTime,
) -> Result<ImportedCprAccountFacts, CodexCredentialAdminError> {
match status
.map(str::trim)
.unwrap_or("active")
.to_ascii_lowercase()
.as_str()
{
"active" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"disabled" => Ok(ImportedCprAccountFacts {
enabled: false,
credential_state: CredentialState::Ready,
quota: QuotaState::unknown(),
error_reason: None,
}),
"expired" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Expired,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::CredentialExpired),
}),
"quota_exhausted" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Ready,
quota: QuotaState::exhausted(QuotaEvidence::ProviderDenied, observed_at, None),
error_reason: None,
}),
"banned" => Ok(ImportedCprAccountFacts {
enabled: true,
credential_state: CredentialState::Banned,
quota: QuotaState::unknown(),
error_reason: Some(AccountErrorReason::AccountBanned),
}),
_ => Err(CodexCredentialAdminError::InvalidInput),
}
}
@@ -1,4 +1,4 @@
//! OpenAI 根会话锚点到 Store 不透明账号亲和键的单向派生。
//! OpenAI 根会话锚点到 Store 不透明账号亲和键及诊断上下文的单向派生。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
@@ -7,20 +7,82 @@ use sha2::{Digest, Sha256};
use crate::transport::protocol::responses::CodexResponsesRequest;
use crate::transport::request::derive_conversation_anchor;
pub(crate) fn derive_codex_session_affinity_key(
const AFFINITY_KEY_HASH_LENGTH: usize = 12;
/// 一次请求派生出的账号亲和键及其结构化日志上下文。
pub(crate) struct CodexSessionAffinity {
key: ProviderSessionAffinityKey,
key_hash: String,
anchor_source: &'static str,
anchor: String,
session_id: Option<String>,
}
impl CodexSessionAffinity {
#[must_use]
pub(crate) const fn key(&self) -> &ProviderSessionAffinityKey {
&self.key
}
#[must_use]
pub(crate) fn key_hash(&self) -> &str {
&self.key_hash
}
#[must_use]
pub(crate) const fn anchor_source(&self) -> &'static str {
self.anchor_source
}
#[must_use]
pub(crate) fn anchor(&self) -> &str {
&self.anchor
}
#[must_use]
pub(crate) fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
#[must_use]
pub(crate) const fn session_id_present(&self) -> bool {
self.session_id.is_some()
}
#[must_use]
pub(crate) fn into_key(self) -> ProviderSessionAffinityKey {
self.key
}
}
pub(crate) fn derive_codex_session_affinity(
request: &CodexResponsesRequest,
client_api_key_id: &ClientApiKeyId,
) -> Option<ProviderSessionAffinityKey> {
let (domain, value) = derive_account_affinity_anchor(request)?;
let session_key = opaque_affinity_key(domain, &value)?;
opaque_affinity_key(
) -> Option<CodexSessionAffinity> {
let session_id = non_empty(request.client_session_id.as_deref()).map(str::to_owned);
let (anchor_source, anchor) = derive_account_affinity_anchor(request)?;
let session_key = opaque_affinity_key(anchor_source, &anchor)?;
let key = opaque_affinity_key(
"client-session",
&format!(
"{}\0{}",
client_api_key_id.as_str(),
session_key.expose_to_store()
),
)
)?;
// 亲和键本身已经是 SHA-256;日志沿用 WebSocket 诊断的 12 位短哈希长度。
let key_hash = key
.expose_to_store()
.chars()
.take(AFFINITY_KEY_HASH_LENGTH)
.collect();
Some(CodexSessionAffinity {
key,
key_hash,
anchor_source,
anchor,
session_id,
})
}
/// 账号选择优先使用根会话事实;`local_conversation_id` 仍可按 child thread 隔离
@@ -1,500 +0,0 @@
//! OpenAI Agent Identity 签名、task 注册与 revision-fenced 恢复。
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{DateTime, SecondsFormat, Utc};
use crypto_box::SecretKey as BoxSecretKey;
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
use ed25519_dalek::{Signer as _, SigningKey};
use gateway_core::engine::credential::{ProviderAccount, ProviderAccountId};
use reqwest::StatusCode;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha512};
use thiserror::Error;
use tokio::sync::Mutex;
use url::Url;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::{CodexRuntimeAuthentication, CodexRuntimeCredential};
use crate::transport::headers::build_codex_profile_headers;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::websocket::CodexWebSocketExchangeError;
use crate::transport::{CodexClientError, CodexWebSocketPool};
const OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL: &str = "https://auth.openai.com/api/accounts/";
const MAX_TASK_RESPONSE_BYTES: usize = 64 * 1024;
/// 已验证且只存在于 OpenAI Provider 运行时的 Agent Identity 密钥。
pub struct CodexAgentIdentitySecret {
runtime_id: String,
signing_key: SigningKey,
task_id: Option<String>,
}
impl CodexAgentIdentitySecret {
pub fn from_pkcs8(
runtime_id: String,
encoded_private_key: &str,
task_id: Option<String>,
) -> Result<Self, CodexAgentIdentityError> {
if !valid_identifier(&runtime_id)
|| task_id
.as_deref()
.is_some_and(|value| !valid_identifier(value))
{
return Err(CodexAgentIdentityError::InvalidCredential);
}
let der = STANDARD
.decode(encoded_private_key.trim())
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
let signing_key = SigningKey::from_pkcs8_der(&der)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(Self {
runtime_id,
signing_key,
task_id,
})
}
#[must_use]
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
#[must_use]
pub fn task_id(&self) -> Option<&str> {
self.task_id.as_deref()
}
pub fn authorization_header(
&self,
now: DateTime<Utc>,
) -> Result<SecretString, CodexAgentIdentityError> {
let task_id = self.task_id().ok_or(CodexAgentIdentityError::MissingTask)?;
let timestamp = timestamp(now);
let payload = format!("{}:{task_id}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
let envelope = AgentAssertionEnvelope {
agent_runtime_id: &self.runtime_id,
task_id,
timestamp: &timestamp,
signature: STANDARD.encode(signature.to_bytes()),
};
let encoded = serde_json::to_vec(&envelope)
.map_err(|_| CodexAgentIdentityError::InvalidCredential)?;
Ok(SecretString::from(format!(
"AgentAssertion {}",
URL_SAFE_NO_PAD.encode(encoded)
)))
}
fn registration(&self, now: DateTime<Utc>) -> AgentTaskRegistration {
let timestamp = timestamp(now);
let payload = format!("{}:{timestamp}", self.runtime_id);
let signature = self.signing_key.sign(payload.as_bytes());
AgentTaskRegistration {
timestamp,
signature: STANDARD.encode(signature.to_bytes()),
}
}
fn decrypt_task_id(&self, encoded: &str) -> Result<String, CodexAgentIdentityError> {
let ciphertext = STANDARD
.decode(encoded.trim())
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let digest = Sha512::digest(self.signing_key.to_bytes());
let mut curve_key = [0_u8; 32];
curve_key.copy_from_slice(&digest[..32]);
let secret = BoxSecretKey::from(curve_key);
let plaintext = secret
.unseal(&ciphertext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = String::from_utf8(plaintext)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
let task_id = task_id.trim().to_owned();
if !valid_identifier(&task_id) {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
Ok(task_id)
}
}
impl fmt::Debug for CodexAgentIdentitySecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentitySecret")
.field("runtime_id", &"<redacted>")
.field("signing_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
#[derive(Serialize)]
struct AgentAssertionEnvelope<'a> {
agent_runtime_id: &'a str,
task_id: &'a str,
timestamp: &'a str,
signature: String,
}
#[derive(Serialize)]
struct AgentTaskRegistration {
timestamp: String,
signature: String,
}
#[derive(Deserialize)]
struct AgentTaskRegistrationResponse {
#[serde(default, alias = "taskId")]
task_id: Option<String>,
#[serde(default, alias = "encryptedTaskId")]
encrypted_task_id: Option<String>,
}
#[async_trait]
pub trait CodexAgentIdentityTaskRegistrar: Send + Sync + 'static {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError>;
}
pub struct OfficialCodexAgentIdentityTaskRegistrar {
client: reqwest::Client,
base_url: Url,
profile: CodexWireProfileState,
}
impl OfficialCodexAgentIdentityTaskRegistrar {
pub fn new(
client: reqwest::Client,
profile: CodexWireProfileState,
) -> Result<Self, CodexAgentIdentityError> {
let base_url = Url::parse(OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
Ok(Self {
client,
base_url,
profile,
})
}
}
impl fmt::Debug for OfficialCodexAgentIdentityTaskRegistrar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("OfficialCodexAgentIdentityTaskRegistrar")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for OfficialCodexAgentIdentityTaskRegistrar {
async fn register(
&self,
credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
let mut endpoint = self.base_url.clone();
endpoint
.path_segments_mut()
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?
.extend(["v1", "agent", credential.runtime_id(), "task", "register"]);
let profile = self.profile.snapshot();
let headers = build_codex_profile_headers(&profile)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
let response = self
.client
.post(endpoint)
.headers(headers)
.header(reqwest::header::ACCEPT, "application/json")
.json(&credential.registration(Utc::now()))
.send()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if !response.status().is_success() {
return Err(CodexAgentIdentityError::TaskRegistrationRejected);
}
if response
.content_length()
.is_some_and(|length| length > MAX_TASK_RESPONSE_BYTES as u64)
{
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let body = response
.bytes()
.await
.map_err(|_| CodexAgentIdentityError::TaskRegistrationUnavailable)?;
if body.len() > MAX_TASK_RESPONSE_BYTES {
return Err(CodexAgentIdentityError::InvalidTaskResponse);
}
let result = serde_json::from_slice::<AgentTaskRegistrationResponse>(&body)
.map_err(|_| CodexAgentIdentityError::InvalidTaskResponse)?;
if let Some(task_id) = result.task_id.map(|value| value.trim().to_owned())
&& valid_identifier(&task_id)
{
return Ok(task_id);
}
let encrypted = result
.encrypted_task_id
.as_deref()
.ok_or(CodexAgentIdentityError::InvalidTaskResponse)?;
credential.decrypt_task_id(encrypted)
}
}
pub struct PreparedCodexRuntimeCredential {
pub account: ProviderAccount,
pub credential: CodexRuntimeCredential,
}
/// Agent task 的唯一注册与恢复 owner。
pub struct CodexAgentIdentityTaskService {
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
account_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
}
impl CodexAgentIdentityTaskService {
#[must_use]
pub fn new(
repository: CodexCredentialRepository,
registrar: Arc<dyn CodexAgentIdentityTaskRegistrar>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Self {
Self {
repository,
registrar,
websocket_pool,
account_locks: Mutex::new(HashMap::new()),
}
}
pub async fn prepare(
&self,
account: &ProviderAccount,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let prepared = self.load_current(account.id()).await?;
if !matches!(
&prepared.credential.authentication,
CodexRuntimeAuthentication::AgentIdentity(secret) if secret.task_id().is_none()
) {
return Ok(prepared);
}
self.ensure_task(account.id(), None).await
}
pub async fn recover(
&self,
account_id: &ProviderAccountId,
expected_task_id: &str,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
self.ensure_task(account_id, Some(expected_task_id)).await
}
pub async fn recover_after_rejected_task(
&self,
account_id: &ProviderAccountId,
authentication: &CodexRuntimeAuthentication,
error: &CodexClientError,
) -> Result<Option<PreparedCodexRuntimeCredential>, CodexAgentIdentityError> {
let CodexRuntimeAuthentication::AgentIdentity(secret) = authentication else {
return Ok(None);
};
let Some(task_id) = secret.task_id() else {
return Ok(None);
};
if !is_agent_identity_task_invalid_client_error(error) {
return Ok(None);
}
self.recover(account_id, task_id).await.map(Some)
}
async fn ensure_task(
&self,
account_id: &ProviderAccountId,
expected_task_id: Option<&str>,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let lock = {
let mut locks = self.account_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let _guard = lock.lock().await;
let current = self.load_current(account_id).await?;
let CodexRuntimeAuthentication::AgentIdentity(secret) = &current.credential.authentication
else {
return Ok(current);
};
match (expected_task_id, secret.task_id()) {
(None, Some(_)) => return Ok(current),
(Some(expected), Some(actual)) if actual != expected => return Ok(current),
_ => {}
}
let task_id = self.registrar.register(secret).await?;
let mut data = self.repository.load_complete_data(&current.account).await?;
let agent = data
.agent_identity_mut()
.ok_or(CodexAgentIdentityError::InvalidCredential)?;
match (expected_task_id, agent.task_id.as_deref()) {
(None, Some(_)) => return self.load_current(account_id).await,
(Some(expected), Some(actual)) if actual != expected => {
return self.load_current(account_id).await;
}
_ => agent.task_id = Some(task_id),
}
self.repository
.compare_and_swap_data(&current.account, data)
.await?;
self.websocket_pool.evict_account(account_id.as_str()).await;
self.load_current(account_id).await
}
pub(crate) async fn load_current(
&self,
account_id: &ProviderAccountId,
) -> Result<PreparedCodexRuntimeCredential, CodexAgentIdentityError> {
let account = self
.repository
.store()
.get_account(account_id)
.await?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexAgentIdentityError::NotFound)?;
let credential = self.repository.load_runtime_credential(&account).await?;
Ok(PreparedCodexRuntimeCredential {
account,
credential,
})
}
}
impl fmt::Debug for CodexAgentIdentityTaskService {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityTaskService")
.field("repository", &"CodexCredentialRepository")
.field("registrar", &"CodexAgentIdentityTaskRegistrar")
.finish_non_exhaustive()
}
}
#[derive(Debug, Error)]
pub enum CodexAgentIdentityError {
#[error("OpenAI Agent Identity credential is invalid")]
InvalidCredential,
#[error("OpenAI Agent Identity task is unavailable")]
MissingTask,
#[error("OpenAI Agent Identity task registration is unavailable")]
TaskRegistrationUnavailable,
#[error("OpenAI Agent Identity task registration was rejected")]
TaskRegistrationRejected,
#[error("OpenAI Agent Identity task registration response is invalid")]
InvalidTaskResponse,
#[error("OpenAI Agent Identity configuration is invalid")]
InvalidConfiguration,
#[error("OpenAI Agent Identity account was not found")]
NotFound,
#[error("OpenAI Agent Identity credential store is unavailable")]
Repository,
}
impl From<CredentialRepositoryError> for CodexAgentIdentityError {
fn from(_: CredentialRepositoryError) -> Self {
Self::Repository
}
}
impl From<gateway_core::error::StoreError> for CodexAgentIdentityError {
fn from(_: gateway_core::error::StoreError) -> Self {
Self::Repository
}
}
#[must_use]
pub fn is_agent_identity_task_invalid_response(status: StatusCode, body: &str) -> bool {
if status != StatusCode::UNAUTHORIZED {
return false;
}
let lower = body.to_ascii_lowercase();
let compact = lower
.chars()
.filter(|character| !character.is_ascii_whitespace())
.collect::<String>();
[
"\"code\":\"invalid_task_id\"",
"\"code\":\"task_not_found\"",
"\"code\":\"task_expired\"",
"\"error\":\"invalid_task_id\"",
]
.iter()
.any(|marker| compact.contains(marker))
|| [
"invalid task_id",
"invalid task id",
"task_id is invalid",
"task id is invalid",
"task not found",
"task expired",
"unknown task_id",
"unknown task id",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn is_agent_identity_task_invalid_client_error(error: &CodexClientError) -> bool {
match error {
CodexClientError::Upstream { status, body, .. } => {
is_agent_identity_task_invalid_response(*status, body)
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexClientError::WebSocket(CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
}) => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn is_agent_identity_task_invalid_websocket_error(error: &CodexWebSocketExchangeError) -> bool {
match error {
CodexWebSocketExchangeError::Upstream(upstream) => {
StatusCode::from_u16(upstream.status_code)
.ok()
.is_some_and(|status| {
is_agent_identity_task_invalid_response(status, &upstream.body)
})
}
CodexWebSocketExchangeError::PostSendAmbiguous {
source: Some(source),
..
} => is_agent_identity_task_invalid_websocket_error(source),
_ => false,
}
}
fn timestamp(now: DateTime<Utc>) -> String {
now.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn valid_identifier(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
@@ -19,7 +19,6 @@ use thiserror::Error;
use tokio::sync::Notify;
use uuid::Uuid;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{CodexBackendClient, CodexCatalogModel, CodexRequestContext};
@@ -182,7 +181,6 @@ pub struct CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
cache: Arc<RwLock<CatalogCacheState>>,
etags: Arc<Mutex<CatalogEtagState>>,
@@ -195,7 +193,6 @@ impl CodexCredentialCatalogService {
profile: CodexWireProfileState,
http: reqwest::Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
plan_catalog_cache: Arc<dyn ProviderCatalogCachePort>,
) -> Self {
Self {
@@ -203,7 +200,6 @@ impl CodexCredentialCatalogService {
profile,
http,
base_url,
agent_identity,
plan_catalog_cache,
cache: Arc::new(RwLock::new(CatalogCacheState::default())),
etags: Arc::new(Mutex::new(CatalogEtagState::default())),
@@ -429,52 +425,24 @@ impl CodexCredentialCatalogService {
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut request_id = format!("catalog_{}", Uuid::now_v7().simple());
let mut authorization = prepared
.credential
let request_id = format!("catalog_{}", Uuid::now_v7().simple());
let authorization = credential
.authentication
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let mut result = client
let result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
account.upstream_account_id(),
&request_id,
None,
))
.await;
if let Err(error) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?
{
prepared = recovered;
request_id = format!("catalog_{}", Uuid::now_v7().simple());
authorization = prepared
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
result = client
.fetch_models_with_context(CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&request_id,
None,
))
.await;
}
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?;
@@ -2,10 +2,10 @@
mod admin;
mod affinity;
mod agent_identity;
mod catalog;
mod cookie;
mod oauth;
mod profile_statistics;
mod quota;
mod recovery_log;
mod refresh;
@@ -16,17 +16,11 @@ pub mod token_client;
mod types;
pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
CodexSessionAffinity, derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
CodexAgentIdentityTaskService, OfficialCodexAgentIdentityTaskRegistrar,
PreparedCodexRuntimeCredential, is_agent_identity_task_invalid_response,
};
pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
@@ -44,13 +38,11 @@ pub use oauth::{
CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthAuthorization,
CompletedCodexOAuthCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
pub use profile_statistics::{CodexCredentialProfileService, CodexProfileStatisticsError};
pub use quota::{
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, CodexUsageStatistics,
CodexUsageStatisticsCycle, CodexUsageStatisticsDay, CodexUsageStatisticsError,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens, parse_codex_quota_usage,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, parse_codex_quota_usage,
};
pub use refresh::{
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
@@ -67,9 +59,7 @@ pub use selector::{
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub use types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCookie,
CodexCookieCaptureOutcome, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RotateCodexCredential, RuntimeCodexCookie,
UpsertCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
RotateCodexCredential, RuntimeCodexCookie, UpsertCodexCookie,
};
@@ -0,0 +1,154 @@
//! OpenAI 当前账号个人资料统计查询编排。
use std::time::SystemTime;
use gateway_core::engine::credential::ProviderAccountId;
use reqwest::Client;
use secrecy::ExposeSecret as _;
use thiserror::Error;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexProfileStatistics, CodexRequestContext,
};
use super::CodexCredentialRepository;
#[derive(Error)]
pub enum CodexProfileStatisticsError {
#[error("Codex profile-statistics credential data is invalid")]
InvalidCredentialData,
#[error("Codex OAuth access token must be refreshed before querying profile statistics")]
CredentialRefreshRequired { upstream_body: Option<String> },
#[error("Codex profile-statistics account was not found")]
NotFound,
#[error("provider account store is unavailable: {detail}")]
Store { detail: String },
#[error("Codex profile-statistics upstream returned HTTP {status}")]
Upstream {
status: u16,
body: String,
retry_after_seconds: Option<u64>,
},
#[error("Codex profile-statistics query transport is unavailable")]
TransportUnavailable,
}
impl std::fmt::Debug for CodexProfileStatisticsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCredentialData => formatter.write_str("InvalidCredentialData"),
Self::CredentialRefreshRequired { .. } => {
formatter.write_str("CredentialRefreshRequired { upstream_body: <redacted> }")
}
Self::NotFound => formatter.write_str("NotFound"),
Self::Store { .. } => formatter.write_str("Store { detail: <redacted> }"),
Self::Upstream {
status,
retry_after_seconds,
..
} => formatter
.debug_struct("Upstream")
.field("status", status)
.field("body", &"<redacted>")
.field("retry_after_seconds", retry_after_seconds)
.finish(),
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
}
}
}
pub struct CodexCredentialProfileService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
base_url: String,
}
impl CodexCredentialProfileService {
#[must_use]
pub const fn new(
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
base_url: String,
) -> Self {
Self {
repository,
profile,
http,
base_url,
}
}
pub async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let account = self
.repository
.store()
.get_account(account_id)
.await
.map_err(|error| CodexProfileStatisticsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexProfileStatisticsError::NotFound)?;
if account
.access_token_expires_at()
.is_some_and(|expires_at| expires_at <= SystemTime::now())
{
return Err(CodexProfileStatisticsError::CredentialRefreshRequired {
upstream_body: None,
});
}
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexProfileStatisticsError::InvalidCredentialData)?;
let authorization = credential
.authentication
.authorization_header()
.map_err(|_| CodexProfileStatisticsError::InvalidCredentialData)?;
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.fetch_profile_statistics(CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
))
.await
.map_err(map_client_error)
}
}
fn map_client_error(error: CodexClientError) -> CodexProfileStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED =>
{
CodexProfileStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
..
} => CodexProfileStatisticsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
},
_ => CodexProfileStatisticsError::TransportUnavailable,
}
}
@@ -7,23 +7,15 @@
mod document;
pub(crate) mod evidence;
pub(crate) mod snapshot;
mod usage_statistics;
pub use snapshot::{
CodexAccountQuotaSnapshot, CodexQuotaFact, CodexQuotaWindow, CodexQuotaWindowKind,
CodexQuotaWindowRole, parse_codex_quota_usage,
};
pub use usage_statistics::{
CodexUsageStatistics, CodexUsageStatisticsCycle, CodexUsageStatisticsDay,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant, SystemTime};
use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
@@ -42,15 +34,13 @@ 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,
};
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::types::CODEX_AUTHENTICATION_KIND_OAUTH;
use super::security::CodexRuntimeCredential;
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
@@ -152,57 +142,6 @@ 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 {
@@ -242,7 +181,6 @@ pub struct CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
scheduling: CodexQuotaSchedulingProjection,
reset_consume_locks: Mutex<HashMap<ProviderAccountId, Arc<Mutex<()>>>>,
@@ -286,6 +224,11 @@ struct FetchedCodexQuota {
value: Value,
}
struct PreparedCodexRuntimeCredential {
account: ProviderAccount,
credential: CodexRuntimeCredential,
}
enum CodexQuotaFetchAttemptError {
InvalidCredential,
Upstream(CodexClientError),
@@ -293,7 +236,6 @@ enum CodexQuotaFetchAttemptError {
enum CodexQuotaFetchError {
InvalidCredential,
Recovery,
Upstream {
account: Box<ProviderAccount>,
error: CodexClientError,
@@ -305,25 +247,6 @@ enum ResetCreditAttemptError {
Upstream(CodexClientError),
}
enum DailyUsageAttemptError {
InvalidCredential,
Upstream(CodexClientError),
}
enum DailyUsageFetchError {
InvalidCredential,
Recovery,
Upstream {
prepared: Box<PreparedCodexRuntimeCredential>,
error: CodexClientError,
},
}
struct FetchedDailyUsage {
model_breakdown: Value,
daily_totals: Value,
}
impl CodexQuotaSchedulingProjection {
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
let mut state = self
@@ -536,7 +459,6 @@ impl CodexCredentialQuotaService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cooldowns: Arc<dyn ProviderCooldownPort>,
) -> Self {
Self {
@@ -545,7 +467,6 @@ impl CodexCredentialQuotaService {
profile,
http,
base_url,
agent_identity,
cooldowns,
scheduling: CodexQuotaSchedulingProjection::default(),
reset_consume_locks: Mutex::new(HashMap::new()),
@@ -563,135 +484,19 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credits_{}", Uuid::now_v7().simple());
let mut result = list_reset_credits_once(&client, &prepared, &request_id).await;
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = list_reset_credits_once(&client, &prepared, &request_id).await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
}
/// 查询当前账号的官方每日用量报表,并把报表范围锚定到最接近七天的额度窗口。
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)
list_reset_credits_once(&client, &prepared, &request_id)
.await
.map_err(|error| CodexUsageStatisticsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexUsageStatisticsError::NotFound)?;
let observed_at = SystemTime::now();
if !access_token_is_current(&account, observed_at) {
return Err(CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: None,
});
}
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } = self
.fetch_usage_with_recovery(&client, &account)
.await
.map_err(map_statistics_quota_fetch_error)?;
let snapshot = parse_account_quota_snapshot(
account.id().clone(),
account.revision(),
observed_at,
&value,
)
.map_err(|_| CodexUsageStatisticsError::InvalidCredentialData)?;
let window = snapshot
.windows()
.iter()
.filter(|window| window.is_account_wide())
.filter_map(|window| {
Some((
window,
window.window_seconds()?.abs_diff(TARGET_WINDOW_SECONDS),
))
})
.min_by_key(|(_, distance)| *distance)
.map(|(window, _)| window)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_seconds = window
.window_seconds()
.filter(|seconds| *seconds > 0)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let end_at = window
.reset_at()
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_delta = chrono::TimeDelta::seconds(
i64::try_from(window_seconds)
.map_err(|_| CodexUsageStatisticsError::QuotaWindowUnavailable)?,
);
let current_start_at = end_at - window_delta;
let timezone = FixedOffset::east_opt(i32::from(utc_offset_minutes) * 60)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let now = DateTime::<Utc>::from(observed_at);
let period = usage_statistics::statistics_period(
current_start_at,
end_at,
window_seconds,
cycle_offset,
timezone,
now,
)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let fetched = self
.fetch_daily_usage_with_recovery(
&client,
&account,
period.query_start_date,
period.query_end_date,
)
.await
.map_err(map_daily_usage_fetch_error)?;
Ok(usage_statistics::build_usage_statistics(
usage_statistics::BuildUsageStatistics {
period,
timezone,
current_used_percent: window.used_percent(),
now,
model_breakdown: &fetched.model_breakdown,
daily_totals: &fetched.daily_totals,
max_cycle_offset: MAX_CYCLE_OFFSET,
},
))
.map_err(|error| map_reset_credit_attempt_error(error, false))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
@@ -716,45 +521,25 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let mut prepared = self
.agent_identity
.prepare(&account)
let credential = self
.repository
.load_runtime_credential(&account)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?;
let prepared = PreparedCodexRuntimeCredential {
account,
credential,
};
let request_id = format!("reset_credit_consume_{}", Uuid::now_v7().simple());
let mut result = consume_reset_credit_once(
consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
// Agent Identity task recovery is the only automatic retry below. It keeps the exact
// official credit selection and redeem_request_id; generic HTTP/transport failures are
// never retried because the debit result may already be committed upstream.
if let Err(ResetCreditAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexResetCreditsError::InvalidCredentialData)?
{
prepared = recovered;
result = consume_reset_credit_once(
&client,
&prepared,
&request_id,
credit_id,
redeem_request_id,
)
.await;
}
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, true))
.await
.map_err(|error| map_reset_credit_attempt_error(error, true))
}
async fn reset_credit_account(
@@ -903,7 +688,7 @@ impl CodexCredentialQuotaService {
);
for account in accounts {
let observed_at = SystemTime::now();
match self.fetch_usage_with_recovery(&client, &account).await {
match self.fetch_usage(&client, &account).await {
Ok(FetchedCodexQuota { account, value }) => {
// 单账号解析或落库失败只影响该账号;其余账号继续同步。
if let Err(error) = self
@@ -921,13 +706,6 @@ impl CodexCredentialQuotaService {
Err(CodexQuotaFetchError::InvalidCredential) => {
summary.stale += 1;
}
Err(CodexQuotaFetchError::Recovery) => {
summary.transient += 1;
tracing::warn!(
account_id = %account.id(),
"OpenAI quota credential recovery failed; refresh cycle will retry later"
);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
@@ -1286,34 +1064,28 @@ impl CodexCredentialQuotaService {
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } =
match self.fetch_usage_with_recovery(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Recovery) => {
return Err(CodexCredentialQuotaError::Upstream {
detail: "credential recovery failed".to_owned(),
});
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
let FetchedCodexQuota { account, value } = match self.fetch_usage(&client, &account).await {
Ok(fetched) => fetched,
Err(CodexQuotaFetchError::InvalidCredential) => {
return Err(CodexCredentialQuotaError::InvalidCredentialData);
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
match classify_quota_endpoint_failure(&error) {
Some(QuotaEndpointFailure::Exhausted(evidence)) => {
self.record_confirmed_exhaustion(&account, evidence, None, observed_at)
.await?;
}
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
Some(QuotaEndpointFailure::Credential { state, reason }) => {
self.persist_credential_failure(&account, state, reason, observed_at)
.await;
}
None => {}
}
};
return Err(CodexCredentialQuotaError::Upstream {
detail: error.to_string(),
});
}
};
let object = normalize_quota_window_placeholders(
value
.as_object()
@@ -1406,31 +1178,21 @@ impl CodexCredentialQuotaService {
}
}
async fn fetch_usage_with_recovery(
async fn fetch_usage(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
) -> Result<FetchedCodexQuota, CodexQuotaFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexQuotaFetchError::InvalidCredential)?;
let mut result = fetch_usage_with_5xx_retry(client, &prepared).await;
if let Err(CodexQuotaFetchAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| CodexQuotaFetchError::Recovery)?
{
prepared = recovered;
result = fetch_usage_with_5xx_retry(client, &prepared).await;
}
let prepared = PreparedCodexRuntimeCredential {
account: account.clone(),
credential,
};
let result = fetch_usage_with_5xx_retry(client, &prepared).await;
match result {
Ok(value) => Ok(FetchedCodexQuota {
account: prepared.account,
@@ -1447,135 +1209,6 @@ impl CodexCredentialQuotaService {
}
}
}
async fn fetch_daily_usage_with_recovery(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
.await
.map_err(|_| DailyUsageFetchError::InvalidCredential)?;
let mut result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
if let Err(DailyUsageAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| DailyUsageFetchError::Recovery)?
{
prepared = recovered;
result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
}
match result {
Ok(value) => Ok(value),
Err(DailyUsageAttemptError::InvalidCredential) => {
Err(DailyUsageFetchError::InvalidCredential)
}
Err(DailyUsageAttemptError::Upstream(error)) => Err(DailyUsageFetchError::Upstream {
prepared: Box::new(prepared),
error,
}),
}
}
}
async fn fetch_daily_usage_once(
client: &CodexBackendClient,
prepared: &PreparedCodexRuntimeCredential,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageAttemptError> {
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.map_err(|_| DailyUsageAttemptError::InvalidCredential)?;
let model_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let totals_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let model_breakdown = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&model_request_id,
None,
),
CodexDailyUsageReport::PersonalModelCredits,
start_date,
end_date,
);
let daily_totals = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&totals_request_id,
None,
),
CodexDailyUsageReport::PersonalTokenTotals,
start_date,
end_date,
);
let (model_breakdown, daily_totals) = tokio::try_join!(model_breakdown, daily_totals)
.map_err(DailyUsageAttemptError::Upstream)?;
Ok(FetchedDailyUsage {
model_breakdown,
daily_totals,
})
}
fn map_statistics_quota_fetch_error(error: CodexQuotaFetchError) -> CodexUsageStatisticsError {
match error {
CodexQuotaFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
CodexQuotaFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
CodexQuotaFetchError::Upstream { account, error } => map_statistics_client_error(
error,
account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH,
),
}
}
fn map_daily_usage_fetch_error(error: DailyUsageFetchError) -> CodexUsageStatisticsError {
match error {
DailyUsageFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
DailyUsageFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
DailyUsageFetchError::Upstream { prepared, error } => {
map_statistics_client_error(error, prepared.credential.authentication.oauth().is_some())
}
}
}
fn map_statistics_client_error(
error: CodexClientError,
credential_can_refresh: bool,
) -> CodexUsageStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED && credential_can_refresh =>
{
CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
..
} => CodexUsageStatisticsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
},
_ => CodexUsageStatisticsError::TransportUnavailable,
}
}
async fn list_reset_credits_once(
@@ -1586,7 +1219,7 @@ async fn list_reset_credits_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.list_rate_limit_reset_credits(CodexRequestContext::auxiliary(
@@ -1609,7 +1242,7 @@ async fn consume_reset_credit_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| ResetCreditAttemptError::InvalidCredential)?;
client
.consume_rate_limit_reset_credit(
@@ -1628,22 +1261,15 @@ async fn consume_reset_credit_once(
fn map_reset_credit_attempt_error(
error: ResetCreditAttemptError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
match error {
ResetCreditAttemptError::InvalidCredential => CodexResetCreditsError::InvalidCredentialData,
ResetCreditAttemptError::Upstream(error) => {
map_reset_credit_client_error(error, prepared, consume)
}
ResetCreditAttemptError::Upstream(error) => map_reset_credit_client_error(error, consume),
}
}
fn map_reset_credit_client_error(
error: CodexClientError,
prepared: &PreparedCodexRuntimeCredential,
consume: bool,
) -> CodexResetCreditsError {
fn map_reset_credit_client_error(error: CodexClientError, consume: bool) -> CodexResetCreditsError {
match error {
CodexClientError::Upstream {
status,
@@ -1651,8 +1277,7 @@ fn map_reset_credit_client_error(
diagnostics,
..
} if status == reqwest::StatusCode::UNAUTHORIZED
&& reset_credit_response_was_explicit_rejection(status, &diagnostics)
&& prepared.credential.authentication.oauth().is_some() =>
&& reset_credit_response_was_explicit_rejection(status, &diagnostics) =>
{
CodexResetCreditsError::CredentialRefreshRequired {
upstream_body: Some(body),
@@ -1735,7 +1360,7 @@ async fn fetch_usage_once(
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.authorization_header()
.map_err(|_| CodexQuotaFetchAttemptError::InvalidCredential)?;
let request_id = format!("quota_{}", Uuid::now_v7().simple());
client
@@ -1,874 +0,0 @@
use std::{
cmp::{Ordering, Reverse},
collections::BTreeMap,
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime, TimeDelta, Utc};
use gateway_core::accounting::{Decimal, Money};
use serde_json::Value;
use crate::transport::{OpenAiBillingUsage, openai_aggregate_billing_breakdown};
const EPSILON: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CodexUsageStatisticsServiceTier {
Standard,
Fast,
}
impl CodexUsageStatisticsServiceTier {
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
const fn billing_service_tier(self) -> &'static str {
match self {
Self::Standard => "default",
Self::Fast => "fast",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CodexUsageStatisticsTokens {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
impl CodexUsageStatisticsTokens {
fn checked_add(self, other: Self) -> Option<Self> {
Some(Self {
uncached_input: self.uncached_input.checked_add(other.uncached_input)?,
cached_input: self.cached_input.checked_add(other.cached_input)?,
output: self.output.checked_add(other.output)?,
total: self.total.checked_add(other.total)?,
})
}
const fn is_empty(self) -> bool {
self.total == 0 && self.uncached_input == 0 && self.cached_input == 0 && self.output == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsModel {
pub key: String,
pub model: String,
pub service_tier: CodexUsageStatisticsServiceTier,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsDay {
pub date: NaiveDate,
pub credit_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsSummary {
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<Money>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatistics {
pub cycle: CodexUsageStatisticsCycle,
pub summary: CodexUsageStatisticsSummary,
pub models: Vec<CodexUsageStatisticsModel>,
pub daily: Vec<CodexUsageStatisticsDay>,
}
#[derive(Debug, Clone, Copy)]
pub struct CodexUsageStatisticsPeriod {
pub cycle_offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub query_start_date: NaiveDate,
pub query_end_date: NaiveDate,
}
pub fn statistics_period(
current_start_at: DateTime<Utc>,
current_end_at: DateTime<Utc>,
window_seconds: u64,
cycle_offset: i8,
timezone: FixedOffset,
now: DateTime<Utc>,
) -> Option<CodexUsageStatisticsPeriod> {
let window_delta = TimeDelta::seconds(i64::try_from(window_seconds).ok()?);
let shift = window_delta.checked_mul(i32::from(cycle_offset))?;
let start_at = current_start_at.checked_add_signed(shift)?;
let end_at = current_end_at.checked_add_signed(shift)?;
let query_start_date = start_at
.checked_sub_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
let effective_end = end_at.min(now);
let query_end_date = effective_end
.checked_add_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
Some(CodexUsageStatisticsPeriod {
cycle_offset,
start_at,
end_at,
window_seconds,
query_start_date,
query_end_date,
})
}
pub(super) struct BuildUsageStatistics<'a> {
pub period: CodexUsageStatisticsPeriod,
pub timezone: FixedOffset,
pub current_used_percent: Option<f64>,
pub now: DateTime<Utc>,
pub model_breakdown: &'a Value,
pub daily_totals: &'a Value,
pub max_cycle_offset: u8,
}
pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUsageStatistics {
let BuildUsageStatistics {
period,
timezone,
current_used_percent,
now,
model_breakdown,
daily_totals,
max_cycle_offset,
} = input;
let all_days = build_personal_days(daily_totals, model_breakdown);
let selected_days = select_cycle_days(
&all_days,
period.start_at,
period.end_at,
timezone,
now,
true,
);
let is_current = period.cycle_offset == 0;
let used_percent = is_current
.then_some(current_used_percent)
.flatten()
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0));
let used_ratio = used_percent
.map(|value| value.clamp(0.0, 100.0) / 100.0)
.unwrap_or_default();
let summary = summarize(&selected_days, used_ratio, is_current);
let models = aggregate_models(&selected_days, used_ratio, is_current);
let total_credits = selected_days
.iter()
.map(|day| day.credit_weight)
.sum::<f64>();
let mut daily = selected_days
.into_iter()
.map(|day| CodexUsageStatisticsDay {
date: day.date,
credit_share: (total_credits > EPSILON).then_some(day.credit_weight / total_credits),
tokens: day.tokens,
estimated_cost: day.estimated_cost,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
.collect::<Vec<_>>();
daily.sort_by_key(|day| Reverse(day.date));
CodexUsageStatistics {
cycle: CodexUsageStatisticsCycle {
offset: period.cycle_offset,
start_at: period.start_at,
end_at: period.end_at,
window_seconds: period.window_seconds,
used_percent,
is_current,
can_go_previous: period.cycle_offset > -(max_cycle_offset as i8),
can_go_next: period.cycle_offset < 0,
},
summary,
models,
daily,
}
}
#[derive(Debug, Clone)]
struct DailyModelUsage {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
credits: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
#[derive(Debug, Clone)]
struct DailyUsage {
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_missing_token_data: bool,
is_boundary_day: bool,
models: Vec<DailyModelUsage>,
}
#[derive(Default)]
struct MoneySum {
value: Option<Money>,
overflowed: bool,
}
impl MoneySum {
fn push(&mut self, value: Option<Money>) {
let Some(value) = value else {
return;
};
if self.overflowed {
return;
}
self.value = match self.value {
Some(current) => match current.checked_add(value) {
Some(total) => Some(total),
None => {
self.overflowed = true;
None
}
},
None => Some(value),
};
}
fn finish(self) -> Option<Money> {
(!self.overflowed).then_some(self.value).flatten()
}
}
fn build_personal_days(totals_payload: &Value, breakdown_payload: &Value) -> Vec<DailyUsage> {
let totals_by_date = rows_by_date(totals_payload);
let breakdown_by_date = rows_by_date(breakdown_payload);
let mut dates = totals_by_date
.keys()
.chain(breakdown_by_date.keys())
.copied()
.collect::<Vec<_>>();
dates.sort_unstable();
dates.dedup();
dates
.into_iter()
.filter_map(|date| {
let totals_row = totals_by_date.get(&date).unwrap_or(&Value::Null);
let breakdown_row = breakdown_by_date.get(&date).unwrap_or(&Value::Null);
let totals = token_parts(totals_row.get("totals").unwrap_or(totals_row));
let credit_weight = day_credit_weight(breakdown_row);
let weighted = weighted_models(breakdown_row);
if totals.is_empty() {
if credit_weight <= EPSILON {
return None;
}
let models = weighted
.into_iter()
.map(|model| DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: model.weight,
tokens: CodexUsageStatisticsTokens::default(),
estimated_cost: None,
has_unknown_pricing: false,
has_estimated_allocation: false,
has_rate_fallback: false,
has_missing_token_data: true,
})
.collect();
return Some(finalize_day(date, credit_weight, totals, models, true));
}
let has_model_breakdown = !weighted.is_empty();
let weighted = if has_model_breakdown {
weighted
} else {
vec![WeightedModel {
model: "unknown".to_owned(),
service_tier: CodexUsageStatisticsServiceTier::Standard,
weight: 1.0,
}]
};
let allocation = rate_adjusted_shares(&weighted, totals);
let uncached = allocate_integer_total(totals.uncached_input, &allocation.token_shares);
let cached = allocate_integer_total(totals.cached_input, &allocation.token_shares);
let output = allocate_integer_total(totals.output, &allocation.token_shares);
let total = allocate_integer_total(totals.total, &allocation.token_shares);
let estimated_allocation = weighted.len() > 1 || !has_model_breakdown;
let rate_fallback = allocation.has_rate_fallback || !has_model_breakdown;
let models = weighted
.into_iter()
.enumerate()
.map(|(index, model)| {
let tokens = CodexUsageStatisticsTokens {
uncached_input: uncached.get(index).copied().unwrap_or_default(),
cached_input: cached.get(index).copied().unwrap_or_default(),
output: output.get(index).copied().unwrap_or_default(),
total: total.get(index).copied().unwrap_or_default(),
};
let estimated_cost = aggregate_cost(&model.model, model.service_tier, tokens);
DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: if has_model_breakdown {
model.weight
} else {
0.0
},
tokens,
has_unknown_pricing: estimated_cost.is_none() && !tokens.is_empty(),
estimated_cost,
has_estimated_allocation: estimated_allocation,
has_rate_fallback: rate_fallback,
has_missing_token_data: false,
}
})
.collect();
Some(finalize_day(date, credit_weight, totals, models, false))
})
.collect()
}
fn finalize_day(
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
models: Vec<DailyModelUsage>,
has_missing_token_data: bool,
) -> DailyUsage {
let mut costs = MoneySum::default();
for model in &models {
costs.push(model.estimated_cost);
}
DailyUsage {
date,
credit_weight,
tokens,
estimated_cost: costs.finish(),
has_unknown_pricing: models.iter().any(|model| model.has_unknown_pricing),
has_missing_token_data,
is_boundary_day: false,
models,
}
}
#[derive(Debug)]
struct WeightedModel {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
weight: f64,
}
struct Allocation {
token_shares: Vec<f64>,
has_rate_fallback: bool,
}
fn weighted_models(row: &Value) -> Vec<WeightedModel> {
value_array(row.get("models"))
.iter()
.filter_map(|value| {
let weight = non_negative_f64(value.get("credits"));
(weight > EPSILON).then(|| WeightedModel {
model: model_name(value),
service_tier: model_service_tier(value.get("speed").or_else(|| value.get("mode"))),
weight,
})
})
.collect()
}
fn rate_adjusted_shares(
models: &[WeightedModel],
totals: CodexUsageStatisticsTokens,
) -> Allocation {
let total_weight = models.iter().map(|model| model.weight).sum::<f64>();
let credit_shares = models
.iter()
.map(|model| model.weight / total_weight)
.collect::<Vec<_>>();
if models.len() == 1 {
return Allocation {
token_shares: vec![1.0],
has_rate_fallback: false,
};
}
let classified_tokens = totals
.uncached_input
.saturating_add(totals.cached_input)
.saturating_add(totals.output);
if classified_tokens == 0 {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let rates = models
.iter()
.map(|model| blended_rate(&model.model, model.service_tier, totals, classified_tokens))
.collect::<Vec<_>>();
let known = rates
.iter()
.enumerate()
.filter_map(|(index, rate)| rate.map(|rate| (index, rate)))
.collect::<Vec<_>>();
if known.is_empty() {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let known_weight = known
.iter()
.map(|(index, _)| models[*index].weight)
.sum::<f64>();
let fallback_rate = known
.iter()
.map(|(index, rate)| rate * models[*index].weight / known_weight)
.sum::<f64>();
let inverse = models
.iter()
.enumerate()
.map(|(index, model)| model.weight / rates[index].unwrap_or(fallback_rate))
.collect::<Vec<_>>();
let inverse_total = inverse.iter().sum::<f64>();
Allocation {
token_shares: inverse
.into_iter()
.map(|value| value / inverse_total)
.collect(),
has_rate_fallback: known.len() != models.len(),
}
}
#[allow(clippy::cast_precision_loss)]
fn blended_rate(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
totals: CodexUsageStatisticsTokens,
classified_tokens: u64,
) -> Option<f64> {
let cost = aggregate_cost(model, service_tier, totals)?;
let rate = cost.amount().scaled() as f64 / classified_tokens as f64;
(rate > EPSILON).then_some(rate)
}
fn aggregate_cost(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
tokens: CodexUsageStatisticsTokens,
) -> Option<Money> {
let input = tokens.uncached_input.checked_add(tokens.cached_input)?;
openai_aggregate_billing_breakdown(
model,
OpenAiBillingUsage::new(input, tokens.output, tokens.cached_input, 0),
Some(service_tier.billing_service_tier()),
)
.map(|breakdown| breakdown.total_amount())
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn allocate_integer_total(total: u64, shares: &[f64]) -> Vec<u64> {
if shares.is_empty() {
return Vec::new();
}
let share_total = shares.iter().copied().sum::<f64>();
if !share_total.is_finite() || share_total <= EPSILON {
return vec![0; shares.len()];
}
let raw = shares
.iter()
.map(|share| total as f64 * (share.max(0.0) / share_total))
.collect::<Vec<_>>();
let mut allocated = raw
.iter()
.map(|value| value.floor() as u64)
.collect::<Vec<_>>();
let allocated_total = allocated.iter().copied().sum::<u64>();
let mut remainder = total.saturating_sub(allocated_total);
let mut order = raw
.iter()
.enumerate()
.map(|(index, value)| (index, value - value.floor()))
.collect::<Vec<_>>();
order.sort_by(|left, right| {
right
.1
.total_cmp(&left.1)
.then_with(|| left.0.cmp(&right.0))
});
let mut index = 0_usize;
while remainder > 0 {
let target = order[index % order.len()].0;
allocated[target] = allocated[target].saturating_add(1);
remainder -= 1;
index += 1;
}
allocated
}
fn select_cycle_days(
days: &[DailyUsage],
start_at: DateTime<Utc>,
end_at: DateTime<Utc>,
timezone: FixedOffset,
now: DateTime<Utc>,
mark_boundary: bool,
) -> Vec<DailyUsage> {
let local_start = start_at.with_timezone(&timezone);
let start_date = local_start.date_naive();
let effective_end = end_at.min(now);
let end_date = effective_end.with_timezone(&timezone).date_naive();
let exclude_end_date = end_at <= now && end_date > start_date;
let is_boundary = local_start.time() != NaiveTime::MIN;
days.iter()
.filter(|day| {
day.date >= start_date
&& if exclude_end_date {
day.date < end_date
} else {
day.date <= end_date
}
})
.cloned()
.map(|mut day| {
day.is_boundary_day = mark_boundary && is_boundary && day.date == start_date;
day
})
.collect()
}
fn summarize(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> CodexUsageStatisticsSummary {
let tokens = sum_tokens(days.iter().map(|day| day.tokens));
let mut costs = MoneySum::default();
for day in days {
costs.push(day.estimated_cost);
}
let estimated_cost = costs.finish();
let has_unknown_pricing = days.iter().any(|day| day.has_unknown_pricing);
let projected_tokens = (is_current && used_ratio > EPSILON && tokens.total > 0)
.then(|| project_u64(tokens.total, used_ratio))
.flatten();
let projected_cost = (is_current && !has_unknown_pricing && used_ratio > EPSILON)
.then(|| estimated_cost.and_then(|cost| project_money(cost, used_ratio)))
.flatten();
CodexUsageStatisticsSummary {
tokens,
estimated_cost,
projected_tokens,
projected_cost,
day_count: u32::try_from(days.len()).unwrap_or(u32::MAX),
has_unknown_pricing,
has_missing_token_data: days.iter().any(|day| day.has_missing_token_data),
}
}
#[derive(Default)]
struct ModelAggregate {
credits: f64,
tokens: CodexUsageStatisticsTokens,
costs: MoneySum,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
fn aggregate_models(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> Vec<CodexUsageStatisticsModel> {
let mut aggregates =
BTreeMap::<(String, CodexUsageStatisticsServiceTier), ModelAggregate>::new();
for model in days.iter().flat_map(|day| &day.models) {
if model.tokens.is_empty() && model.credits <= EPSILON {
continue;
}
let aggregate = aggregates
.entry((model.model.clone(), model.service_tier))
.or_default();
aggregate.credits += model.credits;
aggregate.tokens =
aggregate
.tokens
.checked_add(model.tokens)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
});
aggregate.costs.push(model.estimated_cost);
aggregate.has_unknown_pricing |= model.has_unknown_pricing;
aggregate.has_estimated_allocation |= model.has_estimated_allocation;
aggregate.has_rate_fallback |= model.has_rate_fallback;
aggregate.has_missing_token_data |= model.has_missing_token_data;
}
let total_credits = aggregates
.values()
.map(|aggregate| aggregate.credits)
.sum::<f64>();
let mut rows = aggregates
.into_iter()
.map(|((model, service_tier), aggregate)| {
let credit_share =
(total_credits > EPSILON).then_some(aggregate.credits / total_credits);
let quota_share = (is_current && used_ratio > EPSILON)
.then(|| credit_share.map(|share| share * used_ratio))
.flatten();
CodexUsageStatisticsModel {
key: format!("{model}::{}", service_tier.as_str()),
model,
service_tier,
credit_share,
quota_share,
tokens: aggregate.tokens,
estimated_cost: aggregate.costs.finish(),
has_unknown_pricing: aggregate.has_unknown_pricing,
has_estimated_allocation: aggregate.has_estimated_allocation,
has_rate_fallback: aggregate.has_rate_fallback,
has_missing_token_data: aggregate.has_missing_token_data,
}
})
.collect::<Vec<_>>();
rows.sort_by(compare_model_rows);
rows
}
fn compare_model_rows(
left: &CodexUsageStatisticsModel,
right: &CodexUsageStatisticsModel,
) -> Ordering {
right
.credit_share
.unwrap_or_default()
.total_cmp(&left.credit_share.unwrap_or_default())
.then_with(|| cost_ticks(right.estimated_cost).cmp(&cost_ticks(left.estimated_cost)))
.then_with(|| right.tokens.total.cmp(&left.tokens.total))
.then_with(|| left.model.cmp(&right.model))
.then_with(|| left.service_tier.cmp(&right.service_tier))
}
fn cost_ticks(cost: Option<Money>) -> u128 {
cost.map(|value| value.amount().scaled())
.unwrap_or_default()
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_u64(value: u64, used_ratio: f64) -> Option<u64> {
let projected = value as f64 / used_ratio;
(projected.is_finite() && projected >= 0.0 && projected <= u64::MAX as f64)
.then(|| projected.round() as u64)
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_money(cost: Money, used_ratio: f64) -> Option<Money> {
let projected = cost.amount().scaled() as f64 / used_ratio;
if !projected.is_finite() || projected < 0.0 || projected > u128::MAX as f64 {
return None;
}
let amount = Decimal::from_scaled(projected.round() as u128).ok()?;
Some(Money::new(amount, cost.currency()))
}
fn sum_tokens(
values: impl IntoIterator<Item = CodexUsageStatisticsTokens>,
) -> CodexUsageStatisticsTokens {
values
.into_iter()
.fold(CodexUsageStatisticsTokens::default(), |total, value| {
total
.checked_add(value)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
})
})
}
fn rows_by_date(payload: &Value) -> BTreeMap<NaiveDate, Value> {
extract_daily_list(payload)
.into_iter()
.filter_map(|row| Some((date_value(row.get("date")?)?, row.clone())))
.collect()
}
fn extract_daily_list(payload: &Value) -> Vec<&Value> {
if let Some(rows) = payload.as_array() {
return rows.iter().collect();
}
[
"data",
"items",
"results",
"daily",
"daily_usage",
"dailyWorkspaceUsageCounts",
"daily_workspace_usage_counts",
"workspace_usage_counts",
]
.into_iter()
.find_map(|key| payload.get(key).and_then(Value::as_array))
.map(|rows| rows.iter().collect())
.unwrap_or_default()
}
fn value_array(value: Option<&Value>) -> &[Value] {
value.and_then(Value::as_array).map_or(&[], Vec::as_slice)
}
fn token_parts(value: &Value) -> CodexUsageStatisticsTokens {
let uncached_input = non_negative_u64(value.get("uncached_text_input_tokens"));
let cached_input = non_negative_u64(value.get("cached_text_input_tokens"));
let output = non_negative_u64(value.get("text_output_tokens"));
let derived = uncached_input
.saturating_add(cached_input)
.saturating_add(output);
let reported = non_negative_u64(value.get("text_total_tokens"));
CodexUsageStatisticsTokens {
uncached_input,
cached_input,
output,
total: if reported > 0 { reported } else { derived },
}
}
fn day_credit_weight(value: &Value) -> f64 {
let model_credits = value_array(value.get("models"))
.iter()
.map(|model| non_negative_f64(model.get("credits")))
.sum::<f64>();
if model_credits > EPSILON {
return model_credits;
}
value
.get("product_surface_usage_values")
.and_then(Value::as_object)
.map(|values| {
values
.values()
.map(|value| non_negative_f64(Some(value)))
.sum()
})
.unwrap_or_default()
}
fn model_name(value: &Value) -> String {
["model", "model_id", "model_name", "name", "id"]
.into_iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_ascii_lowercase()
}
fn model_service_tier(value: Option<&Value>) -> CodexUsageStatisticsServiceTier {
match value
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("fast" | "priority") => CodexUsageStatisticsServiceTier::Fast,
Some(_) | None => CodexUsageStatisticsServiceTier::Standard,
}
}
fn date_value(value: &Value) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value.as_str()?.get(..10)?, "%Y-%m-%d").ok()
}
fn non_negative_u64(value: Option<&Value>) -> u64 {
let Some(value) = value else {
return 0;
};
if let Some(number) = value.as_u64() {
return number;
}
let normalized = value
.as_str()
.map(|raw| raw.replace(',', ""))
.unwrap_or_else(|| value.to_string());
normalized
.trim()
.parse::<u64>()
.ok()
.or_else(|| {
normalized
.trim()
.parse::<f64>()
.ok()
.filter(|number| number.is_finite() && *number >= 0.0)
.map(|number| number.round() as u64)
})
.unwrap_or_default()
}
fn non_negative_f64(value: Option<&Value>) -> f64 {
let Some(value) = value else {
return 0.0;
};
let parsed = value.as_f64().or_else(|| {
value
.as_str()
.map(|raw| raw.replace(',', ""))
.and_then(|raw| raw.trim().parse::<f64>().ok())
});
parsed
.filter(|number| number.is_finite() && *number >= 0.0)
.unwrap_or_default()
}
@@ -5,15 +5,13 @@ use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
use thiserror::Error;
use super::agent_identity::{CodexAgentIdentityError, CodexAgentIdentitySecret};
use super::types::{
CodexAccountProfile, CodexAgentIdentityCredentialData, CodexCookie, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
CodexAccountProfile, CodexCookie, CodexCredentialData, CodexCredentialPrincipal,
CodexOAuthCredentialData, CodexOAuthSecret, RuntimeCodexCookie,
};
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
const MAX_CREDENTIAL_BYTES: usize = 256 * 1024;
const MAX_TOKEN_BYTES: usize = 128 * 1024;
const MAX_COOKIES: usize = 128;
/// 已解析且只在 Provider 内可见的认证材料。
@@ -42,27 +40,15 @@ impl std::fmt::Debug for CodexRuntimeCredential {
pub enum CodexRuntimeAuthentication {
OAuth(CodexOAuthSecret),
AgentIdentity(Box<CodexAgentIdentitySecret>),
}
impl CodexRuntimeAuthentication {
#[must_use]
pub const fn is_agent_identity(&self) -> bool {
matches!(self, Self::AgentIdentity(_))
}
pub fn authorization_header(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> Result<SecretString, CodexCredentialDataError> {
pub fn authorization_header(&self) -> Result<SecretString, CodexCredentialDataError> {
match self {
Self::OAuth(secret) => Ok(SecretString::from(format!(
"Bearer {}",
secret.access_token.expose_secret()
))),
Self::AgentIdentity(secret) => secret
.authorization_header(now)
.map_err(|_| CodexCredentialDataError::Invalid),
}
}
@@ -70,7 +56,6 @@ impl CodexRuntimeAuthentication {
pub const fn oauth(&self) -> Option<&CodexOAuthSecret> {
match self {
Self::OAuth(secret) => Some(secret),
Self::AgentIdentity(_) => None,
}
}
}
@@ -79,7 +64,6 @@ impl std::fmt::Debug for CodexRuntimeAuthentication {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OAuth(secret) => secret.fmt(formatter),
Self::AgentIdentity(secret) => secret.fmt(formatter),
}
}
}
@@ -147,12 +131,6 @@ impl CodexCredentialCodec {
}))
}
pub fn encode_agent_identity(
data: CodexAgentIdentityCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::AgentIdentity(data))
}
pub fn encode_complete(
data: CodexCredentialData,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
@@ -200,21 +178,6 @@ impl CodexCredentialCodec {
data.oauth_client_id,
data.oauth_scope,
),
CodexCredentialData::AgentIdentity(data) => (
CodexRuntimeAuthentication::AgentIdentity(Box::new(
CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id,
&data.agent_private_key,
data.task_id,
)
.map_err(map_agent_identity_error)?,
)),
None,
data.installation_id,
data.cookies,
None,
None,
),
};
Ok(CodexRuntimeCredential {
authentication,
@@ -257,21 +220,6 @@ impl CodexCredentialCodec {
(CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => {
incoming.installation_id = existing.installation_id;
}
(
CodexCredentialData::AgentIdentity(incoming),
CodexCredentialData::AgentIdentity(existing),
) => {
if incoming.agent_runtime_id != existing.agent_runtime_id {
return Err(CodexCredentialDataError::Invalid);
}
incoming.installation_id = existing.installation_id;
if incoming.task_id.is_none()
&& incoming.agent_private_key == existing.agent_private_key
{
incoming.task_id = existing.task_id;
}
}
_ => return Err(CodexCredentialDataError::Invalid),
}
Self::encode_complete(incoming)
}
@@ -285,25 +233,6 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
}
(&data.installation_id, &data.cookies)
}
CodexCredentialData::AgentIdentity(data) => {
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION
|| !valid_identity(&data.agent_runtime_id)
|| !valid_secret(&data.agent_private_key)
|| data
.task_id
.as_deref()
.is_some_and(|value| !valid_identity(value))
|| CodexAgentIdentitySecret::from_pkcs8(
data.agent_runtime_id.clone(),
&data.agent_private_key,
data.task_id.clone(),
)
.is_err()
{
return Err(CodexCredentialDataError::Invalid);
}
(&data.installation_id, &data.cookies)
}
};
if !valid_installation_id(installation_id)
|| cookies.len() > MAX_COOKIES
@@ -323,20 +252,8 @@ fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError>
Ok(())
}
const fn map_agent_identity_error(_: CodexAgentIdentityError) -> CodexCredentialDataError {
CodexCredentialDataError::Invalid
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
}
fn valid_installation_id(value: &str) -> bool {
uuid::Uuid::parse_str(value)
.ok()
.is_some_and(|uuid| uuid.get_version_num() == 4)
}
fn valid_secret(value: &str) -> bool {
!value.is_empty() && value.len() <= MAX_TOKEN_BYTES && !value.chars().any(char::is_control)
}
@@ -22,7 +22,7 @@ use secrecy::ExposeSecret;
use thiserror::Error;
use url::Url;
use super::agent_identity::CodexAgentIdentityTaskService;
use super::affinity::CodexSessionAffinity;
use super::catalog::CodexCredentialCatalogService;
use super::cookie::CodexCookiePolicy;
use super::quota::CodexCredentialQuotaService;
@@ -30,8 +30,7 @@ use super::refresh::refresh_recovery_deadline;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::CodexRuntimeAuthentication;
use super::types::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie,
CodexCookieCaptureOutcome, RuntimeCodexCookie,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie, CodexCookieCaptureOutcome, RuntimeCodexCookie,
};
const CLOUDFLARE_RECOVERY_STALE_AFTER: Duration = Duration::from_secs(60 * 60);
@@ -104,6 +103,7 @@ struct CredentialSelectionInput<'a> {
request_url: &'a Url,
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
session_affinity_observation: Option<&'a CodexSessionAffinity>,
}
#[derive(Clone)]
@@ -120,7 +120,6 @@ pub struct CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
cookie_policy: CodexCookiePolicy,
risk_recovery: Mutex<HashMap<String, RiskRecoveryState>>,
account_feedback: Arc<AccountFeedbackStats>,
@@ -269,7 +268,6 @@ impl CodexCredentialSelector {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
cookie_policy: CodexCookiePolicy,
) -> Self {
@@ -281,7 +279,6 @@ impl CodexCredentialSelector {
session_exclusions,
catalog,
quota,
agent_identity,
cookie_policy,
risk_recovery: Mutex::new(HashMap::new()),
account_feedback,
@@ -296,6 +293,7 @@ impl CodexCredentialSelector {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation: None,
};
self.select_inner(
&input,
@@ -309,11 +307,13 @@ impl CodexCredentialSelector {
&self,
request: &SelectCodexCredential<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
session_affinity_observation: Option<&CodexSessionAffinity>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation,
};
self.select_inner(
&input,
@@ -335,6 +335,7 @@ impl CodexCredentialSelector {
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: None,
session_affinity_observation: None,
};
self.select_inner(&input, None, ModelCatalogEligibility::NotApplicable)
.await
@@ -568,6 +569,7 @@ impl CodexCredentialSelector {
.unwrap_or_else(|| account.id().clone())
};
let affinity_telemetry = affinity.telemetry(account.id());
let affinity_observation = request.session_affinity_observation;
tracing::info!(
request_id = %request.attempt.request_id(),
attempt_index = request.attempt.attempt_index().get(),
@@ -578,22 +580,20 @@ impl CodexCredentialSelector {
.escape_reason
.map_or("", AffinityEscapeReason::as_str),
account_switch = affinity_telemetry.account_switch,
affinity_key_hash = affinity_observation
.map_or("", CodexSessionAffinity::key_hash),
affinity_anchor_source = affinity_observation
.map_or("", CodexSessionAffinity::anchor_source),
affinity_anchor = affinity_observation
.map_or("", CodexSessionAffinity::anchor),
session_id = affinity_observation
.and_then(CodexSessionAffinity::session_id)
.unwrap_or(""),
session_id_present = affinity_observation
.is_some_and(CodexSessionAffinity::session_id_present),
"OpenAI account selected"
);
let (account, runtime) = if account.authentication_kind()
== CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY
{
let prepared = if allows_account_state_mutation {
self.agent_identity.prepare(&account).await
} else {
self.agent_identity.load_current(account.id()).await
}
.map_err(|_| CredentialSelectionError::InvalidCredential)?;
(prepared.account, prepared.credential)
} else {
let runtime = self.repository.load_runtime_credential(&account).await?;
(account, runtime)
};
let runtime = self.repository.load_runtime_credential(&account).await?;
let cookies = runtime
.cookies
.into_iter()
@@ -280,7 +280,6 @@ impl fmt::Debug for CodexCookie {
}
pub const CODEX_AUTHENTICATION_KIND_OAUTH: &str = "oauth";
pub const CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY: &str = "agent_identity";
/// Codex OAuth 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
@@ -323,49 +322,11 @@ impl fmt::Debug for CodexOAuthCredentialData {
}
}
/// Agent Identity 文档的固定认证模式。
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum CodexAgentIdentityAuthMode {
#[serde(rename = "agentIdentity")]
AgentIdentity,
}
/// OpenAI Agent Identity 对 `provider_credentials_json` 的完整明文 schema。
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CodexAgentIdentityCredentialData {
pub schema_version: u32,
pub auth_mode: CodexAgentIdentityAuthMode,
pub installation_id: String,
pub agent_runtime_id: String,
pub agent_private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default)]
pub cookies: Vec<CodexCookie>,
}
impl fmt::Debug for CodexAgentIdentityCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexAgentIdentityCredentialData")
.field("schema_version", &self.schema_version)
.field("auth_mode", &self.auth_mode)
.field("installation_id", &"<pseudonymous>")
.field("agent_runtime_id", &"<redacted>")
.field("agent_private_key", &"<redacted>")
.field("task_id", &self.task_id.as_ref().map(|_| "<redacted>"))
.field("cookies", &self.cookies)
.finish()
}
}
/// OpenAI Provider 支持的两种规范化凭据形态。
/// OpenAI Provider 的规范化 OAuth 凭据形态。
#[derive(Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CodexCredentialData {
OAuth(CodexOAuthCredentialData),
AgentIdentity(CodexAgentIdentityCredentialData),
}
impl CodexCredentialData {
@@ -373,7 +334,6 @@ impl CodexCredentialData {
pub const fn authentication_kind(&self) -> &'static str {
match self {
Self::OAuth(_) => CODEX_AUTHENTICATION_KIND_OAUTH,
Self::AgentIdentity(_) => CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY,
}
}
@@ -381,14 +341,12 @@ impl CodexCredentialData {
pub fn installation_id(&self) -> &str {
match self {
Self::OAuth(data) => &data.installation_id,
Self::AgentIdentity(data) => &data.installation_id,
}
}
pub fn set_installation_id(&mut self, installation_id: String) {
match self {
Self::OAuth(data) => data.installation_id = installation_id,
Self::AgentIdentity(data) => data.installation_id = installation_id,
}
}
@@ -396,14 +354,12 @@ impl CodexCredentialData {
pub fn cookies(&self) -> &[CodexCookie] {
match self {
Self::OAuth(data) => &data.cookies,
Self::AgentIdentity(data) => &data.cookies,
}
}
pub fn cookies_mut(&mut self) -> &mut Vec<CodexCookie> {
match self {
Self::OAuth(data) => &mut data.cookies,
Self::AgentIdentity(data) => &mut data.cookies,
}
}
@@ -411,29 +367,12 @@ impl CodexCredentialData {
pub fn oauth(&self) -> Option<&CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
pub fn oauth_mut(&mut self) -> Option<&mut CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::AgentIdentity(_) => None,
}
}
#[must_use]
pub fn agent_identity(&self) -> Option<&CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
pub fn agent_identity_mut(&mut self) -> Option<&mut CodexAgentIdentityCredentialData> {
match self {
Self::OAuth(_) => None,
Self::AgentIdentity(data) => Some(data),
}
}
@@ -448,7 +387,6 @@ impl fmt::Debug for CodexCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth(data) => data.fmt(formatter),
Self::AgentIdentity(data) => data.fmt(formatter),
}
}
}
+10 -17
View File
@@ -16,10 +16,10 @@ use gateway_core::task::WorkerContribution;
use crate::admin::{OpenAiAdminProvider, OpenAiAdminServices, OpenAiOAuthPendingStore};
use crate::credential::token_client::{AuthorizationCodeExchanger, TokenRefresher};
use crate::credential::{
CodexAgentIdentityTaskService, CodexCookiePolicy, CodexCredentialAdmin,
CodexCredentialAdminService, CodexCredentialCatalogService, CodexCredentialQuotaService,
CodexCookiePolicy, CodexCredentialAdmin, CodexCredentialAdminService,
CodexCredentialCatalogService, CodexCredentialProfileService, CodexCredentialQuotaService,
CodexCredentialRefreshService, CodexCredentialRepository, CodexCredentialSelector,
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
CodexOAuthAdmin, CodexOAuthAdminService,
};
use crate::transport::profile::{
CodexArtifactProfileCache, CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport,
@@ -97,20 +97,11 @@ pub async fn initialize(
let websocket_pool = Arc::new(CodexWebSocketPool::with_config(
config.websocket_pool_config(),
));
let agent_identity = Arc::new(CodexAgentIdentityTaskService::new(
repository.clone(),
Arc::new(
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone(), profile.clone())
.map_err(|_| OpenAiInitializeError::AgentIdentity)?,
),
Arc::clone(&websocket_pool),
));
let catalog = Arc::new(CodexCredentialCatalogService::new(
repository.clone(),
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -118,9 +109,14 @@ pub async fn initialize(
profile.clone(),
http.clone(),
config.base_url().to_owned(),
Arc::clone(&agent_identity),
ports.cooldowns(),
));
let profile_statistics = Arc::new(CodexCredentialProfileService::new(
repository.clone(),
profile.clone(),
http.clone(),
config.base_url().to_owned(),
));
let selector = Arc::new(CodexCredentialSelector::new(
provider_kind.clone(),
repository.clone(),
@@ -129,7 +125,6 @@ pub async fn initialize(
session_exclusions,
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().map_err(|_| OpenAiInitializeError::CookiePolicy)?,
));
@@ -138,7 +133,6 @@ pub async fn initialize(
selector,
Arc::clone(&catalog),
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile.clone(),
@@ -187,6 +181,7 @@ pub async fn initialize(
OpenAiAdminServices {
credentials: credential_admin,
oauth: oauth_admin,
profile_statistics,
quota: Arc::clone(&quota),
catalog: Arc::clone(&catalog),
},
@@ -246,8 +241,6 @@ pub enum OpenAiInitializeError {
CookiePolicy,
#[error("OpenAI token client could not initialize")]
TokenClient,
#[error("OpenAI Agent Identity service could not initialize")]
AgentIdentity,
#[error("OpenAI credential administration could not initialize")]
CredentialAdmin,
#[error("OpenAI credential refresh could not initialize")]
+17 -110
View File
@@ -49,12 +49,12 @@ use serde_json::{Map, Value, json};
use url::Url;
use crate::credential::{
CodexAccountFailure, CodexAgentIdentityTaskService, CodexCredentialCatalogError,
CodexCredentialCatalogService, CodexCredentialLease, CodexCredentialQuotaService,
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, CodexCredentialSelector,
CodexCyberPolicyScope, CodexQuotaRefreshPolicy, CredentialSelectionError, RuntimeCodexCookie,
CodexAccountFailure, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialLease, CodexCredentialQuotaService, CodexCredentialRefreshOutcome,
CodexCredentialRefreshService, CodexCredentialSelector, CodexCyberPolicyScope,
CodexQuotaRefreshPolicy, CodexSessionAffinity, CredentialSelectionError, RuntimeCodexCookie,
SelectCodexCredential, SelectCodexProviderEndpointCredential,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
derive_codex_cyber_policy_session_key, derive_codex_session_affinity,
};
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
@@ -121,7 +121,6 @@ pub struct CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
responses_url: Url,
@@ -137,7 +136,6 @@ impl CodexProvider {
selector: Arc<CodexCredentialSelector>,
catalog: Arc<CodexCredentialCatalogService>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
account_feedback: Arc<AccountFeedbackStats>,
http: Client,
profile: CodexWireProfileState,
@@ -157,7 +155,6 @@ impl CodexProvider {
selector,
catalog,
quota,
agent_identity,
account_feedback,
client,
responses_url,
@@ -288,8 +285,8 @@ impl Provider for CodexProvider {
.as_ref()
.and_then(|state| state.turn_state.clone());
}
let session_affinity_key =
derive_codex_session_affinity_key(&upstream_request, context.client_api_key_ref());
let session_affinity =
derive_codex_session_affinity(&upstream_request, context.client_api_key_ref());
let cyber_policy_session_key =
derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref());
let transport = selected_transport(&upstream_request);
@@ -302,9 +299,10 @@ impl Provider for CodexProvider {
upstream_model: upstream_model.as_str(),
request_url: &self.responses_url,
attempt: &context,
session_affinity_key: session_affinity_key.as_ref(),
session_affinity_key: session_affinity.as_ref().map(|affinity| affinity.key()),
},
cyber_policy_session_key.as_ref(),
session_affinity.as_ref(),
)
.await
.map_err(map_selection_error)?;
@@ -407,6 +405,7 @@ impl Provider for CodexProvider {
continuation_scope: None,
});
let allows_account_state_mutation = lease.allows_account_state_mutation();
let session_affinity_key = session_affinity.map(CodexSessionAffinity::into_key);
let events = cold_response_stream(ColdResponse {
client: self.client.clone(),
response_origin: self.responses_url.clone(),
@@ -417,7 +416,6 @@ impl Provider for CodexProvider {
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
catalog: Arc::clone(&self.catalog),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at,
session_affinity_key,
@@ -489,7 +487,6 @@ impl CodexProvider {
context,
selector: Arc::clone(&self.selector),
quota: Arc::clone(&self.quota),
agent_identity: Arc::clone(&self.agent_identity),
lease: Arc::clone(&lease),
output_started_at: Instant::now(),
});
@@ -512,7 +509,6 @@ struct ColdResponse {
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
catalog: Arc<CodexCredentialCatalogService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
session_affinity_key: Option<ProviderSessionAffinityKey>,
@@ -528,7 +524,6 @@ struct ColdJsonResponse {
context: AttemptContext,
selector: Arc<CodexCredentialSelector>,
quota: Arc<CodexCredentialQuotaService>,
agent_identity: Arc<CodexAgentIdentityTaskService>,
lease: Arc<CodexCredentialLease>,
output_started_at: Instant,
}
@@ -641,7 +636,6 @@ enum CodexHandshakeAttemptError {
Client(CodexClientError),
Cancelled,
Timeout,
AgentIdentityRecovery,
}
async fn create_response_attempt(
@@ -678,9 +672,6 @@ fn map_handshake_attempt_error(error: CodexHandshakeAttemptError) -> MappedProvi
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
)),
CodexHandshakeAttemptError::AgentIdentityRecovery => MappedProviderFailure::plain(
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::Sent),
),
}
}
@@ -728,12 +719,12 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
cyber_policy_scope: None,
allows_account_state_mutation,
};
let mut active_account = request.lease.account().clone();
let active_account = request.lease.account().clone();
let cookie_header = build_cookie_header(request.lease.cookies())?;
let mut authorization = request
let authorization = request
.lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -745,7 +736,7 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
request.lease.escape_reason(),
request.lease.account_switch(),
);
let mut response = create_json_attempt(
let response = create_json_attempt(
&request,
&active_account,
request.lease.installation_id(),
@@ -754,44 +745,6 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
account_selection,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match request
.agent_identity
.recover_after_rejected_task(
active_account.id(),
request.lease.authentication(),
error,
)
.await
{
Ok(Some(recovered)) => {
authorization = recovered
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
active_account = recovered.account;
response = create_json_attempt(
&request,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = match response.map_err(map_handshake_attempt_error) {
Ok(response) => response,
Err(mut failure) => {
@@ -867,7 +820,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
selector,
quota,
catalog,
agent_identity,
lease,
output_started_at,
session_affinity_key,
@@ -886,9 +838,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
};
let mut active_account = lease.account().clone();
let cookie_header = build_cookie_header(lease.cookies())?;
let mut authorization = lease
let authorization = lease
.authentication()
.authorization_header(chrono::Utc::now())
.authorization_header()
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
@@ -902,7 +854,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
lease.escape_reason(),
lease.account_switch(),
);
let mut response = create_response_attempt(
let response = create_response_attempt(
&client,
&request,
codex_request_context(
@@ -919,51 +871,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
&cancellation,
)
.await;
if allows_account_state_mutation
&& let Err(CodexHandshakeAttemptError::Client(error)) = &response
{
match agent_identity
.recover_after_rejected_task(
active_account.id(),
lease.authentication(),
error,
)
.await
{
Ok(Some(recovered)) => {
authorization = recovered
.credential
.authentication
.authorization_header(chrono::Utc::now())
.map_err(|_| {
provider_error(
ProviderErrorKind::Unauthorized,
UpstreamSendState::NotSent,
)
})?;
active_account = recovered.account;
response = create_response_attempt(
&client,
&request,
codex_request_context(
&request,
&request_id,
&active_account,
&recovered.credential.installation_id,
&authorization,
cookie_header.as_ref(),
account_selection,
),
active_account.id().as_str(),
context.deadline(),
&cancellation,
)
.await;
}
Ok(None) => {}
Err(_) => response = Err(CodexHandshakeAttemptError::AgentIdentityRecovery),
}
}
let response = response.map_err(map_handshake_attempt_error);
let response = match response {
Ok(response) => response,
@@ -8,11 +8,8 @@ pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
pub const WHAM_USAGE_PATH: &str = "/wham/usage";
/// 个人账号每日模型额度权重。
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_PROFILE_STATISTICS_PATH: &str = "/wham/profiles/me";
/// Codex Desktop 主动额度重置卡列表。
pub const WHAM_RATE_LIMIT_RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits";
/// Codex Desktop 主动消费额度重置卡。
@@ -9,6 +9,7 @@ pub mod diagnostics;
pub mod endpoints;
pub mod headers;
pub mod profile;
pub mod profile_statistics;
pub mod protocol;
pub mod request;
pub mod reset_credits;
@@ -17,7 +18,6 @@ pub(crate) mod session;
mod time;
pub mod tls;
pub mod usage;
pub(crate) mod usage_statistics;
pub mod websocket;
pub use self::{
@@ -36,11 +36,16 @@ pub use self::{
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
CODEX_USAGE_API_PATH, WHAM_PROFILE_STATISTICS_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
},
headers::build_codex_base_headers,
profile_statistics::{
CodexProfileActivityInsights, CodexProfileDailyUsage, CodexProfileInvocation,
CodexProfileStatistics, CodexProfileStatisticsSummary,
MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES,
},
request::{CodexRequestEncodeError, encode_generate_request},
reset_credits::{
CodexRateLimitResetCredit, CodexRateLimitResetCredits,
@@ -52,5 +57,3 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
};
pub(crate) use self::usage::openai_aggregate_billing_breakdown;
@@ -0,0 +1,293 @@
//! Codex Desktop 当前账号个人资料与累计统计 HTTP contract。
use chrono::NaiveDate;
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use reqwest::StatusCode;
use serde::Deserialize;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
client::{
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
retry_after_seconds,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{WHAM_PROFILE_STATISTICS_PATH, endpoint_url},
response_meta,
};
/// 单次 profile 响应允许保留和解析的最大字节数。
pub const MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProfileStatisticsSummary {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexProfileInvocation {
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexProfileActivityInsights {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<CodexProfileInvocation>>,
}
#[derive(Clone, PartialEq)]
pub struct CodexProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: CodexProfileStatisticsSummary,
pub daily_usage: Option<Vec<CodexProfileDailyUsage>>,
pub activity_insights: CodexProfileActivityInsights,
}
impl std::fmt::Debug for CodexProfileStatistics {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexProfileStatistics")
.field(
"display_name",
&self.display_name.as_ref().map(|_| "<redacted>"),
)
.field("username", &self.username.as_ref().map(|_| "<redacted>"))
.field("image_url", &self.image_url.as_ref().map(|_| "<redacted>"))
.field("has_stats_error", &self.has_stats_error)
.field("summary", &"<redacted>")
.field("daily_usage", &"<redacted>")
.field("activity_insights", &"<redacted>")
.finish()
}
}
#[derive(Deserialize)]
struct ProfileStatisticsWire {
profile: ProfileWire,
stats: StatisticsWire,
#[serde(default)]
metadata: MetadataWire,
}
#[derive(Deserialize)]
struct ProfileWire {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
profile_picture_url: Option<String>,
}
#[derive(Deserialize)]
struct StatisticsWire {
#[serde(default)]
lifetime_tokens: Option<u64>,
#[serde(default)]
peak_daily_tokens: Option<u64>,
#[serde(default)]
longest_running_turn_sec: Option<u64>,
#[serde(default)]
current_streak_days: Option<u64>,
#[serde(default)]
longest_streak_days: Option<u64>,
#[serde(default)]
daily_usage_buckets: Option<Vec<DailyUsageWire>>,
#[serde(default)]
fast_mode_usage_percentage: Option<f64>,
#[serde(default)]
most_used_reasoning_effort: Option<String>,
#[serde(default)]
most_used_reasoning_effort_percentage: Option<f64>,
#[serde(default)]
unique_skills_used: Option<u64>,
#[serde(default)]
total_skills_used: Option<u64>,
#[serde(default)]
total_threads: Option<u64>,
#[serde(default)]
top_invocations: Option<Vec<InvocationWire>>,
}
#[derive(Deserialize)]
struct DailyUsageWire {
start_date: NaiveDate,
tokens: u64,
}
#[derive(Deserialize)]
struct InvocationWire {
#[serde(rename = "type")]
invocation_type: String,
#[serde(default)]
plugin_id: Option<String>,
#[serde(default)]
plugin_name: Option<String>,
#[serde(default)]
skill_id: Option<String>,
#[serde(default)]
skill_name: Option<String>,
#[serde(default)]
usage_count: Option<u64>,
}
#[derive(Default, Deserialize)]
struct MetadataWire {
#[serde(default)]
stats_error: Option<String>,
}
impl CodexBackendClient {
pub async fn fetch_profile_statistics(
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexProfileStatistics> {
let response = self
.client
.get(endpoint_url(&self.base_url, WHAM_PROFILE_STATISTICS_PATH))
.headers(self.usage_request_headers(context)?)
.send()
.await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body =
read_capped_response_body(response, MAX_CODEX_PROFILE_STATISTICS_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(upstream_error(
if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
"upstream profile-statistics response exceeded the body limit".to_owned(),
retry_after_seconds,
diagnostics,
));
}
let body = body.into_string();
if !status.is_success() {
return Err(upstream_error(
status,
body.clone(),
retry_after_seconds.or_else(|| retry_after_seconds_from_body(&body)),
diagnostics,
));
}
let wire = serde_json::from_str::<ProfileStatisticsWire>(&body).map_err(|_| {
upstream_error(
StatusCode::BAD_GATEWAY,
"invalid profile-statistics response".to_owned(),
None,
diagnostics,
)
})?;
Ok(profile_statistics(wire))
}
}
fn profile_statistics(wire: ProfileStatisticsWire) -> CodexProfileStatistics {
let longest_task_duration_ms = wire
.stats
.longest_running_turn_sec
.map(|seconds| seconds.saturating_mul(1000));
let mut daily_usage = wire.stats.daily_usage_buckets.map(|daily| {
daily
.into_iter()
.map(|entry| CodexProfileDailyUsage {
date: entry.start_date,
tokens: entry.tokens,
})
.collect::<Vec<_>>()
});
if let Some(daily_usage) = &mut daily_usage {
daily_usage.sort_by_key(|entry| entry.date);
}
let invocations = wire.stats.top_invocations.map(|invocations| {
invocations
.into_iter()
.map(|invocation| CodexProfileInvocation {
invocation_type: invocation.invocation_type.trim().to_owned(),
plugin_id: trimmed(invocation.plugin_id),
plugin_name: trimmed(invocation.plugin_name),
skill_id: trimmed(invocation.skill_id),
skill_name: trimmed(invocation.skill_name),
usage_count: invocation.usage_count,
})
.collect()
});
CodexProfileStatistics {
display_name: trimmed(wire.profile.display_name),
username: trimmed(wire.profile.username),
image_url: trimmed(wire.profile.profile_picture_url),
has_stats_error: trimmed(wire.metadata.stats_error).is_some(),
summary: CodexProfileStatisticsSummary {
total_text_tokens: wire.stats.lifetime_tokens,
peak_tokens: wire.stats.peak_daily_tokens,
longest_task_duration_ms,
current_streak_days: wire.stats.current_streak_days,
longest_streak_days: wire.stats.longest_streak_days,
},
daily_usage,
activity_insights: CodexProfileActivityInsights {
fast_mode_percent: wire.stats.fast_mode_usage_percentage,
reasoning_effort: trimmed(wire.stats.most_used_reasoning_effort),
reasoning_effort_percent: wire.stats.most_used_reasoning_effort_percentage,
skills_explored: wire.stats.unique_skills_used,
total_skills_used: wire.stats.total_skills_used,
total_threads: wire.stats.total_threads,
invocations,
},
}
}
fn trimmed(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
fn upstream_error(
status: StatusCode,
body: String,
retry_after_seconds: Option<u64>,
diagnostics: super::CodexUpstreamDiagnostics,
) -> CodexClientError {
CodexClientError::Upstream {
status,
body,
client_response: None,
retry_after_seconds,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: CodexBackendTransport::HttpSse,
transport_metrics: Box::<CodexTransportMetrics>::default(),
send_phase: CodexUpstreamSendPhase::AfterPayload,
}
}
@@ -421,16 +421,6 @@ pub fn openai_billing_breakdown(
)
}
/// 官方日报没有单次请求边界,不能把整日输入量套用长上下文阶梯。
/// 聚合统计固定使用公开的短上下文价格,其余价格规则与请求级计费共用。
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,
@@ -1,119 +0,0 @@
//! Codex 官方每日用量报表 transport。
use chrono::NaiveDate;
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use reqwest::StatusCode;
use serde_json::Value;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
client::{
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
retry_after_seconds, truncate_for_error,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{
WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH, endpoint_url,
},
response_meta,
};
const MAX_CODEX_DAILY_USAGE_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CodexDailyUsageReport {
PersonalModelCredits,
PersonalTokenTotals,
}
impl CodexDailyUsageReport {
const fn path(self) -> &'static str {
match self {
Self::PersonalModelCredits => WHAM_DAILY_TOKEN_USAGE_PATH,
Self::PersonalTokenTotals => WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
}
}
const fn workspace_user(self) -> bool {
matches!(self, Self::PersonalTokenTotals)
}
}
impl CodexBackendClient {
pub(crate) async fn fetch_daily_usage(
&self,
context: CodexRequestContext<'_>,
report: CodexDailyUsageReport,
start_date: NaiveDate,
end_date: NaiveDate,
) -> CodexClientResult<Value> {
let headers = self.usage_request_headers(context)?;
let workspace_user = if report.workspace_user() {
"&workspace_user=true"
} else {
""
};
let url = format!(
"{}?start_date={start_date}&end_date={end_date}&group_by=day{workspace_user}",
endpoint_url(&self.base_url, report.path()),
);
let response = self.client.get(url).headers(headers).send().await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = read_capped_response_body(response, MAX_CODEX_DAILY_USAGE_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(upstream_error(
if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
"upstream daily usage response exceeded the body limit".to_owned(),
retry_after_seconds,
diagnostics,
));
}
let body = body.into_string();
if !status.is_success() {
return Err(upstream_error(
status,
body.clone(),
retry_after_seconds.or_else(|| retry_after_seconds_from_body(&body)),
diagnostics,
));
}
serde_json::from_str::<Value>(&body).map_err(|_| {
upstream_error(
StatusCode::BAD_GATEWAY,
format!(
"invalid daily usage response: {}",
truncate_for_error(&body)
),
None,
diagnostics,
)
})
}
}
fn upstream_error(
status: StatusCode,
body: String,
retry_after_seconds: Option<u64>,
diagnostics: super::CodexUpstreamDiagnostics,
) -> CodexClientError {
CodexClientError::Upstream {
status,
body,
client_response: None,
retry_after_seconds,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: CodexBackendTransport::HttpSse,
transport_metrics: Box::<CodexTransportMetrics>::default(),
send_phase: CodexUpstreamSendPhase::AfterPayload,
}
}
@@ -1,256 +0,0 @@
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{TimeZone, Utc};
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
use ed25519_dalek::{Signature, SigningKey, Verifier as _};
use gateway_core::engine::credential::{
CredentialRevision, CredentialState, NewProviderAccount, ProviderAccount, ProviderAccountId,
ProviderAccountStore, QuotaState,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
CodexAgentIdentityCredentialData, CodexAgentIdentityError, CodexAgentIdentitySecret,
CodexAgentIdentityTaskRegistrar, CodexAgentIdentityTaskService, CodexCredentialCodec,
CodexRuntimeAuthentication, is_agent_identity_task_invalid_response,
};
use provider_openai::transport::CodexWebSocketPool;
use provider_openai::transport::headers::build_codex_profile_headers;
use reqwest::StatusCode;
use reqwest::header::USER_AGENT;
use secrecy::ExposeSecret as _;
use serde_json::Value;
use crate::support::MemoryAccountStore;
fn signing_fixture(seed: u8) -> (SigningKey, String) {
let signing_key = SigningKey::from_bytes(&[seed; 32]);
let der = signing_key.to_pkcs8_der().expect("encode test PKCS#8 key");
(signing_key, STANDARD.encode(der.as_bytes()))
}
fn agent_account(
account_id: &str,
runtime_id: &str,
private_key: &str,
task_id: Option<&str>,
) -> NewProviderAccount {
let account_id = ProviderAccountId::new(account_id.to_owned()).expect("account id");
let provider = ProviderKind::new("openai").expect("provider");
let credential =
CodexCredentialCodec::encode_agent_identity(CodexAgentIdentityCredentialData {
schema_version: 1,
auth_mode: CodexAgentIdentityAuthMode::AgentIdentity,
installation_id: "00000000-0000-4000-8000-000000000001".to_owned(),
agent_runtime_id: runtime_id.to_owned(),
agent_private_key: private_key.to_owned(),
task_id: task_id.map(str::to_owned),
cookies: Vec::new(),
})
.expect("encode Agent Identity credential");
let account = ProviderAccount::new(
account_id,
provider,
"Agent Identity test".to_owned(),
Some("agent-user".to_owned()),
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY.to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_profile(
Some("agent@example.com".to_owned()),
Some("agent-account".to_owned()),
Some("pro".to_owned()),
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
)
.with_refresh_schedule(false, None);
NewProviderAccount {
account,
credential,
}
}
#[test]
fn agent_identity_secret_builds_a_verifiable_assertion_without_leaking_material() {
let (signing_key, private_key) = signing_fixture(7);
let secret = CodexAgentIdentitySecret::from_pkcs8(
"runtime-test".to_owned(),
&private_key,
Some("task-test".to_owned()),
)
.expect("parse Agent Identity secret");
let now = Utc
.with_ymd_and_hms(2026, 7, 23, 1, 2, 3)
.single()
.expect("timestamp");
let header = secret
.authorization_header(now)
.expect("build AgentAssertion");
let encoded = header
.expose_secret()
.strip_prefix("AgentAssertion ")
.expect("AgentAssertion scheme");
let envelope: Value = serde_json::from_slice(
&URL_SAFE_NO_PAD
.decode(encoded)
.expect("decode assertion envelope"),
)
.expect("parse assertion envelope");
assert_eq!(envelope["agent_runtime_id"], "runtime-test");
assert_eq!(envelope["task_id"], "task-test");
assert_eq!(envelope["timestamp"], "2026-07-23T01:02:03Z");
let signature = Signature::from_slice(
&STANDARD
.decode(envelope["signature"].as_str().expect("signature"))
.expect("decode signature"),
)
.expect("signature shape");
signing_key
.verifying_key()
.verify(b"runtime-test:task-test:2026-07-23T01:02:03Z", &signature)
.expect("verify AgentAssertion");
let debug = format!("{secret:?}");
assert!(!debug.contains(&private_key));
assert!(!debug.contains("runtime-test"));
assert!(!debug.contains("task-test"));
}
#[test]
fn agent_identity_task_error_classifier_requires_unauthorized_task_error() {
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"task expired",
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::FORBIDDEN,
r#"{"error":{"code":"task_not_found"}}"#,
));
assert!(!is_agent_identity_task_invalid_response(
StatusCode::UNAUTHORIZED,
"invalid access token",
));
}
#[test]
fn agent_identity_task_registration_should_use_the_shared_desktop_profile_headers() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
let headers = build_codex_profile_headers(&profile).expect("Desktop profile headers");
assert_eq!(headers.len(), 3);
assert_eq!(headers["originator"], "Codex Desktop");
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert_eq!(
headers[USER_AGENT],
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
struct SequenceTaskRegistrar {
task_ids: Mutex<VecDeque<String>>,
calls: AtomicUsize,
}
#[async_trait]
impl CodexAgentIdentityTaskRegistrar for SequenceTaskRegistrar {
async fn register(
&self,
_credential: &CodexAgentIdentitySecret,
) -> Result<String, CodexAgentIdentityError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.task_ids
.lock()
.expect("task registrar lock")
.pop_front()
.ok_or(CodexAgentIdentityError::TaskRegistrationUnavailable)
}
}
fn task_service(
store: &Arc<MemoryAccountStore>,
registrar: Arc<SequenceTaskRegistrar>,
) -> CodexAgentIdentityTaskService {
CodexAgentIdentityTaskService::new(
store.repository(),
registrar,
Arc::new(CodexWebSocketPool::default()),
)
}
#[tokio::test]
async fn task_registration_and_recovery_are_revision_fenced() {
let (signing_key, private_key) = signing_fixture(8);
let runtime_id = format!(
"runtime-{}",
hex::encode(signing_key.verifying_key().as_bytes())
);
let store = Arc::new(MemoryAccountStore::default());
store
.create_account(agent_account(
"acct_agent_task",
&runtime_id,
&private_key,
None,
))
.await
.expect("seed Agent Identity account");
let registrar = Arc::new(SequenceTaskRegistrar {
task_ids: Mutex::new(VecDeque::from([
"task-first".to_owned(),
"task-recovered".to_owned(),
])),
calls: AtomicUsize::new(0),
});
let service = task_service(&store, Arc::clone(&registrar));
let current = store.account("acct_agent_task").expect("current account");
let prepared = service.prepare(&current).await.expect("register task");
assert_eq!(prepared.account.revision().get(), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = prepared.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-first"));
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let current = store.account("acct_agent_task").expect("updated account");
let prepared = service
.prepare(&current)
.await
.expect("reuse registered task");
assert_eq!(prepared.account.revision().get(), 2);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 1);
let recovered = service
.recover(prepared.account.id(), "task-first")
.await
.expect("rejected task must register a replacement");
assert_eq!(recovered.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
let CodexRuntimeAuthentication::AgentIdentity(secret) = recovered.credential.authentication
else {
panic!("expected Agent Identity runtime credential");
};
assert_eq!(secret.task_id(), Some("task-recovered"));
let fenced = service
.recover(recovered.account.id(), "task-first")
.await
.expect("stale task recovery must keep the newer task");
assert_eq!(fenced.account.revision().get(), 3);
assert_eq!(registrar.calls.load(Ordering::SeqCst), 2);
}
@@ -13,7 +13,7 @@ use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileStat
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::support::{MemoryAccountStore, agent_identity_service, catalog_cache, profile, secret};
use crate::support::{MemoryAccountStore, catalog_cache, profile, secret};
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/fixtures/official_models_snapshot.json");
@@ -84,7 +84,6 @@ fn service_with_catalog_cache(
.build()
.expect("client"),
base_url,
agent_identity_service(store),
catalog_cache,
)
}
@@ -37,7 +37,7 @@ use url::Url;
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service, catalog_cache, profile, secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
fn create_account(store: &Arc<MemoryAccountStore>, id: &str, token: &str) {
@@ -162,13 +162,11 @@ fn selector_with_runtime(
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
let agent_identity = agent_identity_service(store);
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -176,7 +174,6 @@ fn selector_with_runtime(
profile,
http,
OFFICIAL_CODEX_BASE_URL.to_owned(),
Arc::clone(&agent_identity),
cooldowns,
));
CodexCredentialSelector::new(
@@ -187,7 +184,6 @@ fn selector_with_runtime(
Arc::new(MemorySessionExclusions::default()),
catalog,
quota,
agent_identity,
account_feedback,
CodexCookiePolicy::official().expect("official cookie policy"),
)
@@ -1,9 +1,9 @@
mod admin;
mod agent_identity;
mod catalog;
mod contract;
mod cookie;
mod oauth;
mod profile_statistics;
mod quota;
mod refresh;
mod token_client;
@@ -0,0 +1,157 @@
use std::sync::Arc;
use chrono::{TimeZone as _, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, profile, secret};
fn wire_profile() -> CodexWireProfileState {
CodexWireProfileState::new(CodexWireProfile {
originator: "codex_cli_rs".to_owned(),
codex_version: "0.144.0".to_owned(),
desktop_version: "1.0.0".to_owned(),
desktop_build: "1".to_owned(),
os_type: "linux".to_owned(),
os_version: "6.8".to_owned(),
arch: "x86_64".to_owned(),
terminal: "profile-statistics-contract".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
.expect("fixture time"),
})
}
async fn service(
account_id: &str,
server: &MockServer,
) -> (Arc<MemoryAccountStore>, CodexCredentialProfileService) {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
name: account_id.to_owned(),
secret: secret(&format!("token-{account_id}")),
verified_account: profile(&format!("chatgpt-{account_id}")),
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
let service = CodexCredentialProfileService::new(
store.repository(),
wire_profile(),
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
(store, service)
}
#[tokio::test]
async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wham/profiles/me"))
.and(header(
"chatgpt-account-id",
"chatgpt-acct_profile_statistics",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"profile": {
"display_name": " Ada ",
"username": " ada ",
"profile_picture_url": " https://example.test/avatar.png "
},
"stats": {
"lifetime_tokens": 409500000,
"peak_daily_tokens": 267000000,
"longest_running_turn_sec": 51900,
"current_streak_days": 16,
"longest_streak_days": 32,
"daily_usage_buckets": [
{"start_date": "2026-08-18", "tokens": 20},
{"start_date": "2026-08-17", "tokens": 10}
],
"fast_mode_usage_percentage": 0,
"most_used_reasoning_effort": "high",
"most_used_reasoning_effort_percentage": 48,
"unique_skills_used": 8,
"total_skills_used": 61,
"total_threads": 2391,
"top_invocations": [{
"type": "plugin",
"plugin_id": "plugin_1",
"plugin_name": "Example",
"usage_count": 29
}]
},
"metadata": {"stats_error": null}
})))
.expect(1)
.mount(&server)
.await;
let (store, service) = service("acct_profile_statistics", &server).await;
let account = store
.account("acct_profile_statistics")
.expect("profile account");
let statistics = service
.profile_statistics(account.id())
.await
.expect("profile statistics");
assert_eq!(statistics.display_name.as_deref(), Some("Ada"));
assert_eq!(statistics.username.as_deref(), Some("ada"));
assert_eq!(statistics.summary.total_text_tokens, Some(409_500_000));
assert_eq!(
statistics.summary.longest_task_duration_ms,
Some(51_900_000)
);
assert_eq!(
statistics.daily_usage.as_ref().expect("daily")[0].tokens,
10
);
assert_eq!(
statistics
.activity_insights
.invocations
.as_ref()
.expect("invocations")[0]
.usage_count,
Some(29)
);
assert!(!statistics.has_stats_error);
}
#[tokio::test]
async fn profile_statistics_keeps_profile_when_stats_are_unavailable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wham/profiles/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"profile": {"display_name": "Ada"},
"stats": {},
"metadata": {"stats_error": " aggregation unavailable "}
})))
.mount(&server)
.await;
let (_store, service) = service("acct_profile_partial", &server).await;
let account_id = ProviderAccountId::new("acct_profile_partial").expect("account ID");
let statistics = service
.profile_statistics(&account_id)
.await
.expect("partial profile");
assert_eq!(statistics.display_name.as_deref(), Some("Ada"));
assert!(statistics.has_stats_error);
assert_eq!(statistics.summary.total_text_tokens, None);
assert_eq!(statistics.daily_usage, None);
}
@@ -2,7 +2,6 @@
mod recovery;
mod snapshot;
mod usage_statistics;
use std::sync::Arc;
use std::time::SystemTime;
@@ -62,7 +61,6 @@ fn quota_service_with_base_url(
wire_profile(),
http,
base_url,
crate::support::agent_identity_service(store),
Arc::new(crate::support::MemoryCooldownPort::new()),
)
}
@@ -1,217 +0,0 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Days, NaiveTime, TimeDelta, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexUsageStatisticsError, CodexUsageStatisticsServiceTier};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{create_account, quota_service_with_base_url};
use crate::support::MemoryAccountStore;
const PERSONAL_MODEL_REPORT_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
const PERSONAL_TOTALS_REPORT_PATH: &str = "/wham/analytics/daily-workspace-usage-counts";
fn cycle_anchor() -> (DateTime<Utc>, DateTime<Utc>) {
let start_date = Utc::now()
.date_naive()
.checked_sub_days(Days::new(1))
.expect("cycle start date");
let start_at = DateTime::<Utc>::from_naive_utc_and_offset(
start_date.and_time(NaiveTime::from_hms_opt(15, 50, 0).expect("cycle time")),
Utc,
);
(
start_at,
start_at
.checked_add_signed(TimeDelta::days(7))
.expect("cycle end"),
)
}
async fn mount_quota(server: &MockServer, reset_at: DateTime<Utc>) {
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": 20,
"reset_at": reset_at.timestamp(),
"limit_window_seconds": 604800
}
}
})))
.mount(server)
.await;
}
async fn service(
account_id: &str,
server: &MockServer,
reset_at: DateTime<Utc>,
) -> (
Arc<MemoryAccountStore>,
provider_openai::credential::CodexCredentialQuotaService,
) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, account_id).await;
mount_quota(server, reset_at).await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
(store, service)
}
#[tokio::test]
async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_totals() {
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = Utc::now().date_naive().to_string();
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"models": [
{"model": "gpt-5.6-sol", "speed": "fast", "credits": 75},
{"model": "gpt-5.6-terra", "speed": "standard", "credits": 25}
]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"uncached_text_input_tokens": 101,
"cached_text_input_tokens": 203,
"text_output_tokens": 17,
"text_total_tokens": 321
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_personal_statistics", &server, end_at).await;
let account = store
.account("acct_personal_statistics")
.expect("personal account");
let statistics = service
.usage_statistics(account.id(), 0, 0)
.await
.expect("personal statistics");
assert_eq!(statistics.cycle.start_at, start_at);
assert_eq!(statistics.summary.tokens.uncached_input, 101);
assert_eq!(statistics.summary.tokens.cached_input, 203);
assert_eq!(statistics.summary.tokens.output, 17);
assert_eq!(statistics.summary.tokens.total, 321);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.uncached_input)
.sum::<u64>(),
101
);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.total)
.sum::<u64>(),
321
);
assert!(
statistics
.models
.iter()
.all(|model| model.has_estimated_allocation)
);
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-sol" && model.service_tier == CodexUsageStatisticsServiceTier::Fast
}));
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-terra"
&& model.service_tier == CodexUsageStatisticsServiceTier::Standard
}));
}
#[tokio::test]
async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
let server = MockServer::start().await;
let (current_start_at, current_end_at) = cycle_anchor();
let selected_date = current_start_at
.date_naive()
.checked_sub_days(Days::new(14))
.expect("selected date");
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"models": [{
"model": "gpt-5.6-terra",
"speed": "standard",
"credits": 10
}]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_offset_statistics", &server, current_end_at).await;
let account = store
.account("acct_offset_statistics")
.expect("offset account");
let statistics = service
.usage_statistics(account.id(), -2, 0)
.await
.expect("historical statistics");
assert_eq!(statistics.cycle.offset, -2);
assert!(!statistics.cycle.is_current);
assert!(statistics.cycle.can_go_next);
assert!(statistics.cycle.used_percent.is_none());
assert_eq!(statistics.daily[0].date, selected_date);
let requests = server.received_requests().await.expect("recorded requests");
let report = requests
.iter()
.find(|request| request.url.path() == PERSONAL_TOTALS_REPORT_PATH)
.expect("personal totals report request");
let query = report
.url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<BTreeMap<_, _>>();
let expected_start = current_start_at
.date_naive()
.checked_sub_days(Days::new(15))
.expect("query start");
let expected_end = current_start_at
.date_naive()
.checked_sub_days(Days::new(6))
.expect("query end");
assert_eq!(query.get("start_date"), Some(&expected_start.to_string()));
assert_eq!(query.get("end_date"), Some(&expected_end.to_string()));
assert_eq!(query.get("group_by").map(String::as_str), Some("day"));
let invalid_account = ProviderAccountId::new("acct_invalid_offset").expect("account ID");
assert!(matches!(
service.usage_statistics(&invalid_account, -9, 0).await,
Err(CodexUsageStatisticsError::InvalidRequest)
));
}
@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write};
use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -48,13 +49,13 @@ use tokio::{
};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode};
use tracing_subscriber::fmt::MakeWriter;
use wiremock::matchers::{body_bytes, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{
MemoryAccountStore, MemoryCooldownPort, MemorySessionAffinity, MemorySessionExclusions,
TestLeaseCoordinator, account_policy, agent_identity_service_with_pool, catalog_cache, profile,
secret,
TestLeaseCoordinator, account_policy, catalog_cache, profile, secret,
};
use crate::transport::accept_codex_test_websocket;
@@ -65,6 +66,61 @@ const CAPTURE_COMPLETED_SSE: &str = concat!(
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scope_capture\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
);
#[derive(Clone, Default)]
struct CapturedLogs {
bytes: Arc<Mutex<Vec<u8>>>,
}
impl CapturedLogs {
fn json_events(&self) -> Vec<Value> {
let bytes = self.bytes.lock().expect("captured logs lock").clone();
String::from_utf8(bytes)
.expect("captured logs are UTF-8")
.lines()
.map(|line| serde_json::from_str(line).expect("captured log is JSON"))
.collect()
}
}
impl<'writer> MakeWriter<'writer> for CapturedLogs {
type Writer = Self;
fn make_writer(&'writer self) -> Self::Writer {
self.clone()
}
}
impl Write for CapturedLogs {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.bytes
.lock()
.map_err(|_| io::Error::other("captured logs lock poisoned"))?
.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn selected_account_log_fields<'events>(
events: &'events [Value],
request_id: &str,
) -> &'events Map<String, Value> {
events
.iter()
.find_map(|event| {
let fields = event.get("fields")?.as_object()?;
(fields.get("message").and_then(Value::as_str) == Some("OpenAI account selected")
&& fields.get("request_id").and_then(Value::as_str) == Some(request_id))
.then_some(fields)
})
.unwrap_or_else(|| {
panic!("OpenAI account selection log for {request_id}; captured events: {events:#?}")
})
}
fn wire_profile() -> CodexWireProfileState {
CodexWireProfileState::new(CodexWireProfile {
originator: "codex_cli_rs".to_owned(),
@@ -146,13 +202,11 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
.build()
.expect("client");
let websocket_pool = Arc::new(CodexWebSocketPool::default());
let agent_identity = agent_identity_service_with_pool(store, Arc::clone(&websocket_pool));
let catalog = Arc::new(CodexCredentialCatalogService::new(
store.repository(),
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
catalog_cache(),
));
let quota = Arc::new(CodexCredentialQuotaService::new(
@@ -160,7 +214,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
profile.clone(),
http.clone(),
base_url.clone(),
Arc::clone(&agent_identity),
Arc::new(MemoryCooldownPort::new()),
));
let account_feedback = Arc::new(AccountFeedbackStats::default());
@@ -172,7 +225,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
Arc::new(MemorySessionExclusions::default()),
Arc::clone(&catalog),
Arc::clone(&quota),
Arc::clone(&agent_identity),
Arc::clone(&account_feedback),
CodexCookiePolicy::official().expect("cookie policy"),
));
@@ -181,7 +233,6 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases(
selector,
catalog,
Arc::clone(&quota),
agent_identity,
account_feedback,
http,
profile,
@@ -1746,6 +1797,106 @@ async fn account_change_drops_only_account_bound_opaque_headers() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn account_selection_log_should_include_affinity_observation_fields() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_affinity").await;
let provider = provider_with_affinity(&store, Arc::new(MemorySessionAffinity::default()));
let captured = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
.json()
.without_time()
.with_ansi(false)
.with_target(false)
.with_writer(captured.clone())
.finish();
// 该 integration test binary 没有其他 subscriber;全局安装可避免并行测试切换
// thread-local dispatcher 时重建 tracing callsite interest 所产生的竞争。
tracing::subscriber::set_global_default(subscriber)
.expect("install affinity observation log subscriber");
for (request_id, prompt_cache_key, session_id) in [
(
"req_affinity_observation_session_first",
"turn-cache-first",
Some("stable-observation-session"),
),
(
"req_affinity_observation_session_second",
"turn-cache-second",
Some("stable-observation-session"),
),
(
"req_affinity_observation_conversation",
"turn-cache-with-conversation",
None,
),
] {
let mut payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("hello")),
("prompt_cache_key".to_owned(), json!(prompt_cache_key)),
]),
)
.expect("OpenAI payload");
if let Some(session_id) = session_id {
payload = payload.with_context(Map::from_iter([(
"session_id".to_owned(),
json!(session_id),
)]));
} else {
payload = payload.with_context(Map::from_iter([(
"conversation_id".to_owned(),
json!("root-observation-conversation"),
)]));
}
let generation = GenerateRequest::from_protocol_payload(payload);
let stream = provider
.execute(
planned_request("openai", Operation::Generate(generation)),
context(request_id, CancellationToken::new()),
)
.await
.expect("prepare affinity observation request");
drop(stream);
}
let events = captured.json_events();
let first = selected_account_log_fields(&events, "req_affinity_observation_session_first");
let second = selected_account_log_fields(&events, "req_affinity_observation_session_second");
for fields in [first, second] {
assert_eq!(fields["affinity_anchor_source"], "root-session");
assert_eq!(fields["affinity_anchor"], "stable-observation-session");
assert_eq!(fields["session_id"], "stable-observation-session");
assert_eq!(fields["session_id_present"], true);
let key_hash = fields["affinity_key_hash"]
.as_str()
.expect("affinity key hash");
assert_eq!(key_hash.len(), 12);
assert!(key_hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
assert_eq!(
first["affinity_key_hash"], second["affinity_key_hash"],
"the same session must emit the same affinity hash"
);
let conversation =
selected_account_log_fields(&events, "req_affinity_observation_conversation");
assert_eq!(conversation["affinity_anchor_source"], "root-conversation");
assert_eq!(
conversation["affinity_anchor"],
"root-observation-conversation"
);
assert_eq!(conversation["session_id"], "");
assert_eq!(conversation["session_id_present"], false);
assert_ne!(
first["affinity_key_hash"], conversation["affinity_key_hash"],
"different anchors must not share the same affinity hash"
);
}
#[tokio::test]
async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key() {
let store = Arc::new(MemoryAccountStore::default());
@@ -27,11 +27,9 @@ use gateway_core::provider_ports::{
};
use gateway_core::routing::ProviderKind;
use provider_openai::credential::{
CodexAccountProfile, CodexAgentIdentityTaskService, CodexCredentialAdmin,
CodexCredentialRepository, CodexOAuthSecret, ImportCodexOAuthCredential,
OfficialCodexAgentIdentityTaskRegistrar,
CodexAccountProfile, CodexCredentialAdmin, CodexCredentialRepository, CodexOAuthSecret,
ImportCodexOAuthCredential,
};
use provider_openai::transport::CodexWebSocketPool;
use secrecy::SecretString;
#[derive(Clone)]
@@ -510,32 +508,6 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
.with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at)
}
pub(crate) fn agent_identity_service(
store: &Arc<MemoryAccountStore>,
) -> Arc<CodexAgentIdentityTaskService> {
agent_identity_service_with_pool(store, Arc::new(CodexWebSocketPool::default()))
}
pub(crate) fn agent_identity_service_with_pool(
store: &Arc<MemoryAccountStore>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Arc<CodexAgentIdentityTaskService> {
let client = reqwest::Client::builder()
.no_proxy()
.build()
.expect("agent task client");
let registrar = OfficialCodexAgentIdentityTaskRegistrar::new(
client,
provider_openai::OpenAiConfig::default().wire_profile_state(),
)
.expect("agent task registrar");
Arc::new(CodexAgentIdentityTaskService::new(
store.repository(),
Arc::new(registrar),
websocket_pool,
))
}
#[derive(Default)]
pub(crate) struct TestLeaseCoordinator {
pub(crate) requests: Mutex<Vec<ProviderSchedulingLeaseRequest>>,
+2 -20
View File
@@ -131,7 +131,7 @@ Grok wire 与 Responses wire 之间的协议转换层,转换只在 xAI Provide
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和 Agent Identity;
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)和 CPR 账号 bundle;
- OpenAI OAuth token 字段只识别 `accessToken`、`refreshToken`、`idToken`,可以嵌套在账号 object 内;
每项至少包含 AT 或 RT。RT-only 会在导入时换取 AT,AT-only 不具备自动续期能力;
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;包装中的代理、并发、优先级等字段不参与认证;
@@ -160,25 +160,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
重新授权以及普通 credential refresh/rotation 均保留已有分组。分组关系只通过账号编辑维护。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
OpenAI 的 CPR 导出保持 OAuth 账号的既有字段;Agent Identity 账号则输出
`authMode: "agentIdentity"`、`agentRuntimeId`、`agentPrivateKey` 和可选的
`taskId`,不伪造 OAuth token 或过期时间。一个 `sourceFormat: "cpr"` 文档可以包含两种
认证类型,导入时按每个账号的认证模式分别解析。
```json
{
"sourceFormat": "cpr",
"accounts": [{
"id": "acct_...",
"accountId": "...",
"userId": "...",
"authMode": "agentIdentity",
"agentRuntimeId": "...",
"agentPrivateKey": "...",
"taskId": "..."
}]
}
```
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
OpenAI rotation 请求字段为:
+2 -2
View File
@@ -59,7 +59,7 @@ flowchart LR
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin wire、静态管理端;不直接访问 Store 或具体 Provider |
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督和系统更新 |
| `providers/openai` | OpenAI OAuth/Agent Identity、账号选择、目录、额度、Responses/Images transport |
| `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images transport |
| `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 |
| `frontend` | Vue 管理端,仅通过 Admin API 读写状态 |
@@ -189,7 +189,7 @@ PostgreSQL 周期对账才是正确性基础。
credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403
也不能单独证明 refresh token 永久失效。
- OpenAI 支持 OAuth、AT/RT、OAuth JSON 与 Agent Identity;RT-only 导入先换取 AT,AT-only 导入没有
- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON;RT-only 导入先换取 AT,AT-only 导入没有
自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。
- xAI 使用 OAuth session;API Key 不是受支持的账号 credential。
- 新账号导入和首次 OAuth 在 credential 提交后尽力读取一次额度;失败只留下观测,不回滚账号事务。
+38 -61
View File
@@ -170,64 +170,46 @@ export interface AccountQuotaResponse {
account: Account
}
export interface AccountUsageStatisticsTokens {
uncachedInput: number
cachedInput: number
output: number
total: number
export interface AccountProfileStatisticsSummary {
totalTextTokens: number | null
peakTokens: number | null
longestTaskDurationMs: number | null
currentStreakDays: number | null
longestStreakDays: number | null
}
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 {
export interface AccountProfileDailyUsage {
date: string
creditShare: number | null
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
hasUnknownPricing: boolean
hasMissingTokenData: boolean
isBoundaryDay: boolean
tokens: number
}
export interface AccountUsageStatisticsResponse {
cycle: {
offset: number
startAt: string
endAt: string
windowSeconds: number
usedPercent: number | null
isCurrent: boolean
canGoPrevious: boolean
canGoNext: boolean
}
summary: {
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
projectedTokens: number | null
projectedCost: AccountUsageStatisticsCost | null
dayCount: number
hasUnknownPricing: boolean
hasMissingTokenData: boolean
}
models: AccountUsageStatisticsModel[]
daily: AccountUsageStatisticsDay[]
export interface AccountProfileInvocation {
type: string
pluginId: string | null
pluginName: string | null
skillId: string | null
skillName: string | null
usageCount: number | null
}
export interface AccountProfileActivityInsights {
fastModePercent: number | null
reasoningEffort: string | null
reasoningEffortPercent: number | null
skillsExplored: number | null
totalSkillsUsed: number | null
totalThreads: number | null
invocations: AccountProfileInvocation[] | null
}
export interface AccountProfileStatisticsResponse {
displayName: string | null
username: string | null
imageUrl: string | null
hasStatsError: boolean
summary: AccountProfileStatisticsSummary
dailyUsage: AccountProfileDailyUsage[] | null
activityInsights: AccountProfileActivityInsights
}
export interface AccountResetCredit {
@@ -298,11 +280,6 @@ interface AccountIdParam {
accountId: string
}
interface AccountUsageStatisticsParam extends AccountIdParam {
cycleOffset?: number
utcOffsetMinutes?: number
}
interface AccountResetCreditConsumeParam extends AccountIdParam {
creditId?: string
redeemRequestId: string
@@ -391,9 +368,9 @@ export function getAccountQuota(data: AccountIdParam) {
})
}
export function getAccountUsageStatistics(data: AccountUsageStatisticsParam) {
return request<AccountUsageStatisticsResponse>({
url: '/api/admin/accounts/usage-statistics',
export function getAccountProfileStatistics(data: AccountIdParam) {
return request<AccountProfileStatisticsResponse>({
url: '/api/admin/accounts/profile-statistics',
method: 'GET',
params: data,
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
import { Key, Openai, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
@@ -28,8 +28,6 @@ const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'agent_identity')
return 'Agent Identity'
if (normalizedAuthenticationKind.value === 'oauth')
return 'OAuth'
return props.authenticationKind?.trim() || '未知认证类型'
@@ -59,11 +57,6 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<Robot
v-else-if="normalizedAuthenticationKind === 'agent_identity'"
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
+1 -1
View File
@@ -28,7 +28,7 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-bg-elevated',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-fill-alter',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
@@ -5,6 +5,7 @@ import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import BaseIconButton from '../BaseIconButton.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
import { useModalDrag } from './useModalDrag'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
@@ -16,6 +17,7 @@ const props = withDefaults(
size?: ModalSize
tone?: ModalTone
dismissible?: boolean
draggable?: boolean
role?: 'dialog' | 'alertdialog'
}>(),
{
@@ -23,6 +25,7 @@ const props = withDefaults(
size: 'md',
tone: 'neutral',
dismissible: true,
draggable: true,
role: 'dialog',
},
)
@@ -31,6 +34,10 @@ const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLElement>('panel')
const titleId = useId()
const descriptionId = useId()
const { cancelDrag, handlePointerDown, isDragging, resetPosition } = useModalDrag(
panel,
() => props.draggable,
)
let previouslyFocused: HTMLElement | null = null
let ownsScrollLock = false
@@ -161,6 +168,7 @@ watch(
return
}
cancelDrag()
releaseScrollLock()
restorePreviousFocus()
},
@@ -175,7 +183,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<Transition name="cp-modal">
<Transition name="cp-modal" @after-leave="resetPosition">
<div
v-if="open"
class="fixed inset-0 z-50 grid place-items-center overflow-hidden p-3 sm:p-6"
@@ -191,10 +199,11 @@ onBeforeUnmount(() => {
/>
<section
ref="panel"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-bg-container shadow-cp sm:max-h-[calc(100dvh-3rem)]"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-modal-bg shadow-cp sm:max-h-[calc(100dvh-3rem)]"
:class="[
sizeClasses[size],
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
isDragging ? 'cp-modal-panel--dragging' : undefined,
]"
:role="role"
aria-modal="true"
@@ -203,13 +212,15 @@ onBeforeUnmount(() => {
tabindex="-1"
>
<header
class="grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
class="cp-modal-header grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
:class="[
tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
description ? 'items-start' : 'items-center',
draggable ? 'cp-modal-header--draggable' : undefined,
]"
@pointerdown="handlePointerDown"
>
<span
v-if="tone !== 'neutral'"
@@ -291,6 +302,16 @@ onBeforeUnmount(() => {
transform: translate3d(0, 4px, 0) scale(0.99);
}
.cp-modal-header--draggable {
cursor: move;
touch-action: none;
user-select: none;
}
.cp-modal-panel--dragging .cp-modal-header {
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.cp-modal-enter-active,
.cp-modal-leave-active,
@@ -0,0 +1,166 @@
import type { ShallowRef } from 'vue'
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
const VIEWPORT_GUTTER = 8
const interactiveSelector = 'button, a, input, select, textarea, [role="button"], [data-modal-drag-ignore]'
interface DragSession {
pointerId: number
startX: number
startY: number
originX: number
originY: number
minX: number
maxX: number
minY: number
maxY: number
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function useModalDrag(
target: Readonly<ShallowRef<HTMLElement | null>>,
enabled: () => boolean,
) {
const isDragging = shallowRef(false)
const offset = { x: 0, y: 0 }
let session: DragSession | null = null
function applyPosition() {
if (!target.value)
return
target.value.style.transform
= offset.x === 0 && offset.y === 0
? ''
: `translate3d(${offset.x}px, ${offset.y}px, 0)`
}
function removeDragListeners() {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', endDrag)
document.removeEventListener('pointercancel', endDrag)
window.removeEventListener('blur', handleWindowBlur)
}
function endDrag(event?: PointerEvent) {
if (event && session && event.pointerId !== session.pointerId)
return
session = null
isDragging.value = false
removeDragListeners()
}
function handlePointerMove(event: PointerEvent) {
if (!session || event.pointerId !== session.pointerId)
return
if (!enabled()) {
endDrag()
return
}
event.preventDefault()
offset.x = clamp(
session.originX + event.clientX - session.startX,
session.minX,
session.maxX,
)
offset.y = clamp(
session.originY + event.clientY - session.startY,
session.minY,
session.maxY,
)
applyPosition()
}
function handlePointerDown(event: PointerEvent) {
if (!enabled() || !event.isPrimary || event.button !== 0)
return
const eventTarget = event.target
if (eventTarget instanceof Element && eventTarget.closest(interactiveSelector))
return
const element = target.value
if (!element)
return
const rect = element.getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
session = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offset.x,
originY: offset.y,
minX: offset.x - rect.left + VIEWPORT_GUTTER,
maxX: offset.x + viewportWidth - rect.right - VIEWPORT_GUTTER,
minY: offset.y - rect.top + VIEWPORT_GUTTER,
maxY: offset.y + viewportHeight - rect.bottom - VIEWPORT_GUTTER,
}
isDragging.value = true
event.preventDefault()
document.addEventListener('pointermove', handlePointerMove, { passive: false })
document.addEventListener('pointerup', endDrag)
document.addEventListener('pointercancel', endDrag)
window.addEventListener('blur', handleWindowBlur)
}
function handleWindowBlur() {
endDrag()
}
function updatePosition() {
const element = target.value
if (!element)
return
const rect = element.getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
if (rect.left < VIEWPORT_GUTTER)
offset.x += VIEWPORT_GUTTER - rect.left
else if (rect.right > viewportWidth - VIEWPORT_GUTTER)
offset.x -= rect.right - viewportWidth + VIEWPORT_GUTTER
if (rect.top < VIEWPORT_GUTTER)
offset.y += VIEWPORT_GUTTER - rect.top
else if (rect.bottom > viewportHeight - VIEWPORT_GUTTER)
offset.y -= rect.bottom - viewportHeight + VIEWPORT_GUTTER
applyPosition()
}
function resetPosition() {
endDrag()
offset.x = 0
offset.y = 0
applyPosition()
}
function handleResize() {
endDrag()
updatePosition()
}
onMounted(() => window.addEventListener('resize', handleResize))
onBeforeUnmount(() => {
endDrag()
window.removeEventListener('resize', handleResize)
})
return {
cancelDrag: endDrag,
handlePointerDown,
isDragging,
resetPosition,
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-container text-left text-cp-text shadow-cp',
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-elevated text-left text-cp-text shadow-cp',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
+1 -1
View File
@@ -111,7 +111,7 @@ const triggerClasses = computed(() => [
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-container p-1 shadow-cp',
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-elevated p-1 shadow-cp',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -45,7 +45,7 @@ const colorClasses = {
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-container px-3.5 py-3 shadow-cp"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-elevated px-3.5 py-3 shadow-cp"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
+7
View File
@@ -30,6 +30,7 @@
--color-cp-fill-secondary: var(--cp-color-fill-secondary);
--color-cp-fill-tertiary: var(--cp-color-fill-tertiary);
--color-cp-fill-quaternary: var(--cp-color-fill-quaternary);
--color-cp-fill-alter: var(--cp-color-fill-alter);
--color-cp-border: var(--cp-color-border);
--color-cp-border-secondary: var(--cp-color-border-secondary);
--color-cp-split: var(--cp-color-split);
@@ -59,6 +60,7 @@
--color-cp-control-outline: var(--cp-control-outline);
--color-cp-menu-item-selected-bg: var(--cp-menu-item-selected-bg);
--color-cp-progress-remaining: var(--cp-progress-remaining-color);
--color-cp-modal-bg: var(--cp-modal-bg);
--color-cp-info: var(--cp-color-info);
--color-cp-info-hover: var(--cp-color-info-hover);
@@ -66,6 +68,11 @@
--color-cp-info-bg: var(--cp-color-info-bg);
--color-cp-info-border: var(--cp-color-info-border);
--color-cp-info-text: var(--cp-color-info-text);
--color-cp-activity-level-0: var(--cp-activity-level-0);
--color-cp-activity-level-1: var(--cp-activity-level-1);
--color-cp-activity-level-2: var(--cp-activity-level-2);
--color-cp-activity-level-3: var(--cp-activity-level-3);
--color-cp-activity-level-4: var(--cp-activity-level-4);
--color-cp-status-normal: var(--cp-color-status-normal);
--color-cp-status-normal-hover: var(--cp-color-status-normal-hover);
--color-cp-status-normal-active: var(--cp-color-status-normal-active);
+3 -2
View File
@@ -98,7 +98,7 @@ export const WHITE = '#FFFFFF'
export const NEUTRAL_LIGHT_SURFACES = {
colorBgLayout: '#F6F8FB',
colorBgContainer: LIGHT_CONTAINER_BASE,
colorBgElevated: '#F8FAFC',
colorBgElevated: LIGHT_CONTAINER_BASE,
colorBgSpotlight: '#111827',
colorBgMask: '#0E17264D',
colorBgTextHover: '#F1F5F9',
@@ -106,7 +106,7 @@ export const NEUTRAL_LIGHT_SURFACES = {
colorBgContainerDisabled: '#F1F5F9',
colorFillSecondary: '#E9EEF5',
colorFillTertiary: '#F1F5F9',
colorFillQuaternary: '#F8FAFC',
colorFillQuaternary: '#F6F8FB',
colorBorder: '#D8E0EA',
colorBorderSecondary: '#E2E8F0',
colorSplit: '#D8E0EA6B',
@@ -194,6 +194,7 @@ export const EDITABLE_COLOR_TOKEN_NAMES = new Set<ThemeTokenName>([
'--cp-button-primary-hover-bg',
'--cp-button-primary-active-bg',
'--cp-card-bg',
'--cp-modal-bg',
'--cp-table-header-bg',
'--cp-table-row-bg',
'--cp-table-row-stripe-bg',
+70 -7
View File
@@ -65,6 +65,7 @@ export type ThemeTokenName
| '--cp-color-fill-secondary'
| '--cp-color-fill-tertiary'
| '--cp-color-fill-quaternary'
| '--cp-color-fill-alter'
| '--cp-color-border'
| '--cp-color-border-secondary'
| '--cp-color-split'
@@ -103,6 +104,7 @@ export type ThemeTokenName
| '--cp-button-primary-active-bg'
| '--cp-brand-mark-bg'
| '--cp-card-bg'
| '--cp-modal-bg'
| '--cp-table-header-bg'
| '--cp-table-row-bg'
| '--cp-table-row-stripe-bg'
@@ -127,6 +129,11 @@ export type ThemeTokenName
| '--cp-color-info-bg'
| '--cp-color-info-border'
| '--cp-color-info-text'
| '--cp-activity-level-0'
| '--cp-activity-level-1'
| '--cp-activity-level-2'
| '--cp-activity-level-3'
| '--cp-activity-level-4'
| '--cp-color-success'
| '--cp-color-success-bg'
| '--cp-color-success-bg-hover'
@@ -270,6 +277,11 @@ interface ThemeSurfaceMap {
colorShadow: string
}
/** Map Token 经过视觉角色映射后供通用组件消费的 Alias。 */
interface ThemeAliasMap {
colorFillAlter: string
}
interface ThemeShadowMap {
boxShadow: string
boxShadowSecondary: string
@@ -288,6 +300,7 @@ interface ThemeComponentMap {
buttonPrimaryActiveBg: string
brandMarkBg: string
cardBg: string
modalBg: string
tableHeaderBg: string
tableRowBg: string
tableRowStripeBg: string
@@ -323,6 +336,14 @@ interface ThemeSemanticMap {
error: FunctionalColorMap
}
interface ThemeDataMap {
activityLevel0: string
activityLevel1: string
activityLevel2: string
activityLevel3: string
activityLevel4: string
}
interface ThemeDimensionMap {
fontSizeXs: string
fontSizeSm: string
@@ -377,21 +398,34 @@ export function resolveTheme(
appearance,
)
const surfaces = deriveThemeSurfaceMap(name, seedTokens)
const aliases = deriveThemeAliasMap(surfaces)
const primary = deriveThemePrimaryMap(seed, name, surfaces.colorBgContainer)
const link = seedTokens.colorLink === seed
? primary
: deriveThemePrimaryMap(seedTokens.colorLink, name, surfaces.colorBgContainer)
const semantics = deriveThemeSemanticMap(name, surfaces.colorBgContainer, seedTokens)
const data = deriveThemeDataMap(aliases, semantics)
const shadows = deriveThemeShadowMap(name, seedTokens.shadowStrength)
const components = deriveThemeComponentMap(
name,
surfaces,
aliases,
primary,
semantics.error,
seedTokens.shadowStrength,
)
const dimensions = deriveThemeDimensionMap(seedTokens, normalizedCustomization.component)
const tokens = toThemeTokens(surfaces, primary, link, semantics, shadows, components, dimensions)
const tokens = toThemeTokens(
surfaces,
aliases,
primary,
link,
semantics,
data,
shadows,
components,
dimensions,
)
const mergedTokens: ThemeTokens = {
...tokens,
...normalizedCustomization.tokenOverrides,
@@ -600,9 +634,7 @@ function deriveNeutralLightSurfaceMap(seedTokens: ResolvedThemeSeedTokens): Them
? NEUTRAL_LIGHT_SURFACES.colorBgLayout
: mix(background, colorText, 0.035),
colorBgContainer: background,
colorBgElevated: baselineBackground
? NEUTRAL_LIGHT_SURFACES.colorBgElevated
: mix(background, colorText, 0.018),
colorBgElevated: baselineBackground ? NEUTRAL_LIGHT_SURFACES.colorBgElevated : background,
colorBgSpotlight: baselineBackground
? NEUTRAL_LIGHT_SURFACES.colorBgSpotlight
: mix(colorText, BLACK, 0.08),
@@ -644,6 +676,12 @@ function deriveNeutralLightSurfaceMap(seedTokens: ResolvedThemeSeedTokens): Them
}
}
function deriveThemeAliasMap(surfaces: ThemeSurfaceMap): ThemeAliasMap {
return {
colorFillAlter: surfaces.colorFillQuaternary,
}
}
function deriveThemePrimaryMap(
seed: string,
theme: ThemeName,
@@ -767,6 +805,19 @@ function deriveFunctionalColorMap(
}
}
function deriveThemeDataMap(
aliases: ThemeAliasMap,
semantics: ThemeSemanticMap,
): ThemeDataMap {
return {
activityLevel0: aliases.colorFillAlter,
activityLevel1: semantics.info.background,
activityLevel2: semantics.info.backgroundHover,
activityLevel3: semantics.info.backgroundActive,
activityLevel4: semantics.info.color,
}
}
function deriveThemeDimensionMap(
seedTokens: ResolvedThemeSeedTokens,
componentOverrides: ThemeComponentOverrides | undefined,
@@ -820,6 +871,7 @@ function deriveThemeShadowMap(theme: ThemeName, shadowStrength: number): ThemeSh
function deriveThemeComponentMap(
theme: ThemeName,
surfaces: ThemeSurfaceMap,
aliases: ThemeAliasMap,
primary: ThemePrimaryMap,
error: FunctionalColorMap,
shadowStrength: number,
@@ -845,9 +897,10 @@ function deriveThemeComponentMap(
buttonPrimaryActiveBg: primary.colorPrimarySolidActive,
brandMarkBg: surfaces.colorBgElevated,
cardBg: surfaces.colorBgContainer,
modalBg: surfaces.colorBgContainer,
tableHeaderBg: surfaces.colorFillTertiary,
tableRowBg: surfaces.colorBgContainer,
tableRowStripeBg: surfaces.colorBgElevated,
tableRowStripeBg: aliases.colorFillAlter,
tableRowHoverBg: surfaces.colorBgTextHover,
tableRowSelectedBg: primary.colorPrimaryBg,
progressRemainingColor: mix(surfaces.colorBgContainer, surfaces.colorBorderSecondary, 0.92),
@@ -876,7 +929,7 @@ function deriveThemeComponentMap(
inputHoverBg: usesNeutralInput
? NEUTRAL_LIGHT_INPUT.hoverBg
: mix(surfaces.colorBgContainer, surfaces.colorFillTertiary, 0.88),
inputActiveBg: usesNeutralInput ? NEUTRAL_LIGHT_INPUT.activeBg : surfaces.colorBgElevated,
inputActiveBg: usesNeutralInput ? NEUTRAL_LIGHT_INPUT.activeBg : aliases.colorFillAlter,
inputErrorActiveBg: error.background,
buttonPrimaryColor: solidControlTextColor(primary.colorPrimarySolid),
buttonPrimaryBg: primary.colorPrimarySolid,
@@ -884,9 +937,10 @@ function deriveThemeComponentMap(
buttonPrimaryActiveBg: primary.colorPrimarySolidActive,
brandMarkBg: surfaces.colorBgSpotlight,
cardBg: surfaces.colorBgContainer,
modalBg: surfaces.colorBgContainer,
tableHeaderBg: surfaces.colorFillTertiary,
tableRowBg: surfaces.colorBgContainer,
tableRowStripeBg: surfaces.colorBgElevated,
tableRowStripeBg: aliases.colorFillAlter,
tableRowHoverBg: surfaces.colorBgTextHover,
tableRowSelectedBg: primary.colorPrimaryBg,
progressRemainingColor: surfaces.colorBorderSecondary,
@@ -907,9 +961,11 @@ function deriveThemeComponentMap(
function toThemeTokens(
surfaces: ThemeSurfaceMap,
aliases: ThemeAliasMap,
primary: ThemePrimaryMap,
link: ThemePrimaryMap,
semantics: ThemeSemanticMap,
data: ThemeDataMap,
shadows: ThemeShadowMap,
components: ThemeComponentMap,
dimensions: ThemeDimensionMap,
@@ -926,6 +982,7 @@ function toThemeTokens(
'--cp-color-fill-secondary': surfaces.colorFillSecondary,
'--cp-color-fill-tertiary': surfaces.colorFillTertiary,
'--cp-color-fill-quaternary': surfaces.colorFillQuaternary,
'--cp-color-fill-alter': aliases.colorFillAlter,
'--cp-color-border': surfaces.colorBorder,
'--cp-color-border-secondary': surfaces.colorBorderSecondary,
'--cp-color-split': surfaces.colorSplit,
@@ -964,6 +1021,7 @@ function toThemeTokens(
'--cp-button-primary-active-bg': components.buttonPrimaryActiveBg,
'--cp-brand-mark-bg': components.brandMarkBg,
'--cp-card-bg': components.cardBg,
'--cp-modal-bg': components.modalBg,
'--cp-table-header-bg': components.tableHeaderBg,
'--cp-table-row-bg': components.tableRowBg,
'--cp-table-row-stripe-bg': components.tableRowStripeBg,
@@ -988,6 +1046,11 @@ function toThemeTokens(
'--cp-color-info-bg': semantics.info.background,
'--cp-color-info-border': semantics.info.border,
'--cp-color-info-text': semantics.info.text,
'--cp-activity-level-0': data.activityLevel0,
'--cp-activity-level-1': data.activityLevel1,
'--cp-activity-level-2': data.activityLevel2,
'--cp-activity-level-3': data.activityLevel3,
'--cp-activity-level-4': data.activityLevel4,
'--cp-color-success': semantics.success.color,
'--cp-color-success-bg': semantics.success.background,
'--cp-color-success-bg-hover': semantics.success.backgroundHover,
@@ -196,7 +196,7 @@ async function copyText(value: string, successText: string) {
<BaseScrollbar
max-height="92px"
>
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-input">
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="view.oauth.authUrl"
@@ -17,7 +17,6 @@ const modeOptions = {
{ label: 'AT', value: 'access_token' },
{ label: 'RT', value: 'refresh_token' },
{ label: '账号文件', value: 'json' },
{ label: 'Agent 身份', value: 'agent_identity' },
],
xai: [
{ label: 'OAuth', value: 'oauth' },
@@ -89,8 +88,6 @@ function resolveModal(
description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期'
else if (input.form.mode === 'refresh_token')
description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token'
else if (input.form.mode === 'agent_identity')
description = '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
return {
title: '导入账号',
@@ -150,13 +147,6 @@ function resolveImportInput(
uploadable: false,
}
}
if (form.mode === 'agent_identity') {
return {
label: 'Agent 身份文件',
placeholder: '粘贴 Agent 身份文件内容',
uploadable: true,
}
}
if (provider === 'batch') {
return {
label: '批量账号文件',
@@ -13,15 +13,15 @@ const props = withDefaults(
)
const fallbackPalettes = [
'bg-cp-info-bg text-cp-info-text shadow-cp-input',
'bg-cp-success-bg text-cp-success-text shadow-cp-input',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-cp-input',
'bg-cp-warning-bg text-cp-warning-text shadow-cp-input',
'bg-cp-info-bg text-cp-info-text shadow-cp-tertiary',
'bg-cp-success-bg text-cp-success-text shadow-cp-tertiary',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-cp-tertiary',
'bg-cp-warning-bg text-cp-warning-text shadow-cp-tertiary',
]
const usageTierPalettes: Record<string, string> = {
pro: 'bg-cp-account-plan-pro-bg text-cp-account-plan-pro shadow-cp-input',
prolite: 'bg-cp-account-plan-pro-lite-bg text-cp-account-plan-pro-lite shadow-cp-input',
pro: 'bg-cp-account-plan-pro-bg text-cp-account-plan-pro shadow-cp-tertiary',
prolite: 'bg-cp-account-plan-pro-lite-bg text-cp-account-plan-pro-lite shadow-cp-tertiary',
}
const label = computed(() => props.planType?.trim() || 'Free')
@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { AccountProfileActivityInsights, AccountProfileInvocation } from '@/api'
import { Package } from '@lucide/vue'
import { computed } from 'vue'
import { formatInteger } from '@/utils/number'
const props = defineProps<{
insights: AccountProfileActivityInsights
}>()
const insightRows = computed(() => [
{ label: '快速模式', value: formatPercent(props.insights.fastModePercent) },
{
label: '最常用的推理强度',
value: formatReasoning(props.insights.reasoningEffort, props.insights.reasoningEffortPercent),
},
{ label: '已探索的技能', value: formatNumber(props.insights.skillsExplored) },
{ label: '使用的技能总数', value: formatNumber(props.insights.totalSkillsUsed) },
{ label: '聊天总数', value: formatNumber(props.insights.totalThreads) },
])
const invocations = computed(() => (props.insights.invocations ?? [])
.map(invocationView)
.filter((invocation): invocation is NonNullable<typeof invocation> => invocation !== null))
function formatNumber(value: number | null) {
return value === null ? '—' : formatInteger(value)
}
function formatPercent(value: number | null) {
if (value === null)
return '—'
return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value)}%`
}
function formatReasoning(effort: string | null, percent: number | null) {
if (!effort)
return '—'
return percent === null ? effort : `${effort} · ${formatPercent(percent)}`
}
function invocationView(invocation: AccountProfileInvocation) {
const isPlugin = invocation.type === 'plugin'
const name = (isPlugin ? invocation.pluginName : invocation.skillName)?.trim()
if (!name || invocation.usageCount === null)
return null
return {
key: `${invocation.type}:${invocation.pluginId ?? invocation.skillId ?? name}`,
name,
count: invocation.usageCount,
}
}
</script>
<template>
<section class="grid gap-8 sm:grid-cols-2 sm:gap-12" aria-labelledby="profile-activity-insights-title">
<div>
<h3 id="profile-activity-insights-title" class="m-0 text-cp-lg font-heavy text-cp-text">
活动洞察
</h3>
<dl class="mt-4 mb-0 grid gap-3.5">
<div
v-for="row in insightRows"
:key="row.label"
class="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-4"
>
<dt class="min-w-0 text-cp font-semibold text-cp-text-secondary">
{{ row.label }}
</dt>
<dd class="m-0 font-mono font-heavy tabular-nums text-cp-text">
{{ row.value }}
</dd>
</div>
</dl>
</div>
<div>
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
最常用的插件与技能
</h3>
<ol v-if="invocations.length > 0" class="mt-3 mb-0 grid list-none gap-1.5 p-0">
<li
v-for="invocation in invocations"
:key="invocation.key"
class="grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-2.5 py-1.5"
>
<span
class="grid size-6 place-items-center rounded-cp bg-cp-fill-quaternary text-cp-text-secondary"
aria-hidden="true"
>
<Package class="size-3.5" :stroke-width="2" />
</span>
<span class="truncate text-cp font-bold text-cp-text">{{ invocation.name }}</span>
<span class="font-mono text-cp-sm font-semibold tabular-nums text-cp-text-secondary">
{{ formatInteger(invocation.count) }} 次运行
</span>
</li>
</ol>
<p v-else class="mt-4 mb-0 text-cp-sm font-semibold text-cp-text-quaternary">
暂无插件或技能调用记录
</p>
</div>
</section>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { AccountProfileStatisticsResponse } from '@/api'
import { UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import AccountPlanBadge from '../AccountPlanBadge.vue'
const props = defineProps<{
account: AccountRow
profile: AccountProfileStatisticsResponse
}>()
const imageFailed = shallowRef(false)
const displayName = computed(() =>
props.profile.displayName
|| props.profile.username
|| props.account.email
|| props.account.name
|| 'Codex 用户',
)
const username = computed(() => props.profile.username
? `@${props.profile.username.replace(/^@/, '')}`
: null)
const accountIdentity = computed(() => props.account.email?.trim() || props.account.accountId?.trim())
const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperCase() || null)
const metrics = computed(() => [
{
label: '累计 Token 数',
value: formatMetric(props.profile.summary.totalTextTokens),
title: formatExact(props.profile.summary.totalTextTokens),
},
{
label: '峰值 Token 数',
value: formatMetric(props.profile.summary.peakTokens),
title: formatExact(props.profile.summary.peakTokens),
},
{
label: '最长聊天时长',
value: formatDuration(props.profile.summary.longestTaskDurationMs),
},
{
label: '当前连续天数',
value: formatDays(props.profile.summary.currentStreakDays),
},
{
label: '最长连续天数',
value: formatDays(props.profile.summary.longestStreakDays),
},
])
watch(() => props.profile.imageUrl, () => {
imageFailed.value = false
})
function formatMetric(value: number | null) {
return value === null ? '—' : formatCompactNumber(value)
}
function formatExact(value: number | null) {
return value === null ? undefined : `${formatInteger(value)} Tokens`
}
function formatDays(value: number | null) {
return value === null ? '—' : `${formatInteger(value)} 天`
}
function formatDuration(value: number | null) {
if (value === null)
return '—'
const totalMinutes = Math.floor(value / 60_000)
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor(totalMinutes % (24 * 60) / 60)
const minutes = totalMinutes % 60
if (days > 0)
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`
if (hours > 0)
return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`
if (totalMinutes > 0)
return `${totalMinutes} 分`
return `${Math.max(0, Math.floor(value / 1000))} 秒`
}
</script>
<template>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
aria-labelledby="profile-display-name"
>
<div class="flex min-w-0 items-center gap-3.5">
<span
class="grid size-14 shrink-0 place-items-center overflow-hidden rounded-full bg-cp-error-bg text-xl font-heavy text-cp-error-text"
>
<img
v-if="profile.imageUrl && !imageFailed"
:src="profile.imageUrl"
:alt="`${displayName} 的头像`"
class="size-full object-cover"
referrerpolicy="no-referrer"
@error="imageFailed = true"
>
<span v-else-if="initial" aria-hidden="true">{{ initial }}</span>
<UserRound v-else class="size-6" aria-hidden="true" />
</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h3 id="profile-display-name" class="m-0 min-w-0 truncate text-cp-lg leading-tight font-heavy text-cp-text">
{{ displayName }}
</h3>
<AccountPlanBadge v-if="account.planType" :plan-type="account.planType" size="sm" />
</div>
<div
v-if="username || accountIdentity"
class="mt-1.5 grid min-w-0 gap-0.5 text-cp-xs font-semibold text-cp-text-secondary"
>
<span v-if="username" class="truncate">{{ username }}</span>
<span v-if="accountIdentity" class="truncate" :title="accountIdentity">{{ accountIdentity }}</span>
</div>
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<dl class="grid min-w-130 grid-cols-5">
<div
v-for="metric in metrics"
:key="metric.label"
class="min-w-0 px-2 text-center sm:px-3"
>
<dd
class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text"
:title="metric.title"
>
{{ metric.value }}
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</div>
</section>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</script>
<template>
<div class="grid gap-8 sm:gap-10" role="status" aria-live="polite" aria-busy="true">
<span class="sr-only">正在加载官方个人资料</span>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
aria-hidden="true"
>
<div class="flex min-w-0 items-center gap-3.5">
<BaseSkeleton shape="circle" class="size-14 shrink-0" />
<div class="grid min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-5 w-9 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-24 opacity-60" />
<BaseSkeleton shape="text" class="w-full max-w-64 opacity-60" />
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<div class="grid min-w-130 grid-cols-5">
<div
v-for="metric in 5"
:key="metric"
class="grid justify-items-center gap-2 px-2 sm:px-3"
>
<BaseSkeleton shape="text" class="h-4 w-14" />
<BaseSkeleton shape="text" class="w-20 opacity-60" />
</div>
</div>
</div>
</section>
<section class="grid gap-4" aria-hidden="true">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
</div>
<div
class="grid min-w-190 gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
>
<div v-for="week in 52" :key="week" class="grid gap-1">
<BaseSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
</div>
</div>
</section>
<section class="grid gap-10 sm:grid-cols-2" aria-hidden="true">
<div v-for="column in 2" :key="column" class="grid gap-3">
<BaseSkeleton shape="text" class="h-4 w-28" />
<BaseSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
</div>
</section>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { ProfileActivityLevel, ProfileActivityMode } from '../../utils/accountProfileStatistics'
import type { AccountProfileDailyUsage } from '@/api'
import { computed, shallowRef } from 'vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import {
buildProfileActivityGrid,
profileActivityCellLabel,
} from '../../utils/accountProfileStatistics'
const props = defineProps<{
dailyUsage: AccountProfileDailyUsage[] | null
}>()
const mode = shallowRef<ProfileActivityMode>('daily')
const modeOptions = [
{ label: '每日', value: 'daily' },
{ label: '每周', value: 'weekly' },
{ label: '累计', value: 'cumulative' },
]
const levelClasses: Record<ProfileActivityLevel, string> = {
0: 'bg-cp-activity-level-0',
1: 'bg-cp-activity-level-1',
2: 'bg-cp-activity-level-2',
3: 'bg-cp-activity-level-3',
4: 'bg-cp-activity-level-4',
}
const grid = computed(() => buildProfileActivityGrid(props.dailyUsage ?? [], mode.value))
const rangeLabel = computed(() => `${grid.value.rangeStart} 至 ${grid.value.rangeEnd}`)
</script>
<template>
<section aria-labelledby="profile-token-activity-title">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 id="profile-token-activity-title" class="m-0 text-cp-lg font-heavy text-cp-text">
Token 活动
</h3>
</div>
<BaseSegmented
v-model="mode"
label="Token 活动统计方式"
size="sm"
:options="modeOptions"
/>
</div>
<BaseEmpty
v-if="dailyUsage === null"
title="暂无 Token 活动"
description="官方个人资料没有返回每日 Token 活动。"
size="sm"
surface="none"
/>
<div
v-else
class="overflow-x-auto pb-1"
role="img"
:aria-label="`Token 活动热力图,${rangeLabel}`"
>
<div class="min-w-190">
<div
class="mb-1.5 grid gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
aria-hidden="true"
>
<span
v-for="week in grid.weeks"
:key="week.key"
class="h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
>
{{ week.monthLabel }}
</span>
</div>
<div
class="grid gap-1"
style="grid-template-columns: repeat(52, minmax(0, 1fr));"
aria-hidden="true"
>
<div
v-for="week in grid.weeks"
:key="week.key"
class="grid grid-rows-7 gap-1"
>
<span
v-for="cell in week.cells"
:key="cell.date"
class="aspect-square rounded-[3px] transition-colors motion-reduce:transition-none"
:class="cell.isFuture ? 'invisible' : levelClasses[cell.level]"
:title="profileActivityCellLabel(cell, mode)"
/>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import { RefreshCw, TriangleAlert } from '@lucide/vue'
import { toRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useAccountProfileStatistics } from '../../composables/useAccountProfileStatistics'
import AccountProfileActivityInsights from './ActivityInsights.vue'
import AccountProfileHero from './ProfileHero.vue'
import AccountProfileSkeleton from './Skeleton.vue'
import AccountProfileTokenActivity from './TokenActivity.vue'
const props = defineProps<{
account: AccountRow
}>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const { profile, loading, error, load } = useAccountProfileStatistics(accountId, open)
</script>
<template>
<BaseModal
v-model="open"
title="个人资料"
description="来自 Codex 官方个人资料的累计活动与使用洞察"
size="xl"
>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseEmpty
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="load(true)">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="profile" class="flex min-h-0 flex-col gap-8 pb-2 sm:gap-10">
<AccountProfileHero :account="account" :profile="profile" />
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="官方个人资料已加载,但本次没有返回统计数据。"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
</template>
</div>
<template #footer>
<span v-if="error && profile" class="mr-auto self-center text-cp-xs font-semibold text-cp-error-text">
{{ error }}
</span>
<BaseButton variant="ghost" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
<template #loading>
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
</template>
<template #icon>
<RefreshCw class="size-4" />
</template>
刷新资料
</BaseButton>
</template>
</BaseModal>
</template>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import { ChartNoAxesColumnIncreasing, RefreshCw } from '@lucide/vue'
import { RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountUsageStatisticsModal from '../AccountUsageStatisticsModal/index.vue'
import AccountProfileModal from '../AccountProfileModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
@@ -23,7 +23,7 @@ const emit = defineEmits<{
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const statisticsOpen = shallowRef(false)
const profileOpen = shallowRef(false)
</script>
<template>
@@ -48,13 +48,13 @@ const statisticsOpen = shallowRef(false)
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看官方用量统计"
label="查看个人资料"
size="sm"
variant="ghost"
:pressed="statisticsOpen"
@click="statisticsOpen = true"
:pressed="profileOpen"
@click="profileOpen = true"
>
<ChartNoAxesColumnIncreasing class="size-3.5" />
<UserRound class="size-3.5" />
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
@@ -90,9 +90,9 @@ const statisticsOpen = shallowRef(false)
</div>
</section>
<AccountUsageStatisticsModal
<AccountProfileModal
v-if="account.provider === 'openai'"
v-model="statisticsOpen"
v-model="profileOpen"
:account="account"
/>
</template>
@@ -1,16 +1,28 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
defineProps<{
const props = defineProps<{
account: AccountRow
}>()
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() =>
props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'),
)
const totalBillingDisplay = computed(() =>
totalBilling.value?.estimatedAmountDisplay ?? '—',
)
const hasUsageSummary = computed(() =>
(props.account.usage.totalTokens ?? 0) > 0 || totalBilling.value !== undefined,
)
const modelUsageColumns = defineTableColumns<AccountModelUsage>([
{ key: 'model', label: '模型', kind: 'text', size: 'lg' },
{ key: 'requestCountDisplay', label: '调用', kind: 'numeric', size: 'xs' },
@@ -72,11 +84,39 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
<div class="ml-auto flex items-center gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
<Sigma
class="size-3.5 self-center text-cp-text-tertiary"
:stroke-width="1.75"
aria-hidden="true"
/>
<span title="总 Token">
<span class="sr-only">总 Token:</span>
<span class="font-mono text-cp-sm font-emphasis tabular-nums text-cp-text">
{{ account.usage.totalTokensDisplay }}
</span>
</span>
<span
class="mx-0.5 text-[10px] leading-none font-emphasis text-cp-text-quaternary"
aria-hidden="true"
>
/
</span>
<span title="总计费">
<span class="sr-only">总计费:</span>
<span class="font-mono text-cp-sm font-heavy tabular-nums text-cp-success-text">
{{ totalBillingDisplay }}
</span>
</span>
</div>
<span class="whitespace-nowrap text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
</div>
</div>
<div class="h-52 min-w-0">
@@ -1,112 +0,0 @@
<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>
@@ -1,113 +0,0 @@
<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>
@@ -1,96 +0,0 @@
<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>
@@ -1,264 +0,0 @@
<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 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>
</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,61 @@
import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountProfileStatistics } from '@/api'
import { errorMessage } from '@/utils/async'
export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const profile = shallowRef<Awaited<ReturnType<typeof getAccountProfileStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const loading = shallowRef(false)
const error = shallowRef('')
let requestVersion = 0
async function load(force = false) {
const targetAccountId = accountId.value
if (!targetAccountId)
return
if (!force && profile.value && loadedAccountId.value === targetAccountId)
return
const version = ++requestVersion
loading.value = true
error.value = ''
try {
const result = await getAccountProfileStatistics({ accountId: targetAccountId })
if (version !== requestVersion)
return
profile.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
if (version !== requestVersion)
return
error.value = errorMessage(cause, '官方个人资料加载失败')
}
finally {
if (version === requestVersion)
loading.value = false
}
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
requestVersion += 1
profile.value = null
loadedAccountId.value = ''
loading.value = false
error.value = ''
}
if (isOpen)
void load()
}, { immediate: true })
return {
profile,
loading,
error,
load,
}
}
@@ -1,89 +0,0 @@
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,
}
}
@@ -0,0 +1,156 @@
import type { AccountProfileDailyUsage } from '@/api'
import { formatCompactNumber } from '@/utils/number'
export type ProfileActivityMode = 'daily' | 'weekly' | 'cumulative'
export type ProfileActivityLevel = 0 | 1 | 2 | 3 | 4
export interface ProfileActivityCell {
date: string
tokens: number
value: number
level: ProfileActivityLevel
isFuture: boolean
}
export interface ProfileActivityWeek {
key: string
monthLabel: string | null
cells: ProfileActivityCell[]
}
export interface ProfileActivityGrid {
weeks: ProfileActivityWeek[]
rangeStart: string
rangeEnd: string
totalTokens: number
}
const DAY_MS = 24 * 60 * 60 * 1000
const WEEK_COUNT = 52
export function buildProfileActivityGrid(
entries: AccountProfileDailyUsage[],
mode: ProfileActivityMode,
today = new Date(),
): ProfileActivityGrid {
const todayDate = localCalendarDate(today)
const currentWeekStart = addDays(todayDate, -todayDate.getUTCDay())
const rangeStartDate = addDays(currentWeekStart, -(WEEK_COUNT - 1) * 7)
const dailyTokens = normalizedDailyTokens(entries, rangeStartDate, todayDate)
const weeks: ProfileActivityWeek[] = []
let cumulativeTokens = 0
let totalTokens = 0
for (let weekIndex = 0; weekIndex < WEEK_COUNT; weekIndex += 1) {
const weekStart = addDays(rangeStartDate, weekIndex * 7)
const weekDates = Array.from({ length: 7 }, (_, dayIndex) => addDays(weekStart, dayIndex))
const weeklyTokens = weekDates.reduce((sum, date) => sum + (dailyTokens.get(dateKey(date)) ?? 0), 0)
totalTokens += weeklyTokens
const cells = weekDates.map((date) => {
const key = dateKey(date)
const tokens = dailyTokens.get(key) ?? 0
const isFuture = date.getTime() > todayDate.getTime()
cumulativeTokens += isFuture ? 0 : tokens
return {
date: key,
tokens,
value: mode === 'weekly' ? weeklyTokens : mode === 'cumulative' ? cumulativeTokens : tokens,
level: 0 as ProfileActivityLevel,
isFuture,
}
})
weeks.push({
key: dateKey(weekStart),
monthLabel: monthLabel(weekDates, weekIndex),
cells,
})
}
const maxValue = Math.max(0, ...weeks.flatMap(week => week.cells)
.filter(cell => !cell.isFuture)
.map(cell => cell.value))
for (const cell of weeks.flatMap(week => week.cells))
cell.level = activityLevel(cell.value, maxValue)
return {
weeks,
rangeStart: dateKey(rangeStartDate),
rangeEnd: dateKey(todayDate),
totalTokens,
}
}
export function profileActivityCellLabel(cell: ProfileActivityCell, mode: ProfileActivityMode) {
const date = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(parseDateKey(cell.date) ?? new Date(0))
const value = formatCompactNumber(cell.value)
if (mode === 'weekly')
return `${date}所在周:${value} Tokens`
if (mode === 'cumulative')
return `截至${date}:${value} Tokens`
return `${date}:${value} Tokens`
}
function normalizedDailyTokens(
entries: AccountProfileDailyUsage[],
rangeStart: Date,
today: Date,
) {
const result = new Map<string, number>()
for (const entry of entries) {
const date = parseDateKey(entry.date)
if (!date || date < rangeStart || date > today || !Number.isFinite(entry.tokens))
continue
const key = dateKey(date)
result.set(key, (result.get(key) ?? 0) + Math.max(0, entry.tokens))
}
return result
}
function activityLevel(value: number, maxValue: number): ProfileActivityLevel {
if (value <= 0 || maxValue <= 0)
return 0
return Math.min(4, Math.max(1, Math.ceil(value / maxValue * 4))) as ProfileActivityLevel
}
function monthLabel(weekDates: Date[], weekIndex: number) {
const firstDayOfMonth = weekDates.find(date => date.getUTCDate() === 1)
if (weekIndex !== 0 && !firstDayOfMonth)
return null
const date = firstDayOfMonth ?? weekDates[0]
return `${(date?.getUTCMonth() ?? 0) + 1}月`
}
function localCalendarDate(value: Date) {
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()))
}
function parseDateKey(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match)
return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
if (
date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day
) {
return null
}
return date
}
function addDays(value: Date, days: number) {
return new Date(value.getTime() + days * DAY_MS)
}
function dateKey(value: Date) {
return value.toISOString().slice(0, 10)
}
@@ -1,21 +0,0 @@
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 ? '*' : ''}`
}
@@ -103,7 +103,7 @@ const visibleFiles = computed(() => [
<BaseScrollbar
max-height="360px"
>
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-input">
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.65] font-emphasis text-cp-text"
v-text="file.content"
@@ -171,7 +171,7 @@ const statusBars = computed(() => {
</p>
<div class="mt-6.75 grid gap-4 xl:h-82.5 xl:grid-rows-[122px_90px_minmax(0,1fr)]">
<div class="grid h-30.5 content-between rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto">
<div class="grid h-30.5 content-between rounded-cp-lg bg-cp-fill-alter/70 p-4 xl:h-auto">
<span class="block h-3.5 text-xs leading-[1.15] font-emphasis text-cp-text-secondary">槽位占用</span>
<div>
<div class="h-8.5">
@@ -191,7 +191,7 @@ const statusBars = computed(() => {
</div>
<div
class="grid h-22.5 grid-cols-3 gap-4 rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto"
class="grid h-22.5 grid-cols-3 gap-4 rounded-cp-lg bg-cp-fill-alter/70 p-4 xl:h-auto"
>
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">{{ stat.label }}</span>
@@ -203,7 +203,7 @@ const statusBars = computed(() => {
</div>
</div>
<div class="grid h-20.5 content-between rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto">
<div class="grid h-20.5 content-between rounded-cp-lg bg-cp-fill-alter/70 p-4 xl:h-auto">
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">分配策略</span>
<strong class="block text-[17px] leading-[1.15] font-emphasis text-cp-text">
{{ strategyLabel }}
@@ -232,7 +232,7 @@ const statusBars = computed(() => {
<article
v-for="account in accounts"
:key="account.id"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-bg-elevated px-3.5 py-3.5 transition-colors xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-fill-alter/70 px-3.5 py-3.5 transition-colors xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
>
<AccountIdentityCell
:account="account"
@@ -323,7 +323,7 @@ const statusBars = computed(() => {
<div
v-for="row in statusRows"
:key="row.label"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-cp-lg bg-cp-bg-elevated px-3.5"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-cp-lg bg-cp-fill-alter/70 px-3.5"
>
<span
class="inline-flex size-7 items-center justify-center rounded-cp"
@@ -73,7 +73,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<div class="flex items-start gap-3">
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg bg-cp-fill-alter/70!"
:class="metricToneIconClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
@@ -108,7 +108,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</div>
<div
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-lg bg-cp-fill-quaternary px-3"
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-lg bg-cp-fill-alter/70 px-3"
>
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5"
@@ -205,7 +205,7 @@ function providerLabel(provider: string) {
<div v-else class="flex flex-1">
<section
aria-label="请求身份组成"
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-lg bg-cp-bg-elevated px-5 py-5.5 sm:px-6 sm:py-5"
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-lg bg-cp-fill-alter/70 px-5 py-5.5 sm:px-6 sm:py-5"
:class="verifiedLabel || checkedLabel
? 'sm:grid-rows-[auto_minmax(0,1fr)_auto_auto]'
: 'sm:grid-rows-[auto_minmax(0,1fr)_auto]'"
@@ -75,7 +75,9 @@ const viewOptions = [
{ label: '受限', value: 'limited' },
]
const themeSwatches = [
{ label: 'Surface', className: 'bg-cp-bg-container' },
{ label: 'Container', className: 'bg-cp-bg-container' },
{ label: 'Fill Alter', className: 'bg-cp-fill-alter' },
{ label: 'Elevated', className: 'bg-cp-bg-elevated' },
{ label: 'Accent', className: 'bg-cp-primary' },
{ label: 'Success', className: 'bg-cp-success' },
{ label: 'Warning', className: 'bg-cp-warning' },
@@ -87,6 +87,7 @@ const tokenCatalog: Record<ThemeEditorComponent, readonly EditableToken[]> = {
],
surface: [
{ name: '--cp-card-bg', label: '卡片背景', kind: 'color', description: 'colorBgContainer' },
{ name: '--cp-modal-bg', label: '弹窗背景', kind: 'color', description: 'contentBg' },
{
name: '--cp-card-border-radius',
label: '卡片圆角',
+1 -1
View File
@@ -1 +1 @@
version: "3.3.3"
version: "3.3.4"
+1 -1
View File
@@ -47,7 +47,7 @@ README 保持用户导向;不要把上游 URL、重试常量、数据库字段
- 数据面公开 Responses JSON/SSE/WS、review、Images generation/edit 和模型目录;不提供 Chat Completions。
- OpenAI Responses/Images 保持业务 wire 透明;xAI 在 Provider 内完成 Grok/Responses 转换。
- Images 固定走 OpenAI Provider 自有端点,不要求模型,也不参与文本模型映射。
- OpenAI 账号支持 OAuth、AT/RT、OAuth JSON 和 Agent Identity;xAI 只接受 OAuth session/账号 JSON,
- OpenAI 账号支持 OAuth、AT/RT 和 OAuth JSON;xAI 只接受 OAuth session/账号 JSON,
不接受 API Key。
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
或直接改 quota reset 时间。
@@ -90,7 +90,7 @@ OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户
### 导入与授权
- 公共导入合同始终是 `{ provider, data }`,其中 `data` 为 Provider-owned JSON object。
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only、AT+RT+可选 ID Token 和 Agent Identity;
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only 和 AT+RT+可选 ID Token;
最多 200 项。RT-only 先交换 AT,AT-only 不会凭空获得刷新能力。
- OpenAI OAuth 身份来自 `parse_chatgpt_jwt_claims`,不信任导入 object 顶层的 user/account ID。
- 前端 AT/RT 标签只把“每行一个 token”转换成 `accounts` JSON,没有第二条导入 API。