Compare commits

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 11:31:55 +08:00
16 changed files with 68 additions and 303 deletions
@@ -628,23 +628,6 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方用量统计的账号形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderUsageStatisticsMode {
Workspace,
Personal,
}
impl ProviderUsageStatisticsMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Personal => "personal",
}
}
}
/// Provider 官方用量统计的目标额度周期。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatisticsCycle {
@@ -737,7 +720,6 @@ pub struct ProviderUsageStatisticsSummary {
/// Provider 已解释、计算并排序的官方用量统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderUsageStatistics {
pub mode: ProviderUsageStatisticsMode,
pub cycle: ProviderUsageStatisticsCycle,
pub summary: ProviderUsageStatisticsSummary,
pub models: Vec<ProviderUsageStatisticsModel>,
@@ -625,7 +625,6 @@ pub struct AccountQuotaData {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageStatisticsData {
pub mode: String,
pub cycle: AccountUsageStatisticsCycleView,
pub summary: AccountUsageStatisticsSummaryView,
pub models: Vec<AccountUsageStatisticsModelView>,
@@ -704,7 +703,6 @@ pub struct AccountUsageStatisticsDayView {
impl From<ProviderUsageStatistics> for AccountUsageStatisticsData {
fn from(statistics: ProviderUsageStatistics) -> Self {
Self {
mode: statistics.mode.as_str().to_owned(),
cycle: AccountUsageStatisticsCycleView {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at.to_rfc3339(),
@@ -78,9 +78,8 @@ mod usage_statistics {
observability::{CurrencyCost, DecimalAmount},
provider_credentials::{
ProviderUsageStatistics, ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay,
ProviderUsageStatisticsMode, ProviderUsageStatisticsModel,
ProviderUsageStatisticsServiceTier, ProviderUsageStatisticsSummary,
ProviderUsageStatisticsTokens,
ProviderUsageStatisticsModel, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens,
},
};
use gateway_api::admin::accounts::{AccountUsageStatisticsData, AccountUsageStatisticsQuery};
@@ -148,7 +147,6 @@ mod usage_statistics {
.single()
.expect("cycle end");
let response = AccountUsageStatisticsData::from(ProviderUsageStatistics {
mode: ProviderUsageStatisticsMode::Personal,
cycle: ProviderUsageStatisticsCycle {
offset: 0,
start_at,
@@ -193,6 +191,7 @@ mod usage_statistics {
});
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"],
+6 -11
View File
@@ -21,10 +21,9 @@ use gateway_admin::model::provider_credentials::{
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, ProviderUsageStatistics,
ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay, ProviderUsageStatisticsMode,
ProviderUsageStatisticsModel, ProviderUsageStatisticsRequest,
ProviderUsageStatisticsServiceTier, ProviderUsageStatisticsSummary,
ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
ProviderUsageStatisticsCycle, ProviderUsageStatisticsDay, ProviderUsageStatisticsModel,
ProviderUsageStatisticsRequest, ProviderUsageStatisticsServiceTier,
ProviderUsageStatisticsSummary, ProviderUsageStatisticsTokens, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -53,9 +52,9 @@ use crate::credential::{
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexPendingAuthorization, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CodexUsageStatistics, CodexUsageStatisticsError,
CodexUsageStatisticsMode, CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexUsageStatisticsServiceTier, CodexUsageStatisticsTokens, CompleteCodexOAuthAuthorization,
CompletedCodexOAuthCredential, ExportManagedCodexCredential, StartCodexOAuthAuthorization,
StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -861,10 +860,6 @@ fn project_usage_statistics(
})
.collect::<Result<Vec<_>, ProviderAdminError>>()?;
Ok(ProviderUsageStatistics {
mode: match statistics.mode {
CodexUsageStatisticsMode::Workspace => ProviderUsageStatisticsMode::Workspace,
CodexUsageStatisticsMode::Personal => ProviderUsageStatisticsMode::Personal,
},
cycle: ProviderUsageStatisticsCycle {
offset: statistics.cycle.offset,
start_at: statistics.cycle.start_at,
@@ -49,8 +49,8 @@ pub use quota::{
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError, CodexUsageStatistics,
CodexUsageStatisticsCycle, CodexUsageStatisticsDay, CodexUsageStatisticsError,
CodexUsageStatisticsMode, CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier,
CodexUsageStatisticsSummary, CodexUsageStatisticsTokens, parse_codex_quota_usage,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens, parse_codex_quota_usage,
};
pub use refresh::{
CodexCredentialRefreshError, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
@@ -15,8 +15,8 @@ pub use snapshot::{
};
pub use usage_statistics::{
CodexUsageStatistics, CodexUsageStatisticsCycle, CodexUsageStatisticsDay,
CodexUsageStatisticsMode, CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier,
CodexUsageStatisticsSummary, CodexUsageStatisticsTokens,
CodexUsageStatisticsModel, CodexUsageStatisticsServiceTier, CodexUsageStatisticsSummary,
CodexUsageStatisticsTokens,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -320,9 +320,8 @@ enum DailyUsageFetchError {
}
struct FetchedDailyUsage {
mode: CodexUsageStatisticsMode,
model_breakdown: Value,
daily_totals: Option<Value>,
daily_totals: Value,
}
impl CodexQuotaSchedulingProjection {
@@ -684,13 +683,12 @@ impl CodexCredentialQuotaService {
Ok(usage_statistics::build_usage_statistics(
usage_statistics::BuildUsageStatistics {
mode: fetched.mode,
period,
timezone,
current_used_percent: window.used_percent(),
now,
model_breakdown: &fetched.model_breakdown,
daily_totals: fetched.daily_totals.as_ref(),
daily_totals: &fetched.daily_totals,
max_cycle_offset: MAX_CYCLE_OFFSET,
},
))
@@ -1501,32 +1499,6 @@ async fn fetch_daily_usage_once(
.authentication
.authorization_header(Utc::now())
.map_err(|_| DailyUsageAttemptError::InvalidCredential)?;
let workspace_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let workspace = client
.fetch_daily_usage(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
prepared.account.upstream_account_id(),
&workspace_request_id,
None,
),
CodexDailyUsageReport::WorkspaceModelTokens,
start_date,
end_date,
)
.await;
match workspace {
Ok(model_breakdown) => {
return Ok(FetchedDailyUsage {
mode: CodexUsageStatisticsMode::Workspace,
model_breakdown,
daily_totals: None,
});
}
Err(error) if workspace_is_unavailable_for_account(&error) => {}
Err(error) => return Err(DailyUsageAttemptError::Upstream(error)),
}
let model_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let totals_request_id = format!("usage_statistics_{}", Uuid::now_v7().simple());
let model_breakdown = client.fetch_daily_usage(
@@ -1554,21 +1526,11 @@ async fn fetch_daily_usage_once(
let (model_breakdown, daily_totals) = tokio::try_join!(model_breakdown, daily_totals)
.map_err(DailyUsageAttemptError::Upstream)?;
Ok(FetchedDailyUsage {
mode: CodexUsageStatisticsMode::Personal,
model_breakdown,
daily_totals: Some(daily_totals),
daily_totals,
})
}
fn workspace_is_unavailable_for_account(error: &CodexClientError) -> bool {
matches!(
error,
CodexClientError::Upstream { status, body, .. }
if *status == reqwest::StatusCode::BAD_REQUEST
&& body.to_ascii_lowercase().contains("no active workspace")
)
}
fn map_statistics_quota_fetch_error(error: CodexQuotaFetchError) -> CodexUsageStatisticsError {
match error {
CodexQuotaFetchError::InvalidCredential => CodexUsageStatisticsError::InvalidCredentialData,
@@ -11,12 +11,6 @@ use crate::transport::{OpenAiBillingUsage, openai_aggregate_billing_breakdown};
const EPSILON: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexUsageStatisticsMode {
Workspace,
Personal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CodexUsageStatisticsServiceTier {
Standard,
@@ -113,7 +107,6 @@ pub struct CodexUsageStatisticsSummary {
#[derive(Debug, Clone, PartialEq)]
pub struct CodexUsageStatistics {
pub mode: CodexUsageStatisticsMode,
pub cycle: CodexUsageStatisticsCycle,
pub summary: CodexUsageStatisticsSummary,
pub models: Vec<CodexUsageStatisticsModel>,
@@ -162,19 +155,17 @@ pub fn statistics_period(
}
pub(super) struct BuildUsageStatistics<'a> {
pub mode: CodexUsageStatisticsMode,
pub period: CodexUsageStatisticsPeriod,
pub timezone: FixedOffset,
pub current_used_percent: Option<f64>,
pub now: DateTime<Utc>,
pub model_breakdown: &'a Value,
pub daily_totals: Option<&'a Value>,
pub daily_totals: &'a Value,
pub max_cycle_offset: u8,
}
pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUsageStatistics {
let BuildUsageStatistics {
mode,
period,
timezone,
current_used_percent,
@@ -183,12 +174,7 @@ pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUs
daily_totals,
max_cycle_offset,
} = input;
let all_days = match mode {
CodexUsageStatisticsMode::Workspace => build_workspace_days(model_breakdown),
CodexUsageStatisticsMode::Personal => {
build_personal_days(daily_totals.unwrap_or(&Value::Null), model_breakdown)
}
};
let all_days = build_personal_days(daily_totals, model_breakdown);
let selected_days = select_cycle_days(
&all_days,
period.start_at,
@@ -227,7 +213,6 @@ pub(super) fn build_usage_statistics(input: BuildUsageStatistics<'_>) -> CodexUs
daily.sort_by_key(|day| Reverse(day.date));
CodexUsageStatistics {
mode,
cycle: CodexUsageStatisticsCycle {
offset: period.cycle_offset,
start_at: period.start_at,
@@ -300,46 +285,6 @@ impl MoneySum {
}
}
fn build_workspace_days(payload: &Value) -> Vec<DailyUsage> {
extract_daily_list(payload)
.into_iter()
.filter_map(|row| {
let date = date_value(row.get("date")?)?;
let mut models = value_array(row.get("models"))
.iter()
.filter_map(direct_model_usage)
.collect::<Vec<_>>();
let model_tokens = sum_tokens(models.iter().map(|model| model.tokens));
let fallback_tokens = token_parts(row.get("totals").unwrap_or(row));
let tokens = if model_tokens.is_empty() {
fallback_tokens
} else {
model_tokens
};
let credit_weight = day_credit_weight(row);
if tokens.is_empty() && credit_weight <= EPSILON {
return None;
}
let has_missing_token_data = tokens.is_empty() && credit_weight > EPSILON;
if models.is_empty() && !tokens.is_empty() {
models.push(unknown_model(tokens, false));
}
if has_missing_token_data {
for model in &mut models {
model.has_missing_token_data = true;
}
}
Some(finalize_day(
date,
credit_weight,
tokens,
models,
has_missing_token_data,
))
})
.collect()
}
fn build_personal_days(totals_payload: &Value, breakdown_payload: &Value) -> Vec<DailyUsage> {
let totals_by_date = rows_by_date(totals_payload);
let breakdown_by_date = rows_by_date(breakdown_payload);
@@ -430,47 +375,6 @@ fn build_personal_days(totals_payload: &Value, breakdown_payload: &Value) -> Vec
.collect()
}
fn direct_model_usage(value: &Value) -> Option<DailyModelUsage> {
let tokens = token_parts(value);
let credits = non_negative_f64(value.get("credits"));
if tokens.is_empty() && credits <= EPSILON {
return None;
}
let model = model_name(value);
let service_tier = model_service_tier(value.get("speed").or_else(|| value.get("mode")));
let estimated_cost = (!tokens.is_empty())
.then(|| aggregate_cost(&model, service_tier, tokens))
.flatten();
Some(DailyModelUsage {
model,
service_tier,
credits,
tokens,
has_unknown_pricing: estimated_cost.is_none() && !tokens.is_empty(),
estimated_cost,
has_estimated_allocation: false,
has_rate_fallback: false,
has_missing_token_data: tokens.is_empty() && credits > EPSILON,
})
}
fn unknown_model(
tokens: CodexUsageStatisticsTokens,
estimated_allocation: bool,
) -> DailyModelUsage {
DailyModelUsage {
model: "unknown".to_owned(),
service_tier: CodexUsageStatisticsServiceTier::Standard,
credits: 0.0,
tokens,
estimated_cost: None,
has_unknown_pricing: !tokens.is_empty(),
has_estimated_allocation: estimated_allocation,
has_rate_fallback: estimated_allocation,
has_missing_token_data: false,
}
}
fn finalize_day(
date: NaiveDate,
credit_weight: f64,
@@ -8,9 +8,6 @@ pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
pub const WHAM_USAGE_PATH: &str = "/wham/usage";
/// Workspace 账号每日模型 Token 明细。
pub const WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH: &str =
"/wham/usage/daily-workspace-user-token-usage-breakdown";
/// 个人账号每日模型额度权重。
pub const WHAM_DAILY_TOKEN_USAGE_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
/// 个人账号每日 Token 总量。
@@ -37,8 +37,8 @@ pub use self::{
endpoints::{
CODEX_IMAGE_EDITS_PATH, CODEX_IMAGE_GENERATIONS_PATH, CODEX_RESPONSES_PATH,
CODEX_USAGE_API_PATH, WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH, WHAM_RATE_LIMIT_RESET_CREDITS_CONSUME_PATH,
WHAM_RATE_LIMIT_RESET_CREDITS_PATH, WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
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},
@@ -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;
@@ -13,8 +13,7 @@ use super::{
},
diagnostics::CodexUpstreamSendPhase,
endpoints::{
WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH, endpoint_url,
WHAM_DAILY_TOKEN_USAGE_PATH, WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH, endpoint_url,
},
response_meta,
};
@@ -23,7 +22,6 @@ const MAX_CODEX_DAILY_USAGE_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CodexDailyUsageReport {
WorkspaceModelTokens,
PersonalModelCredits,
PersonalTokenTotals,
}
@@ -31,7 +29,6 @@ pub(crate) enum CodexDailyUsageReport {
impl CodexDailyUsageReport {
const fn path(self) -> &'static str {
match self {
Self::WorkspaceModelTokens => WHAM_DAILY_WORKSPACE_USER_TOKEN_USAGE_PATH,
Self::PersonalModelCredits => WHAM_DAILY_TOKEN_USAGE_PATH,
Self::PersonalTokenTotals => WHAM_DAILY_WORKSPACE_USAGE_COUNTS_PATH,
}
@@ -3,9 +3,7 @@ use std::sync::Arc;
use chrono::{DateTime, Days, NaiveTime, TimeDelta, Utc};
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{
CodexUsageStatisticsError, CodexUsageStatisticsMode, CodexUsageStatisticsServiceTier,
};
use provider_openai::credential::{CodexUsageStatisticsError, CodexUsageStatisticsServiceTier};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -13,7 +11,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{create_account, quota_service_with_base_url};
use crate::support::MemoryAccountStore;
const WORKSPACE_REPORT_PATH: &str = "/wham/usage/daily-workspace-user-token-usage-breakdown";
const PERSONAL_MODEL_REPORT_PATH: &str = "/wham/usage/daily-token-usage-breakdown";
const PERSONAL_TOTALS_REPORT_PATH: &str = "/wham/analytics/daily-workspace-usage-counts";
@@ -78,11 +75,6 @@ async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_t
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = Utc::now().date_naive().to_string();
Mock::given(method("GET"))
.and(path(WORKSPACE_REPORT_PATH))
.respond_with(ResponseTemplate::new(400).set_body_string("no active workspace"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(PERSONAL_MODEL_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
@@ -115,7 +107,6 @@ async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_t
.await
.expect("personal statistics");
assert_eq!(statistics.mode, CodexUsageStatisticsMode::Personal);
assert_eq!(statistics.cycle.start_at, start_at);
assert_eq!(statistics.summary.tokens.uncached_input, 101);
assert_eq!(statistics.summary.tokens.cached_input, 203);
@@ -152,95 +143,6 @@ async fn personal_report_allocates_tokens_on_the_server_and_preserves_official_t
}));
}
#[tokio::test]
async fn workspace_report_keeps_direct_rows_marks_boundary_and_never_prices_unknown_models() {
let server = MockServer::start().await;
let (start_at, end_at) = cycle_anchor();
let report_date = start_at.date_naive().to_string();
Mock::given(method("GET"))
.and(path(WORKSPACE_REPORT_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": [{
"date": report_date,
"models": [
{
"model": "gpt-5.6-sol",
"speed": "fast",
"uncached_text_input_tokens": 1000000,
"cached_text_input_tokens": 1000000,
"text_output_tokens": 1000000,
"text_total_tokens": 3000000
},
{
"model": "gpt-5.6-terra",
"speed": "standard",
"uncached_text_input_tokens": 1000000,
"text_total_tokens": 1000000
},
{
"model": "future-private-model",
"speed": "fast",
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
}
]
}]})))
.mount(&server)
.await;
let (store, service) = service("acct_workspace_statistics", &server, end_at).await;
let account = store
.account("acct_workspace_statistics")
.expect("workspace account");
let statistics = service
.usage_statistics(account.id(), 0, 0)
.await
.expect("workspace statistics");
assert_eq!(statistics.mode, CodexUsageStatisticsMode::Workspace);
assert!(statistics.daily[0].is_boundary_day);
let sol = statistics
.models
.iter()
.find(|model| model.model == "gpt-5.6-sol")
.expect("Sol row");
assert_eq!(sol.service_tier, CodexUsageStatisticsServiceTier::Fast);
assert_eq!(
sol.estimated_cost
.expect("published Sol price")
.amount()
.canonical(),
"71"
);
let terra = statistics
.models
.iter()
.find(|model| model.model == "gpt-5.6-terra")
.expect("Terra row");
assert_eq!(
terra.service_tier,
CodexUsageStatisticsServiceTier::Standard
);
assert_eq!(
terra
.estimated_cost
.expect("published Terra price")
.amount()
.canonical(),
"2"
);
let unknown = statistics
.models
.iter()
.find(|model| model.model == "future-private-model")
.expect("unknown row");
assert_eq!(unknown.tokens.total, 10);
assert!(unknown.estimated_cost.is_none());
assert!(unknown.has_unknown_pricing);
assert!(statistics.summary.estimated_cost.is_some());
assert!(statistics.summary.has_unknown_pricing);
assert!(statistics.summary.projected_cost.is_none());
}
#[tokio::test]
async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
let server = MockServer::start().await;
@@ -250,18 +152,26 @@ async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
.checked_sub_days(Days::new(14))
.expect("selected date");
Mock::given(method("GET"))
.and(path(WORKSPACE_REPORT_PATH))
.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",
"uncached_text_input_tokens": 10,
"text_total_tokens": 10
"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")
@@ -280,8 +190,8 @@ async fn cycle_offset_fetches_only_the_selected_cycle_with_boundary_padding() {
let requests = server.received_requests().await.expect("recorded requests");
let report = requests
.iter()
.find(|request| request.url.path() == WORKSPACE_REPORT_PATH)
.expect("workspace report request");
.find(|request| request.url.path() == PERSONAL_TOTALS_REPORT_PATH)
.expect("personal totals report request");
let query = report
.url
.query_pairs()
@@ -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([
-3
View File
@@ -170,8 +170,6 @@ export interface AccountQuotaResponse {
account: Account
}
export type AccountUsageStatisticsMode = 'workspace' | 'personal'
export interface AccountUsageStatisticsTokens {
uncachedInput: number
cachedInput: number
@@ -209,7 +207,6 @@ export interface AccountUsageStatisticsDay {
}
export interface AccountUsageStatisticsResponse {
mode: AccountUsageStatisticsMode
cycle: {
offset: number
startAt: string
@@ -35,7 +35,6 @@ const accountIdentity = computed(() =>
|| props.account.id,
)
const title = computed(() => `用量统计 · ${accountIdentity.value}`)
const modeLabel = computed(() => view.value?.mode === 'personal' ? '个人模式' : '工作区模式')
const quotaRailStyle = computed(() => ({
width: `${view.value?.cycle.usedPercent ?? 0}%`,
}))
@@ -121,9 +120,6 @@ function formatProjectedTokens(value: number | null) {
<span class="rounded-full bg-cp-info-bg px-2 py-0.75 text-cp-xs font-bold text-cp-info-text">
{{ cycleLabel }}
</span>
<span class="rounded-full bg-cp-fill-tertiary px-2 py-0.75 text-cp-xs font-bold text-cp-text-secondary">
{{ modeLabel }}
</span>
</div>
<div
+1 -1
View File
@@ -1 +1 @@
version: "3.3.2"
version: "3.3.3"