Compare commits

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

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

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

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

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

Remove the staged Pen design source.

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

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:06:05 +08:00
49 changed files with 3112 additions and 33055 deletions
@@ -3,7 +3,7 @@
use std::fmt;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use chrono::{DateTime, NaiveDate, Utc};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
@@ -13,6 +13,7 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
observability::CurrencyCost,
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -627,6 +628,104 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方用量统计的目标额度周期。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
/// Provider 官方用量统计查询。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderUsageStatisticsRequest {
pub account_id: ProviderAccountId,
/// `0` 为当前周期,负数为历史周期。
pub cycle_offset: i8,
/// 客户端相对 UTC 的分钟偏移,例如 UTC+8 为 `480`。
pub utc_offset_minutes: i16,
}
/// 官方用量统计中的 Token 分类。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProviderUsageStatisticsTokens {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
/// 官方模型报表中的服务档位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderUsageStatisticsServiceTier {
Standard,
Fast,
}
impl ProviderUsageStatisticsServiceTier {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
}
/// 一个模型与服务档位在目标周期内的统计行。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsModel {
pub key: String,
pub model: String,
pub service_tier: ProviderUsageStatisticsServiceTier,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
/// 目标周期内的一天;官方日报只能提供本地日粒度。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsDay {
pub date: NaiveDate,
pub credit_share: Option<f64>,
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
/// 目标周期汇总。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsSummary {
pub tokens: ProviderUsageStatisticsTokens,
pub estimated_cost: Option<CurrencyCost>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<CurrencyCost>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
/// Provider 已解释、计算并排序的官方用量统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatistics {
pub cycle: ProviderUsageStatisticsCycle,
pub summary: ProviderUsageStatisticsSummary,
pub models: Vec<ProviderUsageStatisticsModel>,
pub daily: Vec<ProviderUsageStatisticsDay>,
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
@@ -18,6 +18,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderUsageStatistics, ProviderUsageStatisticsRequest,
};
/// Provider 管理失败的稳定分类。
@@ -146,6 +147,14 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方用量统计;不支持该能力的 Provider 使用默认拒绝。
async fn usage_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
@@ -26,7 +26,8 @@ use crate::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsRequest,
QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -80,6 +81,15 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn usage_statistics(
&self,
_request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
Err(AdminError::invalid(
"Provider usage statistics are not supported",
))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -538,6 +548,17 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn usage_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, AdminError> {
let (_, provider) = self.provider_for_account(&request.account_id).await?;
provider
.usage_statistics(request)
.await
.map_err(|error| map_provider_error(error, "provider usage statistics"))
}
async fn reset_credits(
&self,
context: &MutationContext,
@@ -29,7 +29,10 @@ use gateway_admin::model::{
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
ProviderResetCredits, ProviderUsageStatistics, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsRequest,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, RotateCredential,
StartAuthorization,
},
};
use gateway_core::{
@@ -58,6 +61,8 @@ const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_USAGE_CYCLE_OFFSET: i8 = 8;
const MAX_UTC_OFFSET_MINUTES: i16 = 14 * 60;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
@@ -420,6 +425,35 @@ impl AccountIdQuery {
}
}
/// 官方每日用量统计查询。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountUsageStatisticsQuery {
pub account_id: String,
pub cycle_offset: Option<i8>,
pub utc_offset_minutes: Option<i16>,
}
impl AccountUsageStatisticsQuery {
pub fn into_request(self) -> Result<ProviderUsageStatisticsRequest, WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
let cycle_offset = self.cycle_offset.unwrap_or_default();
if !(-MAX_USAGE_CYCLE_OFFSET..=0).contains(&cycle_offset) {
return Err(WireValidationError::new("cycleOffset"));
}
let utc_offset_minutes = self.utc_offset_minutes.unwrap_or_default();
if !(-MAX_UTC_OFFSET_MINUTES..=MAX_UTC_OFFSET_MINUTES).contains(&utc_offset_minutes) {
return Err(WireValidationError::new("utcOffsetMinutes"));
}
Ok(ProviderUsageStatisticsRequest {
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
cycle_offset,
utc_offset_minutes,
})
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -587,6 +621,183 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方每日用量统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsData {
pub cycle: AccountUsageStatisticsCycleView,
pub summary: AccountUsageStatisticsSummaryView,
pub models: Vec<AccountUsageStatisticsModelView>,
pub daily: Vec<AccountUsageStatisticsDayView>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsCycleView {
pub offset: i8,
pub start_at: String,
pub end_at: String,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsTokensView {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsCostView {
pub currency: String,
pub amount: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsSummaryView {
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<AccountUsageStatisticsCostView>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsModelView {
pub key: String,
pub model: String,
pub service_tier: String,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsDayView {
pub date: String,
pub credit_share: Option<f64>,
pub tokens: AccountUsageStatisticsTokensView,
pub estimated_cost: Option<AccountUsageStatisticsCostView>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
impl From<ProviderUsageStatistics> for AccountUsageStatisticsData {
fn from(statistics: ProviderUsageStatistics) -> Self {
Self {
cycle: AccountUsageStatisticsCycleView {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at.to_rfc3339(),
end_at: statistics.cycle.end_at.to_rfc3339(),
window_seconds: statistics.cycle.window_seconds,
used_percent: statistics.cycle.used_percent,
is_current: statistics.cycle.is_current,
can_go_previous: statistics.cycle.can_go_previous,
can_go_next: statistics.cycle.can_go_next,
},
summary: account_usage_statistics_summary_view(statistics.summary),
models: statistics
.models
.into_iter()
.map(account_usage_statistics_model_view)
.collect(),
daily: statistics
.daily
.into_iter()
.map(account_usage_statistics_day_view)
.collect(),
}
}
}
fn account_usage_statistics_tokens_view(
tokens: ProviderUsageStatisticsTokens,
) -> AccountUsageStatisticsTokensView {
AccountUsageStatisticsTokensView {
uncached_input: tokens.uncached_input,
cached_input: tokens.cached_input,
output: tokens.output,
total: tokens.total,
}
}
fn account_usage_statistics_cost_view(
cost: gateway_admin::model::observability::CurrencyCost,
) -> AccountUsageStatisticsCostView {
AccountUsageStatisticsCostView {
currency: cost.currency,
amount: cost.amount.to_string(),
}
}
fn account_usage_statistics_summary_view(
summary: ProviderUsageStatisticsSummary,
) -> AccountUsageStatisticsSummaryView {
AccountUsageStatisticsSummaryView {
tokens: account_usage_statistics_tokens_view(summary.tokens),
estimated_cost: summary
.estimated_cost
.map(account_usage_statistics_cost_view),
projected_tokens: summary.projected_tokens,
projected_cost: summary
.projected_cost
.map(account_usage_statistics_cost_view),
day_count: summary.day_count,
has_unknown_pricing: summary.has_unknown_pricing,
has_missing_token_data: summary.has_missing_token_data,
}
}
fn account_usage_statistics_model_view(
model: ProviderUsageStatisticsModel,
) -> AccountUsageStatisticsModelView {
AccountUsageStatisticsModelView {
key: model.key,
model: model.model,
service_tier: model.service_tier.as_str().to_owned(),
credit_share: model.credit_share,
quota_share: model.quota_share,
tokens: account_usage_statistics_tokens_view(model.tokens),
estimated_cost: model.estimated_cost.map(account_usage_statistics_cost_view),
has_unknown_pricing: model.has_unknown_pricing,
has_estimated_allocation: model.has_estimated_allocation,
has_rate_fallback: model.has_rate_fallback,
has_missing_token_data: model.has_missing_token_data,
}
}
fn account_usage_statistics_day_view(
day: ProviderUsageStatisticsDay,
) -> AccountUsageStatisticsDayView {
AccountUsageStatisticsDayView {
date: day.date.to_string(),
credit_share: day.credit_share,
tokens: account_usage_statistics_tokens_view(day.tokens),
estimated_cost: day.estimated_cost.map(account_usage_statistics_cost_view),
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
}
}
/// 主动额度重置卡列表响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -1160,6 +1371,10 @@ where
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/usage-statistics",
get(account_usage_statistics::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -1494,6 +1709,27 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_usage_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountUsageStatisticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let request = query.into_request().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.usage_statistics(request)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountUsageStatisticsData::from(result)),
))
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -142,11 +142,19 @@ pub(crate) fn format_service_tier(service_tier: Option<&str>) -> String {
match service_tier {
Some("priority" | "fast") => "Fast".to_owned(),
Some("flex") => "Flex".to_owned(),
Some("default") | None => "Default".to_owned(),
Some(other) => other.to_owned(),
Some("default" | "standard") | None => "Standard".to_owned(),
Some(other) => capitalize_first(other),
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
chars
.next()
.map(|first| first.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { total, .. } => Some(BillingView {
@@ -70,6 +70,147 @@ mod query {
}
}
mod usage_statistics {
use std::str::FromStr as _;
use chrono::{NaiveDate, TimeZone as _, Utc};
use gateway_admin::model::{
observability::{CurrencyCost, DecimalAmount},
provider_credentials::{
ProviderUsageStatistics, ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay,
ProviderUsageStatisticsModel, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens,
},
};
use gateway_api::admin::accounts::{AccountUsageStatisticsData, AccountUsageStatisticsQuery};
use serde_json::json;
fn tokens(total: u64) -> ProviderUsageStatisticsTokens {
ProviderUsageStatisticsTokens {
uncached_input: total / 4,
cached_input: total / 2,
output: total / 4,
total,
}
}
fn usd(amount: &str) -> CurrencyCost {
CurrencyCost {
currency: "USD".to_owned(),
amount: DecimalAmount::from_str(amount).expect("USD amount"),
}
}
#[test]
fn usage_statistics_query_accepts_only_current_or_bounded_history_offsets() {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": -3,
"utcOffsetMinutes": 480
}))
.expect("decode usage-statistics query");
let request = query.into_request().expect("valid cycle offset");
assert_eq!(request.cycle_offset, -3);
assert_eq!(request.utc_offset_minutes, 480);
for (cycle_offset, field) in [(-9, "cycleOffset"), (1, "cycleOffset")] {
let query: AccountUsageStatisticsQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"cycleOffset": cycle_offset
}))
.expect("decode bounded integer");
assert_eq!(
query
.into_request()
.expect_err("reject cycle offset")
.field(),
field
);
}
assert!(
serde_json::from_value::<AccountUsageStatisticsQuery>(json!({
"accountId": "acct_openai",
"historyCycles": 8
}))
.is_err()
);
}
#[test]
fn usage_statistics_response_serializes_calculated_rows_without_raw_documents() {
let start_at = Utc
.with_ymd_and_hms(2026, 8, 24, 7, 50, 0)
.single()
.expect("cycle start");
let end_at = Utc
.with_ymd_and_hms(2026, 8, 31, 7, 50, 0)
.single()
.expect("cycle end");
let response = AccountUsageStatisticsData::from(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: 0,
start_at,
end_at,
window_seconds: 604_800,
used_percent: Some(20.0),
is_current: true,
can_go_previous: true,
can_go_next: false,
},
summary: ProviderUsageStatisticsSummary {
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
projected_tokens: Some(500),
projected_cost: Some(usd("6.25")),
day_count: 1,
has_unknown_pricing: false,
has_missing_token_data: false,
},
models: vec![ProviderUsageStatisticsModel {
key: "gpt-5.6-sol::standard".to_owned(),
model: "gpt-5.6-sol".to_owned(),
service_tier: ProviderUsageStatisticsServiceTier::Standard,
credit_share: Some(1.0),
quota_share: Some(0.2),
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
has_unknown_pricing: false,
has_estimated_allocation: true,
has_rate_fallback: false,
has_missing_token_data: false,
}],
daily: vec![ProviderUsageStatisticsDay {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("report date"),
credit_share: Some(1.0),
tokens: tokens(100),
estimated_cost: Some(usd("1.25")),
has_unknown_pricing: false,
has_missing_token_data: false,
is_boundary_day: true,
}],
});
let value = serde_json::to_value(response).expect("serialize usage statistics");
assert!(value.get("mode").is_none());
assert_eq!(value["cycle"]["offset"], 0);
assert_eq!(
value["summary"]["estimatedCost"],
json!({
"currency": "USD",
"amount": "1.25"
})
);
assert_eq!(value["models"][0]["serviceTier"], "standard");
assert!(value["models"][0].get("mode").is_none());
assert_eq!(value["daily"][0]["tokens"]["total"], 100);
assert_eq!(value["daily"][0]["isBoundaryDay"], true);
assert!(value["cycle"].get("deltaPercent").is_none());
assert!(value.get("modelBreakdown").is_none());
assert!(value.get("dailyTotals").is_none());
assert!(value.get("usage").is_none());
}
}
mod batch_update {
use gateway_api::admin::accounts::{BatchUpdateAccountsRequest, UpdateAccountRequest};
use serde_json::json;
@@ -832,7 +832,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: Some("reuse".to_owned()),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
provider_metadata_json: Some(
serde_json::json!({
"effectiveModel": "grok-4.5",
@@ -877,7 +877,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
output_price_per_million: usd("60.0000"),
cache_read_price_per_million: usd("1.0000"),
cache_write_price_per_million: usd("12.5000"),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
multiplier_percent: 100,
},
))),
@@ -933,6 +933,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
value["data"]["items"][0]["billing"]["cacheWritePriceDisplay"],
"$12.5 / 1M Token"
);
assert_eq!(
value["data"]["items"][0]["billing"]["serviceTierDisplay"],
"Standard"
);
assert_eq!(
serde_json::json!({
@@ -973,7 +977,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
"routingScope": "groups",
"routingGroupRefs": ["grp_usage"],
"routingGroupNamesSnapshot": ["Usage group"],
"serviceTier": "priority",
"serviceTier": "default",
"accountId": "acct_snapshot",
"accountName": "Snapshot Alpha",
"accountEmail": "alpha@example.invalid",
+152 -3
View File
@@ -20,7 +20,10 @@ use gateway_admin::model::provider_credentials::{
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderResetCreditResult, ProviderResetCredits, ProviderUsageStatistics,
ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay, ProviderUsageStatisticsModel,
ProviderUsageStatisticsRequest, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -48,8 +51,10 @@ use crate::credential::{
CodexCredentialQuotaError, CodexCredentialQuotaService, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexResetCreditsError, CodexUsageStatistics, CodexUsageStatisticsError,
CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens, CompleteCodexOAuthAuthorization,
CompletedCodexOAuthCredential, ExportManagedCodexCredential, StartCodexOAuthAuthorization,
StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -443,6 +448,23 @@ impl ProviderAdmin for OpenAiAdminProvider {
Ok(project_quota(snapshot, &account))
}
async fn usage_statistics(
&self,
request: ProviderUsageStatisticsRequest,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
self.account(&request.account_id).await?;
let statistics = self
.quota
.usage_statistics(
&request.account_id,
request.cycle_offset,
request.utc_offset_minutes,
)
.await
.map_err(map_usage_statistics_error)?;
project_usage_statistics(statistics)
}
async fn reset_credits(
&self,
account_id: &ProviderAccountId,
@@ -800,6 +822,94 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
}
}
fn project_usage_statistics(
statistics: CodexUsageStatistics,
) -> Result<ProviderUsageStatistics, ProviderAdminError> {
let models = statistics
.models
.into_iter()
.map(|model| {
Ok(ProviderUsageStatisticsModel {
key: model.key,
model: model.model,
service_tier: project_usage_service_tier(model.service_tier),
credit_share: model.credit_share,
quota_share: model.quota_share,
tokens: project_usage_tokens(model.tokens),
estimated_cost: model.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: model.has_unknown_pricing,
has_estimated_allocation: model.has_estimated_allocation,
has_rate_fallback: model.has_rate_fallback,
has_missing_token_data: model.has_missing_token_data,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
let daily = statistics
.daily
.into_iter()
.map(|day| {
Ok(ProviderUsageStatisticsDay {
date: day.date,
credit_share: day.credit_share,
tokens: project_usage_tokens(day.tokens),
estimated_cost: day.estimated_cost.map(currency_cost).transpose()?,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
Ok(ProviderUsageStatistics {
cycle: ProviderUsageStatisticsCycle {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at,
end_at: statistics.cycle.end_at,
window_seconds: statistics.cycle.window_seconds,
used_percent: statistics.cycle.used_percent,
is_current: statistics.cycle.is_current,
can_go_previous: statistics.cycle.can_go_previous,
can_go_next: statistics.cycle.can_go_next,
},
summary: ProviderUsageStatisticsSummary {
tokens: project_usage_tokens(statistics.summary.tokens),
estimated_cost: statistics
.summary
.estimated_cost
.map(currency_cost)
.transpose()?,
projected_tokens: statistics.summary.projected_tokens,
projected_cost: statistics
.summary
.projected_cost
.map(currency_cost)
.transpose()?,
day_count: statistics.summary.day_count,
has_unknown_pricing: statistics.summary.has_unknown_pricing,
has_missing_token_data: statistics.summary.has_missing_token_data,
},
models,
daily,
})
}
const fn project_usage_tokens(tokens: CodexUsageStatisticsTokens) -> ProviderUsageStatisticsTokens {
ProviderUsageStatisticsTokens {
uncached_input: tokens.uncached_input,
cached_input: tokens.cached_input,
output: tokens.output,
total: tokens.total,
}
}
const fn project_usage_service_tier(
service_tier: CodexUsageStatisticsServiceTier,
) -> ProviderUsageStatisticsServiceTier {
match service_tier {
CodexUsageStatisticsServiceTier::Standard => ProviderUsageStatisticsServiceTier::Standard,
CodexUsageStatisticsServiceTier::Fast => ProviderUsageStatisticsServiceTier::Fast,
}
}
fn project_reset_credits(
credits: crate::transport::CodexRateLimitResetCredits,
) -> ProviderResetCredits {
@@ -1397,6 +1507,45 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
})
}
fn map_usage_statistics_error(error: CodexUsageStatisticsError) -> ProviderAdminError {
use CodexUsageStatisticsError as Error;
match error {
Error::InvalidRequest | Error::InvalidCredentialData => {
provider_admin_error(ProviderAdminErrorKind::Invalid)
}
Error::NotFound => provider_admin_error(ProviderAdminErrorKind::NotFound),
Error::Store { .. } | Error::TransportUnavailable => {
provider_admin_error(ProviderAdminErrorKind::Unavailable)
}
Error::QuotaWindowUnavailable => provider_admin_error(ProviderAdminErrorKind::Conflict)
.with_message("OpenAI did not expose a resettable quota window for usage statistics"),
Error::CredentialRefreshRequired { upstream_body } => {
let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
if let Some(body) = upstream_body {
error = error.with_message(format!(
"OpenAI usage-statistics upstream returned HTTP 401: {}",
bounded_upstream_body(&body)
));
}
error
}
Error::Upstream {
status,
body,
retry_after_seconds,
} => {
let retry_after = retry_after_seconds
.map(|seconds| format!("; retry-after={seconds}s"))
.unwrap_or_default();
provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!(
"OpenAI usage-statistics upstream returned HTTP {status}{retry_after}: {}",
bounded_upstream_body(&body)
))
}
}
}
fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError {
use CodexResetCreditsError as Error;
@@ -47,7 +47,10 @@ pub use oauth::{
pub use quota::{
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, parse_codex_quota_usage,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, CodexUsageStatistics,
CodexUsageStatisticsCycle, CodexUsageStatisticsDay, CodexUsageStatisticsError,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens, parse_codex_quota_usage,
};
pub use refresh::{
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
@@ -7,17 +7,23 @@
mod document;
pub(crate) mod evidence;
pub(crate) mod snapshot;
mod usage_statistics;
pub use snapshot::{
CodexAccountQuotaSnapshot, CodexQuotaFact, CodexQuotaWindow, CodexQuotaWindowKind,
CodexQuotaWindowRole, parse_codex_quota_usage,
};
pub use usage_statistics::{
CodexUsageStatistics, CodexUsageStatisticsCycle, CodexUsageStatisticsDay,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant, SystemTime};
use chrono::Utc;
use chrono::{DateTime, FixedOffset, Utc};
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
@@ -36,6 +42,7 @@ use tokio::sync::Mutex;
use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::usage_statistics::CodexDailyUsageReport;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexRateLimitResetCredits,
CodexRateLimitResetCreditsConsumeResult, CodexRequestContext,
@@ -43,6 +50,7 @@ use crate::transport::{
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::types::CODEX_AUTHENTICATION_KIND_OAUTH;
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
@@ -144,6 +152,57 @@ pub enum CodexResetCreditsError {
ConsumeResultUnknown,
}
/// 官方每日用量统计查询失败。
#[derive(Error)]
pub enum CodexUsageStatisticsError {
#[error("Codex usage-statistics request is invalid")]
InvalidRequest,
#[error("Codex usage-statistics credential data is invalid")]
InvalidCredentialData,
#[error("Codex OAuth access token must be refreshed before querying usage statistics")]
CredentialRefreshRequired { upstream_body: Option<String> },
#[error("Codex usage-statistics account was not found")]
NotFound,
#[error("provider account store is unavailable: {detail}")]
Store { detail: String },
#[error("Codex usage-statistics quota window is unavailable")]
QuotaWindowUnavailable,
#[error("Codex usage-statistics upstream returned HTTP {status}")]
Upstream {
status: u16,
body: String,
retry_after_seconds: Option<u64>,
},
#[error("Codex usage-statistics query transport is unavailable")]
TransportUnavailable,
}
impl std::fmt::Debug for CodexUsageStatisticsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequest => formatter.write_str("InvalidRequest"),
Self::InvalidCredentialData => formatter.write_str("InvalidCredentialData"),
Self::CredentialRefreshRequired { .. } => {
formatter.write_str("CredentialRefreshRequired { upstream_body: <redacted> }")
}
Self::NotFound => formatter.write_str("NotFound"),
Self::Store { .. } => formatter.write_str("Store { detail: <redacted> }"),
Self::QuotaWindowUnavailable => formatter.write_str("QuotaWindowUnavailable"),
Self::Upstream {
status,
retry_after_seconds,
..
} => formatter
.debug_struct("Upstream")
.field("status", status)
.field("body", &"<redacted>")
.field("retry_after_seconds", retry_after_seconds)
.finish(),
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
}
}
}
impl std::fmt::Debug for CodexResetCreditsError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -246,6 +305,25 @@ enum ResetCreditAttemptError {
Upstream(CodexClientError),
}
enum DailyUsageAttemptError {
InvalidCredential,
Upstream(CodexClientError),
}
enum DailyUsageFetchError {
InvalidCredential,
Recovery,
Upstream {
prepared: Box<PreparedCodexRuntimeCredential>,
error: CodexClientError,
},
}
struct FetchedDailyUsage {
model_breakdown: Value,
daily_totals: Value,
}
impl CodexQuotaSchedulingProjection {
fn invalidate(&self, account_ids: &[ProviderAccountId]) {
let mut state = self
@@ -509,6 +587,113 @@ impl CodexCredentialQuotaService {
result.map_err(|error| map_reset_credit_attempt_error(error, &prepared, false))
}
/// 查询当前账号的官方每日用量报表,并把报表范围锚定到最接近七天的额度窗口。
pub async fn usage_statistics(
&self,
account_id: &ProviderAccountId,
cycle_offset: i8,
utc_offset_minutes: i16,
) -> Result<CodexUsageStatistics, CodexUsageStatisticsError> {
const MAX_CYCLE_OFFSET: u8 = 8;
const MAX_UTC_OFFSET_MINUTES: i16 = 14 * 60;
const TARGET_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60;
if !(-(MAX_CYCLE_OFFSET as i8)..=0).contains(&cycle_offset)
|| !(-MAX_UTC_OFFSET_MINUTES..=MAX_UTC_OFFSET_MINUTES).contains(&utc_offset_minutes)
{
return Err(CodexUsageStatisticsError::InvalidRequest);
}
let account = self
.store
.get_account(account_id)
.await
.map_err(|error| CodexUsageStatisticsError::Store {
detail: error.to_string(),
})?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexUsageStatisticsError::NotFound)?;
let observed_at = SystemTime::now();
if !access_token_is_current(&account, observed_at) {
return Err(CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: None,
});
}
let client = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
);
let FetchedCodexQuota { account, value } = self
.fetch_usage_with_recovery(&client, &account)
.await
.map_err(map_statistics_quota_fetch_error)?;
let snapshot = parse_account_quota_snapshot(
account.id().clone(),
account.revision(),
observed_at,
&value,
)
.map_err(|_| CodexUsageStatisticsError::InvalidCredentialData)?;
let window = snapshot
.windows()
.iter()
.filter(|window| window.is_account_wide())
.filter_map(|window| {
Some((
window,
window.window_seconds()?.abs_diff(TARGET_WINDOW_SECONDS),
))
})
.min_by_key(|(_, distance)| *distance)
.map(|(window, _)| window)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_seconds = window
.window_seconds()
.filter(|seconds| *seconds > 0)
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let end_at = window
.reset_at()
.ok_or(CodexUsageStatisticsError::QuotaWindowUnavailable)?;
let window_delta = chrono::TimeDelta::seconds(
i64::try_from(window_seconds)
.map_err(|_| CodexUsageStatisticsError::QuotaWindowUnavailable)?,
);
let current_start_at = end_at - window_delta;
let timezone = FixedOffset::east_opt(i32::from(utc_offset_minutes) * 60)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let now = DateTime::<Utc>::from(observed_at);
let period = usage_statistics::statistics_period(
current_start_at,
end_at,
window_seconds,
cycle_offset,
timezone,
now,
)
.ok_or(CodexUsageStatisticsError::InvalidRequest)?;
let fetched = self
.fetch_daily_usage_with_recovery(
&client,
&account,
period.query_start_date,
period.query_end_date,
)
.await
.map_err(map_daily_usage_fetch_error)?;
Ok(usage_statistics::build_usage_statistics(
usage_statistics::BuildUsageStatistics {
period,
timezone,
current_used_percent: window.used_percent(),
now,
model_breakdown: &fetched.model_breakdown,
daily_totals: &fetched.daily_totals,
max_cycle_offset: MAX_CYCLE_OFFSET,
},
))
}
/// 消费一张主动额度重置卡。相同账号在本进程内串行,且不做传输重试。
pub async fn consume_reset_credit(
&self,
@@ -1262,6 +1447,135 @@ impl CodexCredentialQuotaService {
}
}
}
async fn fetch_daily_usage_with_recovery(
&self,
client: &CodexBackendClient,
account: &ProviderAccount,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageFetchError> {
let mut prepared = self
.agent_identity
.prepare(account)
.await
.map_err(|_| DailyUsageFetchError::InvalidCredential)?;
let mut result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
if let Err(DailyUsageAttemptError::Upstream(error)) = &result
&& let Some(recovered) = self
.agent_identity
.recover_after_rejected_task(
prepared.account.id(),
&prepared.credential.authentication,
error,
)
.await
.map_err(|_| DailyUsageFetchError::Recovery)?
{
prepared = recovered;
result = fetch_daily_usage_once(client, &prepared, start_date, end_date).await;
}
match result {
Ok(value) => Ok(value),
Err(DailyUsageAttemptError::InvalidCredential) => {
Err(DailyUsageFetchError::InvalidCredential)
}
Err(DailyUsageAttemptError::Upstream(error)) => Err(DailyUsageFetchError::Upstream {
prepared: Box::new(prepared),
error,
}),
}
}
}
async fn fetch_daily_usage_once(
client: &CodexBackendClient,
prepared: &PreparedCodexRuntimeCredential,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> Result<FetchedDailyUsage, DailyUsageAttemptError> {
let authorization = prepared
.credential
.authentication
.authorization_header(Utc::now())
.map_err(|_| DailyUsageAttemptError::InvalidCredential)?;
let model_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let totals_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let model_breakdown = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&model_request_id,
None,
),
CodexDailyUsageReport::PersonalModelCredits,
start_date,
end_date,
);
let daily_totals = client.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&totals_request_id,
None,
),
CodexDailyUsageReport::PersonalTokenTotals,
start_date,
end_date,
);
let (model_breakdown, daily_totals) = tokio::try_join!(model_breakdown, daily_totals)
.map_err(DailyUsageAttemptError::Upstream)?;
Ok(FetchedDailyUsage {
model_breakdown,
daily_totals,
})
}
fn map_statistics_quota_fetch_error(error: CodexQuotaFetchError) -> CodexUsageStatisticsError {
match error {
CodexQuotaFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
CodexQuotaFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
CodexQuotaFetchError::Upstream { account, error } => map_statistics_client_error(
error,
account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH,
),
}
}
fn map_daily_usage_fetch_error(error: DailyUsageFetchError) -> CodexUsageStatisticsError {
match error {
DailyUsageFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
DailyUsageFetchError::Recovery => CodexUsageStatisticsError::TransportUnavailable,
DailyUsageFetchError::Upstream { prepared, error } => {
map_statistics_client_error(error, prepared.credential.authentication.oauth().is_some())
}
}
}
fn map_statistics_client_error(
error: CodexClientError,
credential_can_refresh: bool,
) -> CodexUsageStatisticsError {
match error {
CodexClientError::Upstream { status, body, .. }
if status == reqwest::StatusCode::UNAUTHORIZED && credential_can_refresh =>
{
CodexUsageStatisticsError::CredentialRefreshRequired {
upstream_body: Some(body),
}
}
CodexClientError::Upstream {
status,
body,
retry_after_seconds,
..
} => CodexUsageStatisticsError::Upstream {
status: status.as_u16(),
body,
retry_after_seconds,
},
_ => CodexUsageStatisticsError::TransportUnavailable,
}
}
async fn list_reset_credits_once(
@@ -0,0 +1,874 @@
use std::{
cmp::{Ordering, Reverse},
collections::BTreeMap,
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime, TimeDelta, Utc};
use gateway_core::accounting::{Decimal, Money};
use serde_json::Value;
use crate::transport::{OpenAiBillingUsage, openai_aggregate_billing_breakdown};
const EPSILON: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CodexUsageStatisticsServiceTier {
Standard,
Fast,
}
impl CodexUsageStatisticsServiceTier {
pub const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
const fn billing_service_tier(self) -> &'static str {
match self {
Self::Standard => "default",
Self::Fast => "fast",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CodexUsageStatisticsTokens {
pub uncached_input: u64,
pub cached_input: u64,
pub output: u64,
pub total: u64,
}
impl CodexUsageStatisticsTokens {
fn checked_add(self, other: Self) -> Option<Self> {
Some(Self {
uncached_input: self.uncached_input.checked_add(other.uncached_input)?,
cached_input: self.cached_input.checked_add(other.cached_input)?,
output: self.output.checked_add(other.output)?,
total: self.total.checked_add(other.total)?,
})
}
const fn is_empty(self) -> bool {
self.total == 0 && self.uncached_input == 0 && self.cached_input == 0 && self.output == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsCycle {
pub offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub is_current: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsModel {
pub key: String,
pub model: String,
pub service_tier: CodexUsageStatisticsServiceTier,
pub credit_share: Option<f64>,
pub quota_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_estimated_allocation: bool,
pub has_rate_fallback: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsDay {
pub date: NaiveDate,
pub credit_share: Option<f64>,
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
pub is_boundary_day: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatisticsSummary {
pub tokens: CodexUsageStatisticsTokens,
pub estimated_cost: Option<Money>,
pub projected_tokens: Option<u64>,
pub projected_cost: Option<Money>,
pub day_count: u32,
pub has_unknown_pricing: bool,
pub has_missing_token_data: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatistics {
pub cycle: CodexUsageStatisticsCycle,
pub summary: CodexUsageStatisticsSummary,
pub models: Vec<CodexUsageStatisticsModel>,
pub daily: Vec<CodexUsageStatisticsDay>,
}
#[derive(Debug, Clone, Copy)]
pub struct CodexUsageStatisticsPeriod {
pub cycle_offset: i8,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub window_seconds: u64,
pub query_start_date: NaiveDate,
pub query_end_date: NaiveDate,
}
pub fn statistics_period(
current_start_at: DateTime<Utc>,
current_end_at: DateTime<Utc>,
window_seconds: u64,
cycle_offset: i8,
timezone: FixedOffset,
now: DateTime<Utc>,
) -> Option<CodexUsageStatisticsPeriod> {
let window_delta = TimeDelta::seconds(i64::try_from(window_seconds).ok()?);
let shift = window_delta.checked_mul(i32::from(cycle_offset))?;
let start_at = current_start_at.checked_add_signed(shift)?;
let end_at = current_end_at.checked_add_signed(shift)?;
let query_start_date = start_at
.checked_sub_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
let effective_end = end_at.min(now);
let query_end_date = effective_end
.checked_add_signed(TimeDelta::days(1))?
.with_timezone(&timezone)
.date_naive();
Some(CodexUsageStatisticsPeriod {
cycle_offset,
start_at,
end_at,
window_seconds,
query_start_date,
query_end_date,
})
}
pub(super) struct BuildUsageStatistics<'a> {
pub period: CodexUsageStatisticsPeriod,
pub timezone: FixedOffset,
pub current_used_percent: Option<f64>,
pub now: DateTime<Utc>,
pub model_breakdown: &'a Value,
pub daily_totals: &'a Value,
pub max_cycle_offset: u8,
}
pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUsageStatistics {
let BuildUsageStatistics {
period,
timezone,
current_used_percent,
now,
model_breakdown,
daily_totals,
max_cycle_offset,
} = input;
let all_days = build_personal_days(daily_totals, model_breakdown);
let selected_days = select_cycle_days(
&all_days,
period.start_at,
period.end_at,
timezone,
now,
true,
);
let is_current = period.cycle_offset == 0;
let used_percent = is_current
.then_some(current_used_percent)
.flatten()
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0));
let used_ratio = used_percent
.map(|value| value.clamp(0.0, 100.0) / 100.0)
.unwrap_or_default();
let summary = summarize(&selected_days, used_ratio, is_current);
let models = aggregate_models(&selected_days, used_ratio, is_current);
let total_credits = selected_days
.iter()
.map(|day| day.credit_weight)
.sum::<f64>();
let mut daily = selected_days
.into_iter()
.map(|day| CodexUsageStatisticsDay {
date: day.date,
credit_share: (total_credits > EPSILON).then_some(day.credit_weight / total_credits),
tokens: day.tokens,
estimated_cost: day.estimated_cost,
has_unknown_pricing: day.has_unknown_pricing,
has_missing_token_data: day.has_missing_token_data,
is_boundary_day: day.is_boundary_day,
})
.collect::<Vec<_>>();
daily.sort_by_key(|day| Reverse(day.date));
CodexUsageStatistics {
cycle: CodexUsageStatisticsCycle {
offset: period.cycle_offset,
start_at: period.start_at,
end_at: period.end_at,
window_seconds: period.window_seconds,
used_percent,
is_current,
can_go_previous: period.cycle_offset > -(max_cycle_offset as i8),
can_go_next: period.cycle_offset < 0,
},
summary,
models,
daily,
}
}
#[derive(Debug, Clone)]
struct DailyModelUsage {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
credits: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
#[derive(Debug, Clone)]
struct DailyUsage {
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
estimated_cost: Option<Money>,
has_unknown_pricing: bool,
has_missing_token_data: bool,
is_boundary_day: bool,
models: Vec<DailyModelUsage>,
}
#[derive(Default)]
struct MoneySum {
value: Option<Money>,
overflowed: bool,
}
impl MoneySum {
fn push(&mut self, value: Option<Money>) {
let Some(value) = value else {
return;
};
if self.overflowed {
return;
}
self.value = match self.value {
Some(current) => match current.checked_add(value) {
Some(total) => Some(total),
None => {
self.overflowed = true;
None
}
},
None => Some(value),
};
}
fn finish(self) -> Option<Money> {
(!self.overflowed).then_some(self.value).flatten()
}
}
fn build_personal_days(totals_payload: &Value, breakdown_payload: &Value) -> Vec<DailyUsage> {
let totals_by_date = rows_by_date(totals_payload);
let breakdown_by_date = rows_by_date(breakdown_payload);
let mut dates = totals_by_date
.keys()
.chain(breakdown_by_date.keys())
.copied()
.collect::<Vec<_>>();
dates.sort_unstable();
dates.dedup();
dates
.into_iter()
.filter_map(|date| {
let totals_row = totals_by_date.get(&date).unwrap_or(&Value::Null);
let breakdown_row = breakdown_by_date.get(&date).unwrap_or(&Value::Null);
let totals = token_parts(totals_row.get("totals").unwrap_or(totals_row));
let credit_weight = day_credit_weight(breakdown_row);
let weighted = weighted_models(breakdown_row);
if totals.is_empty() {
if credit_weight <= EPSILON {
return None;
}
let models = weighted
.into_iter()
.map(|model| DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: model.weight,
tokens: CodexUsageStatisticsTokens::default(),
estimated_cost: None,
has_unknown_pricing: false,
has_estimated_allocation: false,
has_rate_fallback: false,
has_missing_token_data: true,
})
.collect();
return Some(finalize_day(date, credit_weight, totals, models, true));
}
let has_model_breakdown = !weighted.is_empty();
let weighted = if has_model_breakdown {
weighted
} else {
vec![WeightedModel {
model: "unknown".to_owned(),
service_tier: CodexUsageStatisticsServiceTier::Standard,
weight: 1.0,
}]
};
let allocation = rate_adjusted_shares(&weighted, totals);
let uncached = allocate_integer_total(totals.uncached_input, &allocation.token_shares);
let cached = allocate_integer_total(totals.cached_input, &allocation.token_shares);
let output = allocate_integer_total(totals.output, &allocation.token_shares);
let total = allocate_integer_total(totals.total, &allocation.token_shares);
let estimated_allocation = weighted.len() > 1 || !has_model_breakdown;
let rate_fallback = allocation.has_rate_fallback || !has_model_breakdown;
let models = weighted
.into_iter()
.enumerate()
.map(|(index, model)| {
let tokens = CodexUsageStatisticsTokens {
uncached_input: uncached.get(index).copied().unwrap_or_default(),
cached_input: cached.get(index).copied().unwrap_or_default(),
output: output.get(index).copied().unwrap_or_default(),
total: total.get(index).copied().unwrap_or_default(),
};
let estimated_cost = aggregate_cost(&model.model, model.service_tier, tokens);
DailyModelUsage {
model: model.model,
service_tier: model.service_tier,
credits: if has_model_breakdown {
model.weight
} else {
0.0
},
tokens,
has_unknown_pricing: estimated_cost.is_none() && !tokens.is_empty(),
estimated_cost,
has_estimated_allocation: estimated_allocation,
has_rate_fallback: rate_fallback,
has_missing_token_data: false,
}
})
.collect();
Some(finalize_day(date, credit_weight, totals, models, false))
})
.collect()
}
fn finalize_day(
date: NaiveDate,
credit_weight: f64,
tokens: CodexUsageStatisticsTokens,
models: Vec<DailyModelUsage>,
has_missing_token_data: bool,
) -> DailyUsage {
let mut costs = MoneySum::default();
for model in &models {
costs.push(model.estimated_cost);
}
DailyUsage {
date,
credit_weight,
tokens,
estimated_cost: costs.finish(),
has_unknown_pricing: models.iter().any(|model| model.has_unknown_pricing),
has_missing_token_data,
is_boundary_day: false,
models,
}
}
#[derive(Debug)]
struct WeightedModel {
model: String,
service_tier: CodexUsageStatisticsServiceTier,
weight: f64,
}
struct Allocation {
token_shares: Vec<f64>,
has_rate_fallback: bool,
}
fn weighted_models(row: &Value) -> Vec<WeightedModel> {
value_array(row.get("models"))
.iter()
.filter_map(|value| {
let weight = non_negative_f64(value.get("credits"));
(weight > EPSILON).then(|| WeightedModel {
model: model_name(value),
service_tier: model_service_tier(value.get("speed").or_else(|| value.get("mode"))),
weight,
})
})
.collect()
}
fn rate_adjusted_shares(
models: &[WeightedModel],
totals: CodexUsageStatisticsTokens,
) -> Allocation {
let total_weight = models.iter().map(|model| model.weight).sum::<f64>();
let credit_shares = models
.iter()
.map(|model| model.weight / total_weight)
.collect::<Vec<_>>();
if models.len() == 1 {
return Allocation {
token_shares: vec![1.0],
has_rate_fallback: false,
};
}
let classified_tokens = totals
.uncached_input
.saturating_add(totals.cached_input)
.saturating_add(totals.output);
if classified_tokens == 0 {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let rates = models
.iter()
.map(|model| blended_rate(&model.model, model.service_tier, totals, classified_tokens))
.collect::<Vec<_>>();
let known = rates
.iter()
.enumerate()
.filter_map(|(index, rate)| rate.map(|rate| (index, rate)))
.collect::<Vec<_>>();
if known.is_empty() {
return Allocation {
token_shares: credit_shares,
has_rate_fallback: true,
};
}
let known_weight = known
.iter()
.map(|(index, _)| models[*index].weight)
.sum::<f64>();
let fallback_rate = known
.iter()
.map(|(index, rate)| rate * models[*index].weight / known_weight)
.sum::<f64>();
let inverse = models
.iter()
.enumerate()
.map(|(index, model)| model.weight / rates[index].unwrap_or(fallback_rate))
.collect::<Vec<_>>();
let inverse_total = inverse.iter().sum::<f64>();
Allocation {
token_shares: inverse
.into_iter()
.map(|value| value / inverse_total)
.collect(),
has_rate_fallback: known.len() != models.len(),
}
}
#[allow(clippy::cast_precision_loss)]
fn blended_rate(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
totals: CodexUsageStatisticsTokens,
classified_tokens: u64,
) -> Option<f64> {
let cost = aggregate_cost(model, service_tier, totals)?;
let rate = cost.amount().scaled() as f64 / classified_tokens as f64;
(rate > EPSILON).then_some(rate)
}
fn aggregate_cost(
model: &str,
service_tier: CodexUsageStatisticsServiceTier,
tokens: CodexUsageStatisticsTokens,
) -> Option<Money> {
let input = tokens.uncached_input.checked_add(tokens.cached_input)?;
openai_aggregate_billing_breakdown(
model,
OpenAiBillingUsage::new(input, tokens.output, tokens.cached_input, 0),
Some(service_tier.billing_service_tier()),
)
.map(|breakdown| breakdown.total_amount())
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn allocate_integer_total(total: u64, shares: &[f64]) -> Vec<u64> {
if shares.is_empty() {
return Vec::new();
}
let share_total = shares.iter().copied().sum::<f64>();
if !share_total.is_finite() || share_total <= EPSILON {
return vec![0; shares.len()];
}
let raw = shares
.iter()
.map(|share| total as f64 * (share.max(0.0) / share_total))
.collect::<Vec<_>>();
let mut allocated = raw
.iter()
.map(|value| value.floor() as u64)
.collect::<Vec<_>>();
let allocated_total = allocated.iter().copied().sum::<u64>();
let mut remainder = total.saturating_sub(allocated_total);
let mut order = raw
.iter()
.enumerate()
.map(|(index, value)| (index, value - value.floor()))
.collect::<Vec<_>>();
order.sort_by(|left, right| {
right
.1
.total_cmp(&left.1)
.then_with(|| left.0.cmp(&right.0))
});
let mut index = 0_usize;
while remainder > 0 {
let target = order[index % order.len()].0;
allocated[target] = allocated[target].saturating_add(1);
remainder -= 1;
index += 1;
}
allocated
}
fn select_cycle_days(
days: &[DailyUsage],
start_at: DateTime<Utc>,
end_at: DateTime<Utc>,
timezone: FixedOffset,
now: DateTime<Utc>,
mark_boundary: bool,
) -> Vec<DailyUsage> {
let local_start = start_at.with_timezone(&timezone);
let start_date = local_start.date_naive();
let effective_end = end_at.min(now);
let end_date = effective_end.with_timezone(&timezone).date_naive();
let exclude_end_date = end_at <= now && end_date > start_date;
let is_boundary = local_start.time() != NaiveTime::MIN;
days.iter()
.filter(|day| {
day.date >= start_date
&& if exclude_end_date {
day.date < end_date
} else {
day.date <= end_date
}
})
.cloned()
.map(|mut day| {
day.is_boundary_day = mark_boundary && is_boundary && day.date == start_date;
day
})
.collect()
}
fn summarize(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> CodexUsageStatisticsSummary {
let tokens = sum_tokens(days.iter().map(|day| day.tokens));
let mut costs = MoneySum::default();
for day in days {
costs.push(day.estimated_cost);
}
let estimated_cost = costs.finish();
let has_unknown_pricing = days.iter().any(|day| day.has_unknown_pricing);
let projected_tokens = (is_current && used_ratio > EPSILON && tokens.total > 0)
.then(|| project_u64(tokens.total, used_ratio))
.flatten();
let projected_cost = (is_current && !has_unknown_pricing && used_ratio > EPSILON)
.then(|| estimated_cost.and_then(|cost| project_money(cost, used_ratio)))
.flatten();
CodexUsageStatisticsSummary {
tokens,
estimated_cost,
projected_tokens,
projected_cost,
day_count: u32::try_from(days.len()).unwrap_or(u32::MAX),
has_unknown_pricing,
has_missing_token_data: days.iter().any(|day| day.has_missing_token_data),
}
}
#[derive(Default)]
struct ModelAggregate {
credits: f64,
tokens: CodexUsageStatisticsTokens,
costs: MoneySum,
has_unknown_pricing: bool,
has_estimated_allocation: bool,
has_rate_fallback: bool,
has_missing_token_data: bool,
}
fn aggregate_models(
days: &[DailyUsage],
used_ratio: f64,
is_current: bool,
) -> Vec<CodexUsageStatisticsModel> {
let mut aggregates =
BTreeMap::<(String, CodexUsageStatisticsServiceTier), ModelAggregate>::new();
for model in days.iter().flat_map(|day| &day.models) {
if model.tokens.is_empty() && model.credits <= EPSILON {
continue;
}
let aggregate = aggregates
.entry((model.model.clone(), model.service_tier))
.or_default();
aggregate.credits += model.credits;
aggregate.tokens =
aggregate
.tokens
.checked_add(model.tokens)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
});
aggregate.costs.push(model.estimated_cost);
aggregate.has_unknown_pricing |= model.has_unknown_pricing;
aggregate.has_estimated_allocation |= model.has_estimated_allocation;
aggregate.has_rate_fallback |= model.has_rate_fallback;
aggregate.has_missing_token_data |= model.has_missing_token_data;
}
let total_credits = aggregates
.values()
.map(|aggregate| aggregate.credits)
.sum::<f64>();
let mut rows = aggregates
.into_iter()
.map(|((model, service_tier), aggregate)| {
let credit_share =
(total_credits > EPSILON).then_some(aggregate.credits / total_credits);
let quota_share = (is_current && used_ratio > EPSILON)
.then(|| credit_share.map(|share| share * used_ratio))
.flatten();
CodexUsageStatisticsModel {
key: format!("{model}::{}", service_tier.as_str()),
model,
service_tier,
credit_share,
quota_share,
tokens: aggregate.tokens,
estimated_cost: aggregate.costs.finish(),
has_unknown_pricing: aggregate.has_unknown_pricing,
has_estimated_allocation: aggregate.has_estimated_allocation,
has_rate_fallback: aggregate.has_rate_fallback,
has_missing_token_data: aggregate.has_missing_token_data,
}
})
.collect::<Vec<_>>();
rows.sort_by(compare_model_rows);
rows
}
fn compare_model_rows(
left: &CodexUsageStatisticsModel,
right: &CodexUsageStatisticsModel,
) -> Ordering {
right
.credit_share
.unwrap_or_default()
.total_cmp(&left.credit_share.unwrap_or_default())
.then_with(|| cost_ticks(right.estimated_cost).cmp(&cost_ticks(left.estimated_cost)))
.then_with(|| right.tokens.total.cmp(&left.tokens.total))
.then_with(|| left.model.cmp(&right.model))
.then_with(|| left.service_tier.cmp(&right.service_tier))
}
fn cost_ticks(cost: Option<Money>) -> u128 {
cost.map(|value| value.amount().scaled())
.unwrap_or_default()
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_u64(value: u64, used_ratio: f64) -> Option<u64> {
let projected = value as f64 / used_ratio;
(projected.is_finite() && projected >= 0.0 && projected <= u64::MAX as f64)
.then(|| projected.round() as u64)
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn project_money(cost: Money, used_ratio: f64) -> Option<Money> {
let projected = cost.amount().scaled() as f64 / used_ratio;
if !projected.is_finite() || projected < 0.0 || projected > u128::MAX as f64 {
return None;
}
let amount = Decimal::from_scaled(projected.round() as u128).ok()?;
Some(Money::new(amount, cost.currency()))
}
fn sum_tokens(
values: impl IntoIterator<Item = CodexUsageStatisticsTokens>,
) -> CodexUsageStatisticsTokens {
values
.into_iter()
.fold(CodexUsageStatisticsTokens::default(), |total, value| {
total
.checked_add(value)
.unwrap_or(CodexUsageStatisticsTokens {
uncached_input: u64::MAX,
cached_input: u64::MAX,
output: u64::MAX,
total: u64::MAX,
})
})
}
fn rows_by_date(payload: &Value) -> BTreeMap<NaiveDate, Value> {
extract_daily_list(payload)
.into_iter()
.filter_map(|row| Some((date_value(row.get("date")?)?, row.clone())))
.collect()
}
fn extract_daily_list(payload: &Value) -> Vec<&Value> {
if let Some(rows) = payload.as_array() {
return rows.iter().collect();
}
[
"data",
"items",
"results",
"daily",
"daily_usage",
"dailyWorkspaceUsageCounts",
"daily_workspace_usage_counts",
"workspace_usage_counts",
]
.into_iter()
.find_map(|key| payload.get(key).and_then(Value::as_array))
.map(|rows| rows.iter().collect())
.unwrap_or_default()
}
fn value_array(value: Option<&Value>) -> &[Value] {
value.and_then(Value::as_array).map_or(&[], Vec::as_slice)
}
fn token_parts(value: &Value) -> CodexUsageStatisticsTokens {
let uncached_input = non_negative_u64(value.get("uncached_text_input_tokens"));
let cached_input = non_negative_u64(value.get("cached_text_input_tokens"));
let output = non_negative_u64(value.get("text_output_tokens"));
let derived = uncached_input
.saturating_add(cached_input)
.saturating_add(output);
let reported = non_negative_u64(value.get("text_total_tokens"));
CodexUsageStatisticsTokens {
uncached_input,
cached_input,
output,
total: if reported > 0 { reported } else { derived },
}
}
fn day_credit_weight(value: &Value) -> f64 {
let model_credits = value_array(value.get("models"))
.iter()
.map(|model| non_negative_f64(model.get("credits")))
.sum::<f64>();
if model_credits > EPSILON {
return model_credits;
}
value
.get("product_surface_usage_values")
.and_then(Value::as_object)
.map(|values| {
values
.values()
.map(|value| non_negative_f64(Some(value)))
.sum()
})
.unwrap_or_default()
}
fn model_name(value: &Value) -> String {
["model", "model_id", "model_name", "name", "id"]
.into_iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_ascii_lowercase()
}
fn model_service_tier(value: Option<&Value>) -> CodexUsageStatisticsServiceTier {
match value
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("fast" | "priority") => CodexUsageStatisticsServiceTier::Fast,
Some(_) | None => CodexUsageStatisticsServiceTier::Standard,
}
}
fn date_value(value: &Value) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value.as_str()?.get(..10)?, "%Y-%m-%d").ok()
}
fn non_negative_u64(value: Option<&Value>) -> u64 {
let Some(value) = value else {
return 0;
};
if let Some(number) = value.as_u64() {
return number;
}
let normalized = value
.as_str()
.map(|raw| raw.replace(',', ""))
.unwrap_or_else(|| value.to_string());
normalized
.trim()
.parse::<u64>()
.ok()
.or_else(|| {
normalized
.trim()
.parse::<f64>()
.ok()
.filter(|number| number.is_finite() && *number >= 0.0)
.map(|number| number.round() as u64)
})
.unwrap_or_default()
}
fn non_negative_f64(value: Option<&Value>) -> f64 {
let Some(value) = value else {
return 0.0;
};
let parsed = value.as_f64().or_else(|| {
value
.as_str()
.map(|raw| raw.replace(',', ""))
.and_then(|raw| raw.trim().parse::<f64>().ok())
});
parsed
.filter(|number| number.is_finite() && *number >= 0.0)
.unwrap_or_default()
}
@@ -8,6 +8,11 @@ pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
pub const WHAM_USAGE_PATH: &str = "/wham/usage";
/// 个人账号每日模型额度权重。
pub const WHAM_DAILY_TOKEN_USAGE_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
/// 个人账号每日 Token 总量。
pub const WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH: &str =
"/wham/analytics/daily-workspace-usage-counts";
/// Codex Desktop 主动额度重置卡列表。
pub const WHAM_RATE_LIMIT_RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits";
/// Codex Desktop 主动消费额度重置卡。
@@ -17,6 +17,7 @@ pub(crate) mod session;
mod time;
pub mod tls;
pub mod usage;
pub(crate) mod usage_statistics;
pub mod websocket;
pub use self::{
@@ -35,8 +36,9 @@ pub use self::{
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_PATH, WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
CODEX_USAGE_API_PATH, WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_PATH,
WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
},
headers::build_codex_base_headers,
request::{CodexRequestEncodeError, encode_generate_request},
@@ -50,3 +52,5 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
};
pub(crate) use self::usage::openai_aggregate_billing_breakdown;
@@ -12,6 +12,7 @@ use crate::transport::protocol::responses::CodexResponsesRequest;
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"];
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
"authorization",
@@ -90,7 +91,7 @@ pub fn encode_generate_request(
return Err(CodexRequestEncodeError::InvalidProtocolPayload);
}
let mut body = payload.body().clone();
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
adapt_codex_responses_body(&mut body, upstream_model);
let mut encoded = CodexResponsesRequest::from_body(body);
encoded.explicit_prompt_cache_key = encoded.prompt_cache_key().is_some();
@@ -99,6 +100,13 @@ pub fn encode_generate_request(
Ok(encoded)
}
fn adapt_codex_responses_body(body: &mut Map<String, Value>, upstream_model: &str) {
body.insert("model".to_owned(), Value::String(upstream_model.to_owned()));
for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS {
body.remove(*field);
}
}
fn extract_request_context(request: &mut CodexResponsesRequest) {
let context = ExtractedRequestContext::from_body(request.body());
request.turn_state = context.turn_state;
@@ -412,6 +412,30 @@ pub fn openai_billing_breakdown(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
openai_billing_breakdown_with_context(
model,
usage,
service_tier,
usage.input_tokens > LONG_CONTEXT_THRESHOLD,
)
}
/// 官方日报没有单次请求边界,不能把整日输入量套用长上下文阶梯。
/// 聚合统计固定使用公开的短上下文价格,其余价格规则与请求级计费共用。
pub(crate) fn openai_aggregate_billing_breakdown(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
openai_billing_breakdown_with_context(model, usage, service_tier, false)
}
fn openai_billing_breakdown_with_context(
model: &str,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
long_context: bool,
) -> Option<CalculatedCostBreakdown> {
if usage.cached_tokens > usage.input_tokens
|| usage.image_input_tokens > 0
@@ -421,7 +445,6 @@ pub fn openai_billing_breakdown(
}
let web_search_ticks = web_search_amount_ticks(usage)?;
let pricing = model_pricing(model)?;
let long_context = usage.input_tokens > LONG_CONTEXT_THRESHOLD;
let normalized_tier = normalize_service_tier(service_tier);
let tier = pricing_tier(normalized_tier.as_deref())?;
let standard_rates = pricing.rates(PricingTier::Standard, long_context)?;
@@ -0,0 +1,119 @@
//! Codex 官方每日用量报表 transport。
use chrono::NaiveDate;
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use reqwest::StatusCode;
use serde_json::Value;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
client::{
CodexBackendTransport, CodexTransportMetrics, read_capped_response_body,
retry_after_seconds, truncate_for_error,
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{
WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH, endpoint_url,
},
response_meta,
};
const MAX_CODEX_DAILY_USAGE_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CodexDailyUsageReport {
PersonalModelCredits,
PersonalTokenTotals,
}
impl CodexDailyUsageReport {
const fn path(self) -> &'static str {
match self {
Self::PersonalModelCredits => WHAM_DAILY_TOKEN_USAGE_PATH,
Self::PersonalTokenTotals => WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
}
}
const fn workspace_user(self) -> bool {
matches!(self, Self::PersonalTokenTotals)
}
}
impl CodexBackendClient {
pub(crate) async fn fetch_daily_usage(
&self,
context: CodexRequestContext<'_>,
report: CodexDailyUsageReport,
start_date: NaiveDate,
end_date: NaiveDate,
) -> CodexClientResult<Value> {
let headers = self.usage_request_headers(context)?;
let workspace_user = if report.workspace_user() {
"&workspace_user=true"
} else {
""
};
let url = format!(
"{}?start_date={start_date}&end_date={end_date}&group_by=day{workspace_user}",
endpoint_url(&self.base_url, report.path()),
);
let response = self.client.get(url).headers(headers).send().await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = read_capped_response_body(response, MAX_CODEX_DAILY_USAGE_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(upstream_error(
if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
"upstream daily usage response exceeded the body limit".to_owned(),
retry_after_seconds,
diagnostics,
));
}
let body = body.into_string();
if !status.is_success() {
return Err(upstream_error(
status,
body.clone(),
retry_after_seconds.or_else(|| retry_after_seconds_from_body(&body)),
diagnostics,
));
}
serde_json::from_str::<Value>(&body).map_err(|_| {
upstream_error(
StatusCode::BAD_GATEWAY,
format!(
"invalid daily usage response: {}",
truncate_for_error(&body)
),
None,
diagnostics,
)
})
}
}
fn upstream_error(
status: StatusCode,
body: String,
retry_after_seconds: Option<u64>,
diagnostics: super::CodexUpstreamDiagnostics,
) -> CodexClientError {
CodexClientError::Upstream {
status,
body,
client_response: None,
retry_after_seconds,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: CodexBackendTransport::HttpSse,
transport_metrics: Box::<CodexTransportMetrics>::default(),
send_phase: CodexUpstreamSendPhase::AfterPayload,
}
}
@@ -2,6 +2,7 @@
mod recovery;
mod snapshot;
mod usage_statistics;
use std::sync::Arc;
use std::time::SystemTime;
@@ -0,0 +1,217 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Days, NaiveTime, TimeDelta, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexUsageStatisticsError, CodexUsageStatisticsServiceTier};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{create_account, quota_service_with_base_url};
use crate::support::MemoryAccountStore;
const PERSONAL_MODEL_REPORT_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
const PERSONAL_TOTALS_REPORT_PATH: &str = "/wham/analytics/daily-workspace-usage-counts";
fn cycle_anchor() -> (DateTime<Utc>, DateTime<Utc>) {
let start_date = Utc::now()
.date_naive()
.checked_sub_days(Days::new(1))
.expect("cycle start date");
let start_at = DateTime::<Utc>::from_naive_utc_and_offset(
start_date.and_time(NaiveTime::from_hms_opt(15, 50, 0).expect("cycle time")),
Utc,
);
(
start_at,
start_at
.checked_add_signed(TimeDelta::days(7))
.expect("cycle end"),
)
}
async fn mount_quota(server: &MockServer, reset_at: DateTime<Utc>) {
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": 20,
"reset_at": reset_at.timestamp(),
"limit_window_seconds": 604800
}
}
})))
.mount(server)
.await;
}
async fn service(
account_id: &str,
server: &MockServer,
reset_at: DateTime<Utc>,
) -> (
Arc<MemoryAccountStore>,
provider_openai::credential::CodexCredentialQuotaService,
) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, account_id).await;
mount_quota(server, reset_at).await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
(store, service)
}
#[tokio::test]
async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_totals() {
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = Utc::now().date_naive().to_string();
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"models": [
{"model": "gpt-5.6-sol", "speed": "fast", "credits": 75},
{"model": "gpt-5.6-terra", "speed": "standard", "credits": 25}
]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"uncached_text_input_tokens": 101,
"cached_text_input_tokens": 203,
"text_output_tokens": 17,
"text_total_tokens": 321
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_personal_statistics", &server, end_at).await;
let account = store
.account("acct_personal_statistics")
.expect("personal account");
let statistics = service
.usage_statistics(account.id(), 0, 0)
.await
.expect("personal statistics");
assert_eq!(statistics.cycle.start_at, start_at);
assert_eq!(statistics.summary.tokens.uncached_input, 101);
assert_eq!(statistics.summary.tokens.cached_input, 203);
assert_eq!(statistics.summary.tokens.output, 17);
assert_eq!(statistics.summary.tokens.total, 321);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.uncached_input)
.sum::<u64>(),
101
);
assert_eq!(
statistics
.models
.iter()
.map(|model| model.tokens.total)
.sum::<u64>(),
321
);
assert!(
statistics
.models
.iter()
.all(|model| model.has_estimated_allocation)
);
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-sol" && model.service_tier == CodexUsageStatisticsServiceTier::Fast
}));
assert!(statistics.models.iter().any(|model| {
model.model == "gpt-5.6-terra"
&& model.service_tier == CodexUsageStatisticsServiceTier::Standard
}));
}
#[tokio::test]
async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
let server = MockServer::start().await;
let (current_start_at, current_end_at) = cycle_anchor();
let selected_date = current_start_at
.date_naive()
.checked_sub_days(Days::new(14))
.expect("selected date");
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"models": [{
"model": "gpt-5.6-terra",
"speed": "standard",
"credits": 10
}]
}]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_TOTALS_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": selected_date.to_string(),
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_offset_statistics", &server, current_end_at).await;
let account = store
.account("acct_offset_statistics")
.expect("offset account");
let statistics = service
.usage_statistics(account.id(), -2, 0)
.await
.expect("historical statistics");
assert_eq!(statistics.cycle.offset, -2);
assert!(!statistics.cycle.is_current);
assert!(statistics.cycle.can_go_next);
assert!(statistics.cycle.used_percent.is_none());
assert_eq!(statistics.daily[0].date, selected_date);
let requests = server.received_requests().await.expect("recorded requests");
let report = requests
.iter()
.find(|request| request.url.path() == PERSONAL_TOTALS_REPORT_PATH)
.expect("personal totals report request");
let query = report
.url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<BTreeMap<_, _>>();
let expected_start = current_start_at
.date_naive()
.checked_sub_days(Days::new(15))
.expect("query start");
let expected_end = current_start_at
.date_naive()
.checked_sub_days(Days::new(6))
.expect("query end");
assert_eq!(query.get("start_date"), Some(&expected_start.to_string()));
assert_eq!(query.get("end_date"), Some(&expected_end.to_string()));
assert_eq!(query.get("group_by").map(String::as_str), Some("day"));
let invalid_account = ProviderAccountId::new("acct_invalid_offset").expect("account ID");
assert!(matches!(
service.usage_statistics(&invalid_account, -9, 0).await,
Err(CodexUsageStatisticsError::InvalidRequest)
));
}
@@ -70,7 +70,7 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity()
}
#[test]
fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("secret prompt")),
@@ -78,7 +78,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
"tools".to_owned(),
json!([{"type": "function", "name": "lookup", "strict": true}]),
),
("max_output_tokens".to_owned(), json!(512)),
("store".to_owned(), json!(false)),
(
"reasoning".to_owned(),
@@ -89,7 +88,6 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
assert!(encoded.body().get("stream").is_none());
assert_eq!(encoded.body().get("max_output_tokens"), Some(&json!(512)));
assert_eq!(encoded.body().get("store"), Some(&json!(false)));
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/tools/0/strict"), Some(&json!(true)));
@@ -97,6 +95,28 @@ fn encoder_should_only_patch_model_and_preserve_raw_generate_semantics() {
assert!(!encoded.force_http_sse);
}
#[test]
fn encoder_should_remove_unsupported_fields_from_upstream_body() {
let request = request(Map::from_iter([
("model".to_owned(), json!("client-model")),
("input".to_owned(), json!("hello")),
("max_output_tokens".to_owned(), json!(512)),
("max_tokens".to_owned(), json!(256)),
("temperature".to_owned(), json!(0.2)),
]));
let encoded = encode_generate_request(&request, "gpt-test").expect("encode");
assert_eq!(
Value::Object(encoded.body().clone()),
json!({
"model": "gpt-test",
"input": "hello",
"max_tokens": 256,
})
);
}
#[test]
fn encoder_should_preserve_client_store_intent() {
let request = request(Map::from_iter([
+1 -2
View File
@@ -1,2 +1 @@
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
+1 -1
View File
@@ -315,7 +315,7 @@ create table model_requests (
operation text not null,
endpoint text not null,
client_transport text not null,
requested_model_id text not null,
requested_model_id text,
provider_kind text,
upstream_model_id text,
service_tier text,
@@ -1,2 +0,0 @@
alter table model_requests
alter column requested_model_id drop not null;
+1 -2
View File
@@ -4,8 +4,7 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
## 冻结规则
+7 -2
View File
@@ -21,7 +21,8 @@ CSS Variables 与 Tailwind CSS 4 utilities 消费主题。
## 设计原则
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;只有 Hover、Active、弱背景和文字色使用派生值。
- **所选即所得**:`colorPrimary` 始终保留用户选择的 Seed 原色;浅色功能基础色沿用 Ant Design P6,
同样等于 Seed。只有深色适配、Hover、Active、弱背景和文字色使用派生值。
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生。
- **语义独立**:`success`、`warning`、`error`、`info` 与 `link` 拥有各自 Seed,不随品牌色隐式改变。
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔。
@@ -110,6 +111,10 @@ interface ThemeCustomization {
| 弱背景 | 从当前容器表面与主色混合,避免低亮度 Seed 产生脏灰色块 |
| 文字与描边 | 从色板取值后,对相邻 Surface 执行对比度校正 |
功能色的基础 Map 对齐 Ant Design:`info`、`success`、`warning`、`error` 均固定取十阶色板 P6。浅色 P6
就是用户选择的 Seed,因此 `--cp-color-info / success / warning / error` 不再经过额外对比度改写;深色 P6
继续使用暗色色板对当前 Surface 做主题适配。功能色的 Hover、Active、弱背景、文字和描边仍按各自角色派生。
背景与文本 Seed 进入独立的 Surface Map,生成:
- `colorBgLayout / Container / Elevated / Spotlight / Mask`
@@ -141,7 +146,7 @@ Alias 层执行以下保护:
- 普通文字与背景的目标对比度为 4.5:1。
- 控件边界与相邻 Surface 的目标对比度为 3:1。
- 极亮的实心按钮背景自动切换为深色文字,避免白底白字。
- 校正只修改最终 Alias,不反写用户保存的 Seed。
- 校正只修改文字、描边和状态等具体角色 Alias,不反写用户保存的 Seed,也不改写基础功能色 Alias。
### Component Token
File diff suppressed because it is too large Load Diff
+73
View File
@@ -170,6 +170,66 @@ export interface AccountQuotaResponse {
account: Account
}
export interface AccountUsageStatisticsTokens {
uncachedInput: number
cachedInput: number
output: number
total: number
}
export interface AccountUsageStatisticsCost {
currency: string
amount: string
}
export interface AccountUsageStatisticsModel {
key: string
model: string
serviceTier: 'standard' | 'fast'
creditShare: number | null
quotaShare: number | null
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
hasUnknownPricing: boolean
hasEstimatedAllocation: boolean
hasRateFallback: boolean
hasMissingTokenData: boolean
}
export interface AccountUsageStatisticsDay {
date: string
creditShare: number | null
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
hasUnknownPricing: boolean
hasMissingTokenData: boolean
isBoundaryDay: boolean
}
export interface AccountUsageStatisticsResponse {
cycle: {
offset: number
startAt: string
endAt: string
windowSeconds: number
usedPercent: number | null
isCurrent: boolean
canGoPrevious: boolean
canGoNext: boolean
}
summary: {
tokens: AccountUsageStatisticsTokens
estimatedCost: AccountUsageStatisticsCost | null
projectedTokens: number | null
projectedCost: AccountUsageStatisticsCost | null
dayCount: number
hasUnknownPricing: boolean
hasMissingTokenData: boolean
}
models: AccountUsageStatisticsModel[]
daily: AccountUsageStatisticsDay[]
}
export interface AccountResetCredit {
id: string
status: string | null
@@ -238,6 +298,11 @@ interface AccountIdParam {
accountId: string
}
interface AccountUsageStatisticsParam extends AccountIdParam {
cycleOffset?: number
utcOffsetMinutes?: number
}
interface AccountResetCreditConsumeParam extends AccountIdParam {
creditId?: string
redeemRequestId: string
@@ -326,6 +391,14 @@ export function getAccountQuota(data: AccountIdParam) {
})
}
export function getAccountUsageStatistics(data: AccountUsageStatisticsParam) {
return request<AccountUsageStatisticsResponse>({
url: '/api/admin/accounts/usage-statistics',
method: 'GET',
params: data,
})
}
export function refreshAccountQuota(data: AccountIdParam) {
return request<AccountQuotaResponse>({
url: '/api/admin/accounts/quota/refresh',
@@ -243,7 +243,9 @@ onBeforeUnmount(() => {
</BaseIconButton>
</header>
<div v-if="$slots.default" class="min-h-0 overflow-hidden px-3.25 py-3.25 sm:px-5.25 sm:py-5.25">
<BaseScrollbar class="h-full">
<BaseScrollbar
class="h-full -mr-2 pr-2 sm:-mr-3 sm:pr-3"
>
<div class="p-0.75">
<slot />
</div>
@@ -44,6 +44,7 @@ export interface BaseTableProps<Row extends TableRow> {
density?: 'compact' | 'default'
loading?: boolean
emptyText?: string
scrollbarAlwaysVisible?: boolean
sort?: BaseTableSort
}
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
density: 'default',
loading: false,
emptyText: '暂无数据',
scrollbarAlwaysVisible: false,
sort: undefined,
})
@@ -196,6 +197,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
:class="density === 'compact'
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
:always-visible="scrollbarAlwaysVisible"
horizontal
@scroll="handleTableScroll"
>
+4 -2
View File
@@ -744,7 +744,9 @@ function deriveFunctionalColorMap(
const palette = generate(seed, theme === 'dark'
? { theme: 'dark', backgroundColor: containerBg }
: undefined).map(color => normalizeHexColor(color) ?? seed)
const color = palette[theme === 'dark' ? 6 : 5]!
// 与 Ant Design 的功能色 Map 保持一致:基础功能色固定取 P6。
// 浅色 P6 即用户选择的 Seed;深色 P6 由暗色色板适配。对比度保护只作用于具体角色 Token。
const color = palette[5]!
const hover = palette[theme === 'dark' ? 7 : 4]!
const active = palette[theme === 'dark' ? 5 : 6]!
const backgroundMix = theme === 'dark' ? 0.18 : 0.08
@@ -753,7 +755,7 @@ function deriveFunctionalColorMap(
const text = baselineText ?? palette[theme === 'dark' ? 8 : 5]!
return {
color: ensureContrast(color, containerBg, 3),
color,
hover: ensureContrast(hover, containerBg, 3),
active: ensureContrast(active, containerBg, 3),
background: mix(containerBg, color, backgroundMix),
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
resolveAccountUsageWindowPresentation,
} from './AccountUsageWindow/presenter'
} from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
@@ -9,7 +9,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseRadio from '@/components/base/BaseRadio.vue'
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
import { useAccountResetCredits } from '../../composables/useAccountResetCredits'
const props = defineProps<{
accountId: string
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { RefreshCw } from '@lucide/vue'
import type { AccountRow } from '../../constants'
import { ChartNoAxesColumnIncreasing, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import { computed, shallowRef } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
import AccountResetCredits from './AccountResetCredits.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountUsageStatisticsModal from '../AccountUsageStatisticsModal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
const props = defineProps<{
account: AccountRow
@@ -22,6 +23,7 @@ const emit = defineEmits<{
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
const statisticsOpen = shallowRef(false)
</script>
<template>
@@ -44,6 +46,16 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看官方用量统计"
size="sm"
variant="ghost"
:pressed="statisticsOpen"
@click="statisticsOpen = true"
>
<ChartNoAxesColumnIncreasing class="size-3.5" />
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
:account-id="account.id"
@@ -77,4 +89,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</p>
</div>
</section>
<AccountUsageStatisticsModal
v-if="account.provider === 'openai'"
v-model="statisticsOpen"
:account="account"
/>
</template>
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountQuotaWindowGroup from './WindowGroup.vue'
const props = defineProps<{
label: string | null
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import type { AccountQuotaWindow } from '../../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
const props = defineProps<{
label: string
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { AccountRow } from '../../constants'
import { computed } from 'vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants'
import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../../constants'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import AccountQuotaSummaryEntry from './Entry.vue'
const props = defineProps<{
account: AccountRow
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { AccountUsageStatisticsDay } from '@/api'
import { computed } from 'vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatUsageCost, formatUsagePercent } from '../../utils/accountUsageStatistics'
const props = defineProps<{
rows: AccountUsageStatisticsDay[]
isCurrentCycle: boolean
usedPercent: number | null
}>()
const sectionHeightClass = computed(() => props.rows.length > 4
? 'h-[min(15rem,24dvh)]'
: undefined)
const columns = defineTableColumns<AccountUsageStatisticsDay>([
{ key: 'date', label: '日期', kind: 'custom', size: '2xl' },
{ key: 'creditShare', label: '额度占比', kind: 'numeric', size: 'sm' },
{ key: 'estimatedCost', label: '估算金额', kind: 'numeric', size: 'sm' },
{ key: 'total', label: '总 Tokens', kind: 'numeric', size: 'lg' },
{ key: 'uncachedInput', label: '未缓存输入', kind: 'numeric', size: 'lg' },
{ key: 'cachedInput', label: '缓存输入', kind: 'numeric', size: 'lg' },
{ key: 'output', label: '输出', kind: 'numeric', size: 'md' },
])
</script>
<template>
<section
class="flex min-h-0 flex-col overflow-hidden"
:class="sectionHeightClass"
aria-labelledby="account-usage-statistics-daily-title"
>
<div class="mb-2.5 flex shrink-0 items-center justify-between gap-3">
<h3
id="account-usage-statistics-daily-title"
class="m-0 text-cp-lg font-heavy text-cp-text"
>
每日用量
</h3>
<span class="text-cp-xs font-semibold text-cp-text-quaternary">官方接口为日粒度</span>
</div>
<BaseTable
v-if="rows.length"
class="min-h-0 min-w-0 flex-[1_1_auto]"
:columns="columns"
:rows="rows"
row-key="date"
density="compact"
scrollbar-always-visible
>
<template #date="{ row }">
<div class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 font-mono font-heavy text-cp-text">{{ row.date }}</span>
<span
v-if="row.isBoundaryDay"
class="shrink-0 rounded-full bg-cp-info-bg px-1.5 py-0.5 font-sans text-[10px] font-bold text-cp-info-text"
>
跨周期起始日
</span>
<span
v-if="row.hasMissingTokenData"
class="shrink-0 rounded-full bg-cp-warning-bg px-1.5 py-0.5 font-sans text-[10px] font-bold text-cp-warning-text"
>
无 Token 明细
</span>
</div>
</template>
<template #creditShare="{ row }">
{{ formatUsagePercent(row.creditShare, true) }}
</template>
<template #estimatedCost="{ row }">
<span class="font-heavy text-cp-success-text">
{{ row.hasMissingTokenData ? '—' : formatUsageCost(row.estimatedCost, row.hasUnknownPricing) }}
</span>
</template>
<template #total="{ row }">
<span class="font-heavy text-cp-text" :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.total)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.total) }}
</span>
</template>
<template #uncachedInput="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.uncachedInput)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.uncachedInput) }}
</span>
</template>
<template #cachedInput="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.cachedInput)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.cachedInput) }}
</span>
</template>
<template #output="{ row }">
<span :title="row.hasMissingTokenData ? undefined : formatInteger(row.tokens.output)">
{{ row.hasMissingTokenData ? '—' : formatCompactNumber(row.tokens.output) }}
</span>
</template>
</BaseTable>
<BaseEmpty
v-else
:title="isCurrentCycle && usedPercent === 0 ? '本周期刚重置' : '该周期没有用量记录'"
:description="isCurrentCycle && usedPercent === 0 ? '尚未产生任何官方日报数据' : undefined"
size="sm"
surface="none"
class="min-h-0 flex-1"
/>
</section>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { AccountUsageStatisticsModel } from '@/api'
import { Info } from '@lucide/vue'
import { computed } from 'vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatUsageCost, formatUsagePercent } from '../../utils/accountUsageStatistics'
const props = defineProps<{
rows: AccountUsageStatisticsModel[]
}>()
const sectionHeightClass = computed(() => props.rows.length > 4
? 'h-[min(15rem,24dvh)]'
: undefined)
const allocationLabel = computed(() => {
const estimatedRows = props.rows.filter(row => row.hasEstimatedAllocation)
if (estimatedRows.length === 0)
return null
const hasRateFallback = estimatedRows.some(row => row.hasRateFallback)
const hasRateAllocation = estimatedRows.some(row => !row.hasRateFallback)
if (hasRateFallback && hasRateAllocation)
return 'Token 为估算值'
return hasRateFallback ? 'Token 按额度估算' : 'Token 按费率估算'
})
const columns = defineTableColumns<AccountUsageStatisticsModel>([
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'serviceTier', label: '服务档位', kind: 'custom', size: 'sm' },
{ key: 'creditShare', label: '额度占比', kind: 'numeric', size: 'sm' },
{ key: 'quotaShare', label: '占周限额', kind: 'numeric', size: 'sm' },
{ key: 'uncachedInput', label: '未缓存输入', kind: 'numeric', size: 'md' },
{ key: 'cachedInput', label: '缓存输入', kind: 'numeric', size: 'md' },
{ key: 'output', label: '输出', kind: 'numeric', size: 'sm' },
{ key: 'total', label: '总 Tokens', kind: 'numeric', size: 'md' },
{ key: 'estimatedCost', label: '估算金额', kind: 'numeric', size: 'sm' },
])
</script>
<template>
<section
class="flex min-h-0 flex-col overflow-hidden"
:class="sectionHeightClass"
aria-labelledby="account-usage-statistics-models-title"
>
<div class="mb-2.5 flex shrink-0 items-center gap-1.5">
<h3
id="account-usage-statistics-models-title"
class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text"
>
模型明细
</h3>
<span
v-if="allocationLabel"
class="inline-flex shrink-0 cursor-help items-center justify-center text-cp-text-quaternary"
role="img"
:aria-label="allocationLabel"
:title="allocationLabel"
>
<Info class="size-3.5" aria-hidden="true" />
</span>
</div>
<BaseTable
class="min-h-0 min-w-0 flex-[1_1_auto]"
:columns="columns"
:rows="rows"
row-key="key"
density="compact"
empty-text="该周期没有模型用量"
scrollbar-always-visible
>
<template #model="{ row }">
<span class="block truncate font-mono font-heavy text-cp-text">{{ row.model }}</span>
</template>
<template #serviceTier="{ row }">
<span class="rounded-full bg-cp-fill-tertiary px-2 py-1 font-mono text-cp-xs font-bold capitalize">
{{ row.serviceTier }}
</span>
</template>
<template #creditShare="{ row }">
{{ formatUsagePercent(row.creditShare, true) }}
</template>
<template #quotaShare="{ row }">
{{ formatUsagePercent(row.quotaShare, true) }}
</template>
<template #uncachedInput="{ row }">
<span :title="formatInteger(row.tokens.uncachedInput)">
{{ row.hasMissingTokenData && row.tokens.total === 0 ? '无明细' : formatCompactNumber(row.tokens.uncachedInput) }}
</span>
</template>
<template #cachedInput="{ row }">
<span :title="formatInteger(row.tokens.cachedInput)">{{ formatCompactNumber(row.tokens.cachedInput) }}</span>
</template>
<template #output="{ row }">
<span :title="formatInteger(row.tokens.output)">{{ formatCompactNumber(row.tokens.output) }}</span>
</template>
<template #total="{ row }">
<span class="font-heavy text-cp-text" :title="formatInteger(row.tokens.total)">
{{ formatCompactNumber(row.tokens.total) }}
</span>
</template>
<template #estimatedCost="{ row }">
<span class="font-heavy text-cp-success-text">
{{ formatUsageCost(row.estimatedCost, row.hasUnknownPricing) }}
</span>
</template>
</BaseTable>
</section>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</script>
<template>
<div
class="grid gap-5"
role="status"
aria-live="polite"
aria-busy="true"
>
<span class="sr-only">正在加载官方用量统计</span>
<section
class="grid gap-2 rounded-cp-lg bg-cp-bg-elevated px-3 py-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center"
aria-hidden="true"
>
<div class="flex items-center gap-1">
<BaseSkeleton shape="circle" class="size-8 opacity-70" />
<BaseSkeleton shape="circle" class="size-8 opacity-70" />
</div>
<div class="flex min-w-0 flex-wrap items-center gap-1.5 sm:justify-center">
<BaseSkeleton shape="text" class="w-36" />
<BaseSkeleton class="h-5 w-12 rounded-full opacity-70" />
<BaseSkeleton class="h-5 w-14 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-36 opacity-60 sm:justify-self-end" />
</section>
<section class="overflow-hidden rounded-cp-lg bg-cp-bg-elevated" aria-hidden="true">
<div class="grid gap-5 p-4 sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] sm:p-5">
<div class="min-w-0">
<BaseSkeleton shape="text" class="w-20 opacity-60" />
<BaseSkeleton class="mt-2 h-7 w-24" />
<BaseSkeleton class="mt-4 h-1.5 w-full rounded-full opacity-70" />
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-cp-split pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5">
<div v-for="metric in 4" :key="metric" class="grid gap-2">
<BaseSkeleton shape="text" class="w-16 opacity-60" />
<BaseSkeleton shape="text" class="h-3.5 w-14" />
</div>
</div>
</div>
<div class="grid grid-cols-2 border-t border-cp-split sm:grid-cols-4">
<div
v-for="metric in 4"
:key="metric"
class="grid gap-2 px-4 py-3 sm:border-l sm:border-cp-split sm:px-5 sm:first:border-l-0"
>
<BaseSkeleton shape="text" class="w-16 opacity-60" />
<BaseSkeleton shape="text" class="h-3.5 w-12" />
</div>
</div>
</section>
<section
v-for="section in 2"
:key="section"
class="min-w-0"
aria-hidden="true"
>
<div class="mb-2.5 flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-24 opacity-50" />
</div>
<div class="overflow-hidden rounded-cp">
<div class="grid grid-cols-[1.4fr_repeat(6,minmax(0,1fr))] items-center gap-3 bg-cp-fill-quaternary px-3 py-2">
<BaseSkeleton
v-for="column in 7"
:key="column"
shape="text"
class="h-2.5 opacity-60"
:class="column === 1 ? 'w-3/4' : 'ml-auto w-2/3'"
/>
</div>
<div
v-for="row in 2"
:key="row"
class="grid grid-cols-[1.4fr_repeat(6,minmax(0,1fr))] items-center gap-3 px-3 py-2.5"
:class="row % 2 === 0 ? 'bg-cp-fill-quaternary/55' : undefined"
>
<BaseSkeleton
v-for="column in 7"
:key="column"
shape="text"
class="opacity-70"
:class="column === 1 ? 'w-4/5' : 'ml-auto w-3/5'"
/>
</div>
</div>
</section>
</div>
</template>
@@ -0,0 +1,264 @@
<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,89 @@
import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountUsageStatistics } from '@/api'
import { errorMessage } from '@/utils/async'
export function useAccountUsageStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const view = shallowRef<Awaited<ReturnType<typeof getAccountUsageStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const loading = shallowRef(false)
const error = shallowRef('')
let requestVersion = 0
async function loadCycle(cycleOffset: number, force = false) {
const targetAccountId = accountId.value
if (!targetAccountId)
return
if (
!force
&& view.value
&& loadedAccountId.value === targetAccountId
&& view.value.cycle.offset === cycleOffset
) {
return
}
const version = ++requestVersion
loading.value = true
error.value = ''
try {
const result = await getAccountUsageStatistics({
accountId: targetAccountId,
cycleOffset,
utcOffsetMinutes: -new Date().getTimezoneOffset(),
})
if (version !== requestVersion)
return
view.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
if (version !== requestVersion)
return
error.value = errorMessage(cause, '官方用量统计加载失败')
}
finally {
if (version === requestVersion)
loading.value = false
}
}
function load(force = false) {
return loadCycle(view.value?.cycle.offset ?? 0, force)
}
function previousCycle() {
const cycle = view.value?.cycle
if (cycle?.canGoPrevious)
void loadCycle(cycle.offset - 1)
}
function nextCycle() {
const cycle = view.value?.cycle
if (cycle?.canGoNext)
void loadCycle(cycle.offset + 1)
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
requestVersion += 1
view.value = null
loadedAccountId.value = ''
loading.value = false
error.value = ''
}
if (isOpen)
void load()
}, { immediate: true })
return {
loading,
error,
view,
load,
previousCycle,
nextCycle,
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ import AccountFilters from './components/AccountFilters.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel/index.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell/index.vue'
import AccountStatusBadge from './components/AccountStatusBadge/index.vue'
import AccountTableActions from './components/AccountTableActions.vue'
import AccountUsagePanel from './components/AccountUsagePanel.vue'
@@ -0,0 +1,21 @@
import type { AccountUsageStatisticsCost } from '@/api'
export function formatUsagePercent(value: number | null, ratio = false) {
if (value === null)
return '—'
const percent = ratio ? value * 100 : value
return `${percent >= 10 ? percent.toFixed(1) : percent.toFixed(2)}%`
}
export function formatUsageCost(
cost: AccountUsageStatisticsCost | null,
hasUnknownPricing = false,
) {
if (!cost)
return hasUnknownPricing ? '未公开' : '—'
const amount = Number(cost.amount)
const display = cost.currency === 'USD' && Number.isFinite(amount)
? `$${amount.toFixed(2)}`
: `${cost.currency} ${cost.amount}`
return `${display}${hasUnknownPricing ? '*' : ''}`
}
@@ -190,7 +190,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
outputPriceDisplay: '$10.00 / 1M',
cacheReadPriceDisplay: '$0.125 / 1M',
cacheWritePriceDisplay: '$0.00 / 1M',
serviceTierDisplay: options.provider === 'openai' ? 'Priority' : 'Standard',
serviceTierDisplay: options.provider === 'openai' ? 'Fast' : 'Standard',
multiplierDisplay: '1.0x',
},
costs: [{ currency: 'USD', estimatedAmount: options.estimatedCost }],
@@ -102,7 +102,7 @@ const detailGroups = computed(() => [
{ label: '传输方式', value: props.record?.transport, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: finalAttemptIndex.value },
{ label: '服务层级', value: props.record?.serviceTier, mono: true },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
],
},
@@ -189,7 +189,7 @@ const billingItems = computed(() => {
{ label: '输出单价', value: value.outputPriceDisplay, mono: true },
{ label: '缓存单价', value: value.cacheReadPriceDisplay, mono: true },
{ label: '缓存写入单价', value: value.cacheWritePriceDisplay, mono: true },
{ label: '服务层级', value: value.serviceTierDisplay },
{ label: '服务档位', value: value.serviceTierDisplay },
{ label: '倍率', value: value.multiplierDisplay, mono: true },
]
})
+1 -1
View File
@@ -1 +1 @@
version: "3.3.1"
version: "3.3.3"
+1 -2
View File
@@ -52,8 +52,7 @@ README 保持用户导向;不要把上游 URL、重试常量、数据库字段
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
或直接改 quota reset 时间。
- credential 与 quota 是独立事实;刷新 token 不等于刷新额度,quota 401/403 也不等于 RT 永久失效。
- 当前业务 schema 是 `0001_initial.sql` 的十二张表,加 `0002_nullable_requested_model.sql` 的无模型请求
兼容变更。
- 当前业务 schema 由 `0001_initial.sql` 建立。
- 运行拓扑是单副本;worker lease 不是完整多副本 leader election,自更新也只替换当前进程。
## 前端约束
@@ -114,8 +114,7 @@ OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户
## 存储与迁移
PostgreSQL 的十二张业务表全部在 `0001_initial.sql` 建立;`0002_nullable_requested_model.sql` 让
`model_requests.requested_model_id` 可空,以记录 Images 等无模型端点。`.frozen-sha256` 覆盖全部迁移,
PostgreSQL 业务 schema 由 `0001_initial.sql` 建立。`.frozen-sha256` 覆盖全部迁移,
已应用文件不可回改。
主要状态边界: