mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3920624862 | ||
|
|
d804bca495 | ||
|
|
ca527d5833 | ||
|
|
ae4eb49afc | ||
|
|
5d97086511 | ||
|
|
0e6da1e731 | ||
|
|
5d0922b9d1 |
@@ -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",
|
||||
|
||||
@@ -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 +1 @@
|
||||
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
|
||||
94f5a0c5e3ded48a18bd90e919d38b536f60055a997cd4b90435b874655febf0 0002_nullable_requested_model.sql
|
||||
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
|
||||
|
||||
@@ -315,7 +315,7 @@ create table model_requests (
|
||||
operation text not null,
|
||||
endpoint text not null,
|
||||
client_transport text not null,
|
||||
requested_model_id text not null,
|
||||
requested_model_id text,
|
||||
provider_kind text,
|
||||
upstream_model_id text,
|
||||
service_tier text,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
alter table model_requests
|
||||
alter column requested_model_id drop not null;
|
||||
@@ -4,8 +4,7 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
|
||||
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
|
||||
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
|
||||
|
||||
当前大版本只有两份冻结迁移:`0001_initial.sql` 建立十二张业务表的完整新库基线,
|
||||
`0002_nullable_requested_model.sql` 允许 Images 等无模型端点把请求模型保存为 `NULL`。
|
||||
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
|
||||
|
||||
## 冻结规则
|
||||
|
||||
|
||||
+7
-2
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
|
||||
+3
-3
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+25
-7
@@ -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>
|
||||
+5
-5
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+4
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
version: "3.3.1"
|
||||
version: "3.3.3"
|
||||
|
||||
@@ -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` 覆盖全部迁移,
|
||||
已应用文件不可回改。
|
||||
|
||||
主要状态边界:
|
||||
|
||||
Reference in New Issue
Block a user