From 804ff96a3c2a5f04d414c1a731374bb808c15247 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:43:50 +0700 Subject: [PATCH 1/3] Port upstream 0.66.0: OpenAI Admin per-day usage history (model, fetch, bridge DTO) --- .../src-tauri/src/commands/bridge.rs | 9 + .../src/commands/bridge/openai_usage.rs | 103 ++++++ .../src/commands/bridge/openai_usage_tests.rs | 108 ++++++ .../src-tauri/src/commands/providers.rs | 1 + .../src-tauri/src/commands/tests.rs | 12 + apps/desktop-tauri/src-tauri/src/powertoys.rs | 2 + .../src-tauri/src/tray_bridge.rs | 1 + .../src-tauri/src/usage_metric.rs | 1 + apps/desktop-tauri/src/types/bridge.ts | 45 +++ rust/src/core/usage_snapshot.rs | 62 ++++ rust/src/providers/openaiapi/history.rs | 208 ++++++++++++ rust/src/providers/openaiapi/history_tests.rs | 316 ++++++++++++++++++ rust/src/providers/openaiapi/mod.rs | 107 ++---- rust/src/providers/openaiapi/tests.rs | 48 +++ 14 files changed, 952 insertions(+), 71 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs create mode 100644 apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs create mode 100644 rust/src/providers/openaiapi/history.rs create mode 100644 rust/src/providers/openaiapi/history_tests.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..e3c44d4b3f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1,5 +1,9 @@ +mod openai_usage; +#[cfg(test)] +mod openai_usage_tests; pub(crate) mod pace; mod status; +pub(crate) use openai_usage::OpenAiApiUsageSnapshot; pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; use super::*; @@ -267,6 +271,9 @@ pub struct ProviderUsageSnapshot { pub fetch_duration_ms: Option, #[serde(default)] pub wayfinder_usage: Option, + /// Per-day OpenAI Admin API history for the daily usage chart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_ai_api_usage: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub session_equivalent_forecast: Option, } @@ -492,6 +499,7 @@ impl ProviderUsageSnapshot { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: result.wayfinder_usage.clone(), + open_ai_api_usage: result.open_ai_api_usage.as_ref().map(Into::into), session_equivalent_forecast, } } @@ -542,6 +550,7 @@ impl ProviderUsageSnapshot { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs new file mode 100644 index 0000000000..58c91192a6 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage.rs @@ -0,0 +1,103 @@ +//! Bridge DTOs for the OpenAI Admin API per-day usage history (`openAiApiUsage`). +//! +//! Mirrors `OpenAiApiUsageSnapshot` in `src/types/bridge.ts`. Times are epoch seconds and +//! counts are non-negative integers, as in upstream 0.66.0's card payload. + +use codexbar::core::{ + OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiUsageSnapshot { + pub history_days: u32, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub daily: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiDailyUsageSnapshot { + pub start_time: i64, + pub end_time: i64, + pub cost_usd: f64, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + #[serde(default)] + pub line_items: Vec, + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiLineItemSnapshot { + pub name: String, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAiApiModelUsageSnapshot { + pub name: String, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, +} + +impl From<&OpenAiApiUsageHistory> for OpenAiApiUsageSnapshot { + fn from(history: &OpenAiApiUsageHistory) -> Self { + Self { + history_days: history.history_days, + project_id: history.project_id.clone(), + daily: history.daily.iter().map(Into::into).collect(), + } + } +} + +impl From<&OpenAiApiDailyUsage> for OpenAiApiDailyUsageSnapshot { + fn from(day: &OpenAiApiDailyUsage) -> Self { + Self { + start_time: day.start_time, + end_time: day.end_time, + cost_usd: day.cost_usd, + requests: day.requests, + input_tokens: day.input_tokens, + cached_input_tokens: day.cached_input_tokens, + output_tokens: day.output_tokens, + total_tokens: day.total_tokens, + line_items: day.line_items.iter().map(Into::into).collect(), + models: day.models.iter().map(Into::into).collect(), + } + } +} + +impl From<&OpenAiApiLineItemCost> for OpenAiApiLineItemSnapshot { + fn from(item: &OpenAiApiLineItemCost) -> Self { + Self { + name: item.name.clone(), + cost_usd: item.cost_usd, + } + } +} + +impl From<&OpenAiApiModelUsage> for OpenAiApiModelUsageSnapshot { + fn from(model: &OpenAiApiModelUsage) -> Self { + Self { + name: model.name.clone(), + requests: model.requests, + input_tokens: model.input_tokens, + cached_input_tokens: model.cached_input_tokens, + output_tokens: model.output_tokens, + total_tokens: model.total_tokens, + } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs new file mode 100644 index 0000000000..5c0343c32f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_usage_tests.rs @@ -0,0 +1,108 @@ +use super::openai_usage::OpenAiApiUsageSnapshot; +use crate::commands::ProviderUsageSnapshot; +use codexbar::core::{ + OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory, + ProviderFetchResult, ProviderId, RateWindow, UsageSnapshot, instantiate_provider, +}; + +fn history() -> OpenAiApiUsageHistory { + OpenAiApiUsageHistory { + history_days: 30, + project_id: Some("proj_abc".to_string()), + daily: vec![OpenAiApiDailyUsage { + start_time: 1_700_000_000, + end_time: 1_700_086_400, + cost_usd: 14.75, + requests: 10, + input_tokens: 1300, + cached_input_tokens: 250, + output_tokens: 700, + total_tokens: 2000, + line_items: vec![OpenAiApiLineItemCost { + name: "Text tokens".to_string(), + cost_usd: 12.5, + }], + models: vec![OpenAiApiModelUsage { + name: "gpt-5.2".to_string(), + requests: 7, + input_tokens: 1000, + cached_input_tokens: 250, + output_tokens: 500, + total_tokens: 1500, + }], + }], + } +} + +fn snapshot(history: Option) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::OpenAIApi) + .metadata() + .clone(); + let mut result = + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "admin-api"); + result.open_ai_api_usage = history; + ProviderUsageSnapshot::from_fetch_result(ProviderId::OpenAIApi, &metadata, &result, None) +} + +#[test] +fn fetch_result_history_reaches_the_bridge_as_camel_case_epoch_seconds() { + let json = serde_json::to_value(snapshot(Some(history()))).unwrap(); + assert_eq!( + json["openAiApiUsage"], + serde_json::json!({ + "historyDays": 30, + "projectId": "proj_abc", + "daily": [{ + "startTime": 1_700_000_000, + "endTime": 1_700_086_400, + "costUsd": 14.75, + "requests": 10, + "inputTokens": 1300, + "cachedInputTokens": 250, + "outputTokens": 700, + "totalTokens": 2000, + "lineItems": [{"name": "Text tokens", "costUsd": 12.5}], + "models": [{ + "name": "gpt-5.2", + "requests": 7, + "inputTokens": 1000, + "cachedInputTokens": 250, + "outputTokens": 500, + "totalTokens": 1500, + }], + }], + }) + ); +} + +#[test] +fn missing_history_is_omitted_from_the_bridge_payload() { + let json = serde_json::to_value(snapshot(None)).unwrap(); + assert!(json.get("openAiApiUsage").is_none()); +} + +#[test] +fn history_round_trips_through_the_bridge_payload() { + let original = snapshot(Some(history())); + let decoded: ProviderUsageSnapshot = + serde_json::from_value(serde_json::to_value(&original).unwrap()).unwrap(); + assert_eq!(decoded.open_ai_api_usage, original.open_ai_api_usage); + assert_eq!( + decoded.open_ai_api_usage.unwrap().project_id.as_deref(), + Some("proj_abc") + ); +} + +#[test] +fn an_empty_history_serializes_a_null_project_and_no_days() { + let empty = OpenAiApiUsageHistory { + history_days: 30, + project_id: None, + daily: Vec::new(), + }; + let json = serde_json::to_value(OpenAiApiUsageSnapshot::from(&empty)).unwrap(); + assert_eq!( + json, + serde_json::json!({"historyDays": 30, "projectId": null, "daily": []}) + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..3ff5c1f72e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1351,6 +1351,7 @@ mod reset_backfill_tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index d25a0a064a..67dae555be 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -942,6 +942,7 @@ fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1166,6 +1167,7 @@ fn provider_cache_upsert_replaces_existing_provider() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), @@ -1194,6 +1196,7 @@ fn provider_cache_prunes_disabled_providers() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), @@ -1230,6 +1233,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1267,6 +1271,7 @@ fn codex_transient_transport_failure_helper_uses_typed_policy() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1303,6 +1308,7 @@ fn claude_repeated_auth_failure_surfaces_error() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1346,6 +1352,7 @@ fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1400,6 +1407,7 @@ fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "Web".to_string(), @@ -1451,6 +1459,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "CLI".to_string(), @@ -1498,6 +1507,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1702,6 +1712,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), @@ -1736,6 +1747,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: "OAuth".to_string(), diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..366e592c1e 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -211,6 +211,7 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, }); let value = serde_json::to_value(snapshot).unwrap(); @@ -261,6 +262,7 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, }); let value = serde_json::to_value(snapshot).unwrap(); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index aebb7c2115..3a348237c4 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1098,6 +1098,7 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index f6e8f5f532..41bd07c498 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -352,6 +352,7 @@ mod tests { tray_status_label: None, fetch_duration_ms: None, wayfinder_usage: None, + open_ai_api_usage: None, session_equivalent_forecast: None, } } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..ee6bdef0f4 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -685,6 +685,8 @@ export interface ProviderUsageSnapshot { trayStatusLabel: string | null; fetchDurationMs?: number | null; wayfinderUsage?: WayfinderUsageSnapshot | null; + /** Per-UTC-day OpenAI Admin API history; only the `openaiapi` Admin path sets it. */ + openAiApiUsage?: OpenAiApiUsageSnapshot | null; sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; } @@ -717,6 +719,49 @@ export interface WayfinderUsageSnapshot { routes: WayfinderRouteSummary[]; } +/** Line item cost for one UTC day; descending by cost, then name. */ +export interface OpenAiApiLineItemSnapshot { + name: string; + costUsd: number; +} + +/** Model usage for one UTC day; descending by total tokens, then name. */ +export interface OpenAiApiModelUsageSnapshot { + name: string; + requests: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; +} + +/** + * One UTC-day bucket. Input and output include audio tokens, cached input is a + * subset of input, and `totalTokens === inputTokens + outputTokens`. + */ +export interface OpenAiApiDailyUsageSnapshot { + /** Bucket start, epoch seconds. */ + startTime: number; + /** Bucket end, epoch seconds; always after `startTime`. */ + endTime: number; + costUsd: number; + requests: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; + lineItems: OpenAiApiLineItemSnapshot[]; + models: OpenAiApiModelUsageSnapshot[]; +} + +export interface OpenAiApiUsageSnapshot { + /** Requested window in days (1-365). */ + historyDays: number; + projectId: string | null; + /** Ascending by `startTime`; empty when the window had no data. */ + daily: OpenAiApiDailyUsageSnapshot[]; +} + export interface RefreshCompletePayload { providerCount: number; errorCount: number; diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index ebbdcb8c20..e0eb197a4d 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -73,6 +73,56 @@ pub struct WayfinderRouteSummary { pub saved: f64, } +/// Per-UTC-day OpenAI Admin API usage behind the daily usage chart (upstream 0.66.0 +/// `openAIAPIUsage`). It carries only what the two Admin endpoints report and stays +/// provider-siloed: it is never persisted and never mixed into quota math. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenAiApiUsageHistory { + /// Length of the requested window in days (1-365). + pub history_days: u32, + /// Project the Admin queries were scoped to, when one is configured. + pub project_id: Option, + /// One bucket per UTC day that has data, ascending by `start_time`. + pub daily: Vec, +} + +/// One UTC-day bucket. `input_tokens` and `output_tokens` include audio tokens and +/// `cached_input_tokens` is a subset of input, so +/// `total_tokens == input_tokens + output_tokens` (cached is never added on top). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenAiApiDailyUsage { + /// Bucket start, epoch seconds. + pub start_time: i64, + /// Bucket end, epoch seconds (always after `start_time`). + pub end_time: i64, + pub cost_usd: f64, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + /// Descending by cost, then name. + pub line_items: Vec, + /// Descending by total tokens, then name. + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenAiApiLineItemCost { + pub name: String, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenAiApiModelUsage { + pub name: String, + pub requests: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, +} + /// A labeled extra usage window surfaced by provider APIs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamedRateWindow { @@ -615,6 +665,11 @@ pub struct ProviderFetchResult { #[serde(skip_serializing_if = "Option::is_none")] pub wayfinder_usage: Option, + /// Per-day OpenAI Admin API history for the daily usage chart. Set only by the + /// Admin usage path; the balance fallback has no per-day data. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_ai_api_usage: Option, + /// Transient non-quota inventory for provider-specific display. /// /// The field is intentionally skipped by serde: it belongs to the current @@ -656,6 +711,7 @@ impl ProviderFetchResult { usage, cost: None, wayfinder_usage: None, + open_ai_api_usage: None, inventory: Vec::new(), display_details: Vec::new(), source_label: source_label.into(), @@ -698,6 +754,12 @@ impl ProviderFetchResult { self } + /// Attach the per-day OpenAI Admin API history. + pub fn with_open_ai_api_usage(mut self, history: OpenAiApiUsageHistory) -> Self { + self.open_ai_api_usage = Some(history); + self + } + /// Attach one display-only inventory item without exposing redemption IDs. pub fn with_inventory_item(mut self, item: ProviderInventoryItem) -> Self { self.inventory.push(item); diff --git a/rust/src/providers/openaiapi/history.rs b/rust/src/providers/openaiapi/history.rs new file mode 100644 index 0000000000..d09dc9358b --- /dev/null +++ b/rust/src/providers/openaiapi/history.rs @@ -0,0 +1,208 @@ +//! Per-UTC-day bucketing of the OpenAI Admin API costs and completions listings. +//! +//! Mirrors upstream v0.66.0 `Resources/Plugins/openai.js` (`daily` map, `bucket()`) and the +//! card bounds in `OpenAIAPIProviderDescriptor.mapPluginCard`. Buckets are keyed by +//! `start_time`; the first bucket seen for a day fixes its `end_time`. + +use chrono::{DateTime, Utc}; +use std::collections::{BTreeMap, HashMap}; + +use super::{CompletionsUsageBucket, CostBucket, cost_amount}; +use crate::core::{ + OpenAiApiDailyUsage, OpenAiApiLineItemCost, OpenAiApiModelUsage, OpenAiApiUsageHistory, + ProviderError, +}; + +/// Upstream card bound: line items plus models across every day. +const MAX_CARD_ENTRIES: usize = 10_000; +/// Largest integer a JavaScript number holds exactly; upstream rejects anything above it. +const MAX_SAFE_COUNT: u64 = 9_007_199_254_740_991; +const DEFAULT_LINE_ITEM: &str = "API"; +const DEFAULT_MODEL: &str = "Responses and Chat Completions"; + +/// Running per-model totals for one day. `input` and `output` include audio tokens and +/// `cached` is a subset of `input`. +#[derive(Default)] +struct ModelTotals { + requests: u64, + input: u64, + cached: u64, + output: u64, +} + +struct DayAccumulator { + start: i64, + end: i64, + cost: f64, + lines: HashMap, + models: HashMap, +} + +impl DayAccumulator { + fn new(start: i64, end: i64) -> Self { + Self { + start, + end, + cost: 0.0, + lines: HashMap::new(), + models: HashMap::new(), + } + } + + fn finish(self) -> OpenAiApiDailyUsage { + let mut line_items: Vec<_> = self + .lines + .into_iter() + .map(|(name, cost_usd)| OpenAiApiLineItemCost { name, cost_usd }) + .collect(); + line_items.sort_by(|a, b| { + b.cost_usd + .total_cmp(&a.cost_usd) + .then_with(|| a.name.cmp(&b.name)) + }); + let mut models: Vec<_> = self + .models + .into_iter() + .map(|(name, totals)| OpenAiApiModelUsage { + name, + requests: totals.requests, + input_tokens: totals.input, + cached_input_tokens: totals.cached, + output_tokens: totals.output, + total_tokens: totals.input.saturating_add(totals.output), + }) + .collect(); + models.sort_by(|a, b| { + b.total_tokens + .cmp(&a.total_tokens) + .then_with(|| a.name.cmp(&b.name)) + }); + let sum = |field: fn(&OpenAiApiModelUsage) -> u64| { + models + .iter() + .fold(0_u64, |sum, model| sum.saturating_add(field(model))) + }; + OpenAiApiDailyUsage { + start_time: self.start, + end_time: self.end, + cost_usd: self.cost, + requests: sum(|model| model.requests), + input_tokens: sum(|model| model.input_tokens), + cached_input_tokens: sum(|model| model.cached_input_tokens), + output_tokens: sum(|model| model.output_tokens), + total_tokens: sum(|model| model.total_tokens), + line_items, + models, + } + } +} + +/// Group both listings by UTC day. Days that start after `now` are dropped and only the +/// newest `history_days` remain, ascending by start time. +pub(super) fn daily_usage( + costs: &[CostBucket], + completions: &[CompletionsUsageBucket], + now: DateTime, + history_days: u32, +) -> Result, ProviderError> { + let mut days: BTreeMap = BTreeMap::new(); + + for bucket in costs { + let day = days + .entry(bucket.start_time) + .or_insert_with(|| DayAccumulator::new(bucket.start_time, bucket.end_time)); + for result in &bucket.results { + let amount = cost_amount(result)?; + day.cost += amount; + *day.lines + .entry(display_name(result.line_item.as_deref(), DEFAULT_LINE_ITEM)) + .or_default() += amount; + } + } + + for bucket in completions { + let day = days + .entry(bucket.start_time) + .or_insert_with(|| DayAccumulator::new(bucket.start_time, bucket.end_time)); + for result in &bucket.results { + let input = count(result.input_tokens, "input_tokens")?; + let cached = count(result.input_cached_tokens, "input_cached_tokens")?; + let audio_input = count(result.input_audio_tokens, "input_audio_tokens")?; + let output = count(result.output_tokens, "output_tokens")?; + let audio_output = count(result.output_audio_tokens, "output_audio_tokens")?; + let requests = count(result.num_model_requests, "num_model_requests")?; + let model = day + .models + .entry(display_name(result.model.as_deref(), DEFAULT_MODEL)) + .or_default(); + model.requests = model.requests.saturating_add(requests); + model.input = model + .input + .saturating_add(input.saturating_add(audio_input)); + model.cached = model.cached.saturating_add(cached); + model.output = model + .output + .saturating_add(output.saturating_add(audio_output)); + } + } + + let now = now.timestamp(); + let mut daily: Vec<_> = days + .into_values() + .filter(|day| day.start <= now) + .map(DayAccumulator::finish) + .collect(); + let excess = daily.len().saturating_sub(history_days as usize); + daily.drain(..excess); + Ok(daily) +} + +/// The card payload for `daily`, or `None` when it breaks an upstream card bound (a day +/// that does not end after it starts, or more than [`MAX_CARD_ENTRIES`] breakdown rows). +/// The chart is auxiliary, so an out-of-bounds history is dropped and the spend summary +/// still shows, where upstream would fail the whole fetch. +pub(super) fn usage_history( + daily: Vec, + history_days: u32, + project_id: Option<&str>, +) -> Option { + if daily.iter().any(|day| day.end_time <= day.start_time) { + tracing::warn!("Dropping OpenAI API daily history: a bucket does not end after it starts"); + return None; + } + let entries: usize = daily + .iter() + .map(|day| day.line_items.len() + day.models.len()) + .sum(); + if entries > MAX_CARD_ENTRIES { + tracing::warn!(entries, "Dropping OpenAI API daily history: too many rows"); + return None; + } + Some(OpenAiApiUsageHistory { + history_days, + project_id: project_id.map(ToOwned::to_owned), + daily, + }) +} + +/// Upstream `name()`: a trimmed non-empty string, else the fallback. +fn display_name(raw: Option<&str>, fallback: &str) -> String { + raw.map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +/// Upstream `integer(value, field, optional)`: absent is zero; negative or beyond the +/// JavaScript safe-integer range is a parse failure. +fn count(value: Option, field: &str) -> Result { + let Some(value) = value else { return Ok(0) }; + u64::try_from(value) + .ok() + .filter(|value| *value <= MAX_SAFE_COUNT) + .ok_or_else(|| { + ProviderError::Parse(format!( + "OpenAI API completions {field} must be a non-negative integer" + )) + }) +} diff --git a/rust/src/providers/openaiapi/history_tests.rs b/rust/src/providers/openaiapi/history_tests.rs new file mode 100644 index 0000000000..b17a895f7a --- /dev/null +++ b/rust/src/providers/openaiapi/history_tests.rs @@ -0,0 +1,316 @@ +use super::*; +use crate::core::{OpenAiApiDailyUsage, OpenAiApiModelUsage}; + +/// 2023-11-17T00:00:00Z, the fixture clock used by upstream `OpenAIAPIUsageFetcherTests`. +const NOW: i64 = 1_700_179_200; +const DAY: i64 = 86_400; + +fn now() -> DateTime { + Utc.timestamp_opt(NOW, 0).single().unwrap() +} + +/// The costs page from upstream `parses admin costs and completions usage into daily summaries`. +const UPSTREAM_COSTS: &str = r#"{ + "object": "page", + "data": [ + {"object": "bucket", "start_time": 1700000000, "end_time": 1700086400, "results": [ + {"object": "organization.costs.result", "amount": {"value": 12.50, "currency": "usd"}, "line_item": "Text tokens"}, + {"object": "organization.costs.result", "amount": {"value": "2.25", "currency": "usd"}, "line_item": "Web search tool calls"} + ]}, + {"object": "bucket", "start_time": 1700086400, "end_time": 1700172800, "results": [ + {"object": "organization.costs.result", "amount": {"value": 4.00, "currency": "usd"}, "line_item": "Text tokens"} + ]} + ], + "has_more": false, + "next_page": null +}"#; + +/// The completions page from the same upstream test. +const UPSTREAM_COMPLETIONS: &str = r#"{ + "object": "page", + "data": [ + {"object": "bucket", "start_time": 1700000000, "end_time": 1700086400, "results": [ + {"object": "organization.usage.completions.result", "input_tokens": 1000, "input_cached_tokens": 250, "output_tokens": 500, "num_model_requests": 7, "model": "gpt-5.2"}, + {"object": "organization.usage.completions.result", "input_tokens": 300, "output_tokens": 200, "num_model_requests": 3, "model": "gpt-5.2-codex"} + ]}, + {"object": "bucket", "start_time": 1700086400, "end_time": 1700172800, "results": [ + {"object": "organization.usage.completions.result", "input_tokens": 200, "output_tokens": 100, "num_model_requests": 2, "model": "gpt-5.2"} + ]} + ], + "has_more": false, + "next_page": null +}"#; + +fn upstream_costs() -> Vec { + serde_json::from_str::>(UPSTREAM_COSTS) + .unwrap() + .data +} + +fn upstream_completions() -> Vec { + serde_json::from_str::>(UPSTREAM_COMPLETIONS) + .unwrap() + .data +} + +fn completion( + model: Option<&str>, + input: i64, + output: i64, + requests: i64, +) -> CompletionsUsageResult { + CompletionsUsageResult { + model: model.map(str::to_string), + input_tokens: Some(input), + input_cached_tokens: None, + output_tokens: Some(output), + input_audio_tokens: None, + output_audio_tokens: None, + num_model_requests: Some(requests), + } +} + +fn completions_bucket(start: i64, results: Vec) -> CompletionsUsageBucket { + CompletionsUsageBucket { + start_time: start, + end_time: start + DAY, + results, + } +} + +fn cost_bucket_at(start: i64, amount: f64, line_item: Option<&str>) -> CostBucket { + CostBucket { + start_time: start, + end_time: start + DAY, + results: vec![CostResult { + amount: Some(CostAmount { + value: serde_json::json!(amount), + }), + line_item: line_item.map(str::to_string), + }], + } +} + +fn daily(costs: &[CostBucket], completions: &[CompletionsUsageBucket]) -> Vec { + history::daily_usage(costs, completions, now(), HISTORY_DAYS).unwrap() +} + +#[test] +fn upstream_fixture_buckets_into_two_days() { + let daily = daily(&upstream_costs(), &upstream_completions()); + assert_eq!(daily.len(), 2); + + let first = &daily[0]; + assert_eq!( + (first.start_time, first.end_time), + (1_700_000_000, 1_700_086_400) + ); + assert_eq!(first.cost_usd, 14.75); + assert_eq!(first.requests, 10); + assert_eq!(first.input_tokens, 1300); + assert_eq!(first.cached_input_tokens, 250); + assert_eq!(first.output_tokens, 700); + assert_eq!(first.total_tokens, 2000); + let items: Vec<_> = first + .line_items + .iter() + .map(|item| (item.name.as_str(), item.cost_usd)) + .collect(); + assert_eq!( + items, + [("Text tokens", 12.5), ("Web search tool calls", 2.25)] + ); + let models: Vec<_> = first + .models + .iter() + .map(|model| (model.name.as_str(), model.total_tokens)) + .collect(); + assert_eq!(models, [("gpt-5.2", 1500), ("gpt-5.2-codex", 500)]); + + let second = &daily[1]; + assert_eq!(second.cost_usd, 4.0); + assert_eq!(second.requests, 2); + assert_eq!(second.total_tokens, 300); + + // Upstream `last30Days` and `topModels`. + assert_eq!(daily.iter().map(|day| day.cost_usd).sum::(), 18.75); + assert_eq!(daily.iter().map(|day| day.requests).sum::(), 12); + assert_eq!(daily.iter().map(|day| day.total_tokens).sum::(), 2300); +} + +#[test] +fn upstream_fixture_result_carries_history_and_summary() { + let result = result_from_admin_usage( + &upstream_costs(), + &upstream_completions(), + now(), + Some("proj_abc"), + ) + .unwrap(); + let history = result.open_ai_api_usage.as_ref().unwrap(); + assert_eq!(history.history_days, 30); + assert_eq!(history.project_id.as_deref(), Some("proj_abc")); + assert_eq!(history.daily.len(), 2); + assert_eq!(result.cost.as_ref().unwrap().used, 18.75); + assert_eq!(result.cost.as_ref().unwrap().period, "Last 30 days"); +} + +#[test] +fn audio_tokens_join_input_and_output_but_cached_stays_a_subset() { + let completions = [completions_bucket( + NOW - DAY, + vec![CompletionsUsageResult { + model: Some("gpt-audio".to_string()), + input_tokens: Some(1000), + input_cached_tokens: Some(400), + output_tokens: Some(500), + input_audio_tokens: Some(40), + output_audio_tokens: Some(10), + num_model_requests: Some(2), + }], + )]; + let day = &daily(&[], &completions)[0]; + assert_eq!(day.input_tokens, 1040); + assert_eq!(day.cached_input_tokens, 400); + assert_eq!(day.output_tokens, 510); + assert_eq!(day.total_tokens, 1550); + assert_eq!(day.models[0].total_tokens, 1550); + assert!(day.line_items.is_empty()); +} + +#[test] +fn costs_and_completions_for_one_day_share_a_bucket_with_the_first_end_time() { + let costs = [CostBucket { + start_time: NOW - DAY, + end_time: NOW, + results: cost_bucket_at(NOW - DAY, 1.0, Some("Text tokens")).results, + }]; + let completions = [CompletionsUsageBucket { + start_time: NOW - DAY, + end_time: NOW + 1, + results: vec![completion(Some("gpt-5.2"), 10, 5, 1)], + }]; + let daily = daily(&costs, &completions); + assert_eq!(daily.len(), 1); + assert_eq!(daily[0].end_time, NOW); + assert_eq!(daily[0].cost_usd, 1.0); + assert_eq!(daily[0].total_tokens, 15); +} + +#[test] +fn ties_sort_by_name_and_blank_names_fall_back() { + let costs = [CostBucket { + start_time: NOW - DAY, + end_time: NOW, + results: ["b", "a", " ", "c"] + .into_iter() + .map(|name| CostResult { + amount: Some(CostAmount { + value: serde_json::json!(1.0), + }), + line_item: Some(name.to_string()), + }) + .collect(), + }]; + let completions = [completions_bucket( + NOW - DAY, + vec![ + completion(Some("zeta"), 5, 5, 1), + completion(Some(" alpha "), 5, 5, 1), + completion(None, 1, 0, 1), + completion(Some(""), 1, 0, 1), + ], + )]; + let day = &daily(&costs, &completions)[0]; + let items: Vec<_> = day + .line_items + .iter() + .map(|item| item.name.as_str()) + .collect(); + assert_eq!(items, ["API", "a", "b", "c"]); + let models: Vec<_> = day.models.iter().map(|model| model.name.as_str()).collect(); + assert_eq!(models, ["alpha", "zeta", "Responses and Chat Completions"]); + // Both blank-named results merge into the default model. + assert_eq!(day.models[2].requests, 2); +} + +#[test] +fn days_are_sorted_future_days_dropped_and_the_window_trimmed() { + let costs: Vec<_> = (0..4) + .map(|offset| cost_bucket_at(NOW - offset * DAY, 1.0, None)) + .chain([cost_bucket_at(NOW + 1, 9.0, None)]) + .collect(); + let all = history::daily_usage(&costs, &[], now(), 30).unwrap(); + let starts: Vec<_> = all.iter().map(|day| day.start_time).collect(); + assert_eq!(starts, [NOW - 3 * DAY, NOW - 2 * DAY, NOW - DAY, NOW]); + + let recent = history::daily_usage(&costs, &[], now(), 2).unwrap(); + let starts: Vec<_> = recent.iter().map(|day| day.start_time).collect(); + assert_eq!(starts, [NOW - DAY, NOW]); +} + +#[test] +fn invalid_token_counts_are_parse_failures() { + for bad in [-1, 9_007_199_254_740_992] { + let completions = [completions_bucket( + NOW - DAY, + vec![completion(Some("gpt-5.2"), bad, 0, 1)], + )]; + let error = history::daily_usage(&[], &completions, now(), 30).unwrap_err(); + assert!(matches!(error, ProviderError::Parse(_)), "{bad}: {error}"); + } + let mut result = completion(Some("gpt-5.2"), 1, 1, 1); + result.num_model_requests = Some(-3); + let completions = [completions_bucket(NOW - DAY, vec![result])]; + assert!(history::daily_usage(&[], &completions, now(), 30).is_err()); +} + +#[test] +fn history_is_dropped_but_the_summary_kept_for_a_bucket_that_does_not_end_after_it_starts() { + let costs = [CostBucket { + start_time: NOW - DAY, + end_time: NOW - DAY, + results: cost_bucket_at(NOW - DAY, 3.0, None).results, + }]; + let result = result_from_admin_usage(&costs, &[], now(), None).unwrap(); + assert!(result.open_ai_api_usage.is_none()); + assert_eq!(result.cost.unwrap().used, 3.0); +} + +#[test] +fn history_is_dropped_beyond_ten_thousand_breakdown_rows() { + let models = |count: usize| -> Vec { + (0..count) + .map(|index| OpenAiApiModelUsage { + name: format!("model-{index}"), + requests: 1, + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + total_tokens: 2, + }) + .collect() + }; + let day = |start: i64, count: usize| OpenAiApiDailyUsage { + start_time: start, + end_time: start + DAY, + cost_usd: 0.0, + requests: 0, + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + line_items: Vec::new(), + models: models(count), + }; + assert!(history::usage_history(vec![day(0, 5_000), day(DAY, 5_000)], 30, None).is_some()); + assert!(history::usage_history(vec![day(0, 5_000), day(DAY, 5_001)], 30, None).is_none()); +} + +#[test] +fn an_empty_window_still_yields_an_empty_history() { + let result = result_from_admin_usage(&[], &[], now(), None).unwrap(); + let history = result.open_ai_api_usage.unwrap(); + assert!(history.daily.is_empty()); + assert_eq!(history.project_id, None); +} diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index bc92d1ebd8..dda101c00b 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -12,6 +12,9 @@ //! subset of input and is never added on top. //! - Each Admin GET gets one transient retry (see [`RetryPolicy`]). //! +//! The Admin path also returns a per-UTC-day [`crate::core::OpenAiApiUsageHistory`] (upstream's +//! `openAIAPIUsage` card, built in [`history`]) next to the spend summary. +//! //! The history window is fixed at 30 days. Upstream's `OPENAI_HISTORY_DAYS` (1-365) is //! deferred until a Windows setting exists for it; [`usage_ranges`] already takes the day //! count, so honoring it later only needs to pass the setting through. @@ -24,6 +27,8 @@ use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::time::Duration; +mod history; + use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, @@ -73,10 +78,6 @@ struct Page { #[derive(Debug, Deserialize)] struct CostBucket { start_time: i64, - #[allow( - dead_code, - reason = "field present in the OpenAI API billing payload; kept so serde preserves it" - )] end_time: i64, results: Vec, } @@ -95,10 +96,6 @@ struct CostAmount { #[derive(Debug, Deserialize)] struct CompletionsUsageBucket { start_time: i64, - #[allow( - dead_code, - reason = "field present in the OpenAI API billing payload; kept so serde preserves it" - )] end_time: i64, results: Vec, } @@ -107,10 +104,6 @@ struct CompletionsUsageBucket { struct CompletionsUsageResult { model: Option, input_tokens: Option, - #[allow( - dead_code, - reason = "cached input is a subset of input_tokens, so it is tracked but never added to totals" - )] input_cached_tokens: Option, output_tokens: Option, input_audio_tokens: Option, @@ -118,21 +111,6 @@ struct CompletionsUsageResult { num_model_requests: Option, } -impl CompletionsUsageResult { - /// Upstream `tokens = input + input_audio + output + output_audio`. - fn total_tokens(&self) -> i64 { - [ - self.input_tokens, - self.input_audio_tokens, - self.output_tokens, - self.output_audio_tokens, - ] - .into_iter() - .map(|tokens| tokens.unwrap_or(0)) - .sum() - } -} - /// One `start_time..end_time` request window of at most [`MAX_BUCKETS_PER_REQUEST`] days. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct UsageRange { @@ -503,41 +481,25 @@ fn result_from_admin_usage( now: DateTime, project_id: Option<&str>, ) -> Result { - let mut cost_total = 0.0; - let mut line_item_costs: HashMap = HashMap::new(); - for result in costs.iter().flat_map(|bucket| &bucket.results) { - let amount = cost_amount(result)?; - cost_total += amount; - let line_item = result - .line_item - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("API"); - *line_item_costs.entry(line_item.to_string()).or_default() += amount; - } - - let mut request_total: i64 = 0; - let mut token_total: i64 = 0; - let mut model_tokens: HashMap = HashMap::new(); - for result in completions.iter().flat_map(|bucket| &bucket.results) { - let tokens = result.total_tokens(); - request_total += result.num_model_requests.unwrap_or(0); - token_total += tokens; - let model = result - .model - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("Responses and Chat Completions"); - *model_tokens.entry(model.to_string()).or_default() += tokens; + let daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?; + + let cost_total: f64 = daily.iter().map(|day| day.cost_usd).sum(); + let request_total: u64 = daily.iter().map(|day| day.requests).sum(); + let token_total: u64 = daily.iter().map(|day| day.total_tokens).sum(); + let mut model_tokens: HashMap<&str, u64> = HashMap::new(); + let mut line_item_costs: HashMap<&str, f64> = HashMap::new(); + for day in &daily { + for model in &day.models { + *model_tokens.entry(&model.name).or_default() += model.total_tokens; + } + for item in &day.line_items { + *line_item_costs.entry(&item.name).or_default() += item.cost_usd; + } } - - let first_bucket = costs + let start = daily .first() - .map(|b| b.start_time) - .or_else(|| completions.first().map(|b| b.start_time)); - let start = first_bucket.and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + .and_then(|day| Utc.timestamp_opt(day.start_time, 0).single()); + let project_id = project_id.filter(|id| !id.is_empty()); let mut usage = UsageSnapshot::new(RateWindow::with_details( 0.0, @@ -557,17 +519,16 @@ fn result_from_admin_usage( ) .with_login_method( project_id - .filter(|id| !id.is_empty()) .map(|id| format!("Admin API: {id}")) .unwrap_or_else(|| "Admin API".to_string()), ); - if let Some(project_id) = project_id.filter(|id| !id.is_empty()) { + if let Some(project_id) = project_id { usage = usage.with_organization(format!("Project: {project_id}")); } usage.updated_at = now; let mut top_models: Vec<_> = model_tokens.into_iter().collect(); - top_models.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + top_models.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); for (idx, (model, tokens)) in top_models.into_iter().take(3).enumerate() { usage = usage.with_extra_rate_window( format!("model-{idx}"), @@ -577,7 +538,7 @@ fn result_from_admin_usage( } let mut top_items: Vec<_> = line_item_costs.into_iter().collect(); - top_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + top_items.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0))); for (idx, (item, amount)) in top_items.into_iter().take(3).enumerate() { usage = usage.with_extra_rate_window( format!("line-item-{idx}"), @@ -586,13 +547,15 @@ fn result_from_admin_usage( ); } - Ok( - ProviderFetchResult::new(usage, "admin-api").with_cost(CostSnapshot::new( - cost_total, - "USD", - format!("Last {HISTORY_DAYS} days"), - )), - ) + let mut result = ProviderFetchResult::new(usage, "admin-api").with_cost(CostSnapshot::new( + cost_total, + "USD", + format!("Last {HISTORY_DAYS} days"), + )); + if let Some(history) = history::usage_history(daily, HISTORY_DAYS, project_id) { + result = result.with_open_ai_api_usage(history); + } + Ok(result) } /// A missing, null or blank amount counts as zero; a present but non-numeric or @@ -779,5 +742,7 @@ fn resolve_api_key( )] fn _assert_datetime_send(_: DateTime) {} +#[cfg(test)] +mod history_tests; #[cfg(test)] mod tests; diff --git a/rust/src/providers/openaiapi/tests.rs b/rust/src/providers/openaiapi/tests.rs index 37493fef4b..f6b4c4cad6 100644 --- a/rust/src/providers/openaiapi/tests.rs +++ b/rust/src/providers/openaiapi/tests.rs @@ -399,6 +399,52 @@ async fn openai_admin_usage_filters_costs_and_completions_by_project() { ); } +#[tokio::test] +async fn openai_admin_usage_returns_per_day_history_from_the_wire_pages() { + let mut server = Server::new_async().await; + let day = 1_700_000_000; + let costs_page = format!( + r#"{{"object":"page","has_more":false,"next_page":null,"data":[ + {{"object":"bucket","start_time":{day},"end_time":{end},"results":[ + {{"object":"organization.costs.result","amount":{{"value":"2.50","currency":"usd"}},"line_item":"Text tokens"}}]}}]}}"#, + end = day + 86_400 + ); + let completions_page = format!( + r#"{{"object":"page","has_more":false,"next_page":null,"data":[ + {{"object":"bucket","start_time":{day},"end_time":{end},"results":[ + {{"object":"organization.usage.completions.result","input_tokens":100,"input_cached_tokens":40,"output_tokens":50,"input_audio_tokens":null,"num_model_requests":4,"model":"gpt-5.2"}}]}}]}}"#, + end = day + 86_400 + ); + let _costs = mock_page(&mut server, COSTS_PATH, Matcher::Any, &costs_page).await; + let _completions = mock_page( + &mut server, + COMPLETIONS_PATH, + Matcher::Any, + &completions_page, + ) + .await; + + let result = provider(&server) + .fetch_admin_usage("sk-test", Some("proj_abc"), fixed_now(3_600)) + .await + .unwrap(); + + let history = result + .open_ai_api_usage + .expect("Admin path returns history"); + assert_eq!(history.history_days, 30); + assert_eq!(history.project_id.as_deref(), Some("proj_abc")); + assert_eq!(history.daily.len(), 1); + let bucket = &history.daily[0]; + assert_eq!((bucket.start_time, bucket.end_time), (day, day + 86_400)); + assert_eq!(bucket.cost_usd, 2.5); + assert_eq!(bucket.requests, 4); + assert_eq!(bucket.cached_input_tokens, 40); + assert_eq!(bucket.total_tokens, 150); + assert_eq!(bucket.line_items[0].name, "Text tokens"); + assert_eq!(bucket.models[0].name, "gpt-5.2"); +} + #[tokio::test] async fn openai_admin_usage_pages_each_range_of_a_long_history() { let mut server = Server::new_async().await; @@ -706,6 +752,8 @@ async fn openai_unscoped_key_falls_back_to_balance_on_admin_auth_failure() { result.usage.login_method.as_deref(), Some("API balance: $75.00") ); + // The balance endpoint has no per-day data, so there is no chart history. + assert!(result.open_ai_api_usage.is_none()); } #[tokio::test] From 5d993548c1411e60d2579a167d02e9ffec24b52f Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:02:15 +0700 Subject: [PATCH 2/3] Address thermo review --- rust/src/core/usage_snapshot.rs | 24 +++- rust/src/providers/openaiapi/history.rs | 109 +++++++++++++----- rust/src/providers/openaiapi/history_tests.rs | 18 +++ rust/src/providers/openaiapi/mod.rs | 8 +- 4 files changed, 124 insertions(+), 35 deletions(-) diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index e0eb197a4d..a8db374133 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -666,8 +666,9 @@ pub struct ProviderFetchResult { pub wayfinder_usage: Option, /// Per-day OpenAI Admin API history for the daily usage chart. Set only by the - /// Admin usage path; the balance fallback has no per-day data. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Admin usage path; the balance fallback has no per-day data. It is projected + /// through the frontend bridge and intentionally excluded from core serialization. + #[serde(skip)] pub open_ai_api_usage: Option, /// Transient non-quota inventory for provider-specific display. @@ -803,6 +804,25 @@ mod tests { assert!(decoded.inventory.is_empty()); } + #[test] + fn openai_api_history_is_transient_and_not_serialized() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + let result = ProviderFetchResult::new(usage, "admin-api").with_open_ai_api_usage( + OpenAiApiUsageHistory { + history_days: 30, + project_id: Some("proj_abc".to_string()), + daily: Vec::new(), + }, + ); + + assert!(result.open_ai_api_usage.is_some()); + let encoded = serde_json::to_value(&result).unwrap(); + assert!(encoded.get("open_ai_api_usage").is_none()); + + let decoded: ProviderFetchResult = serde_json::from_value(encoded).unwrap(); + assert!(decoded.open_ai_api_usage.is_none()); + } + #[test] fn display_details_reject_invalid_shapes_and_duplicate_ids() { let usage = UsageSnapshot::new(RateWindow::new(25.0)); diff --git a/rust/src/providers/openaiapi/history.rs b/rust/src/providers/openaiapi/history.rs index d09dc9358b..b6d28a5910 100644 --- a/rust/src/providers/openaiapi/history.rs +++ b/rust/src/providers/openaiapi/history.rs @@ -49,7 +49,7 @@ impl DayAccumulator { } } - fn finish(self) -> OpenAiApiDailyUsage { + fn finish(self) -> Result { let mut line_items: Vec<_> = self .lines .into_iter() @@ -63,37 +63,46 @@ impl DayAccumulator { let mut models: Vec<_> = self .models .into_iter() - .map(|(name, totals)| OpenAiApiModelUsage { - name, - requests: totals.requests, - input_tokens: totals.input, - cached_input_tokens: totals.cached, - output_tokens: totals.output, - total_tokens: totals.input.saturating_add(totals.output), + .map(|(name, totals)| { + Ok(OpenAiApiModelUsage { + name, + requests: totals.requests, + input_tokens: totals.input, + cached_input_tokens: totals.cached, + output_tokens: totals.output, + total_tokens: checked_count_sum( + totals.input, + totals.output, + "model total_tokens", + )?, + }) }) - .collect(); + .collect::, ProviderError>>()?; models.sort_by(|a, b| { b.total_tokens .cmp(&a.total_tokens) .then_with(|| a.name.cmp(&b.name)) }); - let sum = |field: fn(&OpenAiApiModelUsage) -> u64| { - models - .iter() - .fold(0_u64, |sum, model| sum.saturating_add(field(model))) + let sum = |field: fn(&OpenAiApiModelUsage) -> u64, name: &str| { + models.iter().try_fold(0_u64, |sum, model| { + checked_count_sum(sum, field(model), name) + }) }; - OpenAiApiDailyUsage { + Ok(OpenAiApiDailyUsage { start_time: self.start, end_time: self.end, cost_usd: self.cost, - requests: sum(|model| model.requests), - input_tokens: sum(|model| model.input_tokens), - cached_input_tokens: sum(|model| model.cached_input_tokens), - output_tokens: sum(|model| model.output_tokens), - total_tokens: sum(|model| model.total_tokens), + requests: sum(|model| model.requests, "daily requests")?, + input_tokens: sum(|model| model.input_tokens, "daily input_tokens")?, + cached_input_tokens: sum( + |model| model.cached_input_tokens, + "daily cached_input_tokens", + )?, + output_tokens: sum(|model| model.output_tokens, "daily output_tokens")?, + total_tokens: sum(|model| model.total_tokens, "daily total_tokens")?, line_items, models, - } + }) } } @@ -135,14 +144,18 @@ pub(super) fn daily_usage( .models .entry(display_name(result.model.as_deref(), DEFAULT_MODEL)) .or_default(); - model.requests = model.requests.saturating_add(requests); - model.input = model - .input - .saturating_add(input.saturating_add(audio_input)); - model.cached = model.cached.saturating_add(cached); - model.output = model - .output - .saturating_add(output.saturating_add(audio_output)); + model.requests = checked_count_sum(model.requests, requests, "model requests")?; + model.input = checked_count_sum( + model.input, + checked_count_sum(input, audio_input, "input_tokens")?, + "model input_tokens", + )?; + model.cached = checked_count_sum(model.cached, cached, "model cached_input_tokens")?; + model.output = checked_count_sum( + model.output, + checked_count_sum(output, audio_output, "output_tokens")?, + "model output_tokens", + )?; } } @@ -151,7 +164,7 @@ pub(super) fn daily_usage( .into_values() .filter(|day| day.start <= now) .map(DayAccumulator::finish) - .collect(); + .collect::>()?; let excess = daily.len().saturating_sub(history_days as usize); daily.drain(..excess); Ok(daily) @@ -170,6 +183,12 @@ pub(super) fn usage_history( tracing::warn!("Dropping OpenAI API daily history: a bucket does not end after it starts"); return None; } + if daily.iter().any(|day| !counts_fit_js_number(day)) { + tracing::warn!( + "Dropping OpenAI API daily history: an aggregate count exceeds the JavaScript safe-integer range" + ); + return None; + } let entries: usize = daily .iter() .map(|day| day.line_items.len() + day.models.len()) @@ -185,6 +204,38 @@ pub(super) fn usage_history( }) } +fn counts_fit_js_number(day: &OpenAiApiDailyUsage) -> bool { + let safe = |count| count <= MAX_SAFE_COUNT; + [ + day.requests, + day.input_tokens, + day.cached_input_tokens, + day.output_tokens, + day.total_tokens, + ] + .into_iter() + .all(safe) + && day.models.iter().all(|model| { + [ + model.requests, + model.input_tokens, + model.cached_input_tokens, + model.output_tokens, + model.total_tokens, + ] + .into_iter() + .all(safe) + }) +} + +fn checked_count_sum(left: u64, right: u64, field: &str) -> Result { + left.checked_add(right).ok_or_else(|| { + ProviderError::Parse(format!( + "OpenAI API completions {field} total exceeds the supported integer range" + )) + }) +} + /// Upstream `name()`: a trimmed non-empty string, else the fallback. fn display_name(raw: Option<&str>, fallback: &str) -> String { raw.map(str::trim) diff --git a/rust/src/providers/openaiapi/history_tests.rs b/rust/src/providers/openaiapi/history_tests.rs index b17a895f7a..d037d252c7 100644 --- a/rust/src/providers/openaiapi/history_tests.rs +++ b/rust/src/providers/openaiapi/history_tests.rs @@ -277,6 +277,24 @@ fn history_is_dropped_but_the_summary_kept_for_a_bucket_that_does_not_end_after_ assert_eq!(result.cost.unwrap().used, 3.0); } +#[test] +fn history_is_dropped_when_aggregated_counts_exceed_javascript_integer_precision() { + let max_safe_count = 9_007_199_254_740_991; + let costs = [cost_bucket_at(NOW - DAY, 3.0, None)]; + let completions = [completions_bucket( + NOW - DAY, + vec![ + completion(Some("gpt-5.2"), max_safe_count, 0, 0), + completion(Some("gpt-5.2"), max_safe_count, 0, 0), + ], + )]; + + let result = result_from_admin_usage(&costs, &completions, now(), None).unwrap(); + + assert!(result.open_ai_api_usage.is_none()); + assert_eq!(result.cost.unwrap().used, 3.0); +} + #[test] fn history_is_dropped_beyond_ten_thousand_breakdown_rows() { let models = |count: usize| -> Vec { diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index dda101c00b..4d73b4dada 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -484,13 +484,13 @@ fn result_from_admin_usage( let daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?; let cost_total: f64 = daily.iter().map(|day| day.cost_usd).sum(); - let request_total: u64 = daily.iter().map(|day| day.requests).sum(); - let token_total: u64 = daily.iter().map(|day| day.total_tokens).sum(); - let mut model_tokens: HashMap<&str, u64> = HashMap::new(); + let request_total: u128 = daily.iter().map(|day| u128::from(day.requests)).sum(); + let token_total: u128 = daily.iter().map(|day| u128::from(day.total_tokens)).sum(); + let mut model_tokens: HashMap<&str, u128> = HashMap::new(); let mut line_item_costs: HashMap<&str, f64> = HashMap::new(); for day in &daily { for model in &day.models { - *model_tokens.entry(&model.name).or_default() += model.total_tokens; + *model_tokens.entry(&model.name).or_default() += u128::from(model.total_tokens); } for item in &day.line_items { *line_item_costs.entry(&item.name).or_default() += item.cost_usd; From f328c66eee6bf38c67eb6a1bc0251af76a0f30ed Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:28:12 +0700 Subject: [PATCH 3/3] Keep Codex cost fixtures on the local day Codex session fixtures in the cost scanner tests stamped their events at now - 1h but filed the session under today's local date folder. In the first hour after local midnight (00:00-01:00Z on the UTC CircleCI runner) that hour fell on yesterday, so the tests read the wrong day bucket, and the fork fixtures landed in two different date folders, which flipped the folder scan order. CircleCI builds 977, 978 and 979 failed this way: codex_source_recovery_keeps_appended_duplicate_unpriced_after_cache_reload, paginated_continuation_raises_inherited_baseline_from_total_last and paginated_history_base_equal_parent_keeps_true_fork_subtraction. A shared helper now returns now - 1h, or the start of the local day when that hour reaches back into yesterday, and the fixtures take both the event time and the date folder from it. A unit test pins the helper at fixed times around midnight in UTC and UTC+7. Production code is unchanged. --- rust/src/cost_scanner/tests.rs | 66 ++++++++++++++++++++---- rust/src/cost_scanner/tests/paginated.rs | 4 +- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 3b16c92a1c..b1cc1373b7 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::core::{CodexSessionLineage, CostUsagePricing}; +use chrono::{FixedOffset, NaiveTime, TimeZone}; use std::io::Write; #[test] @@ -671,17 +672,59 @@ fn claude_scan_counts_final_incomplete_jsonl_line() { let _removed = std::fs::remove_file(&path); } +/// An event time for a fresh Codex session fixture: an hour ago, kept on today's local date. +/// +/// The scanner files each event under its local date, and the session fixtures live in today's +/// date folder. A plain `now - 1h` lands on yesterday in the first hour after local midnight +/// (00:00-01:00Z on the UTC CI runner), so tests that read today's bucket or rely on the day +/// folder scan order failed in that hour. +fn recent_codex_fixture_time() -> DateTime { + recent_fixture_time_at(Local::now()) +} + +/// `now - 1h`, or the start of `now`'s local day when that hour reaches back into yesterday. +fn recent_fixture_time_at(now: DateTime) -> DateTime { + let hour_ago = now.clone() - Duration::hours(1); + if hour_ago.date_naive() == now.date_naive() { + return hour_ago.with_timezone(&Utc); + } + now.timezone() + .from_local_datetime(&now.date_naive().and_time(NaiveTime::MIN)) + .earliest() + .unwrap_or(now) + .with_timezone(&Utc) +} + +#[test] +fn recent_codex_fixture_time_stays_on_the_local_day() { + let utc_plus_7 = FixedOffset::east_opt(7 * 3600).unwrap(); + let at = |hour, minute| { + utc_plus_7 + .with_ymd_and_hms(2026, 10, 1, hour, minute, 0) + .unwrap() + }; + assert_eq!(recent_fixture_time_at(at(8, 30)), at(7, 30)); + assert_eq!(recent_fixture_time_at(at(1, 0)), at(0, 0)); + assert_eq!(recent_fixture_time_at(at(0, 40)), at(0, 0)); + assert_eq!(recent_fixture_time_at(at(0, 0)), at(0, 0)); + + let ci_run = Utc.with_ymd_and_hms(2026, 10, 1, 0, 45, 0).unwrap(); + assert_eq!( + recent_fixture_time_at(ci_run), + Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap() + ); +} + fn write_codex_session_fixture(sessions_root: &Path, name: &str, input_tokens: u64) -> PathBuf { - let today = Local::now().date_naive(); + let event_time = recent_codex_fixture_time(); + let today = event_time.with_timezone(&Local).date_naive(); let day_dir = sessions_root .join(today.format("%Y").to_string()) .join(today.format("%m").to_string()) .join(today.format("%d").to_string()); std::fs::create_dir_all(&day_dir).unwrap(); let path = day_dir.join(name); - let ts = (Utc::now() - Duration::hours(1)) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); + let ts = event_time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); let body = format!( r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":{input_tokens},"cached_input_tokens":0,"output_tokens":5}}}}}}}} "# @@ -695,13 +738,13 @@ fn write_codex_session_fixture_with_inputs( name: &str, input_tokens: &[u64], ) -> PathBuf { - let today = Local::now().date_naive(); + let base = recent_codex_fixture_time(); + let today = base.with_timezone(&Local).date_naive(); let day_dir = sessions_root .join(today.format("%Y").to_string()) .join(today.format("%m").to_string()) .join(today.format("%d").to_string()); std::fs::create_dir_all(&day_dir).unwrap(); - let base = Utc::now() - Duration::hours(1); let mut body = String::new(); for (index, input) in input_tokens.iter().enumerate() { let timestamp = (base @@ -1565,9 +1608,9 @@ fn codex_source_recovery_keeps_appended_duplicate_unpriced_after_cache_reload() first_cache.last_scan_unix_ms = 1; JsonlScanner::save_cache(ProviderId::Codex, &mut first_cache, Some(&cache_root)); - let timestamp = (Utc::now() - Duration::minutes(30)) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); + // Half an hour after the fixture's row, so both rows share one local day. + let appended_time = recent_codex_fixture_time() + Duration::minutes(30); + let timestamp = appended_time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); let appended = format!( r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":200,"cached_input_tokens":0,"output_tokens":10}}}}}}}}"# ) + "\n"; @@ -1580,7 +1623,10 @@ fn codex_source_recovery_keeps_appended_duplicate_unpriced_after_cache_reload() let (_, _, second_cache) = scanner.scan_codex_detailed_with_cache(None); let usage = second_cache.files.get(&path_key).expect("file cache"); - let day = Local::now().format("%Y-%m-%d").to_string(); + let day = appended_time + .with_timezone(&Local) + .format("%Y-%m-%d") + .to_string(); assert_eq!(usage.days[&day]["gpt-5-priority"], vec![100, 0, 5]); assert_eq!( usage.days[&day][CostUsagePricing::CODEX_UNATTRIBUTED_MODEL], diff --git a/rust/src/cost_scanner/tests/paginated.rs b/rust/src/cost_scanner/tests/paginated.rs index 592d484823..1853bf4ab4 100644 --- a/rust/src/cost_scanner/tests/paginated.rs +++ b/rust/src/cost_scanner/tests/paginated.rs @@ -140,7 +140,7 @@ fn paginated_continuation_raises_inherited_baseline_from_total_last() { let root = tempfile::tempdir().unwrap(); let sessions = root.path().join("sessions"); let cache_root = root.path().join("cache"); - let base = Utc::now() - Duration::hours(1); + let base = recent_codex_fixture_time(); write_codex_fork_session_fixture( &sessions, "ancestor.jsonl", @@ -201,7 +201,7 @@ fn paginated_history_base_equal_parent_keeps_true_fork_subtraction() { let root = tempfile::tempdir().unwrap(); let sessions = root.path().join("sessions"); let cache_root = root.path().join("cache"); - let base = Utc::now() - Duration::hours(1); + let base = recent_codex_fixture_time(); write_codex_fork_session_fixture( &sessions, "parent.jsonl",