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/2] 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/2] 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;