diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 2edf24acd4..1a85da249b 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1,3 +1,7 @@ +pub(crate) mod pace; +mod status; +pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; + use super::*; // ── Bridge snapshot types ──────────────────────────────────────────── @@ -254,19 +258,6 @@ pub(crate) fn filter_hidden_codex_spark_rows( } } -pub(crate) fn pace_stage_str(stage: codexbar::core::PaceStage) -> &'static str { - use codexbar::core::PaceStage; - match stage { - PaceStage::OnTrack => "on_track", - PaceStage::SlightlyAhead => "slightly_ahead", - PaceStage::Ahead => "ahead", - PaceStage::FarAhead => "far_ahead", - PaceStage::SlightlyBehind => "slightly_behind", - PaceStage::Behind => "behind", - PaceStage::FarBehind => "far_behind", - } -} - impl ProviderUsageSnapshot { pub(super) fn from_fetch_result( id: ProviderId, @@ -275,6 +266,7 @@ impl ProviderUsageSnapshot { token_account_id: Option, ) -> Self { let usage = &result.usage; + let allows_pace = result.pace_authoritative; // A missing session is represented by an informational primary so the // weekly lane keeps its canonical role. Use that weekly lane for the @@ -284,11 +276,14 @@ impl ProviderUsageSnapshot { } else { Some(&usage.primary) }; - let primary_pace = primary_pace_window - .and_then(|window| codexbar::core::UsagePace::weekly(window, None, 10080)); + let primary_pace = allows_pace.then(|| { + primary_pace_window + .and_then(|window| codexbar::core::UsagePace::weekly(window, None, 10080)) + }); + let primary_pace = primary_pace.flatten(); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { - stage: pace_stage_str(p.stage).to_string(), + stage: pace::stage_str(p.stage).to_string(), delta_percent: p.delta_percent, will_last_to_reset: p.will_last_to_reset, eta_seconds: p.eta_seconds, @@ -297,10 +292,13 @@ impl ProviderUsageSnapshot { }); // Compute pace for secondary window (weekly) to derive reserve info - let secondary_pace = usage - .secondary - .as_ref() - .and_then(|sw| codexbar::core::UsagePace::weekly(sw, None, 10080)); + let secondary_pace = allows_pace.then(|| { + usage + .secondary + .as_ref() + .and_then(|sw| codexbar::core::UsagePace::weekly(sw, None, 10080)) + }); + let secondary_pace = secondary_pace.flatten(); let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary); @@ -521,135 +519,6 @@ fn session_equivalent_forecast_for( }) } -/// Build a compact tray status label from a raw snapshot using the current language. -/// Localization is done at render time so cached snapshots stay language-neutral. -pub(crate) fn compact_tray_status_label( - window: &RateWindowSnapshot, - lang: codexbar::settings::Language, -) -> String { - if window.is_informational { - return window - .reset_description - .clone() - .unwrap_or_else(|| "Unavailable".to_string()); - } - - let pct = format!("{:.0}%", window.used_percent); - if let Some(reset) = compact_reset_description(window, lang) { - format!("{pct} • {reset}") - } else { - pct - } -} - -fn compact_reset_description( - window: &RateWindowSnapshot, - lang: codexbar::settings::Language, -) -> Option { - if let Some(ref resets_at) = window.resets_at { - let dt = chrono::DateTime::parse_from_rfc3339(resets_at) - .ok() - .map(|dt| dt.with_timezone(&chrono::Utc))?; - return Some(format_compact_reset_countdown(dt, lang)); - } - - window - .reset_description - .as_deref() - .map(|desc| normalize_reset_description(desc, lang)) - .filter(|desc| !desc.is_empty()) -} - -fn format_compact_reset_countdown( - resets_at: chrono::DateTime, - lang: codexbar::settings::Language, -) -> String { - let now = chrono::Utc::now(); - if resets_at <= now { - return locale::get_text(lang, locale::LocaleKey::ResetInProgress); - } - - let total_minutes = (resets_at - now).num_minutes().max(0); - let days = total_minutes / 1440; - let hours = (total_minutes % 1440) / 60; - let minutes = total_minutes % 60; - - if days > 0 { - locale::format_locale( - lang, - locale::LocaleKey::ResetsInDaysHours, - &[&days.to_string(), &hours.to_string()], - ) - } else { - locale::format_locale( - lang, - locale::LocaleKey::ResetsInHoursMinutes, - &[&hours.to_string(), &format!("{minutes:02}")], - ) - } -} - -fn normalize_reset_description(desc: &str, lang: codexbar::settings::Language) -> String { - let trimmed = desc.trim(); - let lower = trimmed.to_ascii_lowercase(); - let prefix_len = ["resets in ", "reset in ", "in "] - .iter() - .find(|&&p| lower.starts_with(p)) - .map(|p| p.len()) - .unwrap_or(0); - let body = trimmed[prefix_len..].trim_start(); - format!( - "{} {body}", - locale::get_text(lang, locale::LocaleKey::ResetsInShort) - ) -} - -pub(crate) fn friendly_provider_error(id: ProviderId, error: &str) -> String { - if id != ProviderId::Claude { - return error.to_string(); - } - - let trimmed = error.trim(); - let lower = trimmed.to_lowercase(); - - if lower.contains("swift.cancellationerror") - || lower.contains("the operation couldn't be completed") - || lower.contains("the operation could not be completed") - { - return "Claude usage fetch was cancelled before usage data was returned. Refresh Claude, or re-authenticate with Claude Code and try again.".to_string(); - } - - if lower.contains("claude oauth credentials not found") { - return "Claude sign-in was not found. Run `claude` once to authenticate, then refresh Claude in Win-CodexBar.".to_string(); - } - - if lower.contains("oauth token expired") || lower.contains("token invalid or expired") { - return "Claude sign-in expired. Run `claude` to refresh your Claude Code login, then refresh Claude in Win-CodexBar.".to_string(); - } - - if trimmed == "Authentication required" { - return "Claude needs sign-in before Win-CodexBar can read usage. Run `claude` once, or add Claude cookies in Provider settings.".to_string(); - } - - if lower.starts_with("claude usage failed from all configured sources.") { - return trimmed - .replace( - "OAuth: OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", - "OAuth: sign-in not found", - ) - .replace( - "Web: No cookies available for web API", - "Web: no Claude cookies available", - ) - .replace( - "CLI: Provider not installed:", - "CLI: not installed:", - ); - } - - trimmed.to_string() -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BootstrapState { diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs new file mode 100644 index 0000000000..7930c36293 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -0,0 +1,12 @@ +pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { + use codexbar::core::PaceStage; + match stage { + PaceStage::OnTrack => "on_track", + PaceStage::SlightlyAhead => "slightly_ahead", + PaceStage::Ahead => "ahead", + PaceStage::FarAhead => "far_ahead", + PaceStage::SlightlyBehind => "slightly_behind", + PaceStage::Behind => "behind", + PaceStage::FarBehind => "far_behind", + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs new file mode 100644 index 0000000000..6498eec07d --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs @@ -0,0 +1,130 @@ +use super::*; + +/// Build a compact tray status label from a raw snapshot using the current language. +/// Localization is done at render time so cached snapshots stay language-neutral. +pub(crate) fn compact_tray_status_label( + window: &RateWindowSnapshot, + lang: codexbar::settings::Language, +) -> String { + if window.is_informational { + return window + .reset_description + .clone() + .unwrap_or_else(|| "Unavailable".to_string()); + } + + let pct = format!("{:.0}%", window.used_percent); + if let Some(reset) = compact_reset_description(window, lang) { + format!("{pct} • {reset}") + } else { + pct + } +} + +fn compact_reset_description( + window: &RateWindowSnapshot, + lang: codexbar::settings::Language, +) -> Option { + if let Some(ref resets_at) = window.resets_at { + let dt = chrono::DateTime::parse_from_rfc3339(resets_at) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc))?; + return Some(format_compact_reset_countdown(dt, lang)); + } + + window + .reset_description + .as_deref() + .map(|desc| normalize_reset_description(desc, lang)) + .filter(|desc| !desc.is_empty()) +} + +fn format_compact_reset_countdown( + resets_at: chrono::DateTime, + lang: codexbar::settings::Language, +) -> String { + let now = chrono::Utc::now(); + if resets_at <= now { + return locale::get_text(lang, locale::LocaleKey::ResetInProgress); + } + + let total_minutes = (resets_at - now).num_minutes().max(0); + let days = total_minutes / 1440; + let hours = (total_minutes % 1440) / 60; + let minutes = total_minutes % 60; + + if days > 0 { + locale::format_locale( + lang, + locale::LocaleKey::ResetsInDaysHours, + &[&days.to_string(), &hours.to_string()], + ) + } else { + locale::format_locale( + lang, + locale::LocaleKey::ResetsInHoursMinutes, + &[&hours.to_string(), &format!("{minutes:02}")], + ) + } +} + +fn normalize_reset_description(desc: &str, lang: codexbar::settings::Language) -> String { + let trimmed = desc.trim(); + let lower = trimmed.to_ascii_lowercase(); + let prefix_len = ["resets in ", "reset in ", "in "] + .iter() + .find(|&&p| lower.starts_with(p)) + .map(|p| p.len()) + .unwrap_or(0); + let body = trimmed[prefix_len..].trim_start(); + format!( + "{} {body}", + locale::get_text(lang, locale::LocaleKey::ResetsInShort) + ) +} + +pub(crate) fn friendly_provider_error(id: ProviderId, error: &str) -> String { + if id != ProviderId::Claude { + return error.to_string(); + } + + let trimmed = error.trim(); + let lower = trimmed.to_lowercase(); + + if lower.contains("swift.cancellationerror") + || lower.contains("the operation couldn't be completed") + || lower.contains("the operation could not be completed") + { + return "Claude usage fetch was cancelled before usage data was returned. Refresh Claude, or re-authenticate with Claude Code and try again.".to_string(); + } + + if lower.contains("claude oauth credentials not found") { + return "Claude sign-in was not found. Run `claude` once to authenticate, then refresh Claude in Win-CodexBar.".to_string(); + } + + if lower.contains("oauth token expired") || lower.contains("token invalid or expired") { + return "Claude sign-in expired. Run `claude` to refresh your Claude Code login, then refresh Claude in Win-CodexBar.".to_string(); + } + + if trimmed == "Authentication required" { + return "Claude needs sign-in before Win-CodexBar can read usage. Run `claude` once, or add Claude cookies in Provider settings.".to_string(); + } + + if lower.starts_with("claude usage failed from all configured sources.") { + return trimmed + .replace( + "OAuth: OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", + "OAuth: sign-in not found", + ) + .replace( + "Web: No cookies available for web API", + "Web: no Claude cookies available", + ) + .replace( + "CLI: Provider not installed:", + "CLI: not installed:", + ); + } + + trimmed.to_string() +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 403256e4c3..e2223913b5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -214,6 +214,16 @@ fn dashboard_url_for_provider(provider_id: &str) -> Option { ); } + // OpenRouter's Usage Dashboard is the Activity page. Resolve it from the + // provider metadata before the legacy API-key catalog entry, which still + // points at the credits settings page. + if provider_id == ProviderId::OpenRouter.cli_name() { + return instantiate_provider(ProviderId::OpenRouter) + .metadata() + .dashboard_url + .map(|s| s.to_string()); + } + if let Some(url) = codexbar::settings::get_api_key_providers() .into_iter() .find(|p| p.id.cli_name() == provider_id) @@ -381,4 +391,12 @@ mod tests { Some("https://chatgpt.com/codex/settings/usage") ); } + + #[test] + fn dashboard_url_resolves_openrouter_activity() { + assert_eq!( + dashboard_url_for_provider("openrouter").as_deref(), + Some("https://openrouter.ai/activity") + ); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index e520f325ee..e6e27b3eeb 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -771,18 +771,77 @@ fn provider_detail_roundtrips_through_serde() { #[test] fn pace_stage_serializes_to_snake_case_string() { use codexbar::core::PaceStage; - assert_eq!(super::pace_stage_str(PaceStage::OnTrack), "on_track"); assert_eq!( - super::pace_stage_str(PaceStage::SlightlyAhead), + super::bridge::pace::stage_str(PaceStage::OnTrack), + "on_track" + ); + assert_eq!( + super::bridge::pace::stage_str(PaceStage::SlightlyAhead), "slightly_ahead" ); - assert_eq!(super::pace_stage_str(PaceStage::FarAhead), "far_ahead"); assert_eq!( - super::pace_stage_str(PaceStage::SlightlyBehind), + super::bridge::pace::stage_str(PaceStage::FarAhead), + "far_ahead" + ); + assert_eq!( + super::bridge::pace::stage_str(PaceStage::SlightlyBehind), "slightly_behind" ); - assert_eq!(super::pace_stage_str(PaceStage::Behind), "behind"); - assert_eq!(super::pace_stage_str(PaceStage::FarBehind), "far_behind"); + assert_eq!(super::bridge::pace::stage_str(PaceStage::Behind), "behind"); + assert_eq!( + super::bridge::pace::stage_str(PaceStage::FarBehind), + "far_behind" + ); +} + +#[test] +fn local_opencodego_estimates_keep_quota_windows_but_drop_derived_pace() { + let now = chrono::Utc::now(); + let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::with_details( + 12.0, + Some(300), + Some(now + chrono::Duration::hours(2)), + None, + )) + .with_secondary(codexbar::core::RateWindow::with_details( + 23.0, + Some(10080), + Some(now + chrono::Duration::days(3)), + None, + )) + .with_tertiary(codexbar::core::RateWindow::with_details( + 34.0, + Some(43200), + Some(now + chrono::Duration::days(10)), + None, + )); + let result = ProviderFetchResult::new( + usage, + codexbar::providers::opencodego::LOCAL_ESTIMATE_SOURCE_LABEL, + ) + .with_non_authoritative_pace(); + let metadata = instantiate_provider(ProviderId::OpenCodeGo) + .metadata() + .clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::OpenCodeGo, &metadata, &result, None); + + assert_eq!(snapshot.source_label, "local estimate"); + assert_eq!(snapshot.primary.used_percent, 12.0); + assert_eq!(snapshot.secondary.as_ref().unwrap().used_percent, 23.0); + assert_eq!(snapshot.tertiary.as_ref().unwrap().used_percent, 34.0); + assert!(snapshot.primary.resets_at.is_some()); + assert!(snapshot.secondary.as_ref().unwrap().resets_at.is_some()); + assert!(snapshot.tertiary.as_ref().unwrap().resets_at.is_some()); + assert!(snapshot.pace.is_none()); + assert!( + snapshot + .secondary + .as_ref() + .unwrap() + .reserve_percent + .is_none() + ); } #[test] @@ -862,6 +921,7 @@ fn provider_cache_upsert_replaces_existing_provider() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + pace_authoritative: true, }; let mut first = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -885,6 +945,7 @@ fn provider_cache_prunes_disabled_providers() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + pace_authoritative: true, }; let codex = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -915,6 +976,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + pace_authoritative: true, }; let mut snapshot = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None); @@ -945,6 +1007,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + pace_authoritative: true, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -975,6 +1038,7 @@ fn claude_repeated_auth_failure_surfaces_error() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + pace_authoritative: true, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1010,6 +1074,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { cost: None, wayfinder_usage: None, source_label: "CLI".to_string(), + pace_authoritative: true, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1045,6 +1110,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + pace_authoritative: true, }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None); @@ -1195,6 +1261,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + pace_authoritative: true, }; let snapshot = @@ -1224,6 +1291,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { cost: None, wayfinder_usage: None, source_label: "OAuth".to_string(), + pace_authoritative: true, }; let snapshot = diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index bed4d2ca6f..8f16ed05ee 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -483,6 +483,41 @@ describe("MenuCard", () => { expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); }); + it("hides derived pace advice for local OpenCode Go estimates", async () => { + const resetAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); + const snapshot = provider(null, 12); + snapshot.providerId = "opencodego"; + snapshot.displayName = "OpenCode Go"; + snapshot.sourceLabel = "local estimate"; + snapshot.primary = rateWindow(12, { + windowMinutes: 5 * 60, + resetsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + }); + snapshot.secondary = rateWindow(23, { + windowMinutes: 7 * 24 * 60, + resetsAt: resetAt.toISOString(), + reservePercent: 34, + reserveWillLastToReset: true, + }); + snapshot.pace = { + stage: "far_ahead", + deltaPercent: 20, + expectedUsedPercent: 20, + actualUsedPercent: 40, + etaSeconds: 90 * 60, + willLastToReset: false, + }; + + const { container } = renderCard(snapshot); + + expect(await screen.findByText("88% left")).toBeInTheDocument(); + expect(screen.getByText("77% left")).toBeInTheDocument(); + expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); + expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); + expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); + }); + it("renders local token and cost totals after chart data loads", async () => { const { container } = renderCard(provider(null)); diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 14d7fced8a..e445991a8a 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -10,6 +10,7 @@ import type { SessionEquivalentForecastSnapshot, } from "../types/bridge"; import { useLocale } from "../hooks/useLocale"; +import { providerAllowsPace } from "../lib/providerPace"; import { useFormattedResetTime, type ResetTimeFormatMode, @@ -458,7 +459,10 @@ export function describeCard( const hasCost = !!provider.cost && (costSummaryDisplayStyle !== "hidden" || provider.cost.alwaysVisible === true); - const hasPace = showPace && !!provider.pace; + const hasPace = + showPace && + providerAllowsPace(provider.providerId, provider.sourceLabel) && + !!provider.pace; const hasDetails = !provider.error && (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage || !!wayfinderUsage); @@ -486,6 +490,10 @@ export default function MenuCardDetails({ onLayoutChange, }: MenuCardDetailsProps) { const { t } = useLocale(); + const paceEnabled = + display.showPace !== false && + providerAllowsPace(provider.providerId, provider.sourceLabel); + const metricDisplay = paceEnabled ? display : { ...display, showPace: false }; const [expandedPaceWindow, setExpandedPaceWindow] = useState(null); const formattedCostReset = useFormattedResetTime( provider.cost?.resetsAt ?? null, @@ -517,7 +525,7 @@ export default function MenuCardDetails({ title={m.label} snap={m.snap} exhaustedLabel={t("DetailWindowExhausted")} - display={display} + display={metricDisplay} expanded={expandedPaceWindow === m.id} resetFormatMode={m.resetFormatMode} sessionEquivalentForecast={m.sessionEquivalentForecast} @@ -620,7 +628,7 @@ export default function MenuCardDetails({ {(hasMetrics || hasCost) && hasPace &&
} - {hasPace && provider.pace && ( + {paceEnabled && hasPace && provider.pace && (
{t("DetailPaceTitle")} diff --git a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx new file mode 100644 index 0000000000..d2703a7c95 --- /dev/null +++ b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx @@ -0,0 +1,63 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; + +const t = (key: string) => key; + +describe("MiniBarChart history axes", () => { + it("keeps full endpoint dates for cost history", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll(".mini-chart__axis > span"); + expect(labels).toHaveLength(2); + expect(labels[0]).toHaveTextContent("2026-09-01"); + expect(labels[1]).toHaveTextContent("2026-09-30"); + expect((labels[0] as HTMLElement).style.left).toBe("87.5px"); + expect((labels[1] as HTMLElement).style.left).toBe("192.5px"); + expect(container.querySelector(".mini-chart__axis-max")).toBeNull(); + expect(labels[0]).toHaveClass("mini-chart__axis-start"); + expect(labels[1]).toHaveClass("mini-chart__axis-end"); + expect((labels[0] as HTMLElement).style.transform).toBe(""); + expect((labels[1] as HTMLElement).style.transform).toBe(""); + }); + + it("keeps full endpoint dates for usage breakdown history", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll(".mini-chart__axis > span"); + expect(labels).toHaveLength(2); + expect(labels[0]).toHaveTextContent("2026-09-01"); + expect(labels[1]).toHaveTextContent("2026-09-30"); + expect((labels[0] as HTMLElement).style.left).toBe("87.5px"); + expect((labels[1] as HTMLElement).style.left).toBe("192.5px"); + expect(container.querySelector(".mini-chart__axis-max")).toBeNull(); + expect(labels[0]).toHaveClass("mini-chart__axis-start"); + expect(labels[1]).toHaveClass("mini-chart__axis-end"); + expect((labels[0] as HTMLElement).style.transform).toBe(""); + expect((labels[1] as HTMLElement).style.transform).toBe(""); + }); +}); diff --git a/apps/desktop-tauri/src/components/MiniBarChart.tsx b/apps/desktop-tauri/src/components/MiniBarChart.tsx index 5b5f8eff9b..3d0b11a996 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.tsx @@ -2,6 +2,13 @@ import type { DailyCostPoint, DailyUsageBreakdown } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; +import { + WIDTH, + getBarCenter, + getBarWidth, + getBarX, + shouldRenderCenterMax, +} from "./charts/chartGeometry"; interface BarChartProps { points: DailyCostPoint[]; @@ -33,31 +40,25 @@ export function SimpleBarChart({ const knownValues = points.flatMap((p) => (p.value == null ? [] : [p.value])); const max = Math.max(...knownValues, 0.0001); - const BAR_GAP = 2; const fmt = formatValue ?? ((v: number) => v.toFixed(2)); // 最多显示 30 根柱,并将日期标签缩短为末尾两位 const visible = points.slice(-30); - const svgWidth = 280; - const barWidth = Math.max( - 1, - Math.floor((svgWidth - (visible.length - 1) * BAR_GAP) / visible.length), - ); - const actualWidth = visible.length * barWidth + (visible.length - 1) * BAR_GAP; + const barWidth = getBarWidth(visible.length); return (
{label && {label}} {visible.map((p, i) => { const barH = p.value == null ? 1 : Math.max(1, (p.value / max) * (height - 4)); - const x = i * (barWidth + BAR_GAP); + const x = getBarX(i, visible.length); const y = height - barH; return ( {visible.length > 0 && ( <> - {visible[0].date.slice(-5)} - {fmt(max)} - {visible[visible.length - 1].date.slice(-5)} + + {visible[0].date} + + {shouldRenderCenterMax(visible.length) && ( + + {fmt(max)} + + )} + + {visible[visible.length - 1].date} + )}
@@ -139,26 +148,20 @@ export function StackedBarChart({ new Set(visible.flatMap((p) => p.services.map((s) => s.service))), ).sort(); - const BAR_GAP = 2; - const svgWidth = 280; - const barWidth = Math.max( - 1, - Math.floor((svgWidth - (visible.length - 1) * BAR_GAP) / visible.length), - ); - const actualWidth = visible.length * barWidth + (visible.length - 1) * BAR_GAP; + const barWidth = getBarWidth(visible.length); return (
{label && {label}} {visible.map((p, i) => { - const x = i * (barWidth + BAR_GAP); + const x = getBarX(i, visible.length); const totalH = Math.max(1, (p.totalCreditsUsed / max) * (height - 4)); // 固定服务排序,确保堆叠顺序可预测 const sorted = [...p.services].sort((a, b) => @@ -208,9 +211,17 @@ export function StackedBarChart({
{visible.length > 0 && ( <> - {visible[0].day.slice(-5)} - {max.toFixed(1)} - {visible[visible.length - 1].day.slice(-5)} + + {visible[0].day} + + {shouldRenderCenterMax(visible.length) && ( + + {max.toFixed(1)} + + )} + + {visible[visible.length - 1].day} + )}
diff --git a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx index 95403690e9..12934d08e3 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx @@ -22,4 +22,29 @@ describe("BarChart calendar slots", () => { expect(container).toHaveTextContent("unknown"); expect(container).toHaveTextContent("zero: 0.00"); }); -}); \ No newline at end of file + + it("keeps full endpoint dates in the axis", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll(".chart__axis > span"); + expect(labels).toHaveLength(2); + expect(labels[0]).toHaveTextContent("2026-09-01"); + expect(labels[1]).toHaveTextContent("2026-09-30"); + expect((labels[0] as HTMLElement).style.left).toBe("87.5px"); + expect((labels[1] as HTMLElement).style.left).toBe("192.5px"); + expect(container.querySelector(".chart__axis-max")).toBeNull(); + expect(labels[0]).toHaveClass("chart__axis-start"); + expect(labels[1]).toHaveClass("chart__axis-end"); + expect((labels[0] as HTMLElement).style.transform).toBe(""); + expect((labels[1] as HTMLElement).style.transform).toBe(""); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/BarChart.tsx b/apps/desktop-tauri/src/components/charts/BarChart.tsx index 4813543220..0151811f57 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.tsx @@ -1,5 +1,12 @@ import { useMemo, useRef, useState } from "react"; import { useChartAnimation } from "./useChartAnimation"; +import { + WIDTH, + getBarCenter, + getBarWidth, + getBarX, + shouldRenderCenterMax, +} from "./chartGeometry"; /** * BarChart — dependency-free SVG bar chart with entrance animation, @@ -31,8 +38,6 @@ export interface BarChartProps { } const DEFAULT_COLOR = "var(--chart-cost)"; -const BAR_GAP = 2; -const SVG_WIDTH = 280; const CAP_HEIGHT = 5; export function BarChart({ @@ -75,11 +80,7 @@ export function BarChart({ ); } - const barWidth = Math.max( - 1, - Math.floor((SVG_WIDTH - (data.length - 1) * BAR_GAP) / data.length), - ); - const actualWidth = data.length * barWidth + (data.length - 1) * BAR_GAP; + const barWidth = getBarWidth(data.length); const plotHeight = Math.max(1, height - 4); const onMove = (e: React.MouseEvent, i: number) => { @@ -93,9 +94,9 @@ export function BarChart({ return (
CAP_HEIGHT; const bodyH = isPeak ? Math.max(0, barH - CAP_HEIGHT) : barH; @@ -146,9 +147,15 @@ export function BarChart({ })}
- {data[0].label.slice(-5)} - {fmt(max)} - {data[data.length - 1].label.slice(-5)} + + {data[0].label} + + {shouldRenderCenterMax(data.length) && ( + {fmt(max)} + )} + + {data[data.length - 1].label} +
{hover && !anim.running && (
{ expect(container).toHaveTextContent("2026-09-02: 0.00"); expect(container).not.toHaveTextContent("2026-09-03: 0.00"); }); -}); \ No newline at end of file + + it("keeps full endpoint dates in the axis", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll(".chart__axis > span"); + expect(labels).toHaveLength(2); + expect(labels[0]).toHaveTextContent("2026-09-01"); + expect(labels[1]).toHaveTextContent("2026-09-30"); + expect((labels[0] as HTMLElement).style.left).toBe("36px"); + expect((labels[1] as HTMLElement).style.left).toBe("244px"); + expect(container.querySelector(".chart__axis-max")).toBeNull(); + expect(labels[0]).toHaveClass("chart__axis-start"); + expect(labels[1]).toHaveClass("chart__axis-end"); + expect((labels[0] as HTMLElement).style.transform).toBe(""); + expect((labels[1] as HTMLElement).style.transform).toBe(""); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/LineChart.tsx b/apps/desktop-tauri/src/components/charts/LineChart.tsx index a8a1a1bda2..7a5e1c55a6 100644 --- a/apps/desktop-tauri/src/components/charts/LineChart.tsx +++ b/apps/desktop-tauri/src/components/charts/LineChart.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { useChartAnimation } from "./useChartAnimation"; +import { WIDTH, getLineX, shouldRenderCenterMax } from "./chartGeometry"; /** * LineChart — dependency-free SVG line chart with optional area fill, @@ -28,7 +29,6 @@ export interface LineChartProps { } const DEFAULT_COLOR = "var(--chart-credits)"; -const SVG_WIDTH = 280; export function LineChart({ data, @@ -66,15 +66,13 @@ export function LineChart({ const plotHeight = Math.max(1, height - 4); const pad = 2; - const usableWidth = SVG_WIDTH - pad * 2; // Baseline target Y (plot bottom) — the line animates from the // baseline up to its final Y, mirroring the bar entrance. const baselineY = pad + plotHeight; - const step = data.length > 1 ? usableWidth / (data.length - 1) : 0; const coords = data.map((p, i) => { - const x = pad + i * step; + const x = getLineX(i, data.length); if (p.value == null) return null; const finalY = pad + plotHeight - ((p.value - min) / range) * plotHeight; const t = anim.barProgress(i); @@ -96,7 +94,7 @@ export function LineChart({ if (data.length === 1 && segments[0]?.length === 1) { const point = segments[0][0]; - segments[0].push({ x: pad + usableWidth, y: point.y }); + segments[0].push({ x: getLineX(1, 2), y: point.y }); } const onPointMove = (e: React.MouseEvent, i: number) => { @@ -111,9 +109,9 @@ export function LineChart({ return (
- {data[0].label.slice(-5)} - - {hasKnownValues ? fmt(max) : ""} + + {data[0].label} + + {shouldRenderCenterMax(data.length) && ( + + {hasKnownValues ? fmt(max) : ""} + + )} + + {data[data.length - 1].label} - {data[data.length - 1].label.slice(-5)}
{hover && hoveredPoint?.value != null && !anim.running && (
{ + it("keeps two bar centers symmetric around the chart center without a max label", () => { + const first = getBarCenter(0, 2); + const last = getBarCenter(1, 2); + + expect((first + last) / 2).toBe(WIDTH / 2); + expect(first).toBeGreaterThanOrEqual(DATE_EDGE_PADDING); + expect(first).toBeLessThanOrEqual(WIDTH - DATE_EDGE_PADDING); + expect(last).toBeGreaterThanOrEqual(DATE_EDGE_PADDING); + expect(last).toBeLessThanOrEqual(WIDTH - DATE_EDGE_PADDING); + expect(shouldRenderCenterMax(2)).toBe(false); + }); + + it("renders the center max label for three or more points", () => { + expect(shouldRenderCenterMax(3)).toBe(true); + }); +}); diff --git a/apps/desktop-tauri/src/components/charts/chartGeometry.ts b/apps/desktop-tauri/src/components/charts/chartGeometry.ts new file mode 100644 index 0000000000..f3de6d951d --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/chartGeometry.ts @@ -0,0 +1,46 @@ +export const WIDTH = 280; +export const DATE_EDGE_PADDING = 36; +export const BAR_GAP = 2; +export const PLOT_WIDTH = WIDTH - DATE_EDGE_PADDING * 2; +export const AXIS_MAX_X = WIDTH / 2; + +export function shouldRenderCenterMax(count: number): boolean { + return count >= 3; +} + +export function getBarWidth(count: number): number { + const barCount = Math.max(1, count); + return Math.max(1, (PLOT_WIDTH - (barCount - 1) * BAR_GAP) / barCount); +} + +export function getBarX(index: number, count: number): number { + return DATE_EDGE_PADDING + index * (getBarWidth(count) + BAR_GAP); +} + +export function getBarCenter(index: number, count: number): number { + return getBarX(index, count) + getBarWidth(count) / 2; +} + +export interface BarGeometry { + barWidth: number; + showCenterMax: boolean; + x: (index: number) => number; + center: (index: number) => number; +} + +export function getBarGeometry(count: number): BarGeometry { + const barWidth = getBarWidth(count); + const x = (index: number) => getBarX(index, count); + + return { + barWidth, + showCenterMax: shouldRenderCenterMax(count), + x, + center: (index: number) => x(index) + barWidth / 2, + }; +} + +export function getLineX(index: number, count: number): number { + if (count <= 1) return DATE_EDGE_PADDING; + return DATE_EDGE_PADDING + (index / (count - 1)) * PLOT_WIDTH; +} diff --git a/apps/desktop-tauri/src/lib/providerPace.test.ts b/apps/desktop-tauri/src/lib/providerPace.test.ts new file mode 100644 index 0000000000..4e90f997d0 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerPace.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { providerAllowsPace } from "./providerPace"; + +describe("providerAllowsPace", () => { + it("rejects device-local OpenCode Go estimates", () => { + expect(providerAllowsPace("opencodego", " LOCAL ESTIMATE ")).toBe(false); + }); + + it("keeps pace enabled for authoritative and unrelated sources", () => { + expect(providerAllowsPace("opencodego", "api")).toBe(true); + expect(providerAllowsPace("opencodego", "web")).toBe(true); + expect(providerAllowsPace("claude", "local estimate")).toBe(true); + expect(providerAllowsPace("opencodego", null)).toBe(true); + }); +}); diff --git a/apps/desktop-tauri/src/lib/providerPace.ts b/apps/desktop-tauri/src/lib/providerPace.ts new file mode 100644 index 0000000000..d257905954 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerPace.ts @@ -0,0 +1,10 @@ +/** Device-local OpenCode Go quota estimates do not establish account-wide pace. */ +export function providerAllowsPace( + providerId: string, + sourceLabel: string | null | undefined, +): boolean { + return !( + providerId === "opencodego" && + sourceLabel?.trim().toLowerCase() === "local estimate" + ); +} diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index bfc85bdd2e..1818e53865 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -815,6 +815,10 @@ html { } .credential-card__link { + padding: 0; + border: 0; + background: transparent; + cursor: pointer; font-size: 0.78rem; color: var(--text-eyebrow); text-decoration: none; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ApiKeySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ApiKeySection.tsx index 5c3ed8d22b..63923b03e8 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ApiKeySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ApiKeySection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { getApiKeyProviders, getApiKeys, + openProviderDashboard, removeApiKey, setApiKey, } from "../../../lib/tauri"; @@ -181,14 +182,17 @@ export function ApiKeySection({ providerId }: Props) { )} {info.dashboardUrl && !editing && ( - + void openProviderDashboard(providerId).catch((err: unknown) => + setError(err instanceof Error ? err.message : String(err)), + ) + } > {t("OpenProviderDashboard").replace("{}", info.displayName)} ↗ - + )} {editing && ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 243d9dc9e3..0be85b878f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useReducer } from "react"; import type { SettingsSnapshot, SettingsUpdate } from "../../../types/bridge"; import { useLocale } from "../../../hooks/useLocale"; +import { providerAllowsPace } from "../../../lib/providerPace"; import { getCredentialStorageStatus, getProviderCookieSourceOptions, @@ -300,7 +301,14 @@ export function ProviderDetailPane({ t={t} onChange={onSettingsChange} /> - + { + it("keeps full calendar dates for stacked-bar rows", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll(".chart__row-label"); + expect(labels).toHaveLength(2); + expect(labels[0]).toHaveTextContent("2026-09-01"); + expect(labels[1]).toHaveTextContent("2026-09-30"); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/UsageBreakdownChart.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/UsageBreakdownChart.tsx index bae1864d4b..422b918ff9 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/UsageBreakdownChart.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/UsageBreakdownChart.tsx @@ -64,7 +64,7 @@ export function UsageBreakdownChart({ const rowHeight = 14; const rowGap = 2; - const labelWidth = 52; + const labelWidth = 68; const totalWidth = 280; const barAreaWidth = totalWidth - labelWidth; const svgHeight = recent.length * (rowHeight + rowGap); @@ -108,7 +108,7 @@ export function UsageBreakdownChart({ className="chart__row-label" fill="var(--provider-row-text-secondary, #888)" > - {day.day.slice(-5)} + {day.day} {sorted.map((svc) => { const w = diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index c35477a662..1780c4893e 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet, VecDeque}; -use std::fs::{self, File}; +use std::fs::File; use std::future::Future; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; @@ -338,6 +338,7 @@ pub struct AgentSessionDiscovery { remote: RemoteSessionFetcher, } +mod claude_desktop; mod focus; mod parsers; pub mod pi_family; @@ -501,16 +502,21 @@ impl LocalAgentSessionScanner { let (pi_processes, agents): (Vec<_>, Vec<_>) = agents .into_iter() .partition(|process| process.provider == Some(AgentSessionProvider::Pi)); + let mut metadata_budget = pi_family::budget_for(&self.config); let mut rollouts = VecDeque::from(Self::codex_rollouts( codex_root, now.with_timezone(&Local).date_naive(), + &mut metadata_budget, )); let claude_count = agents .iter() .filter(|process| process.provider == Some(AgentSessionProvider::Claude)) .count(); - let mut claude_transcripts = - VecDeque::from(Self::claude_transcripts(claude_roots, claude_count)); + let mut claude_transcripts = VecDeque::from(Self::claude_transcripts( + claude_roots, + claude_count, + &mut metadata_budget, + )); let mut sessions = Vec::new(); for process in agents { @@ -737,41 +743,57 @@ impl LocalAgentSessionScanner { .collect() } - fn codex_rollouts(root: &Path, today: NaiveDate) -> Vec { - let mut rollouts = Vec::new(); + fn codex_rollouts( + root: &Path, + today: NaiveDate, + budget: &mut pi_family::DirectoryScanBudget, + ) -> Vec { + if !budget.has_time_remaining() { + return Vec::new(); + } + + let mut candidates = Vec::new(); for directory in Self::codex_day_directories(root, today) { - let Ok(entries) = fs::read_dir(directory) else { - continue; - }; - for entry in entries.flatten() { + if !budget.has_time_remaining() { + break; + } + let entries = budget.files(&directory); + let day_candidates = budget.compact_map_while_time_remaining(entries, |entry| { let path = entry.path(); let is_rollout = path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with("rollout-")); if !is_rollout || path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; + return None; } - let Some(line) = CodexRolloutFirstLineParser::read_first_line(&path) else { - continue; - }; - let Some(metadata) = CodexRolloutFirstLineParser::parse(&line) else { - continue; - }; - let Some(modified_at) = entry + let modified_at = entry .metadata() .ok() .and_then(|metadata| metadata.modified().ok()) - .map(DateTime::::from) - else { - continue; - }; - rollouts.push(CodexRollout { - path, - modified_at, - metadata, - }); + .map(DateTime::::from)?; + Some((path, modified_at)) + }); + candidates.extend(day_candidates); + } + candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.1)); + + let mut rollouts = Vec::new(); + for (path, modified_at) in candidates { + if !budget.has_time_remaining() { + break; } + let Some(line) = CodexRolloutFirstLineParser::read_first_line(&path) else { + continue; + }; + let Some(metadata) = CodexRolloutFirstLineParser::parse(&line) else { + continue; + }; + rollouts.push(CodexRollout { + path, + modified_at, + metadata, + }); } rollouts.sort_by_key(|rollout| std::cmp::Reverse(rollout.modified_at)); rollouts @@ -780,50 +802,78 @@ impl LocalAgentSessionScanner { fn claude_transcripts( roots: &[PathBuf], live_process_count: usize, + budget: &mut pi_family::DirectoryScanBudget, + ) -> Vec { + if live_process_count == 0 || !budget.has_time_remaining() { + return Vec::new(); + } + let desktop_roots = claude_desktop::ClaudeDesktopProjectsLocator::roots(budget); + Self::claude_transcripts_from_roots(roots, desktop_roots, live_process_count, budget) + } + + fn claude_transcripts_from_roots( + roots: &[PathBuf], + additional_roots: Vec, + live_process_count: usize, + budget: &mut pi_family::DirectoryScanBudget, ) -> Vec { - if live_process_count == 0 { + if live_process_count == 0 || !budget.has_time_remaining() { return Vec::new(); } + + let mut roots = roots.to_vec(); + roots.extend(additional_roots); + roots.sort(); + roots.dedup(); + let mut files = Vec::new(); for root in roots { - let Ok(projects) = fs::read_dir(root) else { - continue; - }; - for project in projects.flatten() { - let Ok(entries) = fs::read_dir(project.path()) else { - continue; - }; - for entry in entries.flatten() { + let projects = budget.child_directories(&root); + for project in projects { + if !budget.has_time_remaining() { + break; + } + let entries = budget.files(&project.path()); + let candidates = budget.compact_map_while_time_remaining(entries, |entry| { let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; + return None; } - let Some(modified_at) = entry + let modified_at = entry .metadata() .ok() .and_then(|metadata| metadata.modified().ok()) - .map(DateTime::::from) - else { - continue; - }; - files.push((path, modified_at)); - } + .map(DateTime::::from)?; + Some((path, modified_at)) + }); + files.extend(candidates); + } + if !budget.has_time_remaining() { + break; } } files.sort_by(|lhs, rhs| rhs.1.cmp(&lhs.1).then_with(|| rhs.0.cmp(&lhs.0))); - files - .into_iter() - .take(live_process_count) - .filter_map(|(path, modified_at)| { - let file = File::open(&path).ok()?; - let metadata = ClaudeTranscriptMetadataParser::parse(file)?; - Some(ClaudeTranscriptCandidate { - path, - modified_at, - metadata, - }) - }) - .collect() + let mut transcripts = Vec::new(); + for (path, modified_at) in files.into_iter().take(live_process_count) { + if !budget.has_time_remaining() { + break; + } + let Ok(file) = File::open(&path) else { + continue; + }; + if !budget.has_time_remaining() { + break; + } + let Some(metadata) = ClaudeTranscriptMetadataParser::parse(file) else { + continue; + }; + transcripts.push(ClaudeTranscriptCandidate { + path, + modified_at, + metadata, + }); + } + transcripts } } diff --git a/rust/src/agent_sessions/claude_desktop.rs b/rust/src/agent_sessions/claude_desktop.rs new file mode 100644 index 0000000000..807b21880e --- /dev/null +++ b/rust/src/agent_sessions/claude_desktop.rs @@ -0,0 +1,129 @@ +//! Windows Claude Desktop project-root discovery. +//! +//! Claude Desktop stores local-agent session work below its Electron +//! application-data directory. The layout has varied by release, so discovery +//! walks only the two known session roots to a shallow fixed depth and returns +//! nested `.claude/projects` directories. Every directory/type/path operation +//! shares the caller's metadata scan budget. + +use super::pi_family::DirectoryScanBudget; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +const SESSION_DIRECTORY_NAMES: [&str; 2] = ["local-agent-mode-sessions", "claude-code-sessions"]; +const MAX_DEPTH: usize = 4; + +const SKIPPED_DIRECTORY_NAMES: [&str; 7] = [ + ".build", + ".git", + "build", + "DerivedData", + "node_modules", + "outputs", + "target", +]; + +pub(crate) struct ClaudeDesktopProjectsLocator; + +impl ClaudeDesktopProjectsLocator { + /// Locate Claude Desktop roots in the current Windows user's AppData. + /// + /// macOS/Linux builds deliberately return no roots: their Claude Desktop + /// layouts are not Windows paths and are handled by their native ports. + #[cfg(windows)] + pub(crate) fn roots(budget: &mut DirectoryScanBudget) -> Vec { + let Some(data_directory) = dirs::data_dir() else { + return Vec::new(); + }; + Self::roots_under(&data_directory.join("Claude"), budget) + } + + #[cfg(not(windows))] + pub(crate) fn roots(_budget: &mut DirectoryScanBudget) -> Vec { + Vec::new() + } + + /// Discover roots below an injected application-data directory. + /// + /// This platform-neutral helper keeps the traversal deterministic and + /// makes the Windows deadline contract testable without touching a real + /// user profile. + pub(crate) fn roots_under( + application_data_root: &Path, + budget: &mut DirectoryScanBudget, + ) -> Vec { + if !budget.has_time_remaining() { + return Vec::new(); + } + + let session_roots = SESSION_DIRECTORY_NAMES + .into_iter() + .map(|name| application_data_root.join(name)) + .collect::>(); + let mut queue = session_roots + .iter() + .cloned() + .map(|path| (path, 0_usize)) + .collect::>(); + let mut visited = session_roots + .iter() + .map(|path| visited_key(path)) + .collect::>(); + let mut roots = Vec::new(); + let mut next_index = 0; + + while next_index < queue.len() && budget.has_time_remaining() { + let (current, depth) = { + let (path, depth) = &queue[next_index]; + (path.clone(), *depth) + }; + next_index += 1; + + let projects = current.join(".claude").join("projects"); + if budget.has_time_remaining() + && projects.is_dir() + && let Some(canonical) = budget.canonicalize_if_time_remaining(&projects) + { + roots.push(canonical); + } + + if depth >= MAX_DEPTH || depth >= budget.max_depth() || !budget.has_time_remaining() { + continue; + } + + let entries = budget.child_directories(¤t); + let children = budget.compact_map_while_time_remaining(entries, |entry| { + let name = entry.file_name(); + if is_skipped_directory_name(&name.to_string_lossy()) { + return None; + } + let canonical = budget.canonicalize_if_time_remaining(&entry.path())?; + let key = visited_key(&canonical); + visited.insert(key).then_some(canonical) + }); + queue.extend(children.into_iter().map(|path| (path, depth + 1))); + } + + roots.sort(); + roots.dedup(); + roots + } +} + +fn is_skipped_directory_name(name: &str) -> bool { + SKIPPED_DIRECTORY_NAMES + .iter() + .any(|skipped| skipped.eq_ignore_ascii_case(name)) +} + +fn visited_key(path: &Path) -> String { + let value = path.to_string_lossy().replace('/', "\\"); + #[cfg(windows)] + { + value.to_ascii_lowercase() + } + #[cfg(not(windows))] + { + value + } +} diff --git a/rust/src/agent_sessions/parsers.rs b/rust/src/agent_sessions/parsers.rs index 6cec88ac3f..49e236e8e6 100644 --- a/rust/src/agent_sessions/parsers.rs +++ b/rust/src/agent_sessions/parsers.rs @@ -378,28 +378,49 @@ impl ClaudeSessionProjectMapper { } pub fn transcripts(cwd: &str, home_directory: &Path) -> Vec { - let mut transcripts = Vec::new(); + let mut budget = + super::pi_family::DirectoryScanBudget::new(4096, 1, std::time::Duration::from_secs(1)); + Self::transcripts_with_budget(cwd, home_directory, &mut budget) + } - for directory in Self::project_directories(cwd, home_directory) { - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; + /// Enumerate Claude transcript metadata with a shared scan deadline. + /// + /// Claude Desktop project roots are included on Windows by the same + /// bounded locator used by the local scanner. The compatibility wrapper + /// above supplies the upstream-style one-second default budget. + pub fn transcripts_with_budget( + cwd: &str, + home_directory: &Path, + budget: &mut super::pi_family::DirectoryScanBudget, + ) -> Vec { + if cwd.trim().is_empty() || !budget.has_time_remaining() { + return Vec::new(); + } - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } + let escaped_cwd = Self::escaped_cwd(cwd); + let mut directories = Self::project_directories(cwd, home_directory); + directories.extend( + super::claude_desktop::ClaudeDesktopProjectsLocator::roots(budget) + .into_iter() + .map(|root| root.join(&escaped_cwd)), + ); - let Ok(metadata) = entry.metadata() else { - continue; - }; - let Ok(modified) = metadata.modified() else { - continue; - }; + let mut transcripts = Vec::new(); - transcripts.push(ClaudeTranscript::new(path, modified.into())); + for directory in directories { + if !budget.has_time_remaining() { + break; } + let entries = budget.files(&directory); + let found = budget.compact_map_while_time_remaining(entries, |entry| { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + return None; + } + let modified = entry.metadata().ok()?.modified().ok()?; + Some(ClaudeTranscript::new(path, modified.into())) + }); + transcripts.extend(found); } transcripts.sort_by(|lhs, rhs| { @@ -422,9 +443,14 @@ impl ClaudeTranscriptMetadataParser { pub fn parse(reader: impl Read) -> Option { let mut session_id = None; let mut cwd = None; - let reader = BufReader::new(reader.take(Self::MAX_BYTES)); + let lines = BufReader::new(reader.take(Self::MAX_BYTES)) + .lines() + .take(Self::MAX_LINES); - for line in reader.lines().take(Self::MAX_LINES).map_while(Result::ok) { + for line in lines { + let Ok(line) = line else { + break; + }; let Ok(value) = serde_json::from_str::(&line) else { continue; }; @@ -483,7 +509,7 @@ impl CodexRolloutFirstLineParser { } pub fn read_first_line(path: &Path) -> Option { - let file = File::open(path).ok()?; + let file = std::fs::File::open(path).ok()?; let mut reader = BufReader::new(file); let mut line = String::new(); let bytes = reader.read_line(&mut line).ok()?; diff --git a/rust/src/agent_sessions/pi_family/mod.rs b/rust/src/agent_sessions/pi_family/mod.rs index 253f5f4992..49092987ea 100644 --- a/rust/src/agent_sessions/pi_family/mod.rs +++ b/rust/src/agent_sessions/pi_family/mod.rs @@ -224,8 +224,9 @@ impl PiFamilySessionScanner { .map(|cwd| standardized_cwd_string(cwd)); let mut record: Option = None; - if let (Some(started_at), Some(standardized), Some(cwd)) = - (process.started_at, standardized_cwd.as_ref(), process_cwd) + if budget.has_time_remaining() + && let (Some(started_at), Some(standardized), Some(cwd)) = + (process.started_at, standardized_cwd.as_ref(), process_cwd) { let cwd_value = cwd.clone(); let cwd_path = PathBuf::from(&cwd_value); @@ -239,7 +240,10 @@ impl PiFamilySessionScanner { if !budget.has_time_remaining() { break; } - let canonical_root = canonicalize_for_scan(&root.path); + let Some(canonical_root) = budget.canonicalize_if_time_remaining(&root.path) + else { + break; + }; let root_key = format!( "{:?}:{:?}:{}", dialect, @@ -250,6 +254,11 @@ impl PiFamilySessionScanner { records_in_root(&canonical_root, now, dialect, root.layout, budget) }); if let Some(candidate) = root_records.iter().find(|candidate| { + // CWD normalization is filesystem-backed on Windows; + // do not start it for queued candidates after expiry. + if !budget.has_time_remaining() { + return false; + } candidate.modified_at >= started_at && candidate .cwd @@ -258,9 +267,9 @@ impl PiFamilySessionScanner { .is_some_and(|record_cwd| { standardized_cwd_string(record_cwd) == *standardized }) - && !used_record_paths.contains(&canonicalize_for_scan(&candidate.path)) + && !used_record_paths.contains(&candidate.path) }) { - used_record_paths.insert(canonicalize_for_scan(&candidate.path)); + used_record_paths.insert(candidate.path.clone()); record = Some(candidate.clone()); break; } diff --git a/rust/src/agent_sessions/pi_family/parser.rs b/rust/src/agent_sessions/pi_family/parser.rs index bd6b410160..d9aa387882 100644 --- a/rust/src/agent_sessions/pi_family/parser.rs +++ b/rust/src/agent_sessions/pi_family/parser.rs @@ -27,6 +27,15 @@ pub fn parse_session_file( dialect: PiSessionDialect, modified_at: DateTime, now: DateTime, +) -> Option { + parse_session_file_inner(path, dialect, modified_at, now) +} + +fn parse_session_file_inner( + path: &Path, + dialect: PiSessionDialect, + modified_at: DateTime, + now: DateTime, ) -> Option { let prefix = read_prefix(path)?; let lines = complete_lines(&prefix)?; diff --git a/rust/src/agent_sessions/pi_family/roots.rs b/rust/src/agent_sessions/pi_family/roots.rs index 1b0a108bc9..017ba8047a 100644 --- a/rust/src/agent_sessions/pi_family/roots.rs +++ b/rust/src/agent_sessions/pi_family/roots.rs @@ -4,7 +4,7 @@ use super::AgentProcessRecord; use super::command_line_value; -use super::parser::{PiFamilySessionRecord, parse_session_file}; +use super::parser::PiFamilySessionRecord; use super::{MAX_PROFILE_ROOTS, MAX_SETTINGS_BYTES, PiSessionDialect}; use chrono::{DateTime, Utc}; @@ -36,17 +36,175 @@ impl DirectoryScanBudget { Instant::now() < self.deadline } + /// Apply enrichment only while the shared metadata deadline remains live. + /// + /// Directory enumeration is charged separately by [`Self::files`] and + /// [`Self::child_directories`]. Callers use this second gate before work + /// such as canonicalization, path checks, or file metadata reads so an + /// already-enumerated queue cannot continue expensive work after expiry. + pub fn compact_map_while_time_remaining( + &self, + values: I, + transform: Transform, + ) -> Vec + where + I: IntoIterator, + Transform: FnMut(I::Item) -> Option, + { + let mut clock = Instant::now; + self.compact_map_while_time_remaining_with_clock(values, &mut clock, transform) + } + + pub(crate) fn compact_map_while_time_remaining_with_clock( + &self, + values: I, + clock: &mut Clock, + mut transform: Transform, + ) -> Vec + where + I: IntoIterator, + Transform: FnMut(I::Item) -> Option, + Clock: FnMut() -> Instant, + { + let mut results = Vec::new(); + for value in values { + // Enumeration already charged the entry count; enrichment shares + // its deadline and must not start after it expires. + if !self.has_time_remaining_at(clock()) { + break; + } + if let Some(result) = transform(value) { + results.push(result); + } + } + results + } + + /// Return non-directory entries from one directory, bounded by this + /// budget. The entry type check is intentionally performed only after the + /// post-enumeration deadline gate. + pub fn files(&mut self, directory: &Path) -> Vec { + let mut clock = Instant::now; + self.files_with_clock(directory, &mut clock) + } + + pub(crate) fn files_with_clock( + &mut self, + directory: &Path, + clock: &mut Clock, + ) -> Vec + where + Clock: FnMut() -> Instant, + { + let entries = self.entries_with_clock(directory, clock); + self.compact_map_while_time_remaining_with_clock(entries, clock, |entry| { + entry + .file_type() + .ok() + .filter(|file_type| !file_type.is_dir())?; + Some(entry) + }) + } + + /// Return child directories from one directory, bounded by this budget. + pub fn child_directories(&mut self, directory: &Path) -> Vec { + let mut clock = Instant::now; + self.child_directories_with_clock(directory, &mut clock) + } + + pub(crate) fn child_directories_with_clock( + &mut self, + directory: &Path, + clock: &mut Clock, + ) -> Vec + where + Clock: FnMut() -> Instant, + { + let entries = self.entries_with_clock(directory, clock); + self.compact_map_while_time_remaining_with_clock(entries, clock, |entry| { + entry + .file_type() + .ok() + .filter(|file_type| file_type.is_dir())?; + Some(entry) + }) + } + + fn entries_with_clock( + &mut self, + directory: &Path, + clock: &mut Clock, + ) -> Vec + where + Clock: FnMut() -> Instant, + { + if self.max_entry_count == 0 || !self.has_time_remaining_at(clock()) { + return Vec::new(); + } + let Ok(mut entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + + let mut retained = Vec::new(); + while self.entries_seen < self.max_entry_count && self.has_time_remaining_at(clock()) { + let Some(entry) = entries.next() else { break }; + let Ok(entry) = entry else { continue }; + self.entries_seen += 1; + + // Do not begin file-type/resource enrichment for an entry fetched + // after the deadline. The entry count remains charged. + if !self.has_time_remaining_at(clock()) { + break; + } + retained.push(entry); + } + retained + } + pub fn visit_entry(&mut self) -> bool { - if !self.has_time_remaining() { + if !self.has_time_remaining() || self.entries_seen >= self.max_entry_count { return false; } self.entries_seen += 1; - self.entries_seen <= self.max_entry_count + true + } + + /// Resolve a path only while the shared scan deadline is live. + /// + /// The operation is gated before it starts. If it finishes after expiry, + /// retain the result and let the next gate prevent further work. + pub fn canonicalize_if_time_remaining(&self, path: &Path) -> Option { + if !self.has_time_remaining() { + return None; + } + Some(canonicalize_for_scan(path)) + } + + pub(crate) fn max_depth(&self) -> usize { + self.max_depth } fn allowed_depth(&self, depth: usize) -> bool { depth <= self.max_depth } + + fn has_time_remaining_at(&self, now: Instant) -> bool { + now < self.deadline + } + + #[cfg(test)] + pub(crate) fn new_with_deadline_for_test( + max_entry_count: usize, + max_depth: usize, + deadline: Instant, + ) -> Self { + Self { + max_entry_count, + max_depth, + entries_seen: 0, + deadline, + } + } } // --------------------------------------------------------------------------- @@ -538,19 +696,18 @@ pub fn records_in_root( if !budget.has_time_remaining() { return Vec::new(); } - let canonical_root = canonicalize_for_scan(root); + let Some(canonical_root) = budget.canonicalize_if_time_remaining(root) else { + return Vec::new(); + }; let project_directories: Vec = match layout { RootLayout::Direct => vec![canonical_root.clone()], RootLayout::ProjectDirectories => { - let Ok(entries) = std::fs::read_dir(&canonical_root) else { - return Vec::new(); - }; - let mut dirs: Vec = entries - .flatten() - .filter(|_entry| budget.visit_entry()) - .map(|entry| canonicalize_for_scan(&entry.path())) - .filter(|path| path.is_dir() && path_is_within(&canonical_root, path)) - .collect(); + let entries = budget.child_directories(&canonical_root); + let mut dirs: Vec = + budget.compact_map_while_time_remaining(entries, |entry| { + let path = budget.canonicalize_if_time_remaining(&entry.path())?; + (path.is_dir() && path_is_within(&canonical_root, &path)).then_some(path) + }); dirs.sort(); dirs } @@ -566,21 +723,20 @@ pub fn records_in_root( if !budget.has_time_remaining() { break; } - let Ok(entries) = std::fs::read_dir(&project_dir) else { - continue; - }; - let mut files: Vec = entries - .flatten() - .filter(|_entry| budget.visit_entry()) - .map(|entry| canonicalize_for_scan(&entry.path())) - .filter(|path| { - path.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) - && path_is_within(&canonical_root, path) - && path.parent() == Some(project_dir.as_path()) - }) - .collect(); + let entries = budget.files(&project_dir); + let mut files: Vec = budget.compact_map_while_time_remaining(entries, |entry| { + let path = entry.path(); + if !path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) + { + return None; + } + let path = budget.canonicalize_if_time_remaining(&path)?; + (path_is_within(&canonical_root, &path) && path.parent() == Some(project_dir.as_path())) + .then_some(path) + }); files.sort(); for path in files { if !budget.has_time_remaining() { @@ -595,7 +751,11 @@ pub fn records_in_root( let Some(modified_at) = metadata.modified().ok().map(DateTime::::from) else { continue; }; - if let Some(record) = parse_session_file(&path, dialect, modified_at, now) + if !budget.has_time_remaining() { + break; + } + if let Some(record) = + super::parser::parse_session_file(&path, dialect, modified_at, now) && visible.insert(record.id.clone()) { records.push(record); @@ -610,7 +770,7 @@ pub fn records_in_root( .then(lhs.path.cmp(&rhs.path)) }); let mut seen_paths = HashSet::new(); - records.retain(|record| seen_paths.insert(canonicalize_for_scan(&record.path))); + records.retain(|record| seen_paths.insert(record.path.clone())); records } diff --git a/rust/src/agent_sessions/pi_family_tests.rs b/rust/src/agent_sessions/pi_family_tests.rs index 01bda38269..8acdc84bc0 100644 --- a/rust/src/agent_sessions/pi_family_tests.rs +++ b/rust/src/agent_sessions/pi_family_tests.rs @@ -3,6 +3,9 @@ mod pi_family_tests { use super::*; use std::fs; use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{Duration, Instant}; // ------------------------------------------------------------------- // Fixture + scaffolding helpers @@ -48,7 +51,117 @@ mod pi_family_tests { } fn budget() -> DirectoryScanBudget { - DirectoryScanBudget::new(512, 1, std::time::Duration::from_secs(5)) + DirectoryScanBudget::new(512, 1, std::time::Duration::from_secs(30)) + } + + #[test] + fn expired_enrichment_does_not_start_transform() { + let started_at = Instant::now(); + let budget = DirectoryScanBudget::new_with_deadline_for_test(512, 1, started_at); + let values = vec![PathBuf::from("first.jsonl"), PathBuf::from("last.jsonl")]; + let mut clock = || started_at; + let mut enriched = Vec::new(); + + let results = budget.compact_map_while_time_remaining_with_clock( + values, + &mut clock, + |path| { + enriched.push(path.clone()); + Some(path) + }, + ); + + assert!(enriched.is_empty()); + assert!(results.is_empty()); + } + + #[test] + fn enrichment_stops_between_items_when_deadline_expires() { + let started_at = Instant::now(); + let deadline = started_at + Duration::from_secs(1); + let budget = DirectoryScanBudget::new_with_deadline_for_test(512, 1, deadline); + let expired = Arc::new(AtomicBool::new(false)); + let clock_expired = Arc::clone(&expired); + let mut clock = move || { + if clock_expired.load(Ordering::Relaxed) { + started_at + Duration::from_secs(2) + } else { + started_at + } + }; + let values = vec![PathBuf::from("first.jsonl"), PathBuf::from("last.jsonl")]; + let mut enriched = Vec::new(); + + let results = budget.compact_map_while_time_remaining_with_clock( + values, + &mut clock, + |path| { + enriched.push(path.clone()); + expired.store(true, Ordering::Relaxed); + Some(path) + }, + ); + + assert_eq!(enriched, vec![PathBuf::from("first.jsonl")]); + assert_eq!(results, enriched); + } + + #[test] + fn live_enrichment_preserves_legacy_filtering_and_order() { + let started_at = Instant::now(); + let budget = DirectoryScanBudget::new_with_deadline_for_test( + 512, + 1, + started_at + Duration::from_secs(1), + ); + let values = vec![ + PathBuf::from("first.jsonl"), + PathBuf::from("skip.txt"), + PathBuf::from("last.jsonl"), + ]; + let expected = values + .iter() + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")) + .cloned() + .collect::>(); + let mut clock = || started_at; + + let actual = budget.compact_map_while_time_remaining_with_clock( + values, + &mut clock, + |path| { + (path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")).then_some(path) + }, + ); + + assert_eq!(actual, expected); + } + + #[test] + fn entry_fetched_after_deadline_is_not_retained() { + let root = tempfile::tempdir().expect("tempdir"); + fs::write(root.path().join("entry.jsonl"), "fixture").expect("fixture"); + let started_at = Instant::now(); + let mut budget = DirectoryScanBudget::new_with_deadline_for_test( + 1, + 1, + started_at + Duration::from_secs(1), + ); + let clock_reads = Arc::new(AtomicUsize::new(0)); + let clock_counter = Arc::clone(&clock_reads); + let mut clock = move || { + let read = clock_counter.fetch_add(1, Ordering::Relaxed) + 1; + if read < 3 { + started_at + } else { + started_at + Duration::from_secs(2) + } + }; + + let files = budget.files_with_clock(root.path(), &mut clock); + + assert_eq!(clock_reads.load(Ordering::Relaxed), 3); + assert!(files.is_empty()); } fn scan_helper( @@ -682,4 +795,56 @@ mod pi_family_tests { assert_eq!(records[0].id, "same-id"); assert_eq!(records[0].modified_at, now - chrono::Duration::seconds(5)); } + + #[test] + fn deadline_gated_path_enrichment_preserves_records_with_time_to_spare() { + let home = tempfile::tempdir().expect("tempdir"); + let bucket = home.path().join("sessions").join("project"); + let now = utc_ts(1_900_000_000); + write_jsonl_session( + &bucket.join("first.jsonl"), + PiSessionDialect::Pi, + "first", + Path::new("/tmp/project"), + (now - chrono::Duration::seconds(10)).into(), + ); + write_jsonl_session( + &bucket.join("second.jsonl"), + PiSessionDialect::Pi, + "second", + Path::new("/tmp/project"), + (now - chrono::Duration::seconds(5)).into(), + ); + + let mut first_budget = DirectoryScanBudget::new_with_deadline_for_test( + 512, + 1, + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + let mut second_budget = DirectoryScanBudget::new_with_deadline_for_test( + 512, + 1, + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + let first = records_in_root( + &home.path().join("sessions"), + now, + PiSessionDialect::Pi, + RootLayout::ProjectDirectories, + &mut first_budget, + ); + let second = records_in_root( + &home.path().join("sessions"), + now, + PiSessionDialect::Pi, + RootLayout::ProjectDirectories, + &mut second_budget, + ); + + assert_eq!(first, second); + assert_eq!( + first.iter().map(|record| record.id.as_str()).collect::>(), + vec!["second", "first"] + ); + } } diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 9315cdd24a..51b51f9760 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -2,8 +2,10 @@ mod tests { use super::*; use chrono::TimeZone; + use std::fs; use std::io; use std::sync::Arc; + use std::time::{Duration, Instant}; #[test] fn process_parser_filters_helpers_app_server_duplicates_and_malformed_lines() { @@ -323,6 +325,161 @@ bad line assert_eq!(metadata.cwd.as_deref(), Some(r"C:\work\proj")); } + #[test] + fn claude_transcript_discovery_preserves_legacy_order_and_metadata() { + let root = tempfile::tempdir().expect("tempdir"); + let older = root.path().join("older"); + let newer = root.path().join("newer"); + fs::create_dir_all(&older).expect("older project"); + fs::create_dir_all(&newer).expect("newer project"); + let older_file = older.join("older.jsonl"); + let newer_file = newer.join("newer.jsonl"); + fs::write( + &older_file, + r#"{"type":"user","sessionId":"older-session","cwd":"C:\\work\\older"} +"#, + ) + .expect("older transcript"); + fs::write( + &newer_file, + r#"{"type":"user","sessionId":"newer-session","cwd":"C:\\work\\newer"} +"#, + ) + .expect("newer transcript"); + + let older_time = std::time::SystemTime::now() - std::time::Duration::from_secs(5); + let newer_time = std::time::SystemTime::now(); + fs::File::options() + .write(true) + .open(&older_file) + .expect("older handle") + .set_modified(older_time) + .expect("older mtime"); + fs::File::options() + .write(true) + .open(&newer_file) + .expect("newer handle") + .set_modified(newer_time) + .expect("newer mtime"); + + let mut budget = pi_family::DirectoryScanBudget::new(512, 1, Duration::from_secs(5)); + let transcripts = LocalAgentSessionScanner::claude_transcripts_from_roots( + &[root.path().to_path_buf()], + Vec::new(), + 2, + &mut budget, + ); + + assert_eq!( + transcripts + .iter() + .map(|transcript| transcript.metadata.session_id.as_deref()) + .collect::>(), + vec![Some("newer-session"), Some("older-session")] + ); + assert_eq!( + transcripts + .iter() + .map(|transcript| transcript.metadata.cwd.as_deref()) + .collect::>(), + vec![Some(r"C:\work\newer"), Some(r"C:\work\older")] + ); + } + + #[test] + fn budgeted_claude_mapper_preserves_legacy_output() { + let home = tempfile::tempdir().expect("home"); + let cwd = r"C:\work\project"; + let project = home + .path() + .join(".claude") + .join("projects") + .join(ClaudeSessionProjectMapper::escaped_cwd(cwd)); + fs::create_dir_all(&project).expect("project"); + let older = project.join("older.jsonl"); + let newer = project.join("newer.jsonl"); + fs::write(&older, b"older").expect("older"); + fs::write(&newer, b"newer").expect("newer"); + fs::File::options() + .write(true) + .open(&older) + .expect("older handle") + .set_modified(std::time::SystemTime::now() - Duration::from_secs(5)) + .expect("older mtime"); + + let legacy = ClaudeSessionProjectMapper::transcripts(cwd, home.path()); + let mut budget = pi_family::DirectoryScanBudget::new_with_deadline_for_test( + 512, + 1, + Instant::now() + Duration::from_secs(30), + ); + let budgeted = ClaudeSessionProjectMapper::transcripts_with_budget( + cwd, + home.path(), + &mut budget, + ); + + assert_eq!(budgeted, legacy); + } + + #[test] + fn claude_transcript_enrichment_does_not_start_after_deadline() { + let root = tempfile::tempdir().expect("tempdir"); + let project = root.path().join("project"); + fs::create_dir_all(&project).expect("project"); + fs::write( + project.join("session.jsonl"), + r#"{"type":"user","sessionId":"session-1","cwd":"C:\\work\\proj"} +"#, + ) + .expect("transcript"); + let mut budget = pi_family::DirectoryScanBudget::new_with_deadline_for_test( + 512, + 1, + Instant::now(), + ); + + let transcripts = LocalAgentSessionScanner::claude_transcripts_from_roots( + &[root.path().to_path_buf()], + Vec::new(), + 1, + &mut budget, + ); + + assert!(transcripts.is_empty()); + } + + #[test] + fn claude_desktop_root_discovery_honors_shared_deadline() { + let app_data = tempfile::tempdir().expect("tempdir"); + let projects = app_data + .path() + .join("claude-code-sessions") + .join("account") + .join("workspace") + .join(".claude") + .join("projects"); + fs::create_dir_all(&projects).expect("desktop projects"); + + let mut live_budget = pi_family::DirectoryScanBudget::new(512, 4, Duration::from_secs(5)); + let roots = claude_desktop::ClaudeDesktopProjectsLocator::roots_under( + app_data.path(), + &mut live_budget, + ); + assert_eq!(roots, vec![pi_family::canonicalize_for_scan(&projects)]); + + let mut expired_budget = pi_family::DirectoryScanBudget::new_with_deadline_for_test( + 512, + 4, + Instant::now(), + ); + let expired = claude_desktop::ClaudeDesktopProjectsLocator::roots_under( + app_data.path(), + &mut expired_budget, + ); + assert!(expired.is_empty()); + } + #[test] fn focus_rejects_remote_and_file_only_targets_explicitly() { let remote = AgentSession { diff --git a/rust/src/codex_costs.rs b/rust/src/codex_costs.rs index 8b6568dbcc..4474c30b2b 100644 --- a/rust/src/codex_costs.rs +++ b/rust/src/codex_costs.rs @@ -41,7 +41,12 @@ pub(crate) fn add_codex_records_to_summary( for record in records.iter().filter(|record| { CostUsageDayRange::is_in_range(&record.day_key, &range.since_key, &range.until_key) }) { - let tokens = CodexTokenCounts::from_values(record.input, record.cached, record.output); + let tokens = CodexTokenCounts::from_values(record.input, record.cached, record.output) + .with_reasoning( + record + .reasoning + .map(|reasoning| u64::try_from(reasoning.max(0)).unwrap_or(0)), + ); let pricing_day = CostUsageDayRange::parse_day_key(&record.day_key); if let Some(cost) = add_codex_tokens_to_summary(summary, &record.model, tokens, pricing_day) { @@ -63,15 +68,8 @@ pub(crate) fn merge_codex_records_into_days( continue; } let models = days.entry(record.day_key.clone()).or_default(); - let packed = models - .entry(record.model.clone()) - .or_insert_with(|| vec![0, 0, 0]); - if packed.len() < 3 { - packed.resize(3, 0); - } - packed[0] = packed[0].saturating_add(record.input.max(0)); - packed[1] = packed[1].saturating_add(record.cached.max(0)); - packed[2] = packed[2].saturating_add(record.output.max(0)); + let packed = models.entry(record.model.clone()).or_default(); + JsonlScanner::merge_codex_record_into_packed(packed, record); } } @@ -85,10 +83,14 @@ pub(crate) fn add_codex_packed_tokens_to_summary( let input = packed.first().copied().unwrap_or(0); let cached = packed.get(1).copied().unwrap_or(0); let output = packed.get(2).copied().unwrap_or(0); + let reasoning = packed + .get(3) + .copied() + .map(|reasoning| u64::try_from(reasoning.max(0)).unwrap_or(0)); add_codex_tokens_to_summary( summary, model, - CodexTokenCounts::from_values(input, cached, output), + CodexTokenCounts::from_values(input, cached, output).with_reasoning(reasoning), pricing_day, ) } @@ -147,6 +149,7 @@ struct CodexTokenCounts { input: u64, cached: u64, output: u64, + reasoning: Option, } impl CodexTokenCounts { @@ -156,18 +159,62 @@ impl CodexTokenCounts { input, cached: (cached.max(0) as u64).min(input), output: output.max(0) as u64, + reasoning: None, } } + fn with_reasoning(mut self, reasoning: Option) -> Self { + self.reasoning = reasoning; + self + } + fn is_empty(self) -> bool { self.input == 0 && self.cached == 0 && self.output == 0 } } fn add_tokens(summary: &mut ModelTokenCounts, tokens: CodexTokenCounts) { - summary.input_tokens += tokens.input; - summary.output_tokens += tokens.output; - summary.cached_tokens += tokens.cached; + let had_core_tokens = has_core_tokens(summary); + merge_reasoning_tokens( + &mut summary.reasoning_tokens, + had_core_tokens, + tokens.reasoning, + ); + summary.input_tokens = summary.input_tokens.saturating_add(tokens.input); + summary.output_tokens = summary.output_tokens.saturating_add(tokens.output); + summary.cached_tokens = summary.cached_tokens.saturating_add(tokens.cached); +} + +fn add_summary_tokens(summary: &mut CostSummary, tokens: CodexTokenCounts) { + let had_core_tokens = + summary.input_tokens != 0 || summary.output_tokens != 0 || summary.cached_tokens != 0; + merge_reasoning_tokens( + &mut summary.reasoning_tokens, + had_core_tokens, + tokens.reasoning, + ); + summary.input_tokens = summary.input_tokens.saturating_add(tokens.input); + summary.cached_tokens = summary.cached_tokens.saturating_add(tokens.cached); + summary.output_tokens = summary.output_tokens.saturating_add(tokens.output); +} + +fn has_core_tokens(counts: &ModelTokenCounts) -> bool { + counts.input_tokens != 0 || counts.output_tokens != 0 || counts.cached_tokens != 0 +} + +fn merge_reasoning_tokens( + reasoning_tokens: &mut Option, + had_core_tokens: bool, + incoming: Option, +) { + match (had_core_tokens, *reasoning_tokens, incoming) { + (false, _, incoming) => *reasoning_tokens = incoming, + (true, None, _) => {} + (true, Some(_), None) => *reasoning_tokens = None, + (true, Some(previous), Some(incoming)) => { + *reasoning_tokens = Some(previous.saturating_add(incoming)); + } + } } fn add_codex_tokens_to_summary( @@ -200,9 +247,7 @@ fn add_codex_tokens_to_summary( // not "unknown yet"). Upstream 0.48.0 F18: codex-auto-review rows are retained // with cost-nil so priced rows in the same history stay ranked. if CostUsagePricing::is_codex_unattributed_model(&model_key) || is_routing_unpriced { - summary.input_tokens += tokens.input; - summary.cached_tokens += tokens.cached; - summary.output_tokens += tokens.output; + add_summary_tokens(summary, tokens); summary.by_model.entry(model_key.clone()).or_insert(0.0); add_tokens( summary @@ -264,9 +309,7 @@ fn add_codex_tokens_to_summary( } } - summary.input_tokens += tokens.input; - summary.cached_tokens += tokens.cached; - summary.output_tokens += tokens.output; + add_summary_tokens(summary, tokens); *summary.by_model.entry(model_key.clone()).or_insert(0.0) += cost; let speed_bucket = codex_speed_bucket(&model_key); @@ -430,6 +473,116 @@ mod tests { assert!((cost - 12.50).abs() < 0.01); } + #[test] + fn token_breakdown_addition_saturates_without_wrapping() { + let counts = ModelTokenCounts { + input_tokens: u64::MAX, + output_tokens: 1, + cached_tokens: u64::MAX, + reasoning_tokens: Some(7), + }; + assert_eq!(counts.total(), u64::MAX); + + let mut merged = ModelTokenCounts { + input_tokens: u64::MAX, + output_tokens: u64::MAX, + cached_tokens: u64::MAX, + reasoning_tokens: Some(7), + }; + add_tokens( + &mut merged, + CodexTokenCounts { + input: 1, + cached: 1, + output: 1, + reasoning: Some(1), + }, + ); + assert_eq!(merged.input_tokens, u64::MAX); + assert_eq!(merged.output_tokens, u64::MAX); + assert_eq!(merged.cached_tokens, u64::MAX); + } + + #[test] + fn known_reasoning_is_exposed_without_changing_cost() { + let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(target, target); + let make_record = |reasoning| CodexUsageRecord { + day_key: "2026-05-31".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 100, + cached: 0, + output: 20, + reasoning, + }; + + let mut known_summary = CostSummary::default(); + let (known_cost, known_has_tokens) = + add_codex_records_to_summary(&mut known_summary, &[make_record(Some(7))], &range); + let mut unknown_summary = CostSummary::default(); + let (unknown_cost, unknown_has_tokens) = + add_codex_records_to_summary(&mut unknown_summary, &[make_record(None)], &range); + + assert!(known_has_tokens && unknown_has_tokens); + assert_eq!(known_summary.output_tokens, 20); + assert_eq!(known_summary.reasoning_tokens, Some(7)); + assert_eq!( + known_summary.by_model_tokens["gpt-5.6-sol"].reasoning_tokens, + Some(7) + ); + assert_eq!(known_cost, unknown_cost); + } + + #[test] + fn reasoning_unknown_is_sticky_for_summary_and_model() { + let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(target, target); + let make_record = |reasoning| CodexUsageRecord { + day_key: "2026-05-31".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 1, + cached: 0, + output: 20, + reasoning, + }; + let records = vec![ + make_record(Some(7)), + make_record(None), + make_record(Some(3)), + ]; + let mut summary = CostSummary::default(); + + add_codex_records_to_summary(&mut summary, &records, &range); + + assert_eq!(summary.reasoning_tokens, None); + assert_eq!( + summary.by_model_tokens["gpt-5.6-sol"].reasoning_tokens, + None + ); + } + + #[test] + fn packed_reasoning_slot_distinguishes_known_from_unknown() { + let target = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let mut known = CostSummary::default(); + add_codex_packed_tokens_to_summary( + &mut known, + "gpt-5.6-sol", + &[100, 0, 20, 7], + Some(target), + ); + assert_eq!(known.reasoning_tokens, Some(7)); + + let mut unknown = CostSummary::default(); + add_codex_packed_tokens_to_summary( + &mut unknown, + "gpt-5.6-sol", + &[100, 0, 20], + Some(target), + ); + assert_eq!(unknown.reasoning_tokens, None); + } + #[test] fn test_codex_pricing_uses_gpt55_standard_short_context_rates() { let cost = codex_cost_usd("gpt-5.5", 1_000_000, 400_000, 1_000_000); @@ -451,6 +604,7 @@ mod tests { input: 200_000, cached: 0, output: 0, + reasoning: None, }, CodexUsageRecord { day_key: "2026-05-31".to_string(), @@ -458,6 +612,7 @@ mod tests { input: 200_000, cached: 0, output: 0, + reasoning: None, }, CodexUsageRecord { day_key: "2026-05-30".to_string(), @@ -465,6 +620,7 @@ mod tests { input: 200_000, cached: 0, output: 0, + reasoning: None, }, ]; let mut summary = CostSummary::default(); @@ -511,6 +667,7 @@ mod tests { input: 100, cached: 0, output: 5, + reasoning: None, }, CodexUsageRecord { day_key: "2026-08-19".to_string(), @@ -518,6 +675,7 @@ mod tests { input: 1_000_000, cached: 0, output: 1_000_000, + reasoning: None, }, ]; let mut summary = CostSummary::default(); @@ -540,6 +698,7 @@ mod tests { input: 10, cached: 0, output: 1, + reasoning: None, }]; let mut days = std::collections::HashMap::new(); merge_codex_records_into_days(&mut days, &records); @@ -556,6 +715,7 @@ mod tests { input: 55_000_000, cached: 0, output: 0, + reasoning: None, }]; let mut summary = CostSummary::default(); @@ -584,6 +744,7 @@ mod tests { input: 1_000_000, cached: 0, output: 1_000_000, + reasoning: None, }]; let mut summary = CostSummary::default(); diff --git a/rust/src/core/cost_cache_budget.rs b/rust/src/core/cost_cache_budget.rs index faeb55d3bc..4edd0e02f4 100644 --- a/rust/src/core/cost_cache_budget.rs +++ b/rust/src/core/cost_cache_budget.rs @@ -325,6 +325,12 @@ mod tests { parsed_bytes: parsed, last_model: None, last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, } } diff --git a/rust/src/core/jsonl_scanner.rs b/rust/src/core/jsonl_scanner.rs index 77be5ee62e..b6328e29b4 100755 --- a/rust/src/core/jsonl_scanner.rs +++ b/rust/src/core/jsonl_scanner.rs @@ -9,13 +9,18 @@ )] use crate::core::{CostUsagePricing, ProviderId}; -use chrono::{DateTime, Local, NaiveDate, Utc}; +use chrono::{NaiveDate, Utc}; + +#[cfg(test)] +use chrono::{DateTime, Local}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::fs::{self, File}; -use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::hash::{Hash, Hasher}; +use std::io::{BufReader, Seek, SeekFrom}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; #[derive(Debug, Clone, Default)] pub struct CachedCostReadStatus { @@ -69,6 +74,12 @@ const CODEX_JSONL_MAX_LINE_BYTES: usize = 256 * 1024; /// Default scanner-side refresh debounce (upstream CostUsageScanner). pub const DEFAULT_COST_SCAN_REFRESH_MIN_INTERVAL_SECS: u64 = 60; +/// Default number of dirty Codex rollouts inspected in one refresh. +pub const DEFAULT_CODEX_CANDIDATE_LIMIT: usize = 512; +/// Default maximum newly-read bytes from one Codex rollout in one refresh. +pub const DEFAULT_CODEX_MAX_SESSION_FILE_BYTES: i64 = 256 * 1024 * 1024; +/// Default maximum newly-read Codex bytes across one refresh. +pub const DEFAULT_CODEX_MAX_SCAN_BYTES_PER_REFRESH: i64 = 512 * 1024 * 1024; /// Options for a cost scan pass (disk-cache-backed full inspections). /// @@ -85,6 +96,14 @@ pub struct CostScanOptions { /// pi/OMP-compatible agent session mirrors from Codex/Claude cost history. /// Defaults to true (include mirrors) for backward compatibility. pub include_pi_sessions: bool, + /// Maximum bytes newly read from one Codex rollout during a refresh. + pub codex_max_session_file_bytes: i64, + /// Maximum Codex JSONL bytes newly read across one refresh. + pub codex_max_scan_bytes_per_refresh: i64, + /// Maximum dirty/new Codex rollout candidates processed per refresh. + pub codex_candidate_limit: usize, + /// Prefer recent Codex rollouts while historical catch-up is pending. + pub prefer_newest_codex_sessions_first: bool, } impl Default for CostScanOptions { @@ -92,6 +111,10 @@ impl Default for CostScanOptions { Self { refresh_min_interval_secs: DEFAULT_COST_SCAN_REFRESH_MIN_INTERVAL_SECS, include_pi_sessions: true, + codex_max_session_file_bytes: DEFAULT_CODEX_MAX_SESSION_FILE_BYTES, + codex_max_scan_bytes_per_refresh: DEFAULT_CODEX_MAX_SCAN_BYTES_PER_REFRESH, + codex_candidate_limit: DEFAULT_CODEX_CANDIDATE_LIMIT, + prefer_newest_codex_sessions_first: true, } } } @@ -101,7 +124,7 @@ impl CostScanOptions { pub fn app_driven() -> Self { Self { refresh_min_interval_secs: 0, - include_pi_sessions: true, + ..Self::default() } } @@ -119,6 +142,23 @@ impl CostScanOptions { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CacheStamp { + byte_len: usize, + content_hash: u64, +} + +impl CacheStamp { + fn from_bytes(bytes: &[u8]) -> Self { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + Self { + byte_len: bytes.len(), + content_hash: hasher.finish(), + } + } +} + /// Cache for scanned file data #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CostUsageCache { @@ -126,7 +166,7 @@ pub struct CostUsageCache { pub last_scan_unix_ms: i64, /// Per-file usage data pub files: HashMap, - /// Aggregated daily data: day_key -> model -> [input, cached, output] + /// Aggregated daily data: day_key -> model -> [input, cached, output, reasoning?] pub days: HashMap>>, /// Inclusive range covered by the last successful full inspection. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -139,6 +179,16 @@ pub struct CostUsageCache { /// publication completeness is carried separately on `CostSummary`. #[serde(default, skip_serializing_if = "Option::is_none")] pub previous_report: Option, + /// Dirty/incomplete Codex rollouts deferred by the foreground work budget. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub codex_pending_paths: Vec, + /// True while bounded Codex catch-up has not completed for this window. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub codex_scan_incomplete: bool, + /// Content stamp of the decoded on-disk baseline. This is process-local + /// and omitted from JSON so a stale reader cannot replace a newer cache. + #[serde(skip)] + pub(crate) loaded_stamp: Option>, } /// Per-file usage tracking @@ -156,6 +206,38 @@ pub struct CostUsageFileUsage { pub last_model: Option, /// Last token totals (for delta calculations) pub last_totals: Option, + /// Whether the parsed Codex token timestamps were non-decreasing. + /// + /// `None` is an old cache entry that has never had its timestamp order + /// validated. Such an entry must not use the append-only fast path until + /// a full parse establishes this state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_token_timestamps_monotonic: Option, + /// The last parsed Codex token timestamp, used to validate an appended + /// suffix without replaying the cached prefix. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_last_token_timestamp: Option, + /// Native Codex session identity from the first authoritative session_meta row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_session_id: Option, + /// Native Codex parent session identity for forked rollouts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_forked_from_id: Option, + /// Native Codex fork timestamp used for safe parent-baseline validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_fork_timestamp: Option, + /// True when a fork cannot be billed safely until its parent is available. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub codex_unresolved_fork_parent: bool, +} + +/// Lightweight identity metadata read from the first authoritative Codex +/// `session_meta` row. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CodexSessionMetadata { + pub session_id: Option, + pub forked_from_id: Option, + pub fork_timestamp: Option, } /// Running totals for Codex token counting @@ -164,6 +246,8 @@ pub struct CodexTotals { pub input: i32, pub cached: i32, pub output: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } /// Snapshot of the last validated cost report, persisted so spend surfaces keep @@ -180,6 +264,9 @@ pub struct CachedCostReport { pub cached_tokens: i32, /// Total output tokens. pub output_tokens: i32, + /// Total reasoning output tokens when every contributing packed row knows it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, /// Number of sessions contributing. pub sessions_count: i32, /// ISO 8601 timestamp when this report was generated. @@ -200,6 +287,19 @@ pub struct CodexParseResult { pub last_model: Option, /// Last totals seen pub last_totals: Option, + /// Timestamp-order state for the parsed token history. + pub token_timestamps_monotonic: Option, + /// Last token timestamp observed by the parser. + pub last_token_timestamp: Option, + /// Number of timestamp comparisons performed while validating this parse. + pub token_timestamp_comparisons: u64, + /// Newly consumed bytes in this parse pass. + pub bytes_read: i64, + /// Whether this pass reached the file's current EOF without cancellation/budget deferral. + pub is_complete: bool, + /// A fork-baseline parse observed a cumulative component below the inherited + /// parent baseline. The child must be discarded rather than billed as fresh. + pub fork_baseline_ambiguous: bool, } /// A billable Codex token-count delta. @@ -210,6 +310,7 @@ pub struct CodexUsageRecord { pub input: i32, pub cached: i32, pub output: i32, + pub reasoning: Option, } /// Day range for scanning @@ -248,823 +349,9 @@ impl CostUsageDayRange { /// JSONL Scanner for cost/usage logs pub struct JsonlScanner; - -struct CodexParserState { - current_model: Option, - previous_totals: Option, - /// High watermark of observed cumulative totals (never lowered). Used for - /// Ultra interleaved-lineage containment (issue #2037 Phase 1). - totals_watermark: Option, - /// Latched once any cumulative component drops below the watermark. - saw_interleaved_totals: bool, - records: Vec, -} - -#[derive(Debug, Deserialize)] -struct CodexFastLine<'a> { - #[serde(rename = "type", borrow)] - event_type: Option<&'a str>, - #[serde(default, borrow)] - timestamp: Option<&'a str>, - #[serde(default, borrow)] - payload: Option>, - #[serde(default, borrow)] - event_msg: Option>, - #[serde(default, borrow)] - model: Option<&'a str>, -} - -#[derive(Debug, Deserialize)] -struct CodexFastPayload<'a> { - #[serde(rename = "type", borrow)] - payload_type: Option<&'a str>, - #[serde(default, borrow)] - model: Option<&'a str>, - #[serde(default, borrow)] - model_name: Option<&'a str>, - #[serde(default, borrow)] - info: Option>, - #[serde(default)] - input_tokens: Option, - #[serde(default)] - cached_input_tokens: Option, - #[serde(default)] - cache_read_input_tokens: Option, - #[serde(default)] - output_tokens: Option, -} - -#[derive(Debug, Deserialize)] -struct CodexFastInfo<'a> { - #[serde(default, borrow)] - model: Option<&'a str>, - #[serde(default, borrow)] - model_name: Option<&'a str>, - #[serde(default)] - total_token_usage: Option, - #[serde(default)] - last_token_usage: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -struct CodexFastTotals { - #[serde(default)] - input_tokens: i32, - #[serde(default)] - cached_input_tokens: Option, - #[serde(default)] - cache_read_input_tokens: Option, - #[serde(default)] - output_tokens: i32, -} - -enum CodexFastEvent<'a> { - TurnContext { - model: Option<&'a str>, - }, - TokenCount { - timestamp: &'a str, - payload: CodexFastPayload<'a>, - }, -} - -impl CodexParserState { - fn new(initial_model: Option, initial_totals: Option) -> Self { - Self { - current_model: initial_model, - previous_totals: initial_totals.clone(), - totals_watermark: initial_totals, - saw_interleaved_totals: false, - records: Vec::new(), - } - } - - fn process_line(&mut self, line: &str, range: &CostUsageDayRange) { - let event_candidate = is_candidate_codex_line(line); - let bare_candidate = !event_candidate && line.contains("\"usage\""); - if !event_candidate && !bare_candidate { - return; - } - - if event_candidate && let Some(event) = parse_codex_fast_event(line) { - self.process_fast_event(event, range); - return; - } - - let Ok(obj) = serde_json::from_str::(line) else { - return; - }; - - if bare_candidate { - if obj.get("type").is_some() { - return; - } - let Some(day_key) = codex_line_day_key(&obj, range) - .or_else(|| self.records.last().map(|record| record.day_key.clone())) - else { - return; - }; - if let Some((totals, model)) = bare_usage_totals(&obj) { - let model = self - .current_model - .as_deref() - .and_then(model_evidence) - .or(model.as_deref().and_then(model_evidence)) - .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) - .to_string(); - self.record_usage(day_key, &model, totals.input, totals.cached, totals.output); - } - return; - } - - let Some(day_key) = codex_line_day_key(&obj, range) else { - return; - }; - if obj.get("type").and_then(|v| v.as_str()) == Some("turn_context") { - self.update_current_model(&obj); - } - - if token_count_payload(&obj).is_some() { - self.record_token_count(&obj, day_key); - } - } - - fn process_fast_event(&mut self, event: CodexFastEvent<'_>, range: &CostUsageDayRange) { - match event { - CodexFastEvent::TurnContext { model } => { - // Explicit blank model evidence clears stale turn context. - if let Some(raw) = model { - self.current_model = model_evidence(raw).map(str::to_string); - } - } - CodexFastEvent::TokenCount { timestamp, payload } => { - let Some(day_key) = codex_timestamp_day_key(timestamp) else { - return; - }; - if !CostUsageDayRange::is_in_range( - &day_key, - &range.scan_since_key, - &range.scan_until_key, - ) { - return; - } - self.record_fast_token_count(payload, day_key); - } - } - } - - fn update_current_model(&mut self, obj: &Value) { - let candidates = [ - obj.get("model").and_then(|v| v.as_str()), - obj.get("payload") - .and_then(|payload| payload.get("model")) - .and_then(|v| v.as_str()), - obj.get("payload") - .and_then(|payload| payload.get("model_name")) - .and_then(|v| v.as_str()), - obj.get("payload") - .and_then(|payload| payload.get("info")) - .and_then(|info| info.get("model")) - .and_then(|v| v.as_str()), - obj.get("payload") - .and_then(|payload| payload.get("info")) - .and_then(|info| info.get("model_name")) - .and_then(|v| v.as_str()), - ]; - // Only rewrite current_model when the turn_context actually carries a - // model field (including blank, which clears stale attribution). - let has_key = candidates.iter().any(|c| c.is_some()); - if !has_key { - return; - } - self.current_model = candidates - .into_iter() - .flatten() - .find_map(model_evidence) - .map(str::to_string); - } - - fn record_token_count(&mut self, obj: &Value, day_key: String) { - let Some(payload) = token_count_payload(obj) else { - return; - }; - let Some((delta_input, delta_cached, delta_output)) = self.token_deltas(payload) else { - return; - }; - if delta_input == 0 && delta_cached == 0 && delta_output == 0 { - return; - } - - let info = payload.get("info"); - let model = self.resolve_token_model(info, payload, obj); - self.record_usage(day_key, &model, delta_input, delta_cached, delta_output); - } - - fn record_fast_token_count(&mut self, payload: CodexFastPayload<'_>, day_key: String) { - let Some((delta_input, delta_cached, delta_output)) = self.fast_token_deltas(&payload) - else { - return; - }; - if delta_input == 0 && delta_cached == 0 && delta_output == 0 { - return; - } - - let event_model = payload - .info - .as_ref() - .and_then(|info| info.model.or(info.model_name)) - .or(payload.model) - .and_then(model_evidence); - // Prefer current turn_context model over a conflicting event model, - // matching upstream precedence. Fall back to unattributed (not gpt-5). - let model = self - .current_model - .as_deref() - .and_then(model_evidence) - .or(event_model) - .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) - .to_string(); - self.record_usage(day_key, &model, delta_input, delta_cached, delta_output); - } - - fn record_usage(&mut self, day_key: String, model: &str, input: i32, cached: i32, output: i32) { - self.records.push(CodexUsageRecord { - day_key, - model: CostUsagePricing::normalize_codex_model(model), - input, - cached: cached.min(input), - output, - }); - } - - fn resolve_token_model(&self, info: Option<&Value>, payload: &Value, obj: &Value) -> String { - let event_model = info - .and_then(|i| i.get("model").or(i.get("model_name"))) - .or_else(|| payload.get("model")) - .or_else(|| obj.get("model")) - .and_then(|v| v.as_str()) - .and_then(model_evidence); - self.current_model - .as_deref() - .and_then(model_evidence) - .or(event_model) - .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) - .to_string() - } - - fn token_deltas(&mut self, payload: &Value) -> Option<(i32, i32, i32)> { - let info = payload.get("info"); - if let Some(total) = info.and_then(|i| i.get("total_token_usage")) { - return Some(self.total_usage_delta(total)); - } - - if let Some(last) = info.and_then(|i| i.get("last_token_usage")) { - return Some(last_usage_delta(last)); - } - - let direct = read_token_totals(payload); - (direct.input != 0 || direct.cached != 0 || direct.output != 0).then_some(( - direct.input.max(0), - direct.cached.max(0), - direct.output.max(0), - )) - } - - fn fast_token_deltas(&mut self, payload: &CodexFastPayload<'_>) -> Option<(i32, i32, i32)> { - if let Some(total) = payload - .info - .as_ref() - .and_then(|info| info.total_token_usage) - { - return Some(self.fast_total_usage_delta(total)); - } - - if let Some(last) = payload.info.as_ref().and_then(|info| info.last_token_usage) { - return Some(fast_last_usage_delta(last)); - } - - let direct = fast_totals_from_payload(payload); - (direct.input != 0 || direct.cached != 0 || direct.output != 0).then_some(( - direct.input.max(0), - direct.cached.max(0), - direct.output.max(0), - )) - } - - fn total_usage_delta(&mut self, total: &Value) -> (i32, i32, i32) { - let totals = read_token_totals(total); - self.apply_totals_delta(totals) - } - - fn fast_total_usage_delta(&mut self, total: CodexFastTotals) -> (i32, i32, i32) { - let totals = codex_totals_from_fast(total); - self.apply_totals_delta(totals) - } - - fn apply_totals_delta(&mut self, totals: CodexTotals) -> (i32, i32, i32) { - self.latch_if_below_watermark(&totals); - - let delta = if self.saw_interleaved_totals { - contained_total_delta( - self.totals_watermark.as_ref(), - self.previous_totals.as_ref(), - &totals, - ) - } else { - let previous = self.previous_totals.as_ref(); - CodexTotals { - input: (totals.input - previous.map_or(0, |t| t.input)).max(0), - cached: (totals.cached - previous.map_or(0, |t| t.cached)).max(0), - output: (totals.output - previous.map_or(0, |t| t.output)).max(0), - } - }; - - self.previous_totals = Some(totals.clone()); - self.raise_watermark(&totals); - (delta.input, delta.cached, delta.output) - } - - fn latch_if_below_watermark(&mut self, totals: &CodexTotals) { - let Some(water) = self.totals_watermark.as_ref() else { - return; - }; - if totals.input < water.input - || totals.cached < water.cached - || totals.output < water.output - { - self.saw_interleaved_totals = true; - } - } - - fn raise_watermark(&mut self, totals: &CodexTotals) { - self.totals_watermark = Some(match self.totals_watermark.as_ref() { - Some(water) => CodexTotals { - input: water.input.max(totals.input), - cached: water.cached.max(totals.cached), - output: water.output.max(totals.output), - }, - None => totals.clone(), - }); - } -} - -fn model_evidence(raw: &str) -> Option<&str> { - let trimmed = raw.trim(); - (!trimmed.is_empty()).then_some(trimmed) -} - -/// When interleaved Ultra lineages reset cumulative counters, only count growth -/// above the historical high watermark so rewound branches do not re-add work. -fn contained_total_delta( - watermark: Option<&CodexTotals>, - counted: Option<&CodexTotals>, - current: &CodexTotals, -) -> CodexTotals { - let water = watermark.cloned().unwrap_or(CodexTotals { - input: 0, - cached: 0, - output: 0, - }); - let counted = counted.cloned().unwrap_or(CodexTotals { - input: 0, - cached: 0, - output: 0, - }); - - let component = |water: i32, counted: i32, current: i32| -> i32 { - if current >= water { - // Only growth above the historical high watermark counts. - (current - water.max(counted)).max(0) - } else { - // Below watermark: rewind / interleaved lineage — do not re-add - // mid-range climbs that would inflate totals after a fork reset. - 0 - } - }; - - CodexTotals { - input: component(water.input, counted.input, current.input), - cached: component(water.cached, counted.cached, current.cached), - output: component(water.output, counted.output, current.output), - } -} - -/// Read one JSONL line, discarding content when it exceeds `max_bytes`. -/// Returns `(line_without_newline, bytes_consumed_including_newline)`. -fn read_bounded_jsonl_line( - reader: &mut R, - max_bytes: usize, -) -> std::io::Result, usize)>> { - let mut line = Vec::new(); - let mut saw_bytes = false; - let mut discarding = false; - let mut consumed_total = 0; - - loop { - let chunk = reader.fill_buf()?; - if chunk.is_empty() { - return Ok( - saw_bytes.then_some((if discarding { Vec::new() } else { line }, consumed_total)) - ); - } - let newline = chunk.iter().position(|byte| *byte == b'\n'); - let segment_end = newline.unwrap_or(chunk.len()); - let segment = &chunk[..segment_end]; - saw_bytes = true; - - if !discarding { - let remaining = max_bytes.saturating_sub(line.len()); - if segment.len() <= remaining { - line.extend_from_slice(segment); - } else { - line.clear(); - discarding = true; - } - } - - let consumed = segment_end + usize::from(newline.is_some()); - reader.consume(consumed); - consumed_total += consumed; - if newline.is_some() { - return Ok(Some(( - if discarding { Vec::new() } else { line }, - consumed_total, - ))); - } - } -} - -fn parse_codex_fast_event(line: &str) -> Option> { - let parsed: CodexFastLine<'_> = serde_json::from_str(line).ok()?; - match parsed.event_type? { - "turn_context" => { - let model = parsed - .payload - .as_ref() - .and_then(|payload| { - payload.model.or(payload.model_name).or_else(|| { - payload - .info - .as_ref() - .and_then(|info| info.model.or(info.model_name)) - }) - }) - .or(parsed.model); - Some(CodexFastEvent::TurnContext { model }) - } - "event_msg" => { - let payload = parsed.payload.or(parsed.event_msg)?; - (payload.payload_type == Some("token_count")).then_some(CodexFastEvent::TokenCount { - timestamp: parsed.timestamp?, - payload, - }) - } - _ => None, - } -} - -fn is_candidate_codex_line(line: &str) -> bool { - if !line.contains("\"type\":\"event_msg\"") - && !line.contains("\"type\":\"turn_context\"") - && !line.contains("\"event_msg\"") - { - return false; - } - - !line.contains("\"type\":\"event_msg\"") || line.contains("\"token_count\"") -} - -fn codex_line_day_key(obj: &Value, range: &CostUsageDayRange) -> Option { - let ts = obj.get("timestamp").and_then(|v| v.as_str())?; - let day_key = codex_timestamp_day_key(ts)?; - - CostUsageDayRange::is_in_range(&day_key, &range.scan_since_key, &range.scan_until_key) - .then_some(day_key) -} - -fn codex_timestamp_day_key(timestamp: &str) -> Option { - DateTime::parse_from_rfc3339(timestamp) - .ok() - .map(|ts| { - ts.with_timezone(&Local) - .date_naive() - .format("%Y-%m-%d") - .to_string() - }) - .or_else(|| timestamp.get(..10).map(str::to_string)) -} - -fn bare_usage_totals(obj: &Value) -> Option<(CodexTotals, Option)> { - let usage = obj - .get("usage") - .or_else(|| obj.get("data").and_then(|v| v.get("usage"))) - .or_else(|| obj.get("result").and_then(|v| v.get("usage"))) - .or_else(|| obj.get("response").and_then(|v| v.get("usage")))?; - // Token counts come from usage records and fit i32, the canonical totals storage type. - #[allow( - clippy::cast_possible_truncation, - reason = "usage token counts fit i32, the canonical totals storage type" - )] - let input = ["input_tokens", "prompt_tokens", "input"] - .into_iter() - .find_map(|key| usage.get(key).and_then(Value::as_i64)) - .unwrap_or(0) - .max(0) as i32; - // Token counts come from usage records and fit i32, the canonical totals storage type. - #[allow( - clippy::cast_possible_truncation, - reason = "usage token counts fit i32, the canonical totals storage type" - )] - let output = ["output_tokens", "completion_tokens", "output"] - .into_iter() - .find_map(|key| usage.get(key).and_then(Value::as_i64)) - .unwrap_or(0) - .max(0) as i32; - // Token counts come from usage records and fit i32, the canonical totals storage type. - #[allow( - clippy::cast_possible_truncation, - reason = "usage token counts fit i32, the canonical totals storage type" - )] - let cached = [ - "cached_input_tokens", - "cache_read_input_tokens", - "cached_tokens", - ] - .into_iter() - .filter_map(|key| usage.get(key).and_then(Value::as_i64)) - .max() - .unwrap_or(0) - .max(0) as i32; - if input == 0 && output == 0 && cached == 0 { - return None; - } - let model = obj - .get("model") - .or_else(|| obj.get("data").and_then(|v| v.get("model"))) - .or_else(|| obj.get("result").and_then(|v| v.get("model"))) - .or_else(|| obj.get("response").and_then(|v| v.get("model"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string); - Some(( - CodexTotals { - input, - cached, - output, - }, - model, - )) -} - -fn token_count_payload(obj: &Value) -> Option<&Value> { - if let Some(payload) = obj.get("payload") - && payload.get("type").and_then(|v| v.as_str()) == Some("token_count") - { - return Some(payload); - } - - let event_msg = obj.get("event_msg")?; - (event_msg.get("type").and_then(|v| v.as_str()) == Some("token_count")).then_some(event_msg) -} - -fn read_token_totals(value: &Value) -> CodexTotals { - // Token counts come from Codex usage records and fit within i32, which is - // the canonical storage type of the totals table. - #[allow( - clippy::cast_possible_truncation, - reason = "token counts from usage records fit i32" - )] - let cached = value - .get("cached_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0) - .max( - value - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0), - ) as i32; - CodexTotals { - input: token_i32(value, "input_tokens"), - cached, - output: token_i32(value, "output_tokens"), - } -} - -fn codex_totals_from_fast(value: CodexFastTotals) -> CodexTotals { - CodexTotals { - input: value.input_tokens, - cached: value - .cached_input_tokens - .unwrap_or(0) - .max(value.cache_read_input_tokens.unwrap_or(0)), - output: value.output_tokens, - } -} - -fn fast_totals_from_payload(value: &CodexFastPayload<'_>) -> CodexTotals { - CodexTotals { - input: value.input_tokens.unwrap_or(0), - cached: value - .cached_input_tokens - .unwrap_or(0) - .max(value.cache_read_input_tokens.unwrap_or(0)), - output: value.output_tokens.unwrap_or(0), - } -} - -fn token_i32(value: &Value, key: &str) -> i32 { - // Token counts from usage records fit i32, the canonical totals storage type. - #[allow( - clippy::cast_possible_truncation, - reason = "token counts from usage records fit i32" - )] - let tokens = value.get(key).and_then(|v| v.as_i64()).unwrap_or(0) as i32; - tokens -} - -fn last_usage_delta(last: &Value) -> (i32, i32, i32) { - let totals = read_token_totals(last); - ( - totals.input.max(0), - totals.cached.max(0), - totals.output.max(0), - ) -} - -fn fast_last_usage_delta(last: CodexFastTotals) -> (i32, i32, i32) { - let totals = codex_totals_from_fast(last); - ( - totals.input.max(0), - totals.cached.max(0), - totals.output.max(0), - ) -} +mod codex; impl JsonlScanner { - /// Get default Codex sessions root directory - pub fn default_codex_sessions_root() -> Option { - // Check CODEX_HOME environment variable - if let Ok(home) = std::env::var("CODEX_HOME") { - let home = home.trim(); - if !home.is_empty() { - return Some(PathBuf::from(home).join("sessions")); - } - } - - // Default to ~/.codex/sessions - dirs::home_dir().map(|h| h.join(".codex").join("sessions")) - } - - /// Get default Claude projects roots - pub fn default_claude_projects_roots() -> Vec { - let mut roots = Vec::new(); - - // Check CLAUDE_CONFIG_DIR - if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { - let path = PathBuf::from(config_dir.trim()).join("projects"); - if path.exists() { - roots.push(path); - } - } - - // Default locations - if let Some(home) = dirs::home_dir() { - let default_path = home.join(".claude").join("projects"); - if default_path.exists() && !roots.contains(&default_path) { - roots.push(default_path); - } - } - - roots - } - - /// List Codex session files in the given date range - pub fn list_codex_session_files( - root: &Path, - scan_since_key: &str, - scan_until_key: &str, - ) -> Vec { - let mut files = Vec::new(); - - let Some(mut date) = CostUsageDayRange::parse_day_key(scan_since_key) else { - return files; - }; - let Some(until_date) = CostUsageDayRange::parse_day_key(scan_until_key) else { - return files; - }; - - while date <= until_date { - let year = format!("{:04}", date.year()); - let month = format!("{:02}", date.month()); - let day = format!("{:02}", date.day()); - - let day_dir = root.join(&year).join(&month).join(&day); - - if let Ok(entries) = fs::read_dir(&day_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path - .extension() - .is_some_and(|e| e.eq_ignore_ascii_case("jsonl")) - { - files.push(path); - } - } - } - - date += chrono::Duration::days(1); - } - - files - } - - /// Parse a Codex JSONL file - pub fn parse_codex_file( - file_path: &Path, - range: &CostUsageDayRange, - start_offset: i64, - initial_model: Option, - initial_totals: Option, - ) -> std::io::Result { - let file = File::open(file_path)?; - // Session JSONL files are bounded by the cache budget; sizes fit i64. - #[allow( - clippy::cast_possible_wrap, - reason = "session JSONL file sizes fit i64" - )] - let file_size = file.metadata()?.len() as i64; - - let mut reader = BufReader::new(file); - if start_offset > 0 { - reader.seek(SeekFrom::Start(start_offset as u64))?; - } - - let mut parser = CodexParserState::new(initial_model, initial_totals); - let mut parsed_bytes = start_offset; - - while let Some((line_bytes, consumed)) = - read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES)? - { - // Per-line byte counts are capped at 256 KiB, far inside i64::MAX. - #[allow( - clippy::cast_possible_wrap, - reason = "per-line consumed bytes are capped at CODEX_JSONL_MAX_LINE_BYTES" - )] - let consumed_i64 = consumed as i64; - parsed_bytes += consumed_i64; - if line_bytes.is_empty() { - continue; - } - let Ok(line) = std::str::from_utf8(&line_bytes) else { - continue; - }; - let line = line.strip_suffix('\r').unwrap_or(line); - parser.process_line(line, range); - } - - Ok(CodexParseResult { - records: parser.records, - parsed_bytes: file_size.max(parsed_bytes), - last_model: parser.current_model, - last_totals: parser.previous_totals, - }) - } - - /// F2 (upstream 0.48.0 #2648): whether a cached resume offset sits on a real - /// line boundary. A partial trailing-line write leaves the cached offset - /// mid-line; resuming there re-parses from mid-line and corrupts the first - /// resumed record. Returns when the byte just before is - /// not a newline (or the probe fails), signalling the caller to fall back - /// to a full re-parse from zero. - pub fn is_line_boundary_offset(file_path: &Path, offset: i64) -> bool { - use std::io::{Read, Seek}; - if offset <= 0 { - return true; - } - // Session JSONL file sizes fit i64; metadata feeds only boundary probes. - #[allow( - clippy::cast_possible_wrap, - reason = "session JSONL file sizes fit i64" - )] - let file_size_i64 = fs::metadata(file_path).map(|m| m.len() as i64); - let Ok(file_size) = file_size_i64 else { - return false; - }; - if offset >= file_size { - return true; - } - let Ok(mut probe) = File::open(file_path) else { - return false; - }; - if probe.seek(SeekFrom::Start((offset - 1) as u64)).is_err() { - return false; - } - let mut prev_byte = [0u8; 1]; - probe.read_exact(&mut prev_byte).is_ok() && prev_byte[0] == b'\n' - } - /// Whether a cached scan should be reused under `options` (issue #2089). pub fn should_skip_cached_scan( cache: &CostUsageCache, @@ -1098,12 +385,18 @@ impl JsonlScanner { } if let Ok(contents) = fs::read_to_string(&cache_path) - && let Ok(cache) = serde_json::from_str(&contents) + && let Ok(mut cache) = serde_json::from_str::(&contents) { + cache.loaded_stamp = Some(Some(CacheStamp::from_bytes(contents.as_bytes()))); return cache; } - CostUsageCache::default() + // Track a missing or unreadable baseline separately from a manually + // constructed cache so a concurrent first writer can invalidate it. + CostUsageCache { + loaded_stamp: Some(Self::cache_stamp(&cache_path)), + ..CostUsageCache::default() + } } /// Read only the cache metadata needed by presentation surfaces. @@ -1139,11 +432,13 @@ impl JsonlScanner { previous_report: projection.previous_report, } } - fn cached_cost_report_from_days(cache: &CostUsageCache) -> CachedCostReport { + pub(crate) fn cached_cost_report_from_days(cache: &CostUsageCache) -> CachedCostReport { let mut total_cost_usd = 0.0; let mut input_tokens = 0_i32; let mut cached_tokens = 0_i32; let mut output_tokens = 0_i32; + let mut reasoning_tokens = 0_i32; + let mut reasoning_known = true; let mut partial = false; for (day_key, models) in &cache.days { @@ -1155,6 +450,14 @@ impl JsonlScanner { input_tokens = input_tokens.saturating_add(input); cached_tokens = cached_tokens.saturating_add(cached); output_tokens = output_tokens.saturating_add(output); + if input > 0 || cached > 0 || output > 0 { + if let Some(reasoning) = values.get(3).copied() { + reasoning_tokens = + reasoning_tokens.saturating_add(reasoning.max(0).min(output)); + } else { + reasoning_known = false; + } + } if CostUsagePricing::is_codex_unattributed_model(model) { partial = true; @@ -1202,12 +505,35 @@ impl JsonlScanner { input_tokens, cached_tokens, output_tokens, + reasoning_tokens: reasoning_known.then_some(reasoning_tokens), sessions_count, updated_at: Some(Utc::now().to_rfc3339()), partial, } } + /// Merge one Codex record into a packed day/model row. A three-slot row is + /// deliberately treated as reasoning-unknown, including when a known row + /// is merged into an existing legacy row. + pub(crate) fn merge_codex_record_into_packed(packed: &mut Vec, record: &CodexUsageRecord) { + let was_empty = packed.is_empty(); + if packed.len() < 3 { + packed.resize(3, 0); + } + packed[0] = packed[0].saturating_add(record.input.max(0)); + packed[1] = packed[1].saturating_add(record.cached.max(0)); + packed[2] = packed[2].saturating_add(record.output.max(0)); + + match record.reasoning { + Some(reasoning) if was_empty => packed.push(reasoning.max(0).min(record.output.max(0))), + Some(reasoning) if packed.len() >= 4 => { + packed[3] = packed[3].saturating_add(reasoning.max(0).min(record.output.max(0))); + } + Some(_) => {} + None => packed.truncate(3), + } + } + /// Save cache to disk (temp sibling + copy into place). /// /// Before encoding, prunes the cache to the persistence budget so the @@ -1239,6 +565,15 @@ impl JsonlScanner { ) { let cache_path = Self::cache_path(provider, cache_root); + // A decoded baseline is only valid for the file contents that produced + // it. Refuse a stale writer before pruning or creating directories so a + // concurrent scan remains authoritative. + if let Some(expected) = cache.loaded_stamp.as_ref() + && Self::cache_stamp(&cache_path).as_ref() != expected.as_ref() + { + return; + } + let Some(parent) = cache_path.parent() else { return; }; @@ -1319,10 +654,23 @@ impl JsonlScanner { if fs::write(&tmp_path, json.as_bytes()).is_err() { return; } + // Recheck after encoding/pruning: another scan may have replaced the + // destination while this writer was preparing its payload. + if let Some(expected) = cache.loaded_stamp.as_ref() + && Self::cache_stamp(&cache_path).as_ref() != expected.as_ref() + { + let _removed_tmp = fs::remove_file(&tmp_path); + return; + } // `copy` replaces an existing target on Windows; prefer it over rename. - if fs::copy(&tmp_path, &cache_path).is_err() { + let wrote = if fs::copy(&tmp_path, &cache_path).is_ok() { + true + } else { // Fallback direct write when copy fails; the copy error already surfaced. - let _fallback_written = fs::write(&cache_path, json.as_bytes()); + fs::write(&cache_path, json.as_bytes()).is_ok() + }; + if wrote { + cache.loaded_stamp = Some(Some(CacheStamp::from_bytes(json.as_bytes()))); } // Best-effort temp cleanup (ignore errors — unique name avoids clashes). let _truncated_tmp = fs::File::create(&tmp_path).and_then(|f| f.set_len(0)); @@ -1344,6 +692,12 @@ impl JsonlScanner { .join(format!("{}-v1.json", provider.cli_name())) } + fn cache_stamp(cache_path: &Path) -> Option { + fs::read(cache_path) + .ok() + .map(|contents| CacheStamp::from_bytes(&contents)) + } + /// Whether `cache` covers the requested day window (for debounce short-circuit). pub fn cache_covers_range(cache: &CostUsageCache, range: &CostUsageDayRange) -> bool { match (&cache.scan_since_key, &cache.scan_until_key) { @@ -1357,764 +711,3 @@ impl JsonlScanner { } use chrono::Datelike; - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - use std::io::Write; - - #[test] - fn test_day_range() { - let since = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); - let until = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap(); - let range = CostUsageDayRange::new(since, until); - - assert_eq!(range.since_key, "2026-01-15"); - assert_eq!(range.until_key, "2026-01-20"); - assert_eq!(range.scan_since_key, "2026-01-14"); - assert_eq!(range.scan_until_key, "2026-01-21"); - } - - #[test] - fn test_is_in_range() { - assert!(CostUsageDayRange::is_in_range( - "2026-01-15", - "2026-01-10", - "2026-01-20" - )); - assert!(!CostUsageDayRange::is_in_range( - "2026-01-05", - "2026-01-10", - "2026-01-20" - )); - assert!(!CostUsageDayRange::is_in_range( - "2026-01-25", - "2026-01-10", - "2026-01-20" - )); - } - - #[test] - fn test_parse_day_key() { - let date = CostUsageDayRange::parse_day_key("2026-01-15"); - assert!(date.is_some()); - let date = date.unwrap(); - assert_eq!(date.year(), 2026); - assert_eq!(date.month(), 1); - assert_eq!(date.day(), 15); - } - - #[test] - fn codex_timestamp_day_key_uses_local_calendar_day() { - let today = Local::now().date_naive(); - let local_midnight = today.and_hms_opt(0, 30, 0).unwrap(); - let Some(local_time) = Local.from_local_datetime(&local_midnight).earliest() else { - return; - }; - let utc_timestamp = local_time.with_timezone(&chrono::Utc).to_rfc3339(); - let expected = today.format("%Y-%m-%d").to_string(); - - assert_eq!( - codex_timestamp_day_key(&utc_timestamp).as_deref(), - Some(expected.as_str()) - ); - } - - #[test] - fn test_fast_codex_parser_reads_last_usage_from_payload() { - let range = CostUsageDayRange::new( - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - ); - let mut parser = CodexParserState::new(None, None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:00.000Z","type":"turn_context","payload":{"info":{"model":"gpt-5.5"}}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":120,"cache_read_input_tokens":40,"output_tokens":9}}}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 1); - let record = &parser.records[0]; - assert_eq!(record.day_key, "2026-05-31"); - assert_eq!(record.model, "gpt-5.5"); - assert_eq!((record.input, record.cached, record.output), (120, 40, 9)); - assert_eq!(parser.current_model.as_deref(), Some("gpt-5.5")); - } - - #[test] - fn test_fast_codex_parser_diffs_total_usage() { - let range = CostUsageDayRange::new( - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - ); - let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":200,"output_tokens":50}}}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1250,"cached_input_tokens":260,"output_tokens":90}}}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 2); - assert_eq!( - parser - .records - .iter() - .map(|record| (record.input, record.cached, record.output)) - .collect::>(), - vec![(1_000, 200, 50), (250, 60, 40)] - ); - let totals = parser.previous_totals.expect("last totals"); - assert_eq!(totals.input, 1250); - assert_eq!(totals.cached, 260); - assert_eq!(totals.output, 90); - } - - #[test] - fn test_fast_codex_parser_reads_legacy_event_msg_shape() { - let range = CostUsageDayRange::new( - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - ); - let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","event_msg":{"type":"token_count","input_tokens":20,"cached_input_tokens":5,"output_tokens":3}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 1); - let record = &parser.records[0]; - assert_eq!(record.model, "gpt-5"); - assert_eq!((record.input, record.cached, record.output), (20, 5, 3)); - } - - #[test] - fn test_parse_codex_file_uses_fast_parser_for_current_logs() { - let mut file = tempfile::NamedTempFile::new().expect("temp file"); - writeln!( - file, - r#"{{"timestamp":"2026-05-31T10:00:00.000Z","type":"turn_context","payload":{{"model":"gpt-5.5"}}}}"# - ) - .unwrap(); - writeln!( - file, - r#"{{"timestamp":"2026-05-31T10:00:01.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":45,"cached_input_tokens":12,"output_tokens":8}}}}}}}}"# - ) - .unwrap(); - - let range = CostUsageDayRange::new( - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), - ); - let parsed = - JsonlScanner::parse_codex_file(file.path(), &range, 0, None, None).expect("parse"); - - assert_eq!(parsed.last_model.as_deref(), Some("gpt-5.5")); - assert_eq!(parsed.records.len(), 1); - let record = &parsed.records[0]; - assert_eq!(record.day_key, "2026-05-31"); - assert_eq!(record.model, "gpt-5.5"); - assert_eq!((record.input, record.cached, record.output), (45, 12, 8)); - } - - #[test] - fn codex_parser_discards_oversized_line_and_recovers_next_record() { - let mut file = tempfile::NamedTempFile::new().expect("temp file"); - let padding = "x".repeat(CODEX_JSONL_MAX_LINE_BYTES); - writeln!( - file, - r#"{{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{{"model":"{padding}"}}}}"# - ) - .unwrap(); - writeln!( - file, - r#"{{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":9,"cached_input_tokens":2,"output_tokens":1}}}}}}}}"# - ) - .unwrap(); - - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let parsed = JsonlScanner::parse_codex_file( - file.path(), - &CostUsageDayRange::new(day, day), - 0, - None, - None, - ) - .expect("parse"); - - assert_eq!(parsed.records.len(), 1); - assert_eq!( - parsed.records[0].model, - CostUsagePricing::CODEX_UNATTRIBUTED_MODEL - ); - assert_eq!( - ( - parsed.records[0].input, - parsed.records[0].cached, - parsed.records[0].output - ), - (9, 2, 1) - ); - } - - #[test] - fn bounded_jsonl_reader_accepts_exact_limit_without_retaining_larger_input() { - let mut input = vec![b'x'; CODEX_JSONL_MAX_LINE_BYTES]; - input.push(b'\n'); - input.extend_from_slice(b"{\"type\":\"event_msg\"}\n"); - let mut reader = BufReader::with_capacity(64 * 1024, std::io::Cursor::new(input)); - - let (exact, _) = read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES) - .expect("read") - .expect("line"); - let (later, _) = read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES) - .expect("read") - .expect("line"); - - assert_eq!(exact.len(), CODEX_JSONL_MAX_LINE_BYTES); - assert_eq!(later, br#"{"type":"event_msg"}"#); - } - - #[test] - fn codex_turn_context_wins_over_conflicting_event_model() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(None, None); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{"model":"gpt-5.5"}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","model":"gpt-5.6-sol","info":{"last_token_usage":{"input_tokens":5,"cached_input_tokens":1,"output_tokens":2}}}}"#, - &range, - ); - - assert_eq!(parser.records[0].model, "gpt-5.5"); - } - - #[test] - fn codex_blank_context_clears_stale_model_and_emits_unattributed_usage() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(Some("gpt-5.5".to_string()), None); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{"model":" "}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":5,"cached_input_tokens":1,"output_tokens":2}}}}"#, - &range, - ); - - assert_eq!( - parser.records[0].model, - CostUsagePricing::CODEX_UNATTRIBUTED_MODEL - ); - } - - #[test] - fn codex_model_less_token_event_uses_unpriced_sentinel() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(None, None); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":2}}}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 1); - assert_eq!( - parser.records[0].model, - CostUsagePricing::CODEX_UNATTRIBUTED_MODEL - ); - } - - #[test] - fn cached_tokens_use_larger_cached_or_cache_read_field() { - let value = serde_json::json!({ - "input_tokens": 100, - "cached_input_tokens": 20, - "cache_read_input_tokens": 35, - "output_tokens": 10 - }); - let totals = read_token_totals(&value); - assert_eq!(totals.cached, 35); - } - - #[test] - fn parses_bare_usage_rows_outside_token_count_envelope() { - let value = serde_json::json!({ - "model": "gpt-5.6-sol", - "usage": { - "prompt_tokens": 120, - "completion_tokens": 30, - "cached_input_tokens": 40, - "cache_read_input_tokens": 55 - } - }); - let (totals, model) = bare_usage_totals(&value).expect("bare usage"); - assert_eq!(totals.input, 120); - assert_eq!(totals.output, 30); - assert_eq!(totals.cached, 55); - assert_eq!(model.as_deref(), Some("gpt-5.6-sol")); - } - - #[test] - fn process_line_accepts_type_less_bare_usage_row() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(None, None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","model":"gpt-5.6-sol","usage":{"prompt_tokens":120,"completion_tokens":30,"cache_read_input_tokens":55}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 1); - assert_eq!(parser.records[0].model, "gpt-5.6-sol"); - assert_eq!( - ( - parser.records[0].input, - parser.records[0].cached, - parser.records[0].output - ), - (120, 55, 30) - ); - } - - #[test] - fn timestamp_less_bare_usage_uses_last_accepted_usage_day() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":1}}}}"#, - &range, - ); - parser.process_line( - r#"{"usage":{"prompt_tokens":20,"completion_tokens":4,"cache_read_input_tokens":3}}"#, - &range, - ); - - assert_eq!(parser.records.len(), 2); - assert_eq!(parser.records[1].day_key, "2026-05-31"); - assert_eq!( - ( - parser.records[1].input, - parser.records[1].cached, - parser.records[1].output - ), - (20, 3, 4) - ); - } - - #[test] - fn interleaved_lineage_totals_never_exceed_high_watermark_growth() { - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); - - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":0,"output_tokens":20}}}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":5,"cached_input_tokens":0,"output_tokens":1}}}}"#, - &range, - ); - parser.process_line( - r#"{"timestamp":"2026-05-31T10:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":101,"cached_input_tokens":0,"output_tokens":21}}}}"#, - &range, - ); - - let total_input: i32 = parser.records.iter().map(|r| r.input).sum(); - let total_output: i32 = parser.records.iter().map(|r| r.output).sum(); - assert!( - total_input <= 101, - "input inflated to {total_input}, expected <= 101" - ); - assert!( - total_output <= 21, - "output inflated to {total_output}, expected <= 21" - ); - } - - #[test] - fn interleaved_lineage_mid_range_climb_below_watermark_does_not_readd() { - // 100 → 5 (rewind) → 80 (mid-range below water) → 101 (above water). - // Phase-1 containment: do not re-add the 5→80 climb; only growth above - // the historical high watermark counts. - let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); - let range = CostUsageDayRange::new(day, day); - let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); - - for (input, output) in [(100, 20), (5, 1), (80, 10), (101, 21)] { - parser.process_line( - &format!( - r#"{{"timestamp":"2026-05-31T10:00:0{input}Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}"# - ), - &range, - ); - } - - let total_input: i32 = parser.records.iter().map(|r| r.input).sum(); - let total_output: i32 = parser.records.iter().map(|r| r.output).sum(); - assert!( - total_input <= 101, - "mid-range climb re-added input to {total_input}, expected <= 101" - ); - assert!( - total_output <= 21, - "mid-range climb re-added output to {total_output}, expected <= 21" - ); - } - - #[test] - fn cost_scan_options_app_driven_bypasses_debounce() { - let debounced = CostScanOptions::default(); - let forced = CostScanOptions::app_driven(); - let last = 1_000_000_i64; - let now = last + 1_000; // 1s later, within 60s window - - assert!(debounced.should_skip_scan(last, now)); - assert!(!forced.should_skip_scan(last, now)); - assert!(!debounced.should_skip_scan(last, last + 61_000)); - - let cache = CostUsageCache { - last_scan_unix_ms: last, - ..Default::default() - }; - assert!(JsonlScanner::should_skip_cached_scan( - &cache, - CostScanOptions::default(), - now - )); - assert!(!JsonlScanner::should_skip_cached_scan( - &cache, - CostScanOptions::app_driven(), - now - )); - } - - #[test] - fn is_line_boundary_offset_zero_returns_true() { - // F2: offset 0 is always a valid boundary (start of file). - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("f.jsonl"); - std::fs::write( - &path, - b"hello -world -", - ) - .unwrap(); - assert!(JsonlScanner::is_line_boundary_offset(&path, 0)); - } - - #[test] - fn is_line_boundary_offset_at_or_past_size_returns_true() { - // F2: offset >= file_size returns true (EOF or beyond is a valid boundary). - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("f.jsonl"); - let content = b"line1 -line2 -"; - std::fs::write(&path, content).unwrap(); - let size = i64::try_from(content.len()).unwrap(); - assert!(JsonlScanner::is_line_boundary_offset(&path, size)); - assert!(JsonlScanner::is_line_boundary_offset(&path, size + 100)); - } - - #[test] - fn is_line_boundary_offset_exact_newline_returns_true() { - // F2: offset pointing right after a newline is a valid boundary. - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("f.jsonl"); - // "line1\nline2\n" — offset 6 is right after first \n - std::fs::write(&path, b"line1\nline2\n").unwrap(); - assert!(JsonlScanner::is_line_boundary_offset(&path, 6)); - } - - #[test] - fn is_line_boundary_offset_midline_returns_false() { - // F2: offset pointing mid-line (byte before is not \n) returns false. - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("f.jsonl"); - // "line1\nline2\n" — offset 3 is mid-line (byte before is 'n') - std::fs::write(&path, b"line1\nline2\n").unwrap(); - assert!(!JsonlScanner::is_line_boundary_offset(&path, 3)); - } - - #[test] - fn is_line_boundary_offset_missing_file_returns_false() { - // F2: missing file returns false (probe fails). - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("nonexistent.jsonl"); - // offset > 0 so it doesn't short-circuit to true - assert!(!JsonlScanner::is_line_boundary_offset(&path, 10)); - } - - #[test] - fn catch_up_snapshot_preserves_established_codex_cost_and_tokens() { - let mut cache = CostUsageCache::default(); - cache.files.insert( - "session.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 100, - days: HashMap::from([( - "2026-08-20".to_string(), - HashMap::from([("gpt-5.6-sol".to_string(), vec![1_000, 250, 100])]), - )]), - parsed_bytes: Some(100), - last_model: Some("gpt-5.6-sol".to_string()), - last_totals: None, - }, - ); - cache.files.insert( - "empty.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 10, - days: HashMap::new(), - parsed_bytes: Some(10), - last_model: None, - last_totals: None, - }, - ); - cache.days.insert( - "2026-08-20".to_string(), - HashMap::from([("gpt-5.6-sol".to_string(), vec![1_000, 250, 100])]), - ); - - let report = JsonlScanner::cached_cost_report_from_days(&cache); - let expected = CostUsagePricing::codex_cost_usd_at_date( - "gpt-5.6-sol", - 1_000, - 250, - 100, - NaiveDate::from_ymd_opt(2026, 8, 20).unwrap(), - ) - .expect("known model price"); - - assert!((report.total_cost_usd - expected).abs() < 1e-12); - assert!(report.total_cost_usd > 0.0); - assert_eq!(report.input_tokens, 1_000); - assert_eq!(report.cached_tokens, 250); - assert_eq!(report.output_tokens, 100); - assert_eq!(report.sessions_count, 1); - assert!(!report.partial); - assert!(report.updated_at.is_some()); - } - - #[test] - fn save_cache_persists_small_codex_artifact() { - // F19 integration: a normal-sized Codex cache is persisted and - // reloadable — the MAX_LOAD_BYTES refusal does not false-positive. - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().to_path_buf(); - let mut cache = CostUsageCache { - scan_since_key: Some("2026-01-01".to_string()), - scan_until_key: Some("2026-01-31".to_string()), - files: HashMap::from([( - "a.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 100, - days: HashMap::from([( - "2026-01-10".to_string(), - HashMap::from([("gpt-5.6-sol".to_string(), vec![10, 0, 1])]), - )]), - parsed_bytes: None, - last_model: None, - last_totals: None, - }, - )]), - ..Default::default() - }; - - JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); - - // File should exist and be reloadable. - let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!( - loaded.files.contains_key("a.jsonl"), - "small artifact persisted" - ); - assert_eq!(loaded.scan_since_key, Some("2026-01-01".to_string())); - } - - #[test] - fn save_cache_refuses_non_bounded_provider_oversize() { - // F19: non-bounded providers (e.g. Claude) skip the refusal check - // entirely — the MAX_LOAD_BYTES guard only applies to bounded providers. - // This test confirms the is_bounded_provider gate works: Claude cache - // is saved regardless of the MAX_LOAD_BYTES check (which is Codex-only). - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().to_path_buf(); - let mut cache = CostUsageCache::default(); - cache.files.insert( - "claude.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 100, - days: HashMap::new(), - parsed_bytes: None, - last_model: None, - last_totals: None, - }, - ); - - JsonlScanner::save_cache(ProviderId::Claude, &mut cache, Some(&cache_root)); - let loaded = JsonlScanner::load_cache(ProviderId::Claude, Some(&cache_root)); - assert!(loaded.files.contains_key("claude.jsonl")); - } - - #[test] - fn save_cache_refusal_removes_preexisting_destination_artifact() { - // F19 integration: when the post-encode check refuses the artifact, any - // pre-existing destination file is removed so a stale/oversized artifact - // cannot persist and trigger load/refuse/rebuild behavior on next scan. - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().to_path_buf(); - - let mut cache = CostUsageCache::default(); - cache.files.insert( - "big.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 100, - days: HashMap::from([( - "2026-01-10".to_string(), - HashMap::from([("gpt-5.6-sol".to_string(), vec![10, 0, 1])]), - )]), - parsed_bytes: None, - last_model: None, - last_totals: None, - }, - ); - - // Precreate a "stale" destination artifact so the refusal must remove - // it. We seed it via a large (over_max) save_limit so the save_cache_with_limit - // first ENCODES the small cache fine under a generous limit, writes the file, - // then a follow-up call with a tiny limit must refuse AND remove. - let cache_path = { - // Exercise the private helper indirectly via the public path: first - // persist a valid artifact under a generous limit via save_cache. - // Then call with an impossible limit (encoded JSON ~hundreds of - // bytes, limit = 1 byte) to force refusal. - JsonlScanner::save_cache_with_limit( - ProviderId::Codex, - &mut cache, - Some(&cache_root), - usize::MAX, - ); - let p = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); - assert!(p.exists(), "precreate destination artifact"); - p - }; - - // Sanity: a normal load succeeds against the precreated artifact. - let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!(loaded.files.contains_key("big.jsonl")); - - // Force refusal with a 1-byte limit: encoded cache will exceed it. - JsonlScanner::save_cache_with_limit(ProviderId::Codex, &mut cache, Some(&cache_root), 1); - - // Destination must be gone — no stale artifact may persist. - assert!( - !cache_path.exists(), - "refusal must remove preexisting destination artifact" - ); - - // No temp file should remain in the cache root (only unique tmp name was used). - let mut tmp_entries = Vec::new(); - for entry in std::fs::read_dir(&cache_root).unwrap() { - let name = entry.unwrap().file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') && name.ends_with(".tmp") { - tmp_entries.push(name.into_owned()); - } - } - // Best-effort temp cleanup writes an empty file at the unique name; the - // invariant is that NO tmp file contains a complete artifact. The set - // should at most contain a single zero-byte remnant from the cleanup - // (or be empty); we persist via copy() rather than rename so no live - // tmp holds data after the save path completes. - for t in &tmp_entries { - let meta = std::fs::metadata(cache_root.join(t)).unwrap(); - assert_eq!(meta.len(), 0, "tmp remnant must be empty: {t}"); - } - - // Loading after removal yields a fresh default cache (no rebuild loop). - let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!( - loaded.files.is_empty(), - "no rebuild loop from removed artifact" - ); - } - - #[test] - fn save_cache_at_exact_limit_is_accepted() { - // F19 boundary: an encoded artifact at exactly the injected limit is - // accepted (only strictly-larger artifacts are refused). - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().to_path_buf(); - - let cache = CostUsageCache::default(); - // Serialize to learn the actual encoded size for this exact struct. - let json = serde_json::to_string(&cache).unwrap(); - let exact_limit = json.len(); - - let mut cache_for_save = cache; - JsonlScanner::save_cache_with_limit( - ProviderId::Codex, - &mut cache_for_save, - Some(&cache_root), - exact_limit, - ); - - let cache_path = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); - assert!( - cache_path.exists(), - "artifact at exact limit must be persisted" - ); - } - - #[test] - fn save_cache_one_over_limit_is_refused_and_removes_destination() { - // F19 boundary: an encoded artifact one byte over the injected limit is - // refused, and any pre-existing destination is removed. - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().to_path_buf(); - - let cache = CostUsageCache::default(); - let json = serde_json::to_string(&cache).unwrap(); - // One byte short of the encoded size forces refusal on the next attempt. - let under_by_one = json.len().saturating_sub(1); - - let mut cache_for_save = cache; - JsonlScanner::save_cache_with_limit( - ProviderId::Codex, - &mut cache_for_save, - Some(&cache_root), - under_by_one, - ); - - let cache_path = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); - assert!( - !cache_path.exists(), - "one-over-limit encoded artifact must be refused" - ); - } -} diff --git a/rust/src/core/jsonl_scanner/codex.rs b/rust/src/core/jsonl_scanner/codex.rs new file mode 100644 index 0000000000..076272c550 --- /dev/null +++ b/rust/src/core/jsonl_scanner/codex.rs @@ -0,0 +1,405 @@ +use super::*; + +mod helpers; +mod parser; + +use helpers::{ + CODEX_JSONL_MAX_LINE_BYTES, nonempty_json_string, parse_rfc3339_timestamp, + read_bounded_jsonl_line, session_meta_field, +}; +use parser::CodexParserState; + +#[cfg(test)] +use helpers::{ + CodexFastTotals, bare_usage_totals, codex_timestamp_day_key, codex_totals_from_fast, + last_usage_delta, parse_codex_timestamp, read_token_totals, +}; + +impl JsonlScanner { + /// Get default Codex sessions root directory + pub fn default_codex_sessions_root() -> Option { + // Check CODEX_HOME environment variable + if let Ok(home) = std::env::var("CODEX_HOME") { + let home = home.trim(); + if !home.is_empty() { + return Some(PathBuf::from(home).join("sessions")); + } + } + + // Default to ~/.codex/sessions + dirs::home_dir().map(|h| h.join(".codex").join("sessions")) + } + + /// Get default Claude projects roots + pub fn default_claude_projects_roots() -> Vec { + let mut roots = Vec::new(); + + // Check CLAUDE_CONFIG_DIR + if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let path = PathBuf::from(config_dir.trim()).join("projects"); + if path.exists() { + roots.push(path); + } + } + + // Default locations + if let Some(home) = dirs::home_dir() { + let default_path = home.join(".claude").join("projects"); + if default_path.exists() && !roots.contains(&default_path) { + roots.push(default_path); + } + } + + roots + } + + /// List Codex session files in the given date range + pub fn list_codex_session_files( + root: &Path, + scan_since_key: &str, + scan_until_key: &str, + ) -> Vec { + let mut files = Vec::new(); + + let Some(mut date) = CostUsageDayRange::parse_day_key(scan_since_key) else { + return files; + }; + let Some(until_date) = CostUsageDayRange::parse_day_key(scan_until_key) else { + return files; + }; + + while date <= until_date { + let year = format!("{:04}", date.year()); + let month = format!("{:02}", date.month()); + let day = format!("{:02}", date.day()); + + let day_dir = root.join(&year).join(&month).join(&day); + + if let Ok(entries) = fs::read_dir(&day_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("jsonl")) + { + files.push(path); + } + } + } + + date += chrono::Duration::days(1); + } + + files + } + + /// Read only a bounded prefix until the first authoritative `session_meta` + /// row is found. Fork decisions must not require parsing the child usage + /// stream before a safe parent baseline is selected. + pub(crate) fn read_codex_session_metadata( + file_path: &Path, + ) -> std::io::Result { + let file = File::open(file_path)?; + let mut reader = BufReader::new(file); + let mut bytes_examined = 0_usize; + + while bytes_examined < CODEX_JSONL_MAX_LINE_BYTES { + let Some((line_bytes, consumed)) = + read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES)? + else { + break; + }; + bytes_examined = bytes_examined.saturating_add(consumed); + if line_bytes.is_empty() { + continue; + } + let Ok(line) = std::str::from_utf8(&line_bytes) else { + continue; + }; + let line = line.strip_suffix('\r').unwrap_or(line); + let Ok(obj) = serde_json::from_str::(line) else { + continue; + }; + if obj.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; + } + + let payload = obj.get("payload").filter(|value| value.is_object()); + return Ok(CodexSessionMetadata { + session_id: session_meta_field(&obj, payload, &["id", "session_id", "sessionId"]), + forked_from_id: session_meta_field( + &obj, + payload, + &[ + "forked_from_id", + "forkedFromId", + "parent_session_id", + "parentSessionId", + ], + ), + fork_timestamp: nonempty_json_string(obj.get("timestamp")).or_else(|| { + payload.and_then(|value| nonempty_json_string(value.get("timestamp"))) + }), + }); + } + + Ok(CodexSessionMetadata::default()) + } + + /// Compare RFC3339 timestamps using parsed instants. Malformed timestamps + /// are unsafe for fork-baseline reconciliation and therefore fail closed. + pub(crate) fn codex_timestamp_at_or_before(earlier: &str, later: &str) -> bool { + match ( + parse_rfc3339_timestamp(earlier), + parse_rfc3339_timestamp(later), + ) { + (Some(earlier), Some(later)) => earlier <= later, + _ => false, + } + } + + /// Parse a Codex JSONL file + pub fn parse_codex_file( + file_path: &Path, + range: &CostUsageDayRange, + start_offset: i64, + initial_model: Option, + initial_totals: Option, + ) -> std::io::Result { + Self::parse_codex_file_with_state( + file_path, + range, + start_offset, + initial_model, + initial_totals, + None, + None, + None, + ) + } + + /// Parse a Codex file while retaining the timestamp-order state of an + /// already decoded prefix. A known prefix only pays for the append + /// boundary and newly read token events; an unknown legacy prefix is + /// intentionally rejected by the caller and should be parsed from zero. + #[allow( + clippy::too_many_arguments, + reason = "resume state mirrors the persisted parser cache" + )] + pub fn parse_codex_file_with_state( + file_path: &Path, + range: &CostUsageDayRange, + start_offset: i64, + initial_model: Option, + initial_totals: Option, + previous_token_timestamp: Option, + token_timestamps_monotonic: Option, + cancel: Option<&AtomicBool>, + ) -> std::io::Result { + Self::parse_codex_file_with_state_bounded( + file_path, + range, + start_offset, + initial_model, + initial_totals, + previous_token_timestamp, + token_timestamps_monotonic, + cancel, + None, + ) + } + + /// Parse a Codex file with an optional cap on bytes newly consumed this pass. + /// The reader may finish the current bounded JSONL line before yielding. + #[allow( + clippy::too_many_arguments, + reason = "resume state mirrors the persisted parser cache" + )] + pub fn parse_codex_file_with_state_bounded( + file_path: &Path, + range: &CostUsageDayRange, + start_offset: i64, + initial_model: Option, + initial_totals: Option, + previous_token_timestamp: Option, + token_timestamps_monotonic: Option, + cancel: Option<&AtomicBool>, + max_bytes_to_read: Option, + ) -> std::io::Result { + Self::parse_codex_file_with_state_bounded_internal( + file_path, + range, + start_offset, + initial_model, + initial_totals, + previous_token_timestamp, + token_timestamps_monotonic, + cancel, + false, + max_bytes_to_read, + ) + } + + /// Parse a forked Codex child from byte zero with a parent cumulative + /// baseline. This is intentionally separate from ordinary append-resume + /// parsing so existing non-fork semantics remain unchanged. + #[allow( + clippy::too_many_arguments, + reason = "fork parse state mirrors the persisted parser cache" + )] + pub(crate) fn parse_codex_file_with_state_bounded_fork( + file_path: &Path, + range: &CostUsageDayRange, + initial_totals: CodexTotals, + cancel: Option<&AtomicBool>, + max_bytes_to_read: Option, + ) -> std::io::Result { + Self::parse_codex_file_with_state_bounded_internal( + file_path, + range, + 0, + None, + Some(initial_totals), + None, + None, + cancel, + true, + max_bytes_to_read, + ) + } + + #[allow( + clippy::too_many_arguments, + reason = "resume state mirrors the persisted parser cache" + )] + fn parse_codex_file_with_state_bounded_internal( + file_path: &Path, + range: &CostUsageDayRange, + start_offset: i64, + initial_model: Option, + initial_totals: Option, + previous_token_timestamp: Option, + token_timestamps_monotonic: Option, + cancel: Option<&AtomicBool>, + fork_baseline_mode: bool, + max_bytes_to_read: Option, + ) -> std::io::Result { + let file = File::open(file_path)?; + // Session JSONL files are bounded by the cache budget; sizes fit i64. + #[allow( + clippy::cast_possible_wrap, + reason = "session JSONL file sizes fit i64" + )] + let file_size = file.metadata()?.len() as i64; + + let mut reader = BufReader::new(file); + if start_offset > 0 { + reader.seek(SeekFrom::Start(start_offset as u64))?; + } + + let mut parser = CodexParserState::with_timestamp_state_and_fork_mode( + initial_model, + initial_totals, + previous_token_timestamp, + token_timestamps_monotonic, + fork_baseline_mode, + ); + let mut parsed_bytes = start_offset; + let mut cancelled = false; + let mut budget_exhausted = false; + + loop { + if max_bytes_to_read.is_some_and(|limit| { + parsed_bytes.saturating_sub(start_offset) >= limit.max(0) + && parsed_bytes < file_size + }) { + budget_exhausted = true; + break; + } + if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + cancelled = true; + break; + } + let Some((line_bytes, consumed)) = + read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES)? + else { + break; + }; + if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + cancelled = true; + break; + } + // Per-line byte counts are capped at 256 KiB, far inside i64::MAX. + #[allow( + clippy::cast_possible_wrap, + reason = "per-line consumed bytes are capped at CODEX_JSONL_MAX_LINE_BYTES" + )] + let consumed_i64 = consumed as i64; + parsed_bytes += consumed_i64; + if line_bytes.is_empty() { + continue; + } + let Ok(line) = std::str::from_utf8(&line_bytes) else { + continue; + }; + let line = line.strip_suffix('\r').unwrap_or(line); + parser.process_line(line, range); + } + + let bytes_read = parsed_bytes.saturating_sub(start_offset).max(0); + let is_complete = !cancelled && !budget_exhausted && parsed_bytes >= file_size; + Ok(CodexParseResult { + records: parser.records, + parsed_bytes: if is_complete { + file_size.max(parsed_bytes) + } else { + parsed_bytes + }, + last_model: parser.current_model, + last_totals: parser.previous_totals, + token_timestamps_monotonic: parser.token_timestamps_monotonic, + last_token_timestamp: parser.previous_token_timestamp, + token_timestamp_comparisons: parser.token_timestamp_comparisons, + bytes_read, + is_complete, + fork_baseline_ambiguous: parser.fork_baseline_ambiguous, + }) + } + + /// F2 (upstream 0.48.0 #2648): whether a cached resume offset sits on a real + /// line boundary. A partial trailing-line write leaves the cached offset + /// mid-line; resuming there re-parses from mid-line and corrupts the first + /// resumed record. Returns when the byte just before is + /// not a newline (or the probe fails), signalling the caller to fall back + /// to a full re-parse from zero. + pub fn is_line_boundary_offset(file_path: &Path, offset: i64) -> bool { + use std::io::{Read, Seek}; + if offset <= 0 { + return true; + } + // Session JSONL file sizes fit i64; metadata feeds only boundary probes. + #[allow( + clippy::cast_possible_wrap, + reason = "session JSONL file sizes fit i64" + )] + let file_size_i64 = fs::metadata(file_path).map(|m| m.len() as i64); + let Ok(file_size) = file_size_i64 else { + return false; + }; + if offset >= file_size { + return true; + } + let Ok(mut probe) = File::open(file_path) else { + return false; + }; + if probe.seek(SeekFrom::Start((offset - 1) as u64)).is_err() { + return false; + } + let mut prev_byte = [0u8; 1]; + probe.read_exact(&mut prev_byte).is_ok() && prev_byte[0] == b'\n' + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/rust/src/core/jsonl_scanner/codex/helpers.rs b/rust/src/core/jsonl_scanner/codex/helpers.rs new file mode 100644 index 0000000000..f0129ff126 --- /dev/null +++ b/rust/src/core/jsonl_scanner/codex/helpers.rs @@ -0,0 +1,572 @@ +use super::CodexTotals; +use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone}; +use serde::Deserialize; +use serde_json::Value; +use std::io::BufRead; + +pub(super) const CODEX_JSONL_MAX_LINE_BYTES: usize = 256 * 1024; + +#[derive(Debug, Deserialize)] +struct CodexFastLine<'a> { + #[serde(rename = "type", borrow)] + event_type: Option<&'a str>, + #[serde(default, borrow)] + timestamp: Option<&'a str>, + #[serde(default, borrow)] + payload: Option>, + #[serde(default, borrow)] + event_msg: Option>, + #[serde(default, borrow)] + model: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct CodexFastPayload<'a> { + #[serde(rename = "type", borrow)] + pub(super) payload_type: Option<&'a str>, + #[serde(default, borrow)] + pub(super) model: Option<&'a str>, + #[serde(default, borrow)] + pub(super) model_name: Option<&'a str>, + #[serde(default, borrow)] + pub(super) info: Option>, + #[serde(default)] + pub(super) input_tokens: Option, + #[serde(default)] + pub(super) cached_input_tokens: Option, + #[serde(default)] + pub(super) cache_read_input_tokens: Option, + #[serde(default)] + pub(super) output_tokens: Option, + #[serde(default)] + pub(super) reasoning_output_tokens: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct CodexFastInfo<'a> { + #[serde(default, borrow)] + pub(super) model: Option<&'a str>, + #[serde(default, borrow)] + pub(super) model_name: Option<&'a str>, + #[serde(default)] + pub(super) total_token_usage: Option, + #[serde(default)] + pub(super) last_token_usage: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub(super) struct CodexFastTotals { + #[serde(default)] + pub(super) input_tokens: i32, + #[serde(default)] + pub(super) cached_input_tokens: Option, + #[serde(default)] + pub(super) cache_read_input_tokens: Option, + #[serde(default)] + pub(super) output_tokens: i32, + #[serde(default)] + pub(super) reasoning_output_tokens: Option, +} + +pub(super) enum CodexFastEvent<'a> { + TurnContext { + model: Option<&'a str>, + }, + TokenCount { + timestamp: &'a str, + payload: CodexFastPayload<'a>, + }, +} + +pub(super) fn model_evidence(raw: &str) -> Option<&str> { + let trimmed = raw.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// When interleaved Ultra lineages reset cumulative counters, only count growth +/// above the historical high watermark so rewound branches do not re-add work. +pub(super) fn contained_total_delta( + watermark: Option<&CodexTotals>, + counted: Option<&CodexTotals>, + current: &CodexTotals, +) -> CodexTotals { + let water = watermark.cloned().unwrap_or(CodexTotals { + input: 0, + cached: 0, + output: 0, + reasoning: None, + }); + let counted = counted.cloned().unwrap_or(CodexTotals { + input: 0, + cached: 0, + output: 0, + reasoning: None, + }); + + let component = |water: i32, counted: i32, current: i32| -> i32 { + if current >= water { + // Only growth above the historical high watermark counts. + (current - water.max(counted)).max(0) + } else { + // Below watermark: rewind / interleaved lineage - do not re-add + // mid-range climbs that would inflate totals after a fork reset. + 0 + } + }; + + CodexTotals { + input: component(water.input, counted.input, current.input), + cached: component(water.cached, counted.cached, current.cached), + output: component(water.output, counted.output, current.output), + reasoning: cumulative_reasoning_delta( + Some(&counted), + current.reasoning, + component(water.output, counted.output, current.output), + ), + } +} + +pub(super) fn cumulative_reasoning_delta( + previous: Option<&CodexTotals>, + current: Option, + output_delta: i32, +) -> Option { + let current = current?; + let previous = match previous { + Some(previous) => previous.reasoning?, + None => 0, + }; + Some( + current + .saturating_sub(previous) + .max(0) + .min(output_delta.max(0)), + ) +} + +/// Read one JSONL line, discarding content when it exceeds `max_bytes`. +/// Returns `(line_without_newline, bytes_consumed_including_newline)`. +pub(super) fn read_bounded_jsonl_line( + reader: &mut R, + max_bytes: usize, +) -> std::io::Result, usize)>> { + let mut line = Vec::new(); + let mut saw_bytes = false; + let mut discarding = false; + let mut consumed_total = 0; + + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok( + saw_bytes.then_some((if discarding { Vec::new() } else { line }, consumed_total)) + ); + } + let newline = chunk.iter().position(|byte| *byte == b'\n'); + let segment_end = newline.unwrap_or(chunk.len()); + let segment = &chunk[..segment_end]; + saw_bytes = true; + + if !discarding { + let remaining = max_bytes.saturating_sub(line.len()); + if segment.len() <= remaining { + line.extend_from_slice(segment); + } else { + line.clear(); + discarding = true; + } + } + + let consumed = segment_end + usize::from(newline.is_some()); + reader.consume(consumed); + consumed_total += consumed; + if newline.is_some() { + return Ok(Some(( + if discarding { Vec::new() } else { line }, + consumed_total, + ))); + } + } +} + +pub(super) fn parse_codex_fast_event(line: &str) -> Option> { + let parsed: CodexFastLine<'_> = serde_json::from_str(line).ok()?; + match parsed.event_type? { + "turn_context" => { + let model = parsed + .payload + .as_ref() + .and_then(|payload| { + payload.model.or(payload.model_name).or_else(|| { + payload + .info + .as_ref() + .and_then(|info| info.model.or(info.model_name)) + }) + }) + .or(parsed.model); + Some(CodexFastEvent::TurnContext { model }) + } + "event_msg" => { + let payload = parsed.payload.or(parsed.event_msg)?; + (payload.payload_type == Some("token_count")).then_some(CodexFastEvent::TokenCount { + timestamp: parsed.timestamp?, + payload, + }) + } + _ => None, + } +} + +pub(super) fn is_candidate_codex_line(line: &str) -> bool { + if !line.contains("\"type\":\"event_msg\"") + && !line.contains("\"type\":\"turn_context\"") + && !line.contains("\"event_msg\"") + { + return false; + } + + !line.contains("\"type\":\"event_msg\"") || line.contains("\"token_count\"") +} + +pub(super) fn codex_timestamp_day_key(timestamp: &str) -> Option { + parse_codex_timestamp(timestamp).map(|parsed| parsed.day_key()) +} + +#[derive(Debug, Clone)] +pub(super) struct ParsedCodexTimestamp { + pub(super) parsed: Option>, + pub(super) fallback_day_key: String, +} + +impl ParsedCodexTimestamp { + pub(super) fn day_key(&self) -> String { + self.parsed + .as_ref() + .map(|timestamp| { + timestamp + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string() + }) + .unwrap_or_else(|| self.fallback_day_key.clone()) + } +} + +pub(super) fn parse_codex_timestamp(timestamp: &str) -> Option { + let bytes = timestamp.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + { + return None; + } + let fallback_day_key = timestamp.get(..10)?; + NaiveDate::parse_from_str(fallback_day_key, "%Y-%m-%d").ok()?; + Some(ParsedCodexTimestamp { + parsed: parse_rfc3339_timestamp(timestamp), + fallback_day_key: fallback_day_key.to_string(), + }) +} + +pub(super) fn parse_rfc3339_timestamp(timestamp: &str) -> Option> { + parse_native_rfc3339(timestamp).or_else(|| DateTime::parse_from_rfc3339(timestamp).ok()) +} + +pub(super) fn nonempty_json_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub(super) fn session_meta_field( + root: &Value, + payload: Option<&Value>, + keys: &[&str], +) -> Option { + payload + .and_then(|payload| { + keys.iter() + .find_map(|key| nonempty_json_string(payload.get(*key))) + }) + .or_else(|| { + keys.iter() + .find_map(|key| nonempty_json_string(root.get(*key))) + }) +} + +/// Fast path for the RFC3339 spelling emitted by native Codex logs. Historical +/// spellings still fall through to chrono's parser, preserving old behavior. +fn parse_native_rfc3339(timestamp: &str) -> Option> { + let bytes = timestamp.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + { + return None; + } + + let year = parse_ascii_number(bytes, 0, 4)?; + if year < 1900 { + return None; + } + let month = parse_ascii_number(bytes, 5, 2)?; + let day = parse_ascii_number(bytes, 8, 2)?; + let hour = parse_ascii_number(bytes, 11, 2)?; + let minute = parse_ascii_number(bytes, 14, 2)?; + let second = parse_ascii_number(bytes, 17, 2)?; + if !(1..=12).contains(&month) || hour >= 24 || minute >= 60 || second >= 60 { + return None; + } + + let mut zone_index = 19; + let mut nanoseconds = 0_u32; + if bytes.get(zone_index) == Some(&b'.') { + zone_index += 1; + let fraction_start = zone_index; + while bytes + .get(zone_index) + .is_some_and(|byte| byte.is_ascii_digit()) + { + let digits = zone_index - fraction_start; + if digits >= 9 { + return None; + } + if digits < 3 { + nanoseconds = nanoseconds * 10 + u32::from(bytes[zone_index] - b'0'); + } + zone_index += 1; + } + let digits = zone_index - fraction_start; + if digits == 0 { + return None; + } + for _ in digits.min(3)..3 { + nanoseconds *= 10; + } + for _ in 0..6 { + nanoseconds *= 10; + } + } + + let offset_seconds = match bytes.get(zone_index) { + Some(b'Z') if zone_index + 1 == bytes.len() => 0, + Some(sign) if (*sign == b'+' || *sign == b'-') && zone_index + 6 == bytes.len() => { + if bytes[zone_index + 3] != b':' { + return None; + } + let hours = parse_ascii_number(bytes, zone_index + 1, 2)?; + let minutes = parse_ascii_number(bytes, zone_index + 4, 2)?; + if hours >= 24 || minutes >= 60 { + return None; + } + let seconds = i32::try_from((hours * 60 + minutes) * 60).ok()?; + if *sign == b'-' { -seconds } else { seconds } + } + _ => return None, + }; + + let year = i32::try_from(year).ok()?; + let date = NaiveDate::from_ymd_opt(year, month, day)?; + let local = date.and_hms_nano_opt(hour, minute, second, nanoseconds)?; + FixedOffset::east_opt(offset_seconds) + .and_then(|offset| offset.from_local_datetime(&local).single()) +} + +fn parse_ascii_number(bytes: &[u8], start: usize, count: usize) -> Option { + let slice = bytes.get(start..start.checked_add(count)?)?; + let mut value = 0_u32; + for byte in slice { + if !byte.is_ascii_digit() { + return None; + } + value = value * 10 + u32::from(*byte - b'0'); + } + Some(value) +} + +pub(super) fn bare_usage_totals(obj: &Value) -> Option<(CodexTotals, Option)> { + let usage = obj + .get("usage") + .or_else(|| obj.get("data").and_then(|v| v.get("usage"))) + .or_else(|| obj.get("result").and_then(|v| v.get("usage"))) + .or_else(|| obj.get("response").and_then(|v| v.get("usage")))?; + // Token counts come from usage records and fit i32, the canonical totals storage type. + #[allow( + clippy::cast_possible_truncation, + reason = "usage token counts fit i32, the canonical totals storage type" + )] + let input = ["input_tokens", "prompt_tokens", "input"] + .into_iter() + .find_map(|key| usage.get(key).and_then(Value::as_i64)) + .unwrap_or(0) + .max(0) as i32; + // Token counts come from usage records and fit i32, the canonical totals storage type. + #[allow( + clippy::cast_possible_truncation, + reason = "usage token counts fit i32, the canonical totals storage type" + )] + let output = ["output_tokens", "completion_tokens", "output"] + .into_iter() + .find_map(|key| usage.get(key).and_then(Value::as_i64)) + .unwrap_or(0) + .max(0) as i32; + // Token counts come from usage records and fit i32, the canonical totals storage type. + #[allow( + clippy::cast_possible_truncation, + reason = "usage token counts fit i32, the canonical totals storage type" + )] + let cached = [ + "cached_input_tokens", + "cache_read_input_tokens", + "cached_tokens", + ] + .into_iter() + .filter_map(|key| usage.get(key).and_then(Value::as_i64)) + .max() + .unwrap_or(0) + .max(0) as i32; + let reasoning = clamp_reasoning(optional_token_i32(usage, "reasoning_output_tokens"), output); + if input == 0 && output == 0 && cached == 0 { + return None; + } + let model = obj + .get("model") + .or_else(|| obj.get("data").and_then(|v| v.get("model"))) + .or_else(|| obj.get("result").and_then(|v| v.get("model"))) + .or_else(|| obj.get("response").and_then(|v| v.get("model"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + Some(( + CodexTotals { + input, + cached, + output, + reasoning, + }, + model, + )) +} + +pub(super) fn token_count_payload(obj: &Value) -> Option<&Value> { + if let Some(payload) = obj.get("payload") + && payload.get("type").and_then(|v| v.as_str()) == Some("token_count") + { + return Some(payload); + } + + let event_msg = obj.get("event_msg")?; + (event_msg.get("type").and_then(|v| v.as_str()) == Some("token_count")).then_some(event_msg) +} + +pub(super) fn read_token_totals(value: &Value) -> CodexTotals { + // Token counts come from Codex usage records and fit within i32, which is + // the canonical storage type of the totals table. + #[allow( + clippy::cast_possible_truncation, + reason = "token counts from usage records fit i32" + )] + let cached = value + .get("cached_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .max( + value + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + ) as i32; + CodexTotals { + input: token_i32(value, "input_tokens"), + cached, + output: token_i32(value, "output_tokens"), + reasoning: clamp_reasoning( + optional_token_i32(value, "reasoning_output_tokens"), + token_i32(value, "output_tokens"), + ), + } +} + +pub(super) fn codex_totals_from_fast(value: CodexFastTotals) -> CodexTotals { + CodexTotals { + input: value.input_tokens, + cached: value + .cached_input_tokens + .unwrap_or(0) + .max(value.cache_read_input_tokens.unwrap_or(0)), + output: value.output_tokens, + reasoning: clamp_reasoning(value.reasoning_output_tokens, value.output_tokens), + } +} + +pub(super) fn fast_totals_from_payload(value: &CodexFastPayload<'_>) -> CodexTotals { + CodexTotals { + input: value.input_tokens.unwrap_or(0), + cached: value + .cached_input_tokens + .unwrap_or(0) + .max(value.cache_read_input_tokens.unwrap_or(0)), + output: value.output_tokens.unwrap_or(0), + reasoning: clamp_reasoning( + value.reasoning_output_tokens, + value.output_tokens.unwrap_or(0), + ), + } +} + +fn token_i32(value: &Value, key: &str) -> i32 { + // Token counts from usage records fit i32, the canonical totals storage type. + #[allow( + clippy::cast_possible_truncation, + reason = "token counts from usage records fit i32" + )] + let tokens = value.get(key).and_then(|v| v.as_i64()).unwrap_or(0) as i32; + tokens +} + +fn optional_token_i32(value: &Value, key: &str) -> Option { + // Token counts from usage records fit i32, the canonical storage type. + #[allow( + clippy::cast_possible_truncation, + reason = "token counts from usage records fit i32" + )] + value + .get(key) + .and_then(Value::as_i64) + .map(|tokens| tokens as i32) +} + +pub(super) fn clamp_reasoning(reasoning: Option, output: i32) -> Option { + reasoning.map(|tokens| tokens.max(0).min(output.max(0))) +} + +pub(super) fn last_usage_delta(last: &Value) -> (i32, i32, i32, Option) { + let totals = read_token_totals(last); + ( + totals.input.max(0), + totals.cached.max(0), + totals.output.max(0), + totals.reasoning, + ) +} + +pub(super) fn fast_last_usage_delta(last: CodexFastTotals) -> (i32, i32, i32, Option) { + let totals = codex_totals_from_fast(last); + ( + totals.input.max(0), + totals.cached.max(0), + totals.output.max(0), + totals.reasoning, + ) +} diff --git a/rust/src/core/jsonl_scanner/codex/parser.rs b/rust/src/core/jsonl_scanner/codex/parser.rs new file mode 100644 index 0000000000..fee86ecbff --- /dev/null +++ b/rust/src/core/jsonl_scanner/codex/parser.rs @@ -0,0 +1,472 @@ +use super::helpers::*; +use super::{CodexTotals, CodexUsageRecord, CostUsageDayRange}; +use crate::core::CostUsagePricing; +use chrono::DateTime; +use serde_json::Value; + +pub(super) struct CodexParserState { + pub(super) current_model: Option, + pub(super) previous_totals: Option, + /// High watermark of observed cumulative totals (never lowered). Used for + /// Ultra interleaved-lineage containment (issue #2037 Phase 1). + totals_watermark: Option, + /// Latched once any cumulative component drops below the watermark. + saw_interleaved_totals: bool, + pub(super) records: Vec, + pub(super) previous_token_timestamp: Option, + previous_token_timestamp_parsed: Option>, + pub(super) token_timestamps_monotonic: Option, + pub(super) token_timestamp_comparisons: u64, + fork_baseline: Option, + pub(super) fork_baseline_ambiguous: bool, +} + +impl CodexParserState { + pub(super) fn new(initial_model: Option, initial_totals: Option) -> Self { + Self::with_timestamp_state(initial_model, initial_totals, None, None) + } + + fn with_timestamp_state( + initial_model: Option, + initial_totals: Option, + previous_token_timestamp: Option, + token_timestamps_monotonic: Option, + ) -> Self { + Self::with_timestamp_state_and_fork_mode( + initial_model, + initial_totals, + previous_token_timestamp, + token_timestamps_monotonic, + false, + ) + } + + pub(super) fn with_timestamp_state_and_fork_mode( + initial_model: Option, + initial_totals: Option, + previous_token_timestamp: Option, + token_timestamps_monotonic: Option, + fork_baseline_mode: bool, + ) -> Self { + let previous_token_timestamp_parsed = previous_token_timestamp + .as_deref() + .and_then(parse_rfc3339_timestamp); + let fork_baseline = fork_baseline_mode.then(|| initial_totals.clone()).flatten(); + Self { + current_model: initial_model, + previous_totals: initial_totals.clone(), + totals_watermark: initial_totals, + saw_interleaved_totals: false, + records: Vec::new(), + previous_token_timestamp, + previous_token_timestamp_parsed, + // A parser always validates a fresh prefix. `None` is only an + // input marker for the legacy-cache path, not an output state. + token_timestamps_monotonic: Some(token_timestamps_monotonic.unwrap_or(true)), + token_timestamp_comparisons: 0, + fork_baseline, + fork_baseline_ambiguous: false, + } + } + + pub(super) fn process_line(&mut self, line: &str, range: &CostUsageDayRange) { + let event_candidate = is_candidate_codex_line(line); + let bare_candidate = !event_candidate && line.contains("\"usage\""); + if !event_candidate && !bare_candidate { + return; + } + + if event_candidate && let Some(event) = parse_codex_fast_event(line) { + self.process_fast_event(event, range); + return; + } + + let Ok(obj) = serde_json::from_str::(line) else { + return; + }; + + if bare_candidate { + if obj.get("type").is_some() { + return; + } + let parsed_timestamp = obj + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_codex_timestamp); + if let Some(timestamp) = obj.get("timestamp").and_then(Value::as_str) { + // Timestamp order is a property of the whole native file, + // including usage records outside the requested day window. + self.observe_token_timestamp(timestamp, parsed_timestamp.as_ref()); + } + let day_key = parsed_timestamp + .as_ref() + .map(ParsedCodexTimestamp::day_key) + .filter(|day_key| { + CostUsageDayRange::is_in_range(day_key, &range.since_key, &range.until_key) + }) + .or_else(|| self.records.last().map(|record| record.day_key.clone())); + let Some(day_key) = day_key else { + return; + }; + if let Some((totals, model)) = bare_usage_totals(&obj) { + let model = self + .current_model + .as_deref() + .and_then(model_evidence) + .or(model.as_deref().and_then(model_evidence)) + .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) + .to_string(); + self.record_usage( + range, + day_key, + &model, + totals.input, + totals.cached, + totals.output, + totals.reasoning, + ); + } + return; + } + + let is_token_count = token_count_payload(&obj).is_some(); + let parsed_timestamp = obj + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_codex_timestamp); + if is_token_count && let Some(timestamp) = obj.get("timestamp").and_then(Value::as_str) { + // Timestamp order is a property of the whole native file, not + // only the requested display window. Validate it before the + // range filter so a cached prefix remains safe to extend. + self.observe_token_timestamp(timestamp, parsed_timestamp.as_ref()); + } + let Some(day_key) = parsed_timestamp + .as_ref() + .map(ParsedCodexTimestamp::day_key) + .filter(|day_key| { + CostUsageDayRange::is_in_range(day_key, &range.since_key, &range.until_key) + }) + else { + return; + }; + if obj.get("type").and_then(|v| v.as_str()) == Some("turn_context") { + self.update_current_model(&obj); + } + + if is_token_count { + self.record_token_count(&obj, day_key, range); + } + } + + fn process_fast_event(&mut self, event: CodexFastEvent<'_>, range: &CostUsageDayRange) { + match event { + CodexFastEvent::TurnContext { model } => { + // Explicit blank model evidence clears stale turn context. + if let Some(raw) = model { + self.current_model = model_evidence(raw).map(str::to_string); + } + } + CodexFastEvent::TokenCount { timestamp, payload } => { + let parsed_timestamp = parse_codex_timestamp(timestamp); + self.observe_token_timestamp(timestamp, parsed_timestamp.as_ref()); + let Some(parsed_timestamp) = parsed_timestamp else { + return; + }; + let day_key = parsed_timestamp.day_key(); + if !CostUsageDayRange::is_in_range(&day_key, &range.since_key, &range.until_key) { + return; + } + self.record_fast_token_count(payload, day_key, range); + } + } + } + + fn update_current_model(&mut self, obj: &Value) { + let candidates = [ + obj.get("model").and_then(|v| v.as_str()), + obj.get("payload") + .and_then(|payload| payload.get("model")) + .and_then(|v| v.as_str()), + obj.get("payload") + .and_then(|payload| payload.get("model_name")) + .and_then(|v| v.as_str()), + obj.get("payload") + .and_then(|payload| payload.get("info")) + .and_then(|info| info.get("model")) + .and_then(|v| v.as_str()), + obj.get("payload") + .and_then(|payload| payload.get("info")) + .and_then(|info| info.get("model_name")) + .and_then(|v| v.as_str()), + ]; + // Only rewrite current_model when the turn_context actually carries a + // model field (including blank, which clears stale attribution). + let has_key = candidates.iter().any(|c| c.is_some()); + if !has_key { + return; + } + self.current_model = candidates + .into_iter() + .flatten() + .find_map(model_evidence) + .map(str::to_string); + } + + fn record_token_count(&mut self, obj: &Value, day_key: String, range: &CostUsageDayRange) { + let Some(payload) = token_count_payload(obj) else { + return; + }; + let Some((delta_input, delta_cached, delta_output, reasoning)) = self.token_deltas(payload) + else { + return; + }; + if delta_input == 0 && delta_cached == 0 && delta_output == 0 { + return; + } + + let info = payload.get("info"); + let model = self.resolve_token_model(info, payload, obj); + self.record_usage( + range, + day_key, + &model, + delta_input, + delta_cached, + delta_output, + reasoning, + ); + } + + fn record_fast_token_count( + &mut self, + payload: CodexFastPayload<'_>, + day_key: String, + range: &CostUsageDayRange, + ) { + let Some((delta_input, delta_cached, delta_output, reasoning)) = + self.fast_token_deltas(&payload) + else { + return; + }; + if delta_input == 0 && delta_cached == 0 && delta_output == 0 { + return; + } + + let event_model = payload + .info + .as_ref() + .and_then(|info| info.model.or(info.model_name)) + .or(payload.model) + .and_then(model_evidence); + // Prefer current turn_context model over a conflicting event model, + // matching upstream precedence. Fall back to unattributed (not gpt-5). + let model = self + .current_model + .as_deref() + .and_then(model_evidence) + .or(event_model) + .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) + .to_string(); + self.record_usage( + range, + day_key, + &model, + delta_input, + delta_cached, + delta_output, + reasoning, + ); + } + + #[allow( + clippy::too_many_arguments, + reason = "record construction mirrors the persisted token fields without changing parser state semantics" + )] + fn record_usage( + &mut self, + range: &CostUsageDayRange, + day_key: String, + model: &str, + input: i32, + cached: i32, + output: i32, + reasoning: Option, + ) { + if !CostUsageDayRange::is_in_range(&day_key, &range.since_key, &range.until_key) { + return; + } + self.records.push(CodexUsageRecord { + day_key, + model: CostUsagePricing::normalize_codex_model(model), + input, + cached: cached.min(input), + output, + reasoning: clamp_reasoning(reasoning, output), + }); + } + + fn resolve_token_model(&self, info: Option<&Value>, payload: &Value, obj: &Value) -> String { + let event_model = info + .and_then(|i| i.get("model").or(i.get("model_name"))) + .or_else(|| payload.get("model")) + .or_else(|| obj.get("model")) + .and_then(|v| v.as_str()) + .and_then(model_evidence); + self.current_model + .as_deref() + .and_then(model_evidence) + .or(event_model) + .unwrap_or(CostUsagePricing::CODEX_UNATTRIBUTED_MODEL) + .to_string() + } + + fn token_deltas(&mut self, payload: &Value) -> Option<(i32, i32, i32, Option)> { + let info = payload.get("info"); + if let Some(total) = info.and_then(|i| i.get("total_token_usage")) { + return Some(self.total_usage_delta(total)); + } + + if let Some(last) = info.and_then(|i| i.get("last_token_usage")) { + return Some(last_usage_delta(last)); + } + + let direct = read_token_totals(payload); + (direct.input != 0 || direct.cached != 0 || direct.output != 0).then_some(( + direct.input.max(0), + direct.cached.max(0), + direct.output.max(0), + direct.reasoning, + )) + } + + fn fast_token_deltas( + &mut self, + payload: &CodexFastPayload<'_>, + ) -> Option<(i32, i32, i32, Option)> { + if let Some(total) = payload + .info + .as_ref() + .and_then(|info| info.total_token_usage) + { + return Some(self.fast_total_usage_delta(total)); + } + + if let Some(last) = payload.info.as_ref().and_then(|info| info.last_token_usage) { + return Some(fast_last_usage_delta(last)); + } + + let direct = fast_totals_from_payload(payload); + (direct.input != 0 || direct.cached != 0 || direct.output != 0).then_some(( + direct.input.max(0), + direct.cached.max(0), + direct.output.max(0), + direct.reasoning, + )) + } + + pub(super) fn total_usage_delta(&mut self, total: &Value) -> (i32, i32, i32, Option) { + let totals = read_token_totals(total); + self.apply_totals_delta(totals) + } + + fn fast_total_usage_delta(&mut self, total: CodexFastTotals) -> (i32, i32, i32, Option) { + let totals = codex_totals_from_fast(total); + self.apply_totals_delta(totals) + } + + pub(super) fn apply_totals_delta( + &mut self, + totals: CodexTotals, + ) -> (i32, i32, i32, Option) { + self.latch_if_below_watermark(&totals); + + let delta = if self.saw_interleaved_totals { + contained_total_delta( + self.totals_watermark.as_ref(), + self.previous_totals.as_ref(), + &totals, + ) + } else { + let previous = self.previous_totals.as_ref(); + let input = (totals.input - previous.map_or(0, |t| t.input)).max(0); + let cached = (totals.cached - previous.map_or(0, |t| t.cached)).max(0); + let output = (totals.output - previous.map_or(0, |t| t.output)).max(0); + CodexTotals { + input, + cached, + output, + reasoning: cumulative_reasoning_delta(previous, totals.reasoning, output), + } + }; + + self.previous_totals = Some(totals.clone()); + self.raise_watermark(&totals); + (delta.input, delta.cached, delta.output, delta.reasoning) + } + + fn observe_token_timestamp( + &mut self, + timestamp: &str, + parsed_timestamp: Option<&ParsedCodexTimestamp>, + ) { + let current_parsed = parsed_timestamp + .map(|parsed| parsed.parsed) + .unwrap_or_else(|| parse_rfc3339_timestamp(timestamp)); + if let Some(previous) = self.previous_token_timestamp.as_deref() + && self.token_timestamps_monotonic != Some(false) + { + self.token_timestamp_comparisons = self.token_timestamp_comparisons.saturating_add(1); + let ordered = match ( + self.previous_token_timestamp_parsed.as_ref(), + current_parsed.as_ref(), + ) { + (Some(previous), Some(current)) => previous <= current, + // A malformed historical timestamp keeps the scanner's + // existing lexical fallback semantics. The current parsed + // value is deliberately not reparsed here. + _ => previous <= timestamp, + }; + if !ordered { + self.token_timestamps_monotonic = Some(false); + } + } + self.previous_token_timestamp = Some(timestamp.to_string()); + self.previous_token_timestamp_parsed = current_parsed; + } + + fn latch_if_below_watermark(&mut self, totals: &CodexTotals) { + if let Some(baseline) = self.fork_baseline.as_ref() + && (totals.input < baseline.input + || totals.cached < baseline.cached + || totals.output < baseline.output) + { + self.fork_baseline_ambiguous = true; + } + let Some(water) = self.totals_watermark.as_ref() else { + return; + }; + if totals.input < water.input + || totals.cached < water.cached + || totals.output < water.output + { + self.saw_interleaved_totals = true; + } + } + + fn raise_watermark(&mut self, totals: &CodexTotals) { + self.totals_watermark = Some(match self.totals_watermark.as_ref() { + Some(water) => CodexTotals { + input: water.input.max(totals.input), + cached: water.cached.max(totals.cached), + output: water.output.max(totals.output), + reasoning: match (water.reasoning, totals.reasoning) { + (Some(water), Some(current)) => Some(water.max(current)), + (Some(water), None) => Some(water), + (None, Some(current)) => Some(current), + (None, None) => None, + }, + }, + None => totals.clone(), + }); + } +} diff --git a/rust/src/core/jsonl_scanner/tests.rs b/rust/src/core/jsonl_scanner/tests.rs new file mode 100644 index 0000000000..fb39fed1cd --- /dev/null +++ b/rust/src/core/jsonl_scanner/tests.rs @@ -0,0 +1,1207 @@ +use super::*; +use chrono::TimeZone; +use std::io::Write; + +#[test] +fn test_day_range() { + let since = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); + let until = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap(); + let range = CostUsageDayRange::new(since, until); + + assert_eq!(range.since_key, "2026-01-15"); + assert_eq!(range.until_key, "2026-01-20"); + assert_eq!(range.scan_since_key, "2026-01-14"); + assert_eq!(range.scan_until_key, "2026-01-21"); +} + +#[test] +fn reasoning_output_is_clamped_and_preserved_for_value_and_fast_shapes() { + let value = serde_json::json!({ + "output_tokens": 20, + "reasoning_output_tokens": 7, + }); + let totals = read_token_totals(&value); + assert_eq!(totals.output, 20); + assert_eq!(totals.reasoning, Some(7)); + assert_eq!(last_usage_delta(&value), (0, 0, 20, Some(7))); + + let fast: CodexFastTotals = serde_json::from_value(serde_json::json!({ + "output_tokens": 20, + "reasoning_output_tokens": 99, + })) + .unwrap(); + let fast_totals = codex_totals_from_fast(fast); + assert_eq!(fast_totals.output, 20); + assert_eq!(fast_totals.reasoning, Some(20)); +} + +#[test] +fn missing_reasoning_stays_unknown_for_cumulative_and_event_usage() { + let value = serde_json::json!({ "output_tokens": 20 }); + assert_eq!(read_token_totals(&value).reasoning, None); + assert_eq!(last_usage_delta(&value), (0, 0, 20, None)); + + let mut state = CodexParserState::new(None, None); + assert_eq!( + state.total_usage_delta(&serde_json::json!({ + "output_tokens": 10, + })), + (0, 0, 10, None) + ); +} + +#[test] +fn cumulative_reasoning_uses_the_comparable_previous_total() { + let mut state = CodexParserState::new(None, None); + assert_eq!( + state.total_usage_delta(&serde_json::json!({ + "output_tokens": 10, + "reasoning_output_tokens": 4, + })), + (0, 0, 10, Some(4)) + ); + assert_eq!( + state.total_usage_delta(&serde_json::json!({ + "output_tokens": 20, + "reasoning_output_tokens": 9, + })), + (0, 0, 10, Some(5)) + ); +} + +#[test] +fn fork_baseline_subtracts_known_reasoning_without_affecting_core_tokens() { + let baseline = CodexTotals { + input: 10, + cached: 2, + output: 10, + reasoning: Some(4), + }; + let mut state = CodexParserState::with_timestamp_state_and_fork_mode( + None, + Some(baseline), + None, + None, + true, + ); + assert_eq!( + state.apply_totals_delta(CodexTotals { + input: 20, + cached: 5, + output: 20, + reasoning: Some(9), + }), + (10, 3, 10, Some(5)) + ); + + let baseline_without_reasoning = CodexTotals { + input: 10, + cached: 2, + output: 10, + reasoning: None, + }; + let mut state = CodexParserState::with_timestamp_state_and_fork_mode( + None, + Some(baseline_without_reasoning), + None, + None, + true, + ); + assert_eq!( + state.apply_totals_delta(CodexTotals { + input: 20, + cached: 5, + output: 20, + reasoning: Some(9), + }), + (10, 3, 10, None) + ); + assert!(!state.fork_baseline_ambiguous); +} + +#[test] +fn legacy_packed_rows_remain_three_slots_and_report_reasoning_is_unknown() { + let record = CodexUsageRecord { + day_key: "2026-05-31".to_string(), + model: "gpt-5.6-sol".to_string(), + input: 5, + cached: 1, + output: 3, + reasoning: Some(2), + }; + let mut packed = vec![10, 2, 4]; + JsonlScanner::merge_codex_record_into_packed(&mut packed, &record); + assert_eq!(packed, vec![15, 3, 7]); + + let mut cache = CostUsageCache::default(); + cache.days.insert( + "2026-05-31".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), packed)]), + ); + let report = JsonlScanner::cached_cost_report_from_days(&cache); + assert_eq!(report.reasoning_tokens, None); +} + +#[test] +fn known_packed_rows_report_reasoning_only_when_all_token_rows_are_known() { + let mut cache = CostUsageCache::default(); + cache.days.insert( + "2026-05-31".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![10, 2, 4, 3])]), + ); + assert_eq!( + JsonlScanner::cached_cost_report_from_days(&cache).reasoning_tokens, + Some(3) + ); + + cache + .days + .get_mut("2026-05-31") + .unwrap() + .insert("gpt-5.6-fast".to_string(), vec![1, 0, 1]); + assert_eq!( + JsonlScanner::cached_cost_report_from_days(&cache).reasoning_tokens, + None + ); +} + +#[test] +fn test_is_in_range() { + assert!(CostUsageDayRange::is_in_range( + "2026-01-15", + "2026-01-10", + "2026-01-20" + )); + assert!(!CostUsageDayRange::is_in_range( + "2026-01-05", + "2026-01-10", + "2026-01-20" + )); + assert!(!CostUsageDayRange::is_in_range( + "2026-01-25", + "2026-01-10", + "2026-01-20" + )); +} + +#[test] +fn test_parse_day_key() { + let date = CostUsageDayRange::parse_day_key("2026-01-15"); + assert!(date.is_some()); + let date = date.unwrap(); + assert_eq!(date.year(), 2026); + assert_eq!(date.month(), 1); + assert_eq!(date.day(), 15); +} + +#[test] +fn codex_timestamp_day_key_uses_local_calendar_day() { + let today = Local::now().date_naive(); + let local_midnight = today.and_hms_opt(0, 30, 0).unwrap(); + let Some(local_time) = Local.from_local_datetime(&local_midnight).earliest() else { + return; + }; + let utc_timestamp = local_time.with_timezone(&chrono::Utc).to_rfc3339(); + let expected = today.format("%Y-%m-%d").to_string(); + + assert_eq!( + codex_timestamp_day_key(&utc_timestamp).as_deref(), + Some(expected.as_str()) + ); +} + +#[test] +fn native_codex_timestamp_parser_matches_chrono_for_supported_spellings() { + for timestamp in [ + "2026-05-31T10:00:00Z", + "2026-05-31T10:00:00.123Z", + "2024-02-29T23:59:59.999+05:30", + "1900-02-28T00:00:00-08:00", + "1899-12-31T23:59:59.000Z", + ] { + assert_eq!( + parse_rfc3339_timestamp(timestamp), + DateTime::parse_from_rfc3339(timestamp).ok(), + "native parser changed {timestamp}" + ); + } + for timestamp in [ + "2026-02-29T10:00:00Z", + "2026-05-31T10:00:00.1234567890Z", + "2026-05-31T10:00:00+0530", + "2026-05-31T24:00:00Z", + ] { + assert_eq!( + parse_rfc3339_timestamp(timestamp), + DateTime::parse_from_rfc3339(timestamp).ok(), + "native parser changed invalid {timestamp}" + ); + } +} + +#[test] +fn codex_timestamp_fallback_rejects_invalid_calendar_prefixes() { + for timestamp in [ + "2026-02-29T10:00:00Z", + "2026-04-31T10:00:00Z", + "not-a-dateT10:00:00Z", + "2026-05-31", + ] { + assert!( + parse_codex_timestamp(timestamp).is_none(), + "invalid timestamp must not be accepted by the day-key fallback: {timestamp}" + ); + } + + for timestamp in ["2026-05-31T10:00:00+0530", "2026-05-31T10:00:00+05"] { + let parsed = parse_codex_timestamp(timestamp).expect("historical timestamp shape"); + assert_eq!(parsed.fallback_day_key, "2026-05-31"); + assert!(parsed.parsed.is_none()); + } +} + +#[test] +fn codex_timestamp_order_latches_false_and_stops_rechecking() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + for (timestamp, input) in [ + ("2026-05-31T10:00:02Z", 10), + ("2026-05-31T10:00:01Z", 20), + ("2026-05-31T10:00:03Z", 30), + ] { + parser.process_line( + &format!( + r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":1}}}}}}}}"# + ), + &range, + ); + } + + assert_eq!(parser.token_timestamps_monotonic, Some(false)); + assert_eq!(parser.token_timestamp_comparisons, 1); + assert_eq!(parser.records.len(), 3); +} + +#[test] +fn codex_timestamp_order_ignores_sub_millisecond_fraction() { + let day = NaiveDate::from_ymd_opt(2026, 8, 30).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + for timestamp in ["2026-08-30T12:00:00.1239Z", "2026-08-30T12:00:00.1231Z"] { + parser.process_line( + &format!( + r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1}}}}}}}}"# + ), + &range, + ); + } + + assert_eq!(parser.token_timestamps_monotonic, Some(true)); + assert_eq!(parser.token_timestamp_comparisons, 1); +} + +#[test] +fn codex_timestamp_order_detects_millisecond_decrease() { + let day = NaiveDate::from_ymd_opt(2026, 8, 30).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + for timestamp in ["2026-08-30T12:00:00.124Z", "2026-08-30T12:00:00.123Z"] { + parser.process_line( + &format!( + r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1}}}}}}}}"# + ), + &range, + ); + } + + assert_eq!(parser.token_timestamps_monotonic, Some(false)); + assert_eq!(parser.token_timestamp_comparisons, 1); +} + +#[test] +fn codex_timestamp_order_checks_token_history_outside_requested_window() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + for line in [ + r#"{"timestamp":"2026-06-01T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1}}}}"#, + r#"{"timestamp":"2026-05-31T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":2}}}}"#, + ] { + parser.process_line(line, &range); + } + + assert_eq!(parser.token_timestamps_monotonic, Some(false)); + assert_eq!(parser.token_timestamp_comparisons, 1); + assert_eq!( + parser.records.len(), + 1, + "only the in-range event is recorded" + ); +} + +#[test] +fn test_fast_codex_parser_reads_last_usage_from_payload() { + let range = CostUsageDayRange::new( + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + ); + let mut parser = CodexParserState::new(None, None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:00.000Z","type":"turn_context","payload":{"info":{"model":"gpt-5.5"}}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":120,"cache_read_input_tokens":40,"output_tokens":9}}}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 1); + let record = &parser.records[0]; + assert_eq!(record.day_key, "2026-05-31"); + assert_eq!(record.model, "gpt-5.5"); + assert_eq!((record.input, record.cached, record.output), (120, 40, 9)); + assert_eq!(parser.current_model.as_deref(), Some("gpt-5.5")); +} + +#[test] +fn test_fast_codex_parser_diffs_total_usage() { + let range = CostUsageDayRange::new( + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + ); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":200,"output_tokens":50}}}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1250,"cached_input_tokens":260,"output_tokens":90}}}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 2); + assert_eq!( + parser + .records + .iter() + .map(|record| (record.input, record.cached, record.output)) + .collect::>(), + vec![(1_000, 200, 50), (250, 60, 40)] + ); + let totals = parser.previous_totals.expect("last totals"); + assert_eq!(totals.input, 1250); + assert_eq!(totals.cached, 260); + assert_eq!(totals.output, 90); +} + +#[test] +fn test_fast_codex_parser_reads_legacy_event_msg_shape() { + let range = CostUsageDayRange::new( + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + ); + let mut parser = CodexParserState::new(Some("gpt-5".to_string()), None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:02.000Z","type":"event_msg","event_msg":{"type":"token_count","input_tokens":20,"cached_input_tokens":5,"output_tokens":3}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 1); + let record = &parser.records[0]; + assert_eq!(record.model, "gpt-5"); + assert_eq!((record.input, record.cached, record.output), (20, 5, 3)); +} + +#[test] +fn test_parse_codex_file_uses_fast_parser_for_current_logs() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + writeln!( + file, + r#"{{"timestamp":"2026-05-31T10:00:00.000Z","type":"turn_context","payload":{{"model":"gpt-5.5"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-05-31T10:00:01.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":45,"cached_input_tokens":12,"output_tokens":8}}}}}}}}"# + ) + .unwrap(); + + let range = CostUsageDayRange::new( + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(), + ); + let parsed = JsonlScanner::parse_codex_file(file.path(), &range, 0, None, None).expect("parse"); + + assert_eq!(parsed.last_model.as_deref(), Some("gpt-5.5")); + assert_eq!(parsed.records.len(), 1); + let record = &parsed.records[0]; + assert_eq!(record.day_key, "2026-05-31"); + assert_eq!(record.model, "gpt-5.5"); + assert_eq!((record.input, record.cached, record.output), (45, 12, 8)); +} + +#[test] +fn codex_append_timestamp_state_is_output_equivalent_and_boundary_only() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + for (timestamp, input, output) in [ + ("2026-05-31T10:00:01.000Z", 10, 1), + ("2026-05-31T10:00:02.000Z", 20, 2), + ] { + writeln!( + file, + r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5.5","total_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}}}"# + ) + .unwrap(); + } + + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let prefix = + JsonlScanner::parse_codex_file(file.path(), &range, 0, None, None).expect("parse prefix"); + assert_eq!(prefix.token_timestamps_monotonic, Some(true)); + assert_eq!(prefix.token_timestamp_comparisons, 1); + let prefix_input: i32 = prefix.records.iter().map(|record| record.input).sum(); + + writeln!( + file, + r#"{{"timestamp":"2026-05-31T10:00:03.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5.5","total_token_usage":{{"input_tokens":30,"cached_input_tokens":0,"output_tokens":3}}}}}}}}"# + ) + .unwrap(); + + let appended = JsonlScanner::parse_codex_file_with_state( + file.path(), + &range, + prefix.parsed_bytes, + prefix.last_model.clone(), + prefix.last_totals.clone(), + prefix.last_token_timestamp.clone(), + prefix.token_timestamps_monotonic, + None, + ) + .expect("parse appended suffix"); + assert_eq!(appended.token_timestamps_monotonic, Some(true)); + assert_eq!( + appended.token_timestamp_comparisons, 1, + "only the cached-prefix boundary is compared" + ); + + let full = JsonlScanner::parse_codex_file(file.path(), &range, 0, None, None) + .expect("parse complete file"); + let full_input: i32 = full.records.iter().map(|record| record.input).sum(); + let appended_input: i32 = appended.records.iter().map(|record| record.input).sum(); + assert_eq!(prefix_input + appended_input, full_input); + assert_eq!(full_input, 30); +} + +#[test] +fn codex_parser_discards_oversized_line_and_recovers_next_record() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + let padding = "x".repeat(CODEX_JSONL_MAX_LINE_BYTES); + writeln!( + file, + r#"{{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{{"model":"{padding}"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":9,"cached_input_tokens":2,"output_tokens":1}}}}}}}}"# + ) + .unwrap(); + + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let parsed = JsonlScanner::parse_codex_file( + file.path(), + &CostUsageDayRange::new(day, day), + 0, + None, + None, + ) + .expect("parse"); + + assert_eq!(parsed.records.len(), 1); + assert_eq!( + parsed.records[0].model, + CostUsagePricing::CODEX_UNATTRIBUTED_MODEL + ); + assert_eq!( + ( + parsed.records[0].input, + parsed.records[0].cached, + parsed.records[0].output + ), + (9, 2, 1) + ); +} + +#[test] +fn bounded_jsonl_reader_accepts_exact_limit_without_retaining_larger_input() { + let mut input = vec![b'x'; CODEX_JSONL_MAX_LINE_BYTES]; + input.push(b'\n'); + input.extend_from_slice(b"{\"type\":\"event_msg\"}\n"); + let mut reader = BufReader::with_capacity(64 * 1024, std::io::Cursor::new(input)); + + let (exact, _) = read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES) + .expect("read") + .expect("line"); + let (later, _) = read_bounded_jsonl_line(&mut reader, CODEX_JSONL_MAX_LINE_BYTES) + .expect("read") + .expect("line"); + + assert_eq!(exact.len(), CODEX_JSONL_MAX_LINE_BYTES); + assert_eq!(later, br#"{"type":"event_msg"}"#); +} + +#[test] +fn codex_turn_context_wins_over_conflicting_event_model() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(None, None); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{"model":"gpt-5.5"}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","model":"gpt-5.6-sol","info":{"last_token_usage":{"input_tokens":5,"cached_input_tokens":1,"output_tokens":2}}}}"#, + &range, + ); + + assert_eq!(parser.records[0].model, "gpt-5.5"); +} + +#[test] +fn codex_blank_context_clears_stale_model_and_emits_unattributed_usage() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5.5".to_string()), None); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:00Z","type":"turn_context","payload":{"model":" "}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":5,"cached_input_tokens":1,"output_tokens":2}}}}"#, + &range, + ); + + assert_eq!( + parser.records[0].model, + CostUsagePricing::CODEX_UNATTRIBUTED_MODEL + ); +} + +#[test] +fn codex_model_less_token_event_uses_unpriced_sentinel() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(None, None); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":2}}}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 1); + assert_eq!( + parser.records[0].model, + CostUsagePricing::CODEX_UNATTRIBUTED_MODEL + ); +} + +#[test] +fn cached_tokens_use_larger_cached_or_cache_read_field() { + let value = serde_json::json!({ + "input_tokens": 100, + "cached_input_tokens": 20, + "cache_read_input_tokens": 35, + "output_tokens": 10 + }); + let totals = read_token_totals(&value); + assert_eq!(totals.cached, 35); +} + +#[test] +fn parses_bare_usage_rows_outside_token_count_envelope() { + let value = serde_json::json!({ + "model": "gpt-5.6-sol", + "usage": { + "prompt_tokens": 120, + "completion_tokens": 30, + "cached_input_tokens": 40, + "cache_read_input_tokens": 55 + } + }); + let (totals, model) = bare_usage_totals(&value).expect("bare usage"); + assert_eq!(totals.input, 120); + assert_eq!(totals.output, 30); + assert_eq!(totals.cached, 55); + assert_eq!(model.as_deref(), Some("gpt-5.6-sol")); +} + +#[test] +fn process_line_accepts_type_less_bare_usage_row() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(None, None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","model":"gpt-5.6-sol","usage":{"prompt_tokens":120,"completion_tokens":30,"cache_read_input_tokens":55}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 1); + assert_eq!(parser.records[0].model, "gpt-5.6-sol"); + assert_eq!( + ( + parser.records[0].input, + parser.records[0].cached, + parser.records[0].output + ), + (120, 55, 30) + ); +} + +#[test] +fn timestamp_less_bare_usage_uses_last_accepted_usage_day() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":1}}}}"#, + &range, + ); + parser.process_line( + r#"{"usage":{"prompt_tokens":20,"completion_tokens":4,"cache_read_input_tokens":3}}"#, + &range, + ); + + assert_eq!(parser.records.len(), 2); + assert_eq!(parser.records[1].day_key, "2026-05-31"); + assert_eq!( + ( + parser.records[1].input, + parser.records[1].cached, + parser.records[1].output + ), + (20, 3, 4) + ); +} + +#[test] +fn interleaved_lineage_totals_never_exceed_high_watermark_growth() { + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); + + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":0,"output_tokens":20}}}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":5,"cached_input_tokens":0,"output_tokens":1}}}}"#, + &range, + ); + parser.process_line( + r#"{"timestamp":"2026-05-31T10:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":101,"cached_input_tokens":0,"output_tokens":21}}}}"#, + &range, + ); + + let total_input: i32 = parser.records.iter().map(|r| r.input).sum(); + let total_output: i32 = parser.records.iter().map(|r| r.output).sum(); + assert!( + total_input <= 101, + "input inflated to {total_input}, expected <= 101" + ); + assert!( + total_output <= 21, + "output inflated to {total_output}, expected <= 21" + ); +} + +#[test] +fn interleaved_lineage_mid_range_climb_below_watermark_does_not_readd() { + // 100 → 5 (rewind) → 80 (mid-range below water) → 101 (above water). + // Phase-1 containment: do not re-add the 5→80 climb; only growth above + // the historical high watermark counts. + let day = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap(); + let range = CostUsageDayRange::new(day, day); + let mut parser = CodexParserState::new(Some("gpt-5.6-sol".to_string()), None); + + for (input, output) in [(100, 20), (5, 1), (80, 10), (101, 21)] { + parser.process_line( + &format!( + r#"{{"timestamp":"2026-05-31T10:00:0{input}Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":{output}}}}}}}"# + ), + &range, + ); + } + + let total_input: i32 = parser.records.iter().map(|r| r.input).sum(); + let total_output: i32 = parser.records.iter().map(|r| r.output).sum(); + assert!( + total_input <= 101, + "mid-range climb re-added input to {total_input}, expected <= 101" + ); + assert!( + total_output <= 21, + "mid-range climb re-added output to {total_output}, expected <= 21" + ); +} + +#[test] +fn cost_scan_options_app_driven_bypasses_debounce() { + let debounced = CostScanOptions::default(); + let forced = CostScanOptions::app_driven(); + let last = 1_000_000_i64; + let now = last + 1_000; // 1s later, within 60s window + + assert!(debounced.should_skip_scan(last, now)); + assert!(!forced.should_skip_scan(last, now)); + assert!(!debounced.should_skip_scan(last, last + 61_000)); + + let cache = CostUsageCache { + last_scan_unix_ms: last, + ..Default::default() + }; + assert!(JsonlScanner::should_skip_cached_scan( + &cache, + CostScanOptions::default(), + now + )); + assert!(!JsonlScanner::should_skip_cached_scan( + &cache, + CostScanOptions::app_driven(), + now + )); +} + +#[test] +fn session_meta_pre_read_accepts_snake_and_camel_fork_identity() { + let root = tempfile::tempdir().unwrap(); + let snake = root.path().join("snake.jsonl"); + std::fs::write( + &snake, + concat!( + r#"{"type":"session_meta","timestamp":"2026-05-31T10:00:00Z","payload":{"session_id":"child-snake","forked_from_id":"parent-snake"}}"#, + "\n" + ), + ) + .unwrap(); + assert_eq!( + JsonlScanner::read_codex_session_metadata(&snake).unwrap(), + CodexSessionMetadata { + session_id: Some("child-snake".to_string()), + forked_from_id: Some("parent-snake".to_string()), + fork_timestamp: Some("2026-05-31T10:00:00Z".to_string()), + } + ); + + let camel = root.path().join("camel.jsonl"); + std::fs::write( + &camel, + concat!( + r#"{"type":"session_meta","payload":{"sessionId":"child-camel","forkedFromId":"parent-camel","timestamp":"2026-05-31T10:00:01Z"}}"#, + "\n" + ), + ) + .unwrap(); + let metadata = JsonlScanner::read_codex_session_metadata(&camel).unwrap(); + assert_eq!(metadata.session_id.as_deref(), Some("child-camel")); + assert_eq!(metadata.forked_from_id.as_deref(), Some("parent-camel")); + assert_eq!( + metadata.fork_timestamp.as_deref(), + Some("2026-05-31T10:00:01Z") + ); +} + +#[test] +fn legacy_file_usage_json_defaults_fork_metadata() { + let usage: CostUsageFileUsage = serde_json::from_str( + r#"{"mtime_unix_ms":0,"size":0,"days":{},"parsed_bytes":null,"last_model":null,"last_totals":null}"#, + ) + .unwrap(); + assert_eq!(usage.codex_session_id, None); + assert_eq!(usage.codex_forked_from_id, None); + assert_eq!(usage.codex_fork_timestamp, None); + assert!(!usage.codex_unresolved_fork_parent); + + let report: CachedCostReport = serde_json::from_str( + r#"{"total_cost_usd":1.5,"input_tokens":10,"cached_tokens":2,"output_tokens":3,"sessions_count":1,"updated_at":null,"partial":false}"#, + ) + .unwrap(); + assert_eq!(report.reasoning_tokens, None); +} + +#[test] +fn is_line_boundary_offset_zero_returns_true() { + // F2: offset 0 is always a valid boundary (start of file). + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("f.jsonl"); + std::fs::write( + &path, + b"hello +world +", + ) + .unwrap(); + assert!(JsonlScanner::is_line_boundary_offset(&path, 0)); +} + +#[test] +fn is_line_boundary_offset_at_or_past_size_returns_true() { + // F2: offset >= file_size returns true (EOF or beyond is a valid boundary). + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("f.jsonl"); + let content = b"line1 +line2 +"; + std::fs::write(&path, content).unwrap(); + let size = i64::try_from(content.len()).unwrap(); + assert!(JsonlScanner::is_line_boundary_offset(&path, size)); + assert!(JsonlScanner::is_line_boundary_offset(&path, size + 100)); +} + +#[test] +fn is_line_boundary_offset_exact_newline_returns_true() { + // F2: offset pointing right after a newline is a valid boundary. + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("f.jsonl"); + // "line1\nline2\n" — offset 6 is right after first \n + std::fs::write(&path, b"line1\nline2\n").unwrap(); + assert!(JsonlScanner::is_line_boundary_offset(&path, 6)); +} + +#[test] +fn is_line_boundary_offset_midline_returns_false() { + // F2: offset pointing mid-line (byte before is not \n) returns false. + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("f.jsonl"); + // "line1\nline2\n" — offset 3 is mid-line (byte before is 'n') + std::fs::write(&path, b"line1\nline2\n").unwrap(); + assert!(!JsonlScanner::is_line_boundary_offset(&path, 3)); +} + +#[test] +fn is_line_boundary_offset_missing_file_returns_false() { + // F2: missing file returns false (probe fails). + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("nonexistent.jsonl"); + // offset > 0 so it doesn't short-circuit to true + assert!(!JsonlScanner::is_line_boundary_offset(&path, 10)); +} + +#[test] +fn catch_up_snapshot_preserves_established_codex_cost_and_tokens() { + let mut cache = CostUsageCache::default(); + cache.files.insert( + "session.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 100, + days: HashMap::from([( + "2026-08-20".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![1_000, 250, 100])]), + )]), + parsed_bytes: Some(100), + last_model: Some("gpt-5.6-sol".to_string()), + last_totals: None, + codex_token_timestamps_monotonic: Some(true), + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + ); + cache.files.insert( + "empty.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 10, + days: HashMap::new(), + parsed_bytes: Some(10), + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + ); + cache.days.insert( + "2026-08-20".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![1_000, 250, 100])]), + ); + + let report = JsonlScanner::cached_cost_report_from_days(&cache); + let expected = CostUsagePricing::codex_cost_usd_at_date( + "gpt-5.6-sol", + 1_000, + 250, + 100, + NaiveDate::from_ymd_opt(2026, 8, 20).unwrap(), + ) + .expect("known model price"); + + assert!((report.total_cost_usd - expected).abs() < 1e-12); + assert!(report.total_cost_usd > 0.0); + assert_eq!(report.input_tokens, 1_000); + assert_eq!(report.cached_tokens, 250); + assert_eq!(report.output_tokens, 100); + assert_eq!(report.sessions_count, 1); + assert!(!report.partial); + assert!(report.updated_at.is_some()); +} + +#[test] +fn save_cache_persists_small_codex_artifact() { + // F19 integration: a normal-sized Codex cache is persisted and + // reloadable — the MAX_LOAD_BYTES refusal does not false-positive. + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().to_path_buf(); + let mut cache = CostUsageCache { + scan_since_key: Some("2026-01-01".to_string()), + scan_until_key: Some("2026-01-31".to_string()), + files: HashMap::from([( + "a.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 100, + days: HashMap::from([( + "2026-01-10".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![10, 0, 1])]), + )]), + parsed_bytes: None, + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + )]), + ..Default::default() + }; + + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); + + // File should exist and be reloadable. + let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + loaded.files.contains_key("a.jsonl"), + "small artifact persisted" + ); + assert_eq!(loaded.scan_since_key, Some("2026-01-01".to_string())); +} + +#[test] +fn stale_loaded_cache_does_not_replace_newer_baseline() { + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path(); + + let mut initial = CostUsageCache { + last_scan_unix_ms: 1, + ..Default::default() + }; + JsonlScanner::save_cache(ProviderId::Codex, &mut initial, Some(cache_root)); + + let mut stale = JsonlScanner::load_cache(ProviderId::Codex, Some(cache_root)); + let mut newer = JsonlScanner::load_cache(ProviderId::Codex, Some(cache_root)); + newer.last_scan_unix_ms = 2; + JsonlScanner::save_cache(ProviderId::Codex, &mut newer, Some(cache_root)); + + stale.last_scan_unix_ms = 3; + JsonlScanner::save_cache(ProviderId::Codex, &mut stale, Some(cache_root)); + + let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(cache_root)); + assert_eq!( + loaded.last_scan_unix_ms, 2, + "a stale decoded baseline must not overwrite the newer cache" + ); +} + +#[test] +fn save_cache_refuses_non_bounded_provider_oversize() { + // F19: non-bounded providers (e.g. Claude) skip the refusal check + // entirely — the MAX_LOAD_BYTES guard only applies to bounded providers. + // This test confirms the is_bounded_provider gate works: Claude cache + // is saved regardless of the MAX_LOAD_BYTES check (which is Codex-only). + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().to_path_buf(); + let mut cache = CostUsageCache::default(); + cache.files.insert( + "claude.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 100, + days: HashMap::new(), + parsed_bytes: None, + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + ); + + JsonlScanner::save_cache(ProviderId::Claude, &mut cache, Some(&cache_root)); + let loaded = JsonlScanner::load_cache(ProviderId::Claude, Some(&cache_root)); + assert!(loaded.files.contains_key("claude.jsonl")); +} + +#[test] +fn save_cache_refusal_removes_preexisting_destination_artifact() { + // F19 integration: when the post-encode check refuses the artifact, any + // pre-existing destination file is removed so a stale/oversized artifact + // cannot persist and trigger load/refuse/rebuild behavior on next scan. + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().to_path_buf(); + + let mut cache = CostUsageCache::default(); + cache.files.insert( + "big.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 100, + days: HashMap::from([( + "2026-01-10".to_string(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![10, 0, 1])]), + )]), + parsed_bytes: None, + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + ); + + // Precreate a "stale" destination artifact so the refusal must remove + // it. We seed it via a large (over_max) save_limit so the save_cache_with_limit + // first ENCODES the small cache fine under a generous limit, writes the file, + // then a follow-up call with a tiny limit must refuse AND remove. + let cache_path = { + // Exercise the private helper indirectly via the public path: first + // persist a valid artifact under a generous limit via save_cache. + // Then call with an impossible limit (encoded JSON ~hundreds of + // bytes, limit = 1 byte) to force refusal. + JsonlScanner::save_cache_with_limit( + ProviderId::Codex, + &mut cache, + Some(&cache_root), + usize::MAX, + ); + let p = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); + assert!(p.exists(), "precreate destination artifact"); + p + }; + + // Sanity: a normal load succeeds against the precreated artifact. + let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!(loaded.files.contains_key("big.jsonl")); + + // Force refusal with a 1-byte limit: encoded cache will exceed it. + JsonlScanner::save_cache_with_limit(ProviderId::Codex, &mut cache, Some(&cache_root), 1); + + // Destination must be gone — no stale artifact may persist. + assert!( + !cache_path.exists(), + "refusal must remove preexisting destination artifact" + ); + + // No temp file should remain in the cache root (only unique tmp name was used). + let mut tmp_entries = Vec::new(); + for entry in std::fs::read_dir(&cache_root).unwrap() { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') && name.ends_with(".tmp") { + tmp_entries.push(name.into_owned()); + } + } + // Best-effort temp cleanup writes an empty file at the unique name; the + // invariant is that NO tmp file contains a complete artifact. The set + // should at most contain a single zero-byte remnant from the cleanup + // (or be empty); we persist via copy() rather than rename so no live + // tmp holds data after the save path completes. + for t in &tmp_entries { + let meta = std::fs::metadata(cache_root.join(t)).unwrap(); + assert_eq!(meta.len(), 0, "tmp remnant must be empty: {t}"); + } + + // Loading after removal yields a fresh default cache (no rebuild loop). + let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + loaded.files.is_empty(), + "no rebuild loop from removed artifact" + ); +} + +#[test] +fn save_cache_at_exact_limit_is_accepted() { + // F19 boundary: an encoded artifact at exactly the injected limit is + // accepted (only strictly-larger artifacts are refused). + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().to_path_buf(); + + let cache = CostUsageCache::default(); + // Serialize to learn the actual encoded size for this exact struct. + let json = serde_json::to_string(&cache).unwrap(); + let exact_limit = json.len(); + + let mut cache_for_save = cache; + JsonlScanner::save_cache_with_limit( + ProviderId::Codex, + &mut cache_for_save, + Some(&cache_root), + exact_limit, + ); + + let cache_path = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); + assert!( + cache_path.exists(), + "artifact at exact limit must be persisted" + ); +} + +#[test] +fn save_cache_one_over_limit_is_refused_and_removes_destination() { + // F19 boundary: an encoded artifact one byte over the injected limit is + // refused, and any pre-existing destination is removed. + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().to_path_buf(); + + let cache = CostUsageCache::default(); + let json = serde_json::to_string(&cache).unwrap(); + // One byte short of the encoded size forces refusal on the next attempt. + let under_by_one = json.len().saturating_sub(1); + + let mut cache_for_save = cache; + JsonlScanner::save_cache_with_limit( + ProviderId::Codex, + &mut cache_for_save, + Some(&cache_root), + under_by_one, + ); + + let cache_path = JsonlScanner::cache_path(ProviderId::Codex, Some(&cache_root)); + assert!( + !cache_path.exists(), + "one-over-limit encoded artifact must be refused" + ); +} diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index f8894609eb..43f85874af 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -408,6 +408,14 @@ pub struct ProviderFetchResult { /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, + + /// Whether quota data is authoritative enough for pace/run-out advice. + #[serde(default = "default_pace_authoritative")] + pub pace_authoritative: bool, +} + +fn default_pace_authoritative() -> bool { + true } impl ProviderFetchResult { @@ -418,9 +426,16 @@ impl ProviderFetchResult { cost: None, wayfinder_usage: None, source_label: source_label.into(), + pace_authoritative: true, } } + /// Mark this result as unsuitable for derived pace/run-out advice. + pub fn with_non_authoritative_pace(mut self) -> Self { + self.pace_authoritative = false; + self + } + /// Builder pattern: set cost pub fn with_cost(mut self, cost: CostSnapshot) -> Self { self.cost = Some(cost); @@ -438,6 +453,17 @@ impl ProviderFetchResult { mod tests { use super::*; + #[test] + fn fetch_result_pace_authority_defaults_true_and_can_be_disabled() { + let usage = UsageSnapshot::new(RateWindow::new(25.0)); + assert!(ProviderFetchResult::new(usage.clone(), "api").pace_authoritative); + assert!( + !ProviderFetchResult::new(usage, "local estimate") + .with_non_authoritative_pace() + .pace_authoritative + ); + } + #[test] fn cost_snapshot_ignores_non_finite_values() { let cost = CostSnapshot::new(f64::NAN, "USD", "Monthly").with_limit(f64::INFINITY); diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 4ba65aa86e..09862e017b 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; use serde::Deserialize; +use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; use std::io::{BufRead, BufReader}; @@ -25,11 +26,12 @@ use crate::codex_costs::{ }; use crate::codex_sessions::{codex_sessions_dir_candidates, default_wsl_roots}; use crate::core::{ - CostScanOptions, CostUsageCache, CostUsageDayRange, CostUsageFileUsage, CostUsagePricing, - JsonlScanner, ProviderId, + CachedCostReport, CostScanOptions, CostUsageCache, CostUsageDayRange, CostUsageFileUsage, + CostUsagePricing, JsonlScanner, ProviderId, }; use crate::providers::opencodego::local as opencodego_local; use crate::settings::Settings; +mod codex; /// Completeness of the pricing coverage in a [`CostSummary`] (upstream 0.48.0 F18). /// @@ -66,6 +68,8 @@ pub struct CostSummary { pub output_tokens: u64, /// Total cached input tokens pub cached_tokens: u64, + /// Total reasoning tokens when every contributing row reports them + pub reasoning_tokens: Option, /// Number of sessions/conversations scanned pub sessions_count: u32, /// Cost breakdown by model @@ -98,16 +102,17 @@ pub struct CostSummary { } /// Per-model token counts -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ModelTokenCounts { pub input_tokens: u64, pub output_tokens: u64, pub cached_tokens: u64, + pub reasoning_tokens: Option, } impl ModelTokenCounts { pub fn total(&self) -> u64 { - self.input_tokens + self.output_tokens + self.input_tokens.saturating_add(self.output_tokens) } } @@ -151,32 +156,6 @@ fn system_time_to_unix_ms(modified: Option) -> i64 { millis } -fn rebuild_cache_days(cache: &mut CostUsageCache) { - cache.days.clear(); - for usage in cache.files.values() { - for (day, models) in &usage.days { - let day_entry = cache.days.entry(day.clone()).or_default(); - for (model, packed) in models { - let dest = day_entry - .entry(model.clone()) - .or_insert_with(|| vec![0, 0, 0]); - if dest.len() < 3 { - dest.resize(3, 0); - } - for (i, value) in packed.iter().take(3).enumerate() { - dest[i] = dest[i].saturating_add(*value); - } - } - } - } -} - -/// Claude cost calculation for the usage scanner. -/// -/// Per-token rates come from the canonical `CostUsagePricing::claude_cost_usd` -/// table (the single source of truth for Claude pricing). The only -/// scanner-specific piece is the one-hour cache-write premium, which the -/// canonical cost function doesn't model: one-hour cache writes bill at 2x the /// input rate. struct ClaudePricing; @@ -252,8 +231,12 @@ struct CodexEventMsg { output_tokens: Option, } -/// JSONL event structures for Claude transcripts. Unknown fields are -/// ignored, so lines that are not assistant usage events still parse. +/// JSONL event structures for Claude transcripts. +/// +/// The flattened values retain otherwise-unknown metadata long enough to +/// distinguish Anthropic rows from Vertex AI rows. Claude's local transcript +/// format can contain both shapes, and counting Vertex rows with Anthropic +/// pricing would misstate both cost and token history. #[derive(Debug, Deserialize)] struct ClaudeEvent { #[serde(rename = "type")] @@ -262,6 +245,8 @@ struct ClaudeEvent { #[serde(rename = "requestId", alias = "request_id")] request_id: Option, message: Option, + #[serde(flatten)] + extra: HashMap, } impl ClaudeEvent { @@ -271,6 +256,39 @@ impl ClaudeEvent { .ok() .map(|ts| ts.with_timezone(&Utc)) } + + fn is_vertex_ai_usage_entry(&self) -> bool { + // Vertex AI message/request identifiers use the `_vrtx_` marker. + if self + .message + .as_ref() + .and_then(|message| message.id.as_deref()) + .is_some_and(|id| id.contains("_vrtx_")) + || self + .request_id + .as_deref() + .is_some_and(|request_id| request_id.contains("_vrtx_")) + { + return true; + } + + // Vertex AI model names use `@` as the version separator. + if self + .message + .as_ref() + .and_then(|message| message.model.as_deref()) + .is_some_and(model_name_looks_vertex) + { + return true; + } + + if contains_claude_vertex_metadata_entries(self.extra.iter()) { + return true; + } + self.message + .as_ref() + .is_some_and(ClaudeMessage::contains_vertex_metadata) + } } #[derive(Debug, Deserialize)] @@ -278,6 +296,19 @@ struct ClaudeMessage { id: Option, model: Option, usage: Option, + #[serde(flatten)] + extra: HashMap, +} + +impl ClaudeMessage { + fn contains_vertex_metadata(&self) -> bool { + if contains_claude_vertex_metadata_entries(self.extra.iter()) { + return true; + } + self.usage + .as_ref() + .is_some_and(ClaudeUsage::contains_vertex_metadata) + } } #[derive(Debug, Deserialize)] @@ -287,6 +318,19 @@ struct ClaudeUsage { cache_creation_input_tokens: Option, cache_read_input_tokens: Option, cache_creation: Option, + #[serde(flatten)] + extra: HashMap, +} + +impl ClaudeUsage { + fn contains_vertex_metadata(&self) -> bool { + if contains_claude_vertex_metadata_entries(self.extra.iter()) { + return true; + } + self.cache_creation + .as_ref() + .is_some_and(ClaudeCacheCreation::contains_vertex_metadata) + } } impl ClaudeUsage { @@ -304,6 +348,82 @@ impl ClaudeUsage { #[derive(Debug, Deserialize)] struct ClaudeCacheCreation { ephemeral_1h_input_tokens: Option, + #[serde(flatten)] + extra: HashMap, +} + +impl ClaudeCacheCreation { + fn contains_vertex_metadata(&self) -> bool { + contains_claude_vertex_metadata_entries(self.extra.iter()) + } +} + +const CLAUDE_VERTEX_PROVIDER_KEYS: &[&str] = &[ + "provider", + "platform", + "backend", + "api_provider", + "apiprovider", + "api_type", + "apitype", + "source", + "vendor", + "client", +]; + +fn model_name_looks_vertex(model: &str) -> bool { + model.starts_with("claude-") && model.contains('@') +} + +/// Match the upstream Claude classifier's recursive metadata rules. Marker +/// keys (`vertex`/`gcp`) classify regardless of value; provider-key values +/// classify only when their text contains `vertex` (not merely `gcp`). +fn contains_claude_vertex_metadata(value: &Value) -> bool { + match value { + Value::Object(object) => contains_claude_vertex_metadata_entries(object.iter()), + Value::Array(array) => array.iter().any(contains_claude_vertex_metadata), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => false, + } +} + +fn contains_claude_vertex_metadata_entries<'a, I>(entries: I) -> bool +where + I: IntoIterator, +{ + entries.into_iter().any(|(key, value)| { + contains_claude_vertex_marker(key, true) + || (CLAUDE_VERTEX_PROVIDER_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + && value + .as_str() + .is_some_and(|text| contains_claude_vertex_marker(text, false))) + || contains_claude_vertex_metadata(value) + }) +} + +fn contains_claude_vertex_marker(value: &str, include_gcp: bool) -> bool { + let bytes = value.as_bytes(); + let has_marker = |marker: &[u8]| { + bytes.windows(marker.len()).any(|window| { + window + .iter() + .zip(marker) + .all(|(byte, expected)| byte.to_ascii_lowercase() == *expected) + }) + }; + + if has_marker(b"vertex") || (include_gcp && has_marker(b"gcp")) { + return true; + } + + // ASCII folding above is enough for the common path. Unicode lowercasing + // preserves the historical classifier's behavior for non-ASCII strings. + if value.is_ascii() { + return false; + } + let lower = value.to_lowercase(); + lower.contains("vertex") || (include_gcp && lower.contains("gcp")) } #[derive(Debug)] @@ -325,10 +445,16 @@ pub struct CostScanStats { pub files_parsed: u32, pub files_skipped: u32, pub files_resumed: u32, + /// Files deferred to a later bounded Codex catch-up pass. + pub files_deferred: u32, + /// Newly consumed Codex JSONL bytes in this refresh. + pub codex_bytes_read: u64, + /// Timestamp comparisons performed while validating Codex append history. + pub token_timestamp_comparisons: u64, pub used_cache_debounce: bool, } -/// Cost usage scanner +#[derive(Debug, Clone)] pub struct CostScanner { days: u32, options: CostScanOptions, @@ -367,135 +493,6 @@ impl CostScanner { } /// Scan Codex local logs - pub fn scan_codex(&self) -> CostSummary { - self.scan_codex_with_cancel(None) - } - - /// Scan Codex local logs, stopping early when the caller cancels the scan. - pub fn scan_codex_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { - self.scan_codex_detailed(cancel).0 - } - - /// Scan Codex and return cache/resume stats alongside the summary. - pub fn scan_codex_detailed(&self, cancel: Option<&AtomicBool>) -> (CostSummary, CostScanStats) { - let mut summary = CostSummary::default(); - let mut stats = CostScanStats::default(); - let today = Local::now().date_naive(); - let start_date = codex_period_start(today, self.days); - let range = CostUsageDayRange::new(start_date, today); - let now_ms = unix_now_ms(); - - summary.period_start = Some(start_date); - summary.period_end = Some(today); - - let cache_root = self.cache_root.as_deref(); - let mut cache = JsonlScanner::load_cache(ProviderId::Codex, cache_root); - - // Debounce: rebuild from disk cache without re-walking session files. - if JsonlScanner::should_skip_cached_scan(&cache, self.options, now_ms) - && JsonlScanner::cache_covers_range(&cache, &range) - && (!cache.days.is_empty() || !cache.files.is_empty()) - { - stats.used_cache_debounce = true; - // A16 (upstream 0.48.0): cache hit within debounce = coverage established - // when the cache has data and no catch-up is pending. Final publication - // also waits for the cancellable Pi/OMP scan below. - let cached_history_coverage_established = - !cache.days.is_empty() && cache.previous_report.is_none(); - let (cost, _) = add_codex_days_map_to_summary(&mut summary, &cache.days, &range); - summary.total_cost_usd += cost; - // Session count is a display field; the cache holds far fewer files than u32::MAX. - #[allow(clippy::cast_possible_truncation, reason = "cache file counts fit u32")] - let sessions_count = cache - .files - .values() - .filter(|usage| { - usage.days.keys().any(|day| { - CostUsageDayRange::is_in_range(day, &range.since_key, &range.until_key) - }) - }) - .count() as u32; - summary.sessions_count = sessions_count; - - // Pi-compatible sessions are outside the Codex JSONL cache. - // Skip when tests inject sessions roots — avoid scanning the real home tree. - if self.sessions_dirs_override.is_none() { - let mut seen_pi = HashSet::new(); - crate::pi_session_cost::scan_pi_compatible_into( - &mut summary, - crate::pi_session_cost::PiMappedProvider::Codex, - self.days, - cancel, - &mut seen_pi, - ); - } - summary.history_coverage_established = - cached_history_coverage_established && !is_cancelled(cancel); - // Upstream 0.50.1 #2932: debounce cache hit with coverage - // established but zero sessions in-range is a known-zero. - summary.known_zero = - summary.history_coverage_established && summary.sessions_count == 0; - return (summary, stats); - } - - for sessions_dir in self.get_codex_sessions_dirs() { - if is_cancelled(cancel) { - break; - } - if sessions_dir.exists() { - self.scan_codex_sessions_dir( - &sessions_dir, - &range, - &mut summary, - &mut cache, - cancel, - &mut stats, - ); - } - } - - if !is_cancelled(cancel) { - rebuild_cache_days(&mut cache); - cache.last_scan_unix_ms = now_ms; - cache.scan_since_key = Some(range.since_key.clone()); - cache.scan_until_key = Some(range.until_key.clone()); - // F8 (upstream 0.48.0): a completed full scan rebuilds the cache for - // the current window, so any prior catch-up state is no longer - // pending. Clear previous_report before save so the persisted - // artifact no longer signals stale/refreshing (audit: must clear). - cache.previous_report = None; - JsonlScanner::save_cache(ProviderId::Codex, &mut cache, cache_root); - } - - // OMP / pi-compatible agent sessions (upstream #2269). Dedup by entry id. - // Skip when tests inject sessions roots — avoid scanning the real home tree. - // A16 --provider-native-only: skip pi/OMP mirrors when disabled. - if self.sessions_dirs_override.is_none() && self.options.include_pi_sessions { - let mut seen_pi = HashSet::new(); - crate::pi_session_cost::scan_pi_compatible_into( - &mut summary, - crate::pi_session_cost::PiMappedProvider::Codex, - self.days, - cancel, - &mut seen_pi, - ); - } - - // v0.56.1 #3279: only publish authoritative coverage after all - // cancellable scan work, including Pi/OMP, has completed. Persistence - // pruning may retain `previous_report`, but that must not make a - // completed in-memory scan stale or make a cancelled partial scan look - // complete. - summary.history_coverage_established = !is_cancelled(cancel); - // Upstream 0.50.1 #2932: a completed scan with zero results is a - // *known* zero. Only set when coverage is established; an incomplete - // scan must NOT fabricate a zero. - summary.known_zero = summary.history_coverage_established && summary.sessions_count == 0; - - (summary, stats) - } - - /// Scan Claude local logs pub fn scan_claude(&self) -> CostSummary { self.scan_claude_with_cancel(None) } @@ -564,59 +561,6 @@ impl CostScanner { } } - fn get_codex_sessions_dirs(&self) -> Vec { - if let Some(dirs) = &self.sessions_dirs_override { - return dirs.clone(); - } - let settings = Settings::load(); - let codex_home = std::env::var("CODEX_HOME").ok(); - codex_sessions_dir_candidates( - dirs::home_dir(), - codex_home, - &settings.codex_custom_sessions_dirs, - &default_wsl_roots(), - ) - } - - fn scan_codex_sessions_dir( - &self, - sessions_dir: &Path, - range: &CostUsageDayRange, - summary: &mut CostSummary, - cache: &mut CostUsageCache, - cancel: Option<&AtomicBool>, - stats: &mut CostScanStats, - ) { - // Iterate through the date-based directory structure with one day of - // padding on each side. Codex JSONL timestamps are UTC, while the tray - // presents local calendar days; the parser filters back to `range`. - for date in codex_scan_dates(range) { - if is_cancelled(cancel) { - break; - } - let year = date.format("%Y").to_string(); - let month = date.format("%m").to_string(); - let day = date.format("%d").to_string(); - - let day_dir = sessions_dir.join(&year).join(&month).join(&day); - if !day_dir.exists() { - continue; - } - - if let Ok(entries) = fs::read_dir(&day_dir) { - for entry in entries.flatten() { - if is_cancelled(cancel) { - break; - } - let path = entry.path(); - if path.extension().is_some_and(|e| e == "jsonl") { - self.parse_codex_file(&path, range, summary, cache, cancel, stats); - } - } - } - } - } - fn get_claude_projects_dir(&self) -> PathBuf { if let Ok(claude_config) = std::env::var("CLAUDE_CONFIG_DIR") { let trimmed = claude_config.trim(); @@ -636,130 +580,6 @@ impl CostScanner { home.join(".config").join("claude").join("projects") } - fn parse_codex_file( - &self, - path: &Path, - range: &CostUsageDayRange, - summary: &mut CostSummary, - cache: &mut CostUsageCache, - cancel: Option<&AtomicBool>, - stats: &mut CostScanStats, - ) { - if is_cancelled(cancel) { - return; - } - stats.files_seen += 1; - - let metadata = match fs::metadata(path) { - Ok(m) => m, - Err(_) => return, - }; - // File sizes are clamped to i64::MAX before casting. - #[allow( - clippy::cast_possible_wrap, - reason = "file sizes are clamped to i64::MAX" - )] - let size = metadata.len().min(i64::MAX as u64) as i64; - let mtime_ms = system_time_to_unix_ms(metadata.modified().ok()); - let path_key = path.to_string_lossy().to_string(); - let cached = cache.files.get(&path_key).cloned(); - - // Unchanged complete file: reuse packed days, skip re-parse. - if let Some(entry) = &cached - && entry.mtime_unix_ms == mtime_ms - && entry.size == size - && entry.parsed_bytes.unwrap_or(0) >= size - && size > 0 - { - let (session_cost, has_tokens) = - add_codex_days_map_to_summary(summary, &entry.days, range); - if has_tokens { - summary.total_cost_usd += session_cost; - summary.sessions_count += 1; - } - stats.files_skipped += 1; - return; - } - - // Growing file: resume from last parsed offset when safe. - if let Some(entry) = &cached { - let start_offset = entry.parsed_bytes.unwrap_or(0); - if size > entry.size - && start_offset > 0 - && start_offset <= size - && entry.last_totals.is_some() - && JsonlScanner::is_line_boundary_offset(path, start_offset) - { - let parse_result = match JsonlScanner::parse_codex_file( - path, - range, - start_offset, - entry.last_model.clone(), - entry.last_totals.clone(), - ) { - Ok(result) => result, - Err(_) => return, - }; - - let mut days = entry.days.clone(); - merge_codex_records_into_days(&mut days, &parse_result.records); - - let (session_cost, has_tokens) = - add_codex_days_map_to_summary(summary, &days, range); - if has_tokens { - summary.total_cost_usd += session_cost; - summary.sessions_count += 1; - } - - cache.files.insert( - path_key, - CostUsageFileUsage { - mtime_unix_ms: mtime_ms, - size, - days, - parsed_bytes: Some(parse_result.parsed_bytes), - last_model: parse_result.last_model.or_else(|| entry.last_model.clone()), - last_totals: parse_result - .last_totals - .or_else(|| entry.last_totals.clone()), - }, - ); - stats.files_resumed += 1; - return; - } - } - - // Full parse from offset 0. - let parse_result = match JsonlScanner::parse_codex_file(path, range, 0, None, None) { - Ok(result) => result, - Err(_) => return, - }; - - let mut days = HashMap::new(); - merge_codex_records_into_days(&mut days, &parse_result.records); - - let (session_cost, has_tokens) = - add_codex_records_to_summary(summary, &parse_result.records, range); - - if has_tokens { - summary.total_cost_usd += session_cost; - summary.sessions_count += 1; - } - - cache.files.insert( - path_key, - CostUsageFileUsage { - mtime_unix_ms: mtime_ms, - size, - days, - parsed_bytes: Some(parse_result.parsed_bytes), - last_model: parse_result.last_model, - last_totals: parse_result.last_totals, - }, - ); - stats.files_parsed += 1; - } - fn walk_claude_files( &self, dir: &Path, @@ -827,6 +647,7 @@ where return false; } if let Ok(event) = serde_json::from_str::(line) + && !event.is_vertex_ai_usage_entry() && let Some(record) = claude_usage_record_from_event(&event) && should_count_claude_record(&record, cutoff, seen) { @@ -1012,9 +833,8 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< // Warm/refresh the disk cache, then price from packed days. v0.56.1 // preserves every calendar slot and distinguishes covered zero from // unscanned/unpriced history. - let _scan = scanner.scan_codex(); - let cache = JsonlScanner::load_cache(ProviderId::Codex, scanner.cache_root.as_deref()); - if cache.previous_report.is_none() { + let (_summary, _stats, cache) = scanner.scan_codex_detailed_with_cache(None); + if cache.previous_report.is_none() && !cache.codex_scan_incomplete { for (day_key, slot) in &mut daily_costs { if cache .scan_since_key @@ -1099,8 +919,7 @@ pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)> // Warm/refresh the disk cache, then read exact local token totals // from packed days through the same summary path the cost chart // uses. - let _ = scanner.scan_codex(); - let cache = JsonlScanner::load_cache(ProviderId::Codex, scanner.cache_root.as_deref()); + let (_summary, _stats, cache) = scanner.scan_codex_detailed_with_cache(None); for (day_key, models) in &cache.days { if !daily_tokens.contains_key(day_key) { continue; @@ -1171,649 +990,3 @@ fn add_claude_record_to_daily_tokens( *slot += record.input + record.output; } } - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn test_unknown_model_falls_back_to_sonnet() { - // Unknown/retired Claude IDs fall back to Sonnet 4.6 base pricing - // ($3/1M input, $15/1M output). 100k tokens stay under the 200k tier. - let cost = - ClaudePricing::cost_usd_with_cache_ttl("claude-3-5-sonnet", 100_000, 0, 0, 0, 100_000); - // 100k * $3/M + 100k * $15/M = 0.30 + 1.50 = 1.80 - assert!((cost - 1.80).abs() < 0.001); - } - - #[test] - fn records_unknown_claude_model_while_using_fallback_cost() { - let event: ClaudeEvent = serde_json::from_str( - r#"{"type":"assistant","timestamp":"2026-01-15T10:00:00Z","requestId":"req_unknown","message":{"id":"msg_unknown","model":"claude-retired-unknown","usage":{"input_tokens":100000,"output_tokens":100000}}}"#, - ) - .unwrap(); - let record = claude_usage_record_from_event(&event).expect("usage record"); - let mut summary = CostSummary::default(); - - add_claude_record_to_summary(&mut summary, &record); - - assert!(summary.total_cost_usd > 0.0); - assert!(summary.unknown_models.contains("claude-retired-unknown")); - } - - #[test] - fn test_claude_fable_5_pricing() { - let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-fable-5", 100, 10, 0, 20, 5); - let expected = (100.0 / 1_000_000.0) * 10.00 - + (10.0 / 1_000_000.0) * 12.50 - + (20.0 / 1_000_000.0) * 1.00 - + (5.0 / 1_000_000.0) * 50.00; - assert!((cost - expected).abs() < f64::EPSILON); - } - - #[test] - fn test_claude_one_hour_cache_write_pricing() { - let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-fable-5", 100, 30, 20, 20, 5); - let expected = (100.0 / 1_000_000.0) * 10.00 - + (10.0 / 1_000_000.0) * 12.50 - + (20.0 / 1_000_000.0) * 20.00 - + (20.0 / 1_000_000.0) * 1.00 - + (5.0 / 1_000_000.0) * 50.00; - assert!((cost - expected).abs() < f64::EPSILON); - } - - #[test] - fn test_claude_sonnet_46_honors_200k_tier() { - // Delegating to the canonical table means the scanner now honors the - // 200k long-context tier: 200k @ $3/M + 40k @ $6/M = 0.60 + 0.24 = 0.84 - // (the scanner's old inline table applied a flat $3/M = 0.72). - let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-sonnet-4-6", 240_000, 0, 0, 0, 0); - assert!((cost - 0.84).abs() < 0.001); - } - - #[test] - fn test_current_gen_opus_uses_5_25_pricing() { - // Opus 4.5/4.6/4.7/4.8 bill at $5/1M input + $25/1M output = $30 total. - // Delegation regression guard: opus-4-8 in particular must resolve - // through the canonical table (it was missing there before this fix). - for model in [ - "claude-opus-4-5", - "claude-opus-4-6", - "claude-opus-4-7", - "claude-opus-4-8", - ] { - let cost = ClaudePricing::cost_usd_with_cache_ttl(model, 1_000_000, 0, 0, 0, 1_000_000); - assert!( - (cost - 30.00).abs() < 0.001, - "{model} should bill $30 ($5 in + $25 out), got {cost}" - ); - } - } - - #[test] - fn test_legacy_opus_keeps_legacy_pricing() { - // Legacy Opus 4.0 / 4.1 remain at $15/1M input + $75/1M output = $90 in - // the canonical table. (Retired IDs absent from the table — e.g. Opus 3 - // `claude-3-opus-...` — fall back to Sonnet instead; they are outside - // any realistic 30-day scan window.) - for model in ["claude-opus-4-20250514", "claude-opus-4-1"] { - let cost = ClaudePricing::cost_usd_with_cache_ttl(model, 1_000_000, 0, 0, 0, 1_000_000); - assert!( - (cost - 90.00).abs() < 0.001, - "{model} should bill $90 ($15 in + $75 out), got {cost}" - ); - } - } - - #[test] - fn test_haiku_45_uses_current_pricing() { - // Haiku 4.5 bills at $1/1M input + $5/1M output = $6 via the canonical - // table (previously the scanner under-priced it at the Haiku 3 rate). - let cost = ClaudePricing::cost_usd_with_cache_ttl( - "claude-haiku-4-5", - 1_000_000, - 0, - 0, - 0, - 1_000_000, - ); - assert!( - (cost - 6.00).abs() < 0.001, - "haiku-4-5 should bill $6 ($1 in + $5 out), got {cost}" - ); - } - - #[test] - fn parses_current_codex_payload_token_count_events() { - let path = std::env::temp_dir().join(format!( - "codexbar-current-codex-token-count-{}.jsonl", - std::process::id() - )); - // Use a recent timestamp so the event stays inside the scanner's - // 30-day window no matter when the test runs. A hardcoded date - // silently ages out of the window and makes this test fail with 0 - // sessions once it is more than 30 days in the past. - let recent = (Utc::now() - Duration::hours(1)) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); - let mut file = File::create(&path).unwrap(); - writeln!( - file, - r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":125,"cached_input_tokens":30,"output_tokens":15}}}}}}}}"#, - ts = recent - ) - .unwrap(); - let scanner = CostScanner::new(30); - let mut summary = CostSummary::default(); - let today = Local::now().date_naive(); - let range = CostUsageDayRange::new(codex_period_start(today, 30), today); - let mut cache = CostUsageCache::default(); - let mut stats = CostScanStats::default(); - scanner.parse_codex_file(&path, &range, &mut summary, &mut cache, None, &mut stats); - - assert_eq!(summary.sessions_count, 1); - assert_eq!(summary.input_tokens, 125); - assert_eq!(summary.cached_tokens, 30); - assert_eq!(summary.output_tokens, 15); - assert_eq!( - summary - .by_model_tokens - .get("gpt-5") - .map(ModelTokenCounts::total), - Some(140) - ); - assert!(scan_codex_file_cost(&path) > 0.0); - // Best-effort test cleanup; the file may already be gone. - let _removed = std::fs::remove_file(&path); - } - - #[test] - fn derives_claude_dedup_key_from_message_and_request_ids() { - assert_eq!( - claude_usage_dedup_key(Some("msg_1"), Some("req_1")).as_deref(), - Some("msg_1:req_1") - ); - assert_eq!( - claude_usage_dedup_key(Some("msg_1"), None).as_deref(), - Some("message:msg_1") - ); - assert_eq!( - claude_usage_dedup_key(None, Some("req_1")).as_deref(), - Some("request:req_1") - ); - assert_eq!(claude_usage_dedup_key(None, None), None); - } - - #[test] - fn counts_claude_usage_once_across_duplicate_records() { - // The same API response can be replayed into several transcript files - // (session resume, sidechains); it must only be counted once. - let event: ClaudeEvent = serde_json::from_str( - r#"{"type":"assistant","timestamp":"2026-01-15T10:00:00Z","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}"#, - ) - .unwrap(); - - let record = claude_usage_record_from_event(&event).expect("usage record"); - assert_eq!(record.model, "claude-sonnet-4-6"); - assert_eq!(record.input, 100); - assert_eq!(record.output, 50); - assert_eq!(record.cache_create, 10); - assert_eq!(record.cache_read, 20); - assert!(record.cost > 0.0); - - let cutoff = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let mut seen = HashSet::new(); - assert!(should_count_claude_record(&record, &cutoff, &mut seen)); - assert!(!should_count_claude_record(&record, &cutoff, &mut seen)); - } - - #[test] - fn rejects_claude_records_before_cutoff() { - let event: ClaudeEvent = serde_json::from_str( - r#"{"type":"assistant","timestamp":"2025-12-01T10:00:00Z","requestId":"req_old","message":{"id":"msg_old","model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1}}}"#, - ) - .unwrap(); - let record = claude_usage_record_from_event(&event).expect("usage record"); - let cutoff = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let mut seen = HashSet::new(); - assert!(!should_count_claude_record(&record, &cutoff, &mut seen)); - } - - #[test] - fn ignores_claude_events_without_countable_usage() { - // Non-assistant events carry no billable usage. - let event: ClaudeEvent = - serde_json::from_str(r#"{"type":"user","message":{"usage":{"input_tokens":5}}}"#) - .unwrap(); - assert!(claude_usage_record_from_event(&event).is_none()); - - // Zero-token usage blocks (e.g. synthetic messages) are not sessions. - let event: ClaudeEvent = serde_json::from_str( - r#"{"type":"assistant","message":{"id":"msg_zero","model":"claude-sonnet-4-6","usage":{"input_tokens":0,"output_tokens":0}}}"#, - ) - .unwrap(); - assert!(claude_usage_record_from_event(&event).is_none()); - } - - fn claude_transcript_line( - timestamp: &str, - request_key: &str, - request_id: &str, - message_id: &str, - ) -> String { - format!( - r#"{{"type":"assistant","timestamp":"{timestamp}","{request_key}":"{request_id}","message":{{"id":"{message_id}","model":"claude-sonnet-4-6","usage":{{"input_tokens":1000,"output_tokens":500}}}}}}"# - ) - } - - #[test] - fn daily_history_dedups_across_files_and_buckets_by_local_day() { - // End-to-end regression for the daily buckets: two transcript files, - // two different days, plus a replay of the day-one record in the - // second file (snake_case request_id, as another writer would emit). - let dir = std::env::temp_dir(); - let file_a = dir.join(format!( - "codexbar-claude-daily-a-{}.jsonl", - std::process::id() - )); - let file_b = dir.join(format!( - "codexbar-claude-daily-b-{}.jsonl", - std::process::id() - )); - - // >24h apart guarantees two distinct local calendar days. - let day_one = Utc::now() - Duration::hours(30); - let day_two = Utc::now() - Duration::hours(2); - let ts_one = day_one.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); - let ts_two = day_two.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); - - std::fs::write( - &file_a, - format!( - "{}\n{}\n", - claude_transcript_line(&ts_one, "requestId", "req_1", "msg_1"), - claude_transcript_line(&ts_two, "requestId", "req_2", "msg_2"), - ), - ) - .unwrap(); - std::fs::write( - &file_b, - format!( - "{}\n", - claude_transcript_line(&ts_one, "request_id", "req_1", "msg_1"), - ), - ) - .unwrap(); - - let day_key = |ts: &DateTime| { - ts.with_timezone(&Local) - .date_naive() - .format("%Y-%m-%d") - .to_string() - }; - let mut daily_costs = HashMap::new(); - daily_costs.insert(day_key(&day_one), Some(0.0)); - daily_costs.insert(day_key(&day_two), Some(0.0)); - - let cutoff = Utc::now() - Duration::days(30); - let mut seen = HashSet::new(); - for path in [&file_a, &file_b] { - for_each_claude_usage_record(path, &cutoff, &mut seen, None, |record| { - add_claude_record_to_daily_costs(&mut daily_costs, record); - }); - } - - let day_one_cost = daily_costs[&day_key(&day_one)].expect("day one cost"); - let day_two_cost = daily_costs[&day_key(&day_two)].expect("day two cost"); - assert!(day_one_cost > 0.0, "day one should carry real cost"); - // Identical usage on both days: equal buckets proves the file-b - // replay was de-duplicated (a leak would double day one). - assert!( - (day_one_cost - day_two_cost).abs() < f64::EPSILON, - "each day should hold exactly one record's cost, got {day_one_cost} vs {day_two_cost}" - ); - - // Best-effort test cleanup; the files may already be gone. - let _removed_a = std::fs::remove_file(&file_a); - let _removed_b = std::fs::remove_file(&file_b); - } - - #[test] - fn claude_scan_counts_final_incomplete_jsonl_line() { - let path = - std::env::temp_dir().join(format!("codexbar-claude-tail-{}.jsonl", std::process::id())); - let ts = (Utc::now() - Duration::hours(1)) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); - // No trailing newline — the last (only) record must still be counted. - let body = claude_transcript_line(&ts, "requestId", "req_tail", "msg_tail"); - std::fs::write(&path, body.as_bytes()).unwrap(); - - let cutoff = Utc::now() - Duration::days(1); - let mut seen = HashSet::new(); - let counted = for_each_claude_usage_record(&path, &cutoff, &mut seen, None, |_| {}); - assert_eq!(counted, 1, "incomplete final JSONL line must be processed"); - // Best-effort test cleanup; the file may already be gone. - let _removed = std::fs::remove_file(&path); - } - - fn write_codex_session_fixture(sessions_root: &Path, name: &str, input_tokens: u64) -> PathBuf { - let today = Local::now().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 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}}}}}}}} -"# - ); - std::fs::write(&path, body).unwrap(); - path - } - - #[test] - fn cost_scan_second_pass_skips_unchanged_files_via_cache() { - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - write_codex_session_fixture(&sessions, "a.jsonl", 100); - write_codex_session_fixture(&sessions, "b.jsonl", 200); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - - let (summary1, stats1) = scanner.scan_codex_detailed(None); - assert_eq!(stats1.files_parsed, 2, "first pass parses both files"); - assert_eq!(stats1.files_skipped, 0); - assert!(summary1.total_cost_usd > 0.0); - assert_eq!(summary1.sessions_count, 2); - - // Second pass with default debounce still inspects files but skips re-parse. - // Use app_driven so we exercise per-file mtime skip rather than whole-scan debounce. - let (summary2, stats2) = scanner.scan_codex_detailed(None); - assert_eq!(stats2.files_seen, 2); - assert_eq!(stats2.files_skipped, 2, "cache hit skips re-parse"); - assert_eq!(stats2.files_parsed, 0); - assert_eq!(summary2.input_tokens, summary1.input_tokens); - assert!((summary2.total_cost_usd - summary1.total_cost_usd).abs() < 1e-9); - - // Force path already used above; confirm debounce short-circuit with default options. - let debounced = CostScanner::new(7) - .with_options(CostScanOptions::default()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - let (summary3, stats3) = debounced.scan_codex_detailed(None); - assert!( - stats3.used_cache_debounce, - "default options debounce within 60s" - ); - assert_eq!(stats3.files_seen, 0); - assert_eq!(summary3.input_tokens, summary1.input_tokens); - - // app_driven after debounce still re-reads (skip via mtime, not full re-parse). - let forced = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions]); - let (_, stats4) = forced.scan_codex_detailed(None); - assert!(!stats4.used_cache_debounce); - assert_eq!(stats4.files_skipped, 2); - assert_eq!(stats4.files_parsed, 0); - } - - #[test] - fn cancelled_fresh_cache_hit_is_not_authoritative() { - let root = tempfile::tempdir().unwrap(); - let cache_root = root.path().join("cache"); - let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); - let usage = HashMap::from([( - today.clone(), - HashMap::from([("gpt-5.6-sol".to_string(), vec![100, 0, 10])]), - )]); - let mut cache = CostUsageCache { - last_scan_unix_ms: unix_now_ms(), - files: HashMap::from([( - "cached.jsonl".to_string(), - CostUsageFileUsage { - mtime_unix_ms: 0, - size: 100, - days: usage.clone(), - parsed_bytes: Some(100), - last_model: Some("gpt-5.6-sol".to_string()), - last_totals: None, - }, - )]), - days: usage, - ..Default::default() - }; - JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); - - let cancel = AtomicBool::new(true); - let scanner = CostScanner::new(7) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![root.path().join("sessions")]); - let (summary, stats) = scanner.scan_codex_detailed(Some(&cancel)); - - assert!( - stats.used_cache_debounce, - "fresh cache should use debounce path" - ); - assert_eq!(summary.sessions_count, 1, "cached usage is still visible"); - assert!( - !summary.history_coverage_established, - "cancelled cache publication must not claim complete history" - ); - assert!(!summary.known_zero); - } - - #[test] - fn cost_scan_cancel_stops_between_files() { - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - write_codex_session_fixture(&sessions, "a.jsonl", 100); - write_codex_session_fixture(&sessions, "b.jsonl", 200); - write_codex_session_fixture(&sessions, "c.jsonl", 300); - - let cancel = AtomicBool::new(true); - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(cache_root) - .with_sessions_dirs(vec![sessions]); - let (summary, stats) = scanner.scan_codex_detailed(Some(&cancel)); - assert_eq!(stats.files_seen, 0, "cancel before first file stops walk"); - assert_eq!(summary.sessions_count, 0); - } - - #[test] - fn cost_scan_resumes_appended_bytes() { - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - let path = write_codex_session_fixture(&sessions, "grow.jsonl", 50); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - let (s1, st1) = scanner.scan_codex_detailed(None); - assert_eq!(st1.files_parsed, 1); - assert_eq!(s1.input_tokens, 50); - - // Append another cumulative token_count event (100 total => +50 delta). - let ts = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); - let extra = format!( - r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":100,"cached_input_tokens":0,"output_tokens":10}}}}}}}} -"# - ); - use std::io::Write as _; - let mut f = std::fs::OpenOptions::new() - .append(true) - .open(&path) - .unwrap(); - f.write_all(extra.as_bytes()).unwrap(); - drop(f); - - // Bump mtime/size visibly on some FS by rewriting metadata via reopen. - let (s2, st2) = scanner.scan_codex_detailed(None); - assert_eq!(st2.files_resumed, 1, "grown file resumes from offset"); - assert_eq!(st2.files_parsed, 0); - assert_eq!(s2.input_tokens, 100); - } - - #[test] - fn cost_scan_midline_rewrite_forces_full_parse_not_resume() { - // F2 (upstream 0.48.0 #2648): when a file is rewritten/truncated so the - // cached resume offset is now mid-line (byte before offset is not \n), - // the scanner must fall through to a full re-parse from offset 0 rather - // than resuming from the stale mid-line offset. - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - let _path = write_codex_session_fixture(&sessions, "a.jsonl", 100); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - let (s1, st1) = scanner.scan_codex_detailed(None); - assert_eq!(st1.files_parsed, 1); - assert_eq!(s1.input_tokens, 100); - - // Rewrite the file with a shorter body at the same path so the cached - // parsed_bytes offset now points mid-line in the new content. - let today = Local::now().date_naive(); - let day_dir = sessions - .join(today.format("%Y").to_string()) - .join(today.format("%m").to_string()) - .join(today.format("%d").to_string()); - let ts = (Utc::now() - Duration::minutes(30)) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); - // Shorter content with different token count — the cached offset will - // be past EOF or mid-line in this new content. - let body = format!( - r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":50,"cached_input_tokens":0,"output_tokens":5}}}}}}}} -"# - ); - std::fs::write(day_dir.join("a.jsonl"), body).unwrap(); - - let (s2, st2) = scanner.scan_codex_detailed(None); - // The scanner must full-parse (not resume) because the cached offset - // no longer sits on a line boundary in the rewritten content. - assert!( - st2.files_parsed >= 1 || st2.files_resumed == 0, - "midline rewrite forces full parse, not resume (parsed={}, resumed={})", - st2.files_parsed, - st2.files_resumed - ); - assert_eq!(s2.input_tokens, 50, "full parse picks up new token count"); - } - - #[test] - fn previous_report_clears_after_successful_full_scan() { - // F8 (upstream 0.48.0): a completed full scan clears previous_report so - // the refreshing indicator does not stay permanently on. - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - write_codex_session_fixture(&sessions, "a.jsonl", 100); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - - // First scan: builds cache fresh; no previous_report expected. - let (summary1, _) = scanner.scan_codex_detailed(None); - assert!(summary1.history_coverage_established); - let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!( - cache.previous_report.is_none(), - "first scan clears previous_report" - ); - - // Inject a previous_report to simulate trim-set catch-up. - let mut cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - cache.previous_report = Some(crate::core::CachedCostReport { - total_cost_usd: 0.0, - input_tokens: 0, - cached_tokens: 0, - output_tokens: 0, - sessions_count: 0, - updated_at: None, - partial: false, - }); - JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); - - // Verify the cache now has previous_report set. - let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!( - cache.previous_report.is_some(), - "injected previous_report persists" - ); - - // Full scan with app_driven clears previous_report on success. - let (summary2, _) = scanner.scan_codex_detailed(None); - assert!( - summary2.history_coverage_established, - "after full scan coverage is established" - ); - - let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); - assert!( - cache.previous_report.is_none(), - "full scan clears previous_report" - ); - } - - // ── Upstream 0.50.1 #2932: known-zero history ──────────────────────────── - - #[test] - fn known_zero_is_set_when_scan_completes_with_no_sessions() { - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - std::fs::create_dir_all(&sessions).unwrap(); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - - let (summary, _) = scanner.scan_codex_detailed(None); - assert!(summary.history_coverage_established, "scan completed"); - assert_eq!(summary.sessions_count, 0, "no sessions"); - assert!(summary.known_zero, "completed scan with zero = known-zero"); - } - - #[test] - fn known_zero_is_not_set_when_scan_has_results() { - let root = tempfile::tempdir().unwrap(); - let sessions = root.path().join("sessions"); - let cache_root = root.path().join("cache"); - write_codex_session_fixture(&sessions, "a.jsonl", 100); - - let scanner = CostScanner::new(7) - .with_options(CostScanOptions::app_driven()) - .with_cache_root(&cache_root) - .with_sessions_dirs(vec![sessions.clone()]); - - let (summary, _) = scanner.scan_codex_detailed(None); - assert!(summary.history_coverage_established); - assert_eq!(summary.sessions_count, 1); - assert!(!summary.known_zero, "scan with results is not known-zero"); - } -} diff --git a/rust/src/cost_scanner/codex.rs b/rust/src/cost_scanner/codex.rs new file mode 100644 index 0000000000..311cef1138 --- /dev/null +++ b/rust/src/cost_scanner/codex.rs @@ -0,0 +1,899 @@ +use super::*; + +fn rebuild_cache_days(cache: &mut CostUsageCache) { + cache.days.clear(); + for usage in cache.files.values() { + for (day, models) in &usage.days { + let day_entry = cache.days.entry(day.clone()).or_default(); + for (model, packed) in models { + let dest = day_entry + .entry(model.clone()) + .or_insert_with(|| vec![0, 0, 0]); + if dest.len() < 3 { + dest.resize(3, 0); + } + + let had_core_tokens = dest[0] != 0 || dest[1] != 0 || dest[2] != 0; + let source_input = packed.first().copied().unwrap_or(0); + let source_cached = packed.get(1).copied().unwrap_or(0); + let source_output = packed.get(2).copied().unwrap_or(0); + let source_has_tokens = + source_input != 0 || source_cached != 0 || source_output != 0; + let source_reasoning = packed + .get(3) + .copied() + .map(|reasoning| reasoning.max(0).min(source_output.max(0))); + + dest[0] = dest[0].saturating_add(source_input); + dest[1] = dest[1].saturating_add(source_cached); + dest[2] = dest[2].saturating_add(source_output); + + if !source_has_tokens { + continue; + } + + if !had_core_tokens { + match source_reasoning { + Some(reasoning) => { + if dest.len() >= 4 { + dest[3] = reasoning.min(dest[2].max(0)); + } else { + dest.push(reasoning.min(dest[2].max(0))); + } + } + None => dest.truncate(3), + } + continue; + } + + match (dest.get(3).copied(), source_reasoning) { + (Some(previous), Some(reasoning)) => { + let merged = previous.saturating_add(reasoning).min(dest[2].max(0)); + dest[3] = merged; + } + _ => dest.truncate(3), + } + } + } + } +} + +fn summary_from_cached_report( + report: &CachedCostReport, + period_start: NaiveDate, + period_end: NaiveDate, +) -> CostSummary { + CostSummary { + total_cost_usd: report.total_cost_usd, + input_tokens: u64::try_from(report.input_tokens.max(0)).unwrap_or(0), + cached_tokens: u64::try_from(report.cached_tokens.max(0)).unwrap_or(0), + output_tokens: u64::try_from(report.output_tokens.max(0)).unwrap_or(0), + reasoning_tokens: report + .reasoning_tokens + .map(|reasoning| u64::try_from(reasoning.max(0)).unwrap_or(0)), + sessions_count: u32::try_from(report.sessions_count.max(0)).unwrap_or(0), + // The persisted report has no model-level breakdown. A catch-up + // summary must not claim that its newly rebuilt partial breakdown is + // complete, even when the validated report itself was fully priced. + model_pricing_completeness: ModelPricingCompleteness::Partial { + unpriced_models: Vec::new(), + }, + history_coverage_established: false, + known_zero: false, + period_start: Some(period_start), + period_end: Some(period_end), + ..CostSummary::default() + } +} + +/// Remove cached Codex files that are provably gone from the portion of the +/// sessions tree covered by this scan. Entries outside the current roots or +/// date directories are intentionally retained for a later scan. +fn reconcile_missing_codex_cache_files( + cache: &mut CostUsageCache, + sessions_dirs: &[PathBuf], + range: &CostUsageDayRange, +) { + let scanned_date_dirs: Vec = sessions_dirs + .iter() + .flat_map(|sessions_dir| { + codex_scan_dates(range).into_iter().map(|date| { + sessions_dir + .join(date.format("%Y").to_string()) + .join(date.format("%m").to_string()) + .join(date.format("%d").to_string()) + }) + }) + .collect(); + + cache.files.retain(|path_key, _| { + let path = Path::new(path_key); + let in_scanned_root = sessions_dirs + .iter() + .any(|sessions_dir| path.starts_with(sessions_dir)); + let in_scanned_date = scanned_date_dirs + .iter() + .any(|date_dir| path.starts_with(date_dir)); + + !(in_scanned_root && in_scanned_date && !path.exists()) + }); + cache + .codex_pending_paths + .retain(|path| Path::new(path).exists()); +} + +fn cached_codex_file_is_complete_for_range( + cache: &CostUsageCache, + path_key: &str, + range: &CostUsageDayRange, +) -> bool { + JsonlScanner::cache_covers_range(cache, range) + && cache.files.get(path_key).is_some_and(|usage| { + let Ok(metadata) = fs::metadata(path_key) else { + return false; + }; + #[allow(clippy::cast_possible_wrap, reason = "session file sizes fit i64")] + let size = metadata.len().min(i64::MAX as u64) as i64; + usage.mtime_unix_ms == system_time_to_unix_ms(metadata.modified().ok()) + && usage.size == size + && usage.parsed_bytes.unwrap_or(0) >= size + && !usage.codex_unresolved_fork_parent + && codex_fork_parent_is_safe(cache, usage) + }) +} + +fn codex_fork_parent_is_safe(cache: &CostUsageCache, usage: &CostUsageFileUsage) -> bool { + usage.codex_forked_from_id.as_deref().is_none() + || codex_parent_baseline( + cache, + usage.codex_forked_from_id.as_deref().unwrap_or_default(), + usage.codex_fork_timestamp.as_deref(), + ) + .is_some() +} + +/// Return a parent cumulative baseline only when exactly one cached session +/// identity is current, complete, timestamp-ordered, and safe to trust. +fn codex_parent_baseline( + cache: &CostUsageCache, + parent_session_id: &str, + child_fork_timestamp: Option<&str>, +) -> Option { + let mut baseline = None; + for (path_key, usage) in &cache.files { + if usage.codex_session_id.as_deref() != Some(parent_session_id) { + continue; + } + if usage.codex_unresolved_fork_parent + || usage.codex_token_timestamps_monotonic != Some(true) + { + return None; + } + let metadata = fs::metadata(path_key).ok()?; + #[allow(clippy::cast_possible_wrap, reason = "session file sizes fit i64")] + let size = metadata.len().min(i64::MAX as u64) as i64; + if usage.mtime_unix_ms != system_time_to_unix_ms(metadata.modified().ok()) + || usage.size != size + || usage.parsed_bytes.unwrap_or(0) < size + { + return None; + } + let last_totals = usage.last_totals.clone()?; + let last_token_timestamp = usage.codex_last_token_timestamp.as_deref()?; + let child_fork_timestamp = child_fork_timestamp?; + if !JsonlScanner::codex_timestamp_at_or_before(last_token_timestamp, child_fork_timestamp) { + return None; + } + if baseline.replace(last_totals).is_some() { + // Duplicate identities make the dependency ambiguous. + return None; + } + } + baseline +} + +fn is_codex_path_in_scan_window( + path: &Path, + sessions_dirs: &[PathBuf], + range: &CostUsageDayRange, +) -> bool { + sessions_dirs.iter().any(|sessions_dir| { + codex_scan_dates(range).into_iter().any(|date| { + let date_dir = sessions_dir + .join(date.format("%Y").to_string()) + .join(date.format("%m").to_string()) + .join(date.format("%d").to_string()); + path.starts_with(date_dir) + }) + }) +} + +/// Claude cost calculation for the usage scanner. +/// +/// Per-token rates come from the canonical `CostUsagePricing::claude_cost_usd` +/// table (the single source of truth for Claude pricing). The only +/// scanner-specific piece is the one-hour cache-write premium, which the +/// canonical cost function doesn't model: one-hour cache writes bill at 2x the +struct CodexScanCandidate { + path: PathBuf, + mtime_unix_ms: i64, +} + +#[derive(Debug, Clone, Copy, Default)] +struct CodexFileScanOutcome { + bytes_read: i64, + is_complete: bool, +} + +/// Cost usage scanner +impl CostScanner { + pub fn scan_codex(&self) -> CostSummary { + self.scan_codex_with_cancel(None) + } + + /// Scan Codex local logs, stopping early when the caller cancels the scan. + pub fn scan_codex_with_cancel(&self, cancel: Option<&AtomicBool>) -> CostSummary { + self.scan_codex_detailed(cancel).0 + } + + /// Scan Codex and return cache/resume stats alongside the summary. + pub fn scan_codex_detailed(&self, cancel: Option<&AtomicBool>) -> (CostSummary, CostScanStats) { + let (summary, stats, _cache) = self.scan_codex_detailed_with_cache(cancel); + (summary, stats) + } + + /// Scan Codex and retain the decoded cache baseline for same-cycle readers. + /// + /// The returned cache is the exact in-memory value used for publication, + /// including any persistence-budget pruning. Callers that only need the + /// summary should use [`Self::scan_codex_detailed`]; daily history readers + /// use this seam to avoid decoding the same native cache a second time. + pub(crate) fn scan_codex_detailed_with_cache( + &self, + cancel: Option<&AtomicBool>, + ) -> (CostSummary, CostScanStats, CostUsageCache) { + let mut summary = CostSummary::default(); + let mut stats = CostScanStats::default(); + let today = Local::now().date_naive(); + let start_date = codex_period_start(today, self.days); + let range = CostUsageDayRange::new(start_date, today); + let now_ms = unix_now_ms(); + + summary.period_start = Some(start_date); + summary.period_end = Some(today); + + let cache_root = self.cache_root.as_deref(); + let mut cache = JsonlScanner::load_cache(ProviderId::Codex, cache_root); + + // Debounce: rebuild from disk cache without re-walking session files. + if JsonlScanner::should_skip_cached_scan(&cache, self.options, now_ms) + && !cache.codex_scan_incomplete + && JsonlScanner::cache_covers_range(&cache, &range) + && (!cache.days.is_empty() || !cache.files.is_empty()) + { + stats.used_cache_debounce = true; + // A16 (upstream 0.48.0): cache hit within debounce = coverage established + // when the cache has data and no catch-up is pending. Final publication + // also waits for the cancellable Pi/OMP scan below. + let cached_history_coverage_established = !cache.codex_scan_incomplete + && cache.previous_report.is_none() + && JsonlScanner::cache_covers_range(&cache, &range); + let (cost, _) = add_codex_days_map_to_summary(&mut summary, &cache.days, &range); + summary.total_cost_usd += cost; + // Session count is a display field; the cache holds far fewer files than u32::MAX. + #[allow(clippy::cast_possible_truncation, reason = "cache file counts fit u32")] + let sessions_count = cache + .files + .values() + .filter(|usage| { + usage.days.keys().any(|day| { + CostUsageDayRange::is_in_range(day, &range.since_key, &range.until_key) + }) + }) + .count() as u32; + summary.sessions_count = sessions_count; + + // Pi-compatible sessions are outside the Codex JSONL cache. + // Skip when tests inject sessions roots — avoid scanning the real home tree. + if self.sessions_dirs_override.is_none() { + let mut seen_pi = HashSet::new(); + crate::pi_session_cost::scan_pi_compatible_into( + &mut summary, + crate::pi_session_cost::PiMappedProvider::Codex, + self.days, + cancel, + &mut seen_pi, + ); + } + summary.history_coverage_established = + cached_history_coverage_established && !is_cancelled(cancel); + // Upstream 0.50.1 #2932: debounce cache hit with coverage + // established but zero sessions in-range is a known-zero. + summary.known_zero = + summary.history_coverage_established && summary.sessions_count == 0; + return (summary, stats, cache); + } + + let sessions_dirs = self.get_codex_sessions_dirs(); + let established_report_before_scan = (!cache.codex_scan_incomplete + && cache.previous_report.is_none() + && (cache.scan_since_key.is_some() + || !cache.days.is_empty() + || !cache.files.is_empty())) + .then(|| JsonlScanner::cached_cost_report_from_days(&cache)); + + let (candidates, discovery_complete) = + self.collect_codex_candidates(&sessions_dirs, &range, &cache, cancel, &mut stats); + let candidate_limit = if self.options.codex_candidate_limit == 0 { + usize::MAX + } else { + self.options.codex_candidate_limit + }; + let refresh_byte_limit = if self.options.codex_max_scan_bytes_per_refresh <= 0 { + i64::MAX + } else { + self.options.codex_max_scan_bytes_per_refresh + }; + let per_file_limit = if self.options.codex_max_session_file_bytes <= 0 { + i64::MAX + } else { + self.options.codex_max_session_file_bytes + }; + let mut bytes_read_this_refresh = 0_i64; + let mut pending_next = cache.codex_pending_paths.clone(); + let pending_paths_before_pass = cache.codex_pending_paths.clone(); + if discovery_complete && !is_cancelled(cancel) { + pending_next + .retain(|path| !cached_codex_file_is_complete_for_range(&cache, path, &range)); + } + + for (index, candidate) in candidates.iter().enumerate() { + if is_cancelled(cancel) + || index >= candidate_limit + || bytes_read_this_refresh >= refresh_byte_limit + { + for deferred in &candidates[index..] { + let key = deferred.path.to_string_lossy().to_string(); + if !pending_next.contains(&key) { + pending_next.push(key); + } + } + stats.files_deferred = stats.files_deferred.saturating_add( + u32::try_from((candidates.len() - index).min(u32::MAX as usize)) + .unwrap_or(u32::MAX), + ); + break; + } + + let refresh_remaining = refresh_byte_limit.saturating_sub(bytes_read_this_refresh); + let allowance = per_file_limit.min(refresh_remaining); + if allowance <= 0 { + for deferred in &candidates[index..] { + let key = deferred.path.to_string_lossy().to_string(); + if !pending_next.contains(&key) { + pending_next.push(key); + } + } + stats.files_deferred = stats.files_deferred.saturating_add( + u32::try_from((candidates.len() - index).min(u32::MAX as usize)) + .unwrap_or(u32::MAX), + ); + break; + } + + let outcome = self.parse_codex_file_bounded( + &candidate.path, + &range, + &mut summary, + &mut cache, + cancel, + &mut stats, + Some(allowance), + ); + bytes_read_this_refresh = + bytes_read_this_refresh.saturating_add(outcome.bytes_read.max(0)); + stats.codex_bytes_read = stats + .codex_bytes_read + .saturating_add(u64::try_from(outcome.bytes_read.max(0)).unwrap_or(u64::MAX)); + let key = candidate.path.to_string_lossy().to_string(); + pending_next.retain(|pending| pending != &key); + if !outcome.is_complete { + pending_next.push(key); + stats.files_deferred = stats.files_deferred.saturating_add(1); + } + } + + if discovery_complete && !is_cancelled(cancel) { + reconcile_missing_codex_cache_files(&mut cache, &sessions_dirs, &range); + for path in &pending_paths_before_pass { + if !Path::new(path).exists() { + cache.files.remove(path); + } + } + } + pending_next.retain(|path| { + Path::new(path).exists() + && (!discovery_complete + || is_cancelled(cancel) + || is_codex_path_in_scan_window(Path::new(path), &sessions_dirs, &range)) + }); + pending_next.sort(); + pending_next.dedup(); + cache.codex_pending_paths = pending_next; + cache.codex_scan_incomplete = + !discovery_complete || is_cancelled(cancel) || !cache.codex_pending_paths.is_empty(); + rebuild_cache_days(&mut cache); + cache.last_scan_unix_ms = now_ms; + if cache.codex_scan_incomplete { + if cache.previous_report.is_none() { + cache.previous_report = established_report_before_scan; + } + } else { + cache.scan_since_key = Some(range.since_key.clone()); + cache.scan_until_key = Some(range.until_key.clone()); + cache.previous_report = None; + } + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, cache_root); + + // Build the current native summary from the complete decoded cache view, + // including prior cached files that were not reread in this bounded pass. + // A cancelled pass retains missing rows on disk for deletion + // reconciliation, but must not publish those stale rows in its summary. + let mut summary_cache = cache.clone(); + if is_cancelled(cancel) { + summary_cache + .files + .retain(|path, _| Path::new(path).exists()); + rebuild_cache_days(&mut summary_cache); + } + let mut rebuilt = CostSummary { + period_start: Some(start_date), + period_end: Some(today), + ..CostSummary::default() + }; + let (native_cost, _) = + add_codex_days_map_to_summary(&mut rebuilt, &summary_cache.days, &range); + rebuilt.total_cost_usd += native_cost; + #[allow(clippy::cast_possible_truncation, reason = "cache file counts fit u32")] + { + rebuilt.sessions_count = summary_cache + .files + .values() + .filter(|usage| { + usage.days.keys().any(|day| { + CostUsageDayRange::is_in_range(day, &range.since_key, &range.until_key) + }) + }) + .count() as u32; + } + let cancelled_with_missing_cache_rows = + is_cancelled(cancel) && cache.files.keys().any(|path| !Path::new(path).exists()); + let preserving_previous_report = cache.codex_scan_incomplete + && cache.previous_report.is_some() + && !cancelled_with_missing_cache_rows; + summary = if preserving_previous_report { + cache + .previous_report + .as_ref() + .map(|report| summary_from_cached_report(report, start_date, today)) + .unwrap_or(rebuilt) + } else { + rebuilt + }; + + // OMP / pi-compatible agent sessions (upstream #2269). Dedup by entry id. + // Skip when tests inject sessions roots — avoid scanning the real home tree. + // A16 --provider-native-only: skip pi/OMP mirrors when disabled. + if !preserving_previous_report + && self.sessions_dirs_override.is_none() + && self.options.include_pi_sessions + { + let mut seen_pi = HashSet::new(); + crate::pi_session_cost::scan_pi_compatible_into( + &mut summary, + crate::pi_session_cost::PiMappedProvider::Codex, + self.days, + cancel, + &mut seen_pi, + ); + } + + // v0.56.1 #3279: only publish authoritative coverage after all + // cancellable scan work, including Pi/OMP, has completed. Persistence + // pruning may retain `previous_report`, but that must not make a + // completed in-memory scan stale or make a cancelled partial scan look + // complete. + summary.history_coverage_established = + !is_cancelled(cancel) && !cache.codex_scan_incomplete; + // Upstream 0.50.1 #2932: a completed scan with zero results is a + // *known* zero. Only set when coverage is established; an incomplete + // scan must NOT fabricate a zero. + summary.known_zero = summary.history_coverage_established && summary.sessions_count == 0; + + (summary, stats, cache) + } + + /// Scan Claude local logs + pub(super) fn get_codex_sessions_dirs(&self) -> Vec { + if let Some(dirs) = &self.sessions_dirs_override { + return dirs.clone(); + } + let settings = Settings::load(); + let codex_home = std::env::var("CODEX_HOME").ok(); + codex_sessions_dir_candidates( + dirs::home_dir(), + codex_home, + &settings.codex_custom_sessions_dirs, + &default_wsl_roots(), + ) + } + + fn collect_codex_candidates( + &self, + sessions_dirs: &[PathBuf], + range: &CostUsageDayRange, + cache: &CostUsageCache, + cancel: Option<&AtomicBool>, + stats: &mut CostScanStats, + ) -> (Vec, bool) { + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + let mut dates = codex_scan_dates(range); + if self.options.prefer_newest_codex_sessions_first { + dates.reverse(); + } + + for sessions_dir in sessions_dirs { + for date in &dates { + if is_cancelled(cancel) { + return (candidates, false); + } + let day_dir = sessions_dir + .join(date.format("%Y").to_string()) + .join(date.format("%m").to_string()) + .join(date.format("%d").to_string()); + let Ok(entries) = fs::read_dir(&day_dir) else { + continue; + }; + for entry in entries.flatten() { + if is_cancelled(cancel) { + return (candidates, false); + } + let path = entry.path(); + if !path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) + { + continue; + } + let path_key = path.to_string_lossy().to_string(); + if !seen.insert(path_key.clone()) { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + let mtime_unix_ms = system_time_to_unix_ms(metadata.modified().ok()); + let unchanged_complete = + cached_codex_file_is_complete_for_range(cache, &path_key, range); + if unchanged_complete { + stats.files_seen = stats.files_seen.saturating_add(1); + stats.files_skipped = stats.files_skipped.saturating_add(1); + continue; + } + candidates.push(CodexScanCandidate { + path, + mtime_unix_ms, + }); + } + } + } + + // Persisted paths are retried even if their directory partition was not + // rediscovered this pass, as long as they remain in the requested scan + // window. Missing paths are pruned after a complete discovery pass. + for path_key in &cache.codex_pending_paths { + if seen.contains(path_key) { + continue; + } + let path = PathBuf::from(path_key); + if !is_codex_path_in_scan_window(&path, sessions_dirs, range) { + continue; + } + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + candidates.push(CodexScanCandidate { + path, + mtime_unix_ms: system_time_to_unix_ms(metadata.modified().ok()), + }); + } + + if self.options.prefer_newest_codex_sessions_first { + candidates.sort_by(|lhs, rhs| { + rhs.mtime_unix_ms + .cmp(&lhs.mtime_unix_ms) + .then_with(|| rhs.path.cmp(&lhs.path)) + }); + } else { + candidates.sort_by(|lhs, rhs| { + lhs.mtime_unix_ms + .cmp(&rhs.mtime_unix_ms) + .then_with(|| lhs.path.cmp(&rhs.path)) + }); + } + (candidates, true) + } + + #[cfg(test)] + fn parse_codex_file( + &self, + path: &Path, + range: &CostUsageDayRange, + summary: &mut CostSummary, + cache: &mut CostUsageCache, + cancel: Option<&AtomicBool>, + stats: &mut CostScanStats, + ) { + let _ = self.parse_codex_file_bounded(path, range, summary, cache, cancel, stats, None); + } + + #[allow( + clippy::too_many_arguments, + reason = "bounded file scan carries shared scan state" + )] + fn parse_codex_file_bounded( + &self, + path: &Path, + range: &CostUsageDayRange, + summary: &mut CostSummary, + cache: &mut CostUsageCache, + cancel: Option<&AtomicBool>, + stats: &mut CostScanStats, + max_bytes_to_read: Option, + ) -> CodexFileScanOutcome { + if is_cancelled(cancel) { + return CodexFileScanOutcome::default(); + } + stats.files_seen = stats.files_seen.saturating_add(1); + + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(_) => return CodexFileScanOutcome::default(), + }; + #[allow( + clippy::cast_possible_wrap, + reason = "file sizes are clamped to i64::MAX" + )] + let size = metadata.len().min(i64::MAX as u64) as i64; + let mtime_ms = system_time_to_unix_ms(metadata.modified().ok()); + let path_key = path.to_string_lossy().to_string(); + let cached = cache.files.get(&path_key).cloned(); + let cache_covers_range = JsonlScanner::cache_covers_range(cache, range); + let session_metadata = JsonlScanner::read_codex_session_metadata(path).unwrap_or_default(); + let cached_identity_matches = cached + .as_ref() + .is_some_and(|entry| entry.mtime_unix_ms == mtime_ms && entry.size == size); + let codex_session_id = session_metadata.session_id.clone().or_else(|| { + cached_identity_matches + .then(|| cached.as_ref()?.codex_session_id.clone()) + .flatten() + }); + let codex_forked_from_id = session_metadata.forked_from_id.clone().or_else(|| { + cached_identity_matches + .then(|| cached.as_ref()?.codex_forked_from_id.clone()) + .flatten() + }); + let codex_fork_timestamp = session_metadata.fork_timestamp.clone().or_else(|| { + cached_identity_matches + .then(|| cached.as_ref()?.codex_fork_timestamp.clone()) + .flatten() + }); + let is_fork = codex_forked_from_id.is_some(); + let fork_baseline = codex_forked_from_id.as_deref().and_then(|parent_id| { + codex_parent_baseline(cache, parent_id, codex_fork_timestamp.as_deref()) + }); + + if is_fork && fork_baseline.is_none() { + cache.files.insert( + path_key, + CostUsageFileUsage { + mtime_unix_ms: mtime_ms, + size, + days: HashMap::new(), + parsed_bytes: Some(0), + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id, + codex_forked_from_id, + codex_fork_timestamp, + codex_unresolved_fork_parent: true, + }, + ); + stats.files_parsed = stats.files_parsed.saturating_add(1); + return CodexFileScanOutcome { + bytes_read: 0, + is_complete: false, + }; + } + + if let Some(entry) = &cached + && cache_covers_range + && !entry.codex_unresolved_fork_parent + && entry.mtime_unix_ms == mtime_ms + && entry.size == size + && entry.parsed_bytes.unwrap_or(0) >= size + { + let (session_cost, has_tokens) = + add_codex_days_map_to_summary(summary, &entry.days, range); + if has_tokens { + summary.total_cost_usd += session_cost; + summary.sessions_count += 1; + } + stats.files_skipped = stats.files_skipped.saturating_add(1); + return CodexFileScanOutcome { + bytes_read: 0, + is_complete: true, + }; + } + + if !is_fork && let Some(entry) = &cached { + let start_offset = entry.parsed_bytes.unwrap_or(0); + let same_partial = + size == entry.size && mtime_ms == entry.mtime_unix_ms && start_offset < size; + let growing = size > entry.size; + let parser_state_safe = entry.codex_token_timestamps_monotonic.is_some(); + if cache_covers_range + && (same_partial || growing) + && start_offset > 0 + && start_offset <= size + && parser_state_safe + && JsonlScanner::is_line_boundary_offset(path, start_offset) + { + let parse_result = match JsonlScanner::parse_codex_file_with_state_bounded( + path, + range, + start_offset, + entry.last_model.clone(), + entry.last_totals.clone(), + entry.codex_last_token_timestamp.clone(), + entry.codex_token_timestamps_monotonic, + cancel, + max_bytes_to_read, + ) { + Ok(result) => result, + Err(_) => return CodexFileScanOutcome::default(), + }; + stats.token_timestamp_comparisons = stats + .token_timestamp_comparisons + .saturating_add(parse_result.token_timestamp_comparisons); + let mut days = entry.days.clone(); + merge_codex_records_into_days(&mut days, &parse_result.records); + let (session_cost, has_tokens) = + add_codex_days_map_to_summary(summary, &days, range); + if has_tokens { + summary.total_cost_usd += session_cost; + summary.sessions_count += 1; + } + let outcome = CodexFileScanOutcome { + bytes_read: parse_result.bytes_read, + is_complete: parse_result.is_complete, + }; + cache.files.insert( + path_key, + CostUsageFileUsage { + mtime_unix_ms: mtime_ms, + size, + days, + parsed_bytes: Some(parse_result.parsed_bytes), + last_model: parse_result.last_model.or_else(|| entry.last_model.clone()), + last_totals: parse_result + .last_totals + .or_else(|| entry.last_totals.clone()), + codex_token_timestamps_monotonic: parse_result + .token_timestamps_monotonic + .or(entry.codex_token_timestamps_monotonic), + codex_last_token_timestamp: parse_result + .last_token_timestamp + .or_else(|| entry.codex_last_token_timestamp.clone()), + codex_session_id: codex_session_id.clone(), + codex_forked_from_id: codex_forked_from_id.clone(), + codex_fork_timestamp: codex_fork_timestamp.clone(), + codex_unresolved_fork_parent: false, + }, + ); + stats.files_resumed = stats.files_resumed.saturating_add(1); + return outcome; + } + } + + let parse_result = match if let Some(baseline) = fork_baseline.clone() { + JsonlScanner::parse_codex_file_with_state_bounded_fork( + path, + range, + baseline, + cancel, + max_bytes_to_read, + ) + } else { + JsonlScanner::parse_codex_file_with_state_bounded( + path, + range, + 0, + None, + None, + None, + None, + cancel, + max_bytes_to_read, + ) + } { + Ok(result) => result, + Err(_) => return CodexFileScanOutcome::default(), + }; + stats.token_timestamp_comparisons = stats + .token_timestamp_comparisons + .saturating_add(parse_result.token_timestamp_comparisons); + if parse_result.fork_baseline_ambiguous { + cache.files.insert( + path_key, + CostUsageFileUsage { + mtime_unix_ms: mtime_ms, + size, + days: HashMap::new(), + parsed_bytes: Some(0), + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id, + codex_forked_from_id, + codex_fork_timestamp, + codex_unresolved_fork_parent: true, + }, + ); + stats.files_parsed = stats.files_parsed.saturating_add(1); + return CodexFileScanOutcome { + bytes_read: parse_result.bytes_read, + is_complete: false, + }; + } + let mut days = HashMap::new(); + merge_codex_records_into_days(&mut days, &parse_result.records); + let (session_cost, has_tokens) = + add_codex_records_to_summary(summary, &parse_result.records, range); + if has_tokens { + summary.total_cost_usd += session_cost; + summary.sessions_count += 1; + } + let outcome = CodexFileScanOutcome { + bytes_read: parse_result.bytes_read, + is_complete: parse_result.is_complete, + }; + cache.files.insert( + path_key, + CostUsageFileUsage { + mtime_unix_ms: mtime_ms, + size, + days, + parsed_bytes: Some(parse_result.parsed_bytes), + last_model: parse_result.last_model, + last_totals: parse_result.last_totals, + codex_token_timestamps_monotonic: parse_result.token_timestamps_monotonic, + codex_last_token_timestamp: parse_result.last_token_timestamp, + codex_session_id, + codex_forked_from_id, + codex_fork_timestamp, + codex_unresolved_fork_parent: false, + }, + ); + stats.files_parsed = stats.files_parsed.saturating_add(1); + outcome + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs new file mode 100644 index 0000000000..04ca6ed842 --- /dev/null +++ b/rust/src/cost_scanner/tests.rs @@ -0,0 +1,1719 @@ +use super::*; +use std::io::Write; + +#[test] +fn test_unknown_model_falls_back_to_sonnet() { + // Unknown/retired Claude IDs fall back to Sonnet 4.6 base pricing + // ($3/1M input, $15/1M output). 100k tokens stay under the 200k tier. + let cost = + ClaudePricing::cost_usd_with_cache_ttl("claude-3-5-sonnet", 100_000, 0, 0, 0, 100_000); + // 100k * $3/M + 100k * $15/M = 0.30 + 1.50 = 1.80 + assert!((cost - 1.80).abs() < 0.001); +} + +#[test] +fn records_unknown_claude_model_while_using_fallback_cost() { + let event: ClaudeEvent = serde_json::from_str( + r#"{"type":"assistant","timestamp":"2026-01-15T10:00:00Z","requestId":"req_unknown","message":{"id":"msg_unknown","model":"claude-retired-unknown","usage":{"input_tokens":100000,"output_tokens":100000}}}"#, + ) + .unwrap(); + let record = claude_usage_record_from_event(&event).expect("usage record"); + let mut summary = CostSummary::default(); + + add_claude_record_to_summary(&mut summary, &record); + + assert!(summary.total_cost_usd > 0.0); + assert!(summary.unknown_models.contains("claude-retired-unknown")); +} + +#[test] +fn test_claude_fable_5_pricing() { + let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-fable-5", 100, 10, 0, 20, 5); + let expected = (100.0 / 1_000_000.0) * 10.00 + + (10.0 / 1_000_000.0) * 12.50 + + (20.0 / 1_000_000.0) * 1.00 + + (5.0 / 1_000_000.0) * 50.00; + assert!((cost - expected).abs() < f64::EPSILON); +} + +#[test] +fn test_claude_one_hour_cache_write_pricing() { + let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-fable-5", 100, 30, 20, 20, 5); + let expected = (100.0 / 1_000_000.0) * 10.00 + + (10.0 / 1_000_000.0) * 12.50 + + (20.0 / 1_000_000.0) * 20.00 + + (20.0 / 1_000_000.0) * 1.00 + + (5.0 / 1_000_000.0) * 50.00; + assert!((cost - expected).abs() < f64::EPSILON); +} + +#[test] +fn test_claude_sonnet_46_honors_200k_tier() { + // Delegating to the canonical table means the scanner now honors the + // 200k long-context tier: 200k @ $3/M + 40k @ $6/M = 0.60 + 0.24 = 0.84 + // (the scanner's old inline table applied a flat $3/M = 0.72). + let cost = ClaudePricing::cost_usd_with_cache_ttl("claude-sonnet-4-6", 240_000, 0, 0, 0, 0); + assert!((cost - 0.84).abs() < 0.001); +} + +#[test] +fn test_current_gen_opus_uses_5_25_pricing() { + // Opus 4.5/4.6/4.7/4.8 bill at $5/1M input + $25/1M output = $30 total. + // Delegation regression guard: opus-4-8 in particular must resolve + // through the canonical table (it was missing there before this fix). + for model in [ + "claude-opus-4-5", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + ] { + let cost = ClaudePricing::cost_usd_with_cache_ttl(model, 1_000_000, 0, 0, 0, 1_000_000); + assert!( + (cost - 30.00).abs() < 0.001, + "{model} should bill $30 ($5 in + $25 out), got {cost}" + ); + } +} + +#[test] +fn test_legacy_opus_keeps_legacy_pricing() { + // Legacy Opus 4.0 / 4.1 remain at $15/1M input + $75/1M output = $90 in + // the canonical table. (Retired IDs absent from the table — e.g. Opus 3 + // `claude-3-opus-...` — fall back to Sonnet instead; they are outside + // any realistic 30-day scan window.) + for model in ["claude-opus-4-20250514", "claude-opus-4-1"] { + let cost = ClaudePricing::cost_usd_with_cache_ttl(model, 1_000_000, 0, 0, 0, 1_000_000); + assert!( + (cost - 90.00).abs() < 0.001, + "{model} should bill $90 ($15 in + $75 out), got {cost}" + ); + } +} + +#[test] +fn test_haiku_45_uses_current_pricing() { + // Haiku 4.5 bills at $1/1M input + $5/1M output = $6 via the canonical + // table (previously the scanner under-priced it at the Haiku 3 rate). + let cost = + ClaudePricing::cost_usd_with_cache_ttl("claude-haiku-4-5", 1_000_000, 0, 0, 0, 1_000_000); + assert!( + (cost - 6.00).abs() < 0.001, + "haiku-4-5 should bill $6 ($1 in + $5 out), got {cost}" + ); +} + +#[test] +fn parses_current_codex_payload_token_count_events() { + let path = std::env::temp_dir().join(format!( + "codexbar-current-codex-token-count-{}.jsonl", + std::process::id() + )); + // Use a recent timestamp so the event stays inside the scanner's + // 30-day window no matter when the test runs. A hardcoded date + // silently ages out of the window and makes this test fail with 0 + // sessions once it is more than 30 days in the past. + let recent = (Utc::now() - Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let mut file = File::create(&path).unwrap(); + writeln!( + file, + r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":125,"cached_input_tokens":30,"output_tokens":15}}}}}}}}"#, + ts = recent + ) + .unwrap(); + let scanner = CostScanner::new(30); + let mut summary = CostSummary::default(); + let today = Local::now().date_naive(); + let range = CostUsageDayRange::new(codex_period_start(today, 30), today); + let mut cache = CostUsageCache::default(); + let mut stats = CostScanStats::default(); + scanner.parse_codex_file(&path, &range, &mut summary, &mut cache, None, &mut stats); + + assert_eq!(summary.sessions_count, 1); + assert_eq!(summary.input_tokens, 125); + assert_eq!(summary.cached_tokens, 30); + assert_eq!(summary.output_tokens, 15); + assert_eq!( + summary + .by_model_tokens + .get("gpt-5") + .map(ModelTokenCounts::total), + Some(140) + ); + assert!(scan_codex_file_cost(&path) > 0.0); + // Best-effort test cleanup; the file may already be gone. + let _removed = std::fs::remove_file(&path); +} + +#[test] +fn derives_claude_dedup_key_from_message_and_request_ids() { + assert_eq!( + claude_usage_dedup_key(Some("msg_1"), Some("req_1")).as_deref(), + Some("msg_1:req_1") + ); + assert_eq!( + claude_usage_dedup_key(Some("msg_1"), None).as_deref(), + Some("message:msg_1") + ); + assert_eq!( + claude_usage_dedup_key(None, Some("req_1")).as_deref(), + Some("request:req_1") + ); + assert_eq!(claude_usage_dedup_key(None, None), None); +} + +#[test] +fn counts_claude_usage_once_across_duplicate_records() { + // The same API response can be replayed into several transcript files + // (session resume, sidechains); it must only be counted once. + let event: ClaudeEvent = serde_json::from_str( + r#"{"type":"assistant","timestamp":"2026-01-15T10:00:00Z","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}}}"#, + ) + .unwrap(); + + let record = claude_usage_record_from_event(&event).expect("usage record"); + assert_eq!(record.model, "claude-sonnet-4-6"); + assert_eq!(record.input, 100); + assert_eq!(record.output, 50); + assert_eq!(record.cache_create, 10); + assert_eq!(record.cache_read, 20); + assert!(record.cost > 0.0); + + let cutoff = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut seen = HashSet::new(); + assert!(should_count_claude_record(&record, &cutoff, &mut seen)); + assert!(!should_count_claude_record(&record, &cutoff, &mut seen)); +} + +#[test] +fn rejects_claude_records_before_cutoff() { + let event: ClaudeEvent = serde_json::from_str( + r#"{"type":"assistant","timestamp":"2025-12-01T10:00:00Z","requestId":"req_old","message":{"id":"msg_old","model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1}}}"#, + ) + .unwrap(); + let record = claude_usage_record_from_event(&event).expect("usage record"); + let cutoff = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut seen = HashSet::new(); + assert!(!should_count_claude_record(&record, &cutoff, &mut seen)); +} + +#[test] +fn ignores_claude_events_without_countable_usage() { + // Non-assistant events carry no billable usage. + let event: ClaudeEvent = + serde_json::from_str(r#"{"type":"user","message":{"usage":{"input_tokens":5}}}"#).unwrap(); + assert!(claude_usage_record_from_event(&event).is_none()); + + // Zero-token usage blocks (e.g. synthetic messages) are not sessions. + let event: ClaudeEvent = serde_json::from_str( + r#"{"type":"assistant","message":{"id":"msg_zero","model":"claude-sonnet-4-6","usage":{"input_tokens":0,"output_tokens":0}}}"#, + ) + .unwrap(); + assert!(claude_usage_record_from_event(&event).is_none()); +} + +#[test] +fn classifies_vertex_ai_claude_metadata_without_changing_anthropic_rows() { + let cases = [ + ( + r#"{"type":"assistant","requestId":"req_vrtx_123","message":{"id":"msg_1","model":"claude-sonnet-4-6","usage":{"input_tokens":1}}}"#, + true, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_vrtx_123","model":"claude-sonnet-4-6","usage":{"input_tokens":1}}}"#, + true, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6@20260217","usage":{"input_tokens":1}}}"#, + true, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","metadata":{"provider":"Google-Vertex-AI"},"usage":{"input_tokens":1}}}"#, + true, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","content":[{"context":{"gcp_project":false}}],"usage":{"input_tokens":1}}}"#, + true, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","usage":{"input_tokens":1}} ,"metadata":{"provider":"anthropic"}}"#, + false, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","usage":{"input_tokens":1}} ,"metadata":{"provider":"gcp"}}"#, + false, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"claude-sonnet-4-6","content":[{"text":"vertex"}],"usage":{"input_tokens":1}}}"#, + false, + ), + ( + r#"{"type":"assistant","requestId":"req_1","message":{"id":"msg_1","model":"Claude-sonnet-4-6@20260217","usage":{"input_tokens":1}}}"#, + false, + ), + ]; + + for (json, expected) in cases { + let event: ClaudeEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.is_vertex_ai_usage_entry(), expected, "{json}"); + } +} + +#[test] +fn shared_claude_reader_excludes_vertex_rows_but_keeps_anthropic_usage() { + let path = std::env::temp_dir().join(format!( + "codexbar-claude-vertex-filter-{}.jsonl", + std::process::id() + )); + let timestamp = (Utc::now() - Duration::hours(1)).to_rfc3339(); + let anthropic = format!( + r#"{{"type":"assistant","timestamp":"{timestamp}","requestId":"req_anthropic","message":{{"id":"msg_anthropic","model":"claude-sonnet-4-6","usage":{{"input_tokens":10,"output_tokens":5}}}}}}"# + ); + let vertex = format!( + r#"{{"type":"assistant","timestamp":"{timestamp}","requestId":"req_vrtx_123","message":{{"id":"msg_vrtx_123","model":"claude-sonnet-4-6","usage":{{"input_tokens":1000,"output_tokens":500}}}}}}"# + ); + std::fs::write(&path, format!("{anthropic}\n{vertex}\n")).unwrap(); + + let cutoff = Utc::now() - Duration::days(30); + let mut seen = HashSet::new(); + let mut records = Vec::new(); + let counted = for_each_claude_usage_record(&path, &cutoff, &mut seen, None, |record| { + records.push((record.input, record.output)) + }); + + assert_eq!(counted, 1); + assert_eq!(records, vec![(10, 5)]); + let _removed = std::fs::remove_file(&path); +} + +fn claude_transcript_line( + timestamp: &str, + request_key: &str, + request_id: &str, + message_id: &str, +) -> String { + format!( + r#"{{"type":"assistant","timestamp":"{timestamp}","{request_key}":"{request_id}","message":{{"id":"{message_id}","model":"claude-sonnet-4-6","usage":{{"input_tokens":1000,"output_tokens":500}}}}}}"# + ) +} + +#[test] +fn daily_history_dedups_across_files_and_buckets_by_local_day() { + // End-to-end regression for the daily buckets: two transcript files, + // two different days, plus a replay of the day-one record in the + // second file (snake_case request_id, as another writer would emit). + let dir = std::env::temp_dir(); + let file_a = dir.join(format!( + "codexbar-claude-daily-a-{}.jsonl", + std::process::id() + )); + let file_b = dir.join(format!( + "codexbar-claude-daily-b-{}.jsonl", + std::process::id() + )); + + // >24h apart guarantees two distinct local calendar days. + let day_one = Utc::now() - Duration::hours(30); + let day_two = Utc::now() - Duration::hours(2); + let ts_one = day_one.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let ts_two = day_two.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + std::fs::write( + &file_a, + format!( + "{}\n{}\n", + claude_transcript_line(&ts_one, "requestId", "req_1", "msg_1"), + claude_transcript_line(&ts_two, "requestId", "req_2", "msg_2"), + ), + ) + .unwrap(); + std::fs::write( + &file_b, + format!( + "{}\n", + claude_transcript_line(&ts_one, "request_id", "req_1", "msg_1"), + ), + ) + .unwrap(); + + let day_key = |ts: &DateTime| { + ts.with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string() + }; + let mut daily_costs = HashMap::new(); + daily_costs.insert(day_key(&day_one), Some(0.0)); + daily_costs.insert(day_key(&day_two), Some(0.0)); + + let cutoff = Utc::now() - Duration::days(30); + let mut seen = HashSet::new(); + for path in [&file_a, &file_b] { + for_each_claude_usage_record(path, &cutoff, &mut seen, None, |record| { + add_claude_record_to_daily_costs(&mut daily_costs, record); + }); + } + + let day_one_cost = daily_costs[&day_key(&day_one)].expect("day one cost"); + let day_two_cost = daily_costs[&day_key(&day_two)].expect("day two cost"); + assert!(day_one_cost > 0.0, "day one should carry real cost"); + // Identical usage on both days: equal buckets proves the file-b + // replay was de-duplicated (a leak would double day one). + assert!( + (day_one_cost - day_two_cost).abs() < f64::EPSILON, + "each day should hold exactly one record's cost, got {day_one_cost} vs {day_two_cost}" + ); + + // Best-effort test cleanup; the files may already be gone. + let _removed_a = std::fs::remove_file(&file_a); + let _removed_b = std::fs::remove_file(&file_b); +} + +#[test] +fn claude_scan_counts_final_incomplete_jsonl_line() { + let path = + std::env::temp_dir().join(format!("codexbar-claude-tail-{}.jsonl", std::process::id())); + let ts = (Utc::now() - Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + // No trailing newline — the last (only) record must still be counted. + let body = claude_transcript_line(&ts, "requestId", "req_tail", "msg_tail"); + std::fs::write(&path, body.as_bytes()).unwrap(); + + let cutoff = Utc::now() - Duration::days(1); + let mut seen = HashSet::new(); + let counted = for_each_claude_usage_record(&path, &cutoff, &mut seen, None, |_| {}); + assert_eq!(counted, 1, "incomplete final JSONL line must be processed"); + // Best-effort test cleanup; the file may already be gone. + let _removed = std::fs::remove_file(&path); +} + +fn write_codex_session_fixture(sessions_root: &Path, name: &str, input_tokens: u64) -> PathBuf { + let today = Local::now().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 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}}}}}}}} +"# + ); + std::fs::write(&path, body).unwrap(); + path +} + +fn write_codex_session_fixture_with_inputs( + sessions_root: &Path, + name: &str, + input_tokens: &[u64], +) -> PathBuf { + let today = Local::now().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 + + Duration::seconds(i64::try_from(index).expect("fixture index fits i64"))) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + body.push_str(&format!( + r#"{{"timestamp":"{timestamp}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":{input},"cached_input_tokens":0,"output_tokens":5}}}}}}}} +"# + )); + } + let path = day_dir.join(name); + std::fs::write(&path, body).unwrap(); + path +} + +fn cached_usage_with_packed(day: &str, model: &str, packed: Vec) -> CostUsageFileUsage { + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 1, + days: HashMap::from([( + day.to_string(), + HashMap::from([(model.to_string(), packed)]), + )]), + parsed_bytes: Some(1), + last_model: None, + last_totals: None, + codex_token_timestamps_monotonic: None, + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + } +} + +#[test] +fn rebuild_cache_days_preserves_known_reasoning() { + let day = Local::now().format("%Y-%m-%d").to_string(); + let mut cache = CostUsageCache { + files: HashMap::from([ + ( + "a".to_string(), + cached_usage_with_packed(&day, "gpt-5", vec![10, 0, 4, 3]), + ), + ( + "b".to_string(), + cached_usage_with_packed(&day, "gpt-5", vec![5, 0, 2, 1]), + ), + ]), + ..CostUsageCache::default() + }; + + rebuild_cache_days(&mut cache); + + assert_eq!(cache.days[&day]["gpt-5"], vec![15, 0, 6, 4]); +} + +#[test] +fn rebuild_cache_days_reasoning_unknown_is_order_independent() { + let run = |first: Vec, second: Vec| { + let day = Local::now().format("%Y-%m-%d").to_string(); + let mut cache = CostUsageCache { + files: HashMap::from([ + ( + "a".to_string(), + cached_usage_with_packed(&day, "gpt-5", first), + ), + ( + "b".to_string(), + cached_usage_with_packed(&day, "gpt-5", second), + ), + ]), + ..CostUsageCache::default() + }; + + rebuild_cache_days(&mut cache); + cache.days[&day]["gpt-5"].clone() + }; + + assert_eq!(run(vec![10, 0, 4, 3], vec![5, 0, 2]), vec![15, 0, 6]); + assert_eq!(run(vec![5, 0, 2], vec![10, 0, 4, 3]), vec![15, 0, 6]); +} + +#[test] +fn rebuild_cache_days_zero_row_does_not_poison_reasoning() { + let day = Local::now().format("%Y-%m-%d").to_string(); + let mut cache = CostUsageCache { + files: HashMap::from([ + ( + "a".to_string(), + cached_usage_with_packed(&day, "gpt-5", vec![10, 0, 4, 3]), + ), + ( + "b".to_string(), + cached_usage_with_packed(&day, "gpt-5", vec![0, 0, 0]), + ), + ]), + ..CostUsageCache::default() + }; + + rebuild_cache_days(&mut cache); + + assert_eq!(cache.days[&day]["gpt-5"], vec![10, 0, 4, 3]); +} + +#[test] +fn reasoning_survives_scan_rebuild_and_cache_reload() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let today = Local::now().date_naive(); + let day = today.format("%Y-%m-%d").to_string(); + let day_dir = sessions + .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 timestamp = (Utc::now() - Duration::hours(1)) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let reasoning_line = serde_json::json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "model": "gpt-5", + "total_token_usage": { + "input_tokens": 100, + "cached_input_tokens": 0, + "output_tokens": 20, + "reasoning_output_tokens": 7 + } + } + } + }); + std::fs::write( + day_dir.join("reasoning.jsonl"), + format!("{reasoning_line}\n"), + ) + .unwrap(); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + assert_eq!(summary.output_tokens, 20); + assert_eq!(summary.reasoning_tokens, Some(7)); + let row = &cache.days[&day]["gpt-5"]; + assert!(row.len() >= 4); + assert_eq!(row[3], 7); + + let loaded = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + let loaded_row = &loaded.days[&day]["gpt-5"]; + assert!(loaded_row.len() >= 4); + assert_eq!(loaded_row[3], 7); + assert_eq!( + JsonlScanner::cached_cost_report_from_days(&loaded).reasoning_tokens, + Some(7) + ); + + let legacy_root = tempfile::tempdir().unwrap(); + let legacy_sessions = legacy_root.path().join("sessions"); + let legacy_cache_root = legacy_root.path().join("cache"); + let legacy_day_dir = legacy_sessions + .join(today.format("%Y").to_string()) + .join(today.format("%m").to_string()) + .join(today.format("%d").to_string()); + std::fs::create_dir_all(&legacy_day_dir).unwrap(); + let legacy_line = serde_json::json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "model": "gpt-5", + "total_token_usage": { + "input_tokens": 100, + "cached_input_tokens": 0, + "output_tokens": 20 + } + } + } + }); + std::fs::write( + legacy_day_dir.join("legacy.jsonl"), + format!("{legacy_line}\n"), + ) + .unwrap(); + + let legacy_scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&legacy_cache_root) + .with_sessions_dirs(vec![legacy_sessions]); + let (legacy_summary, _, legacy_cache) = legacy_scanner.scan_codex_detailed_with_cache(None); + assert_eq!(legacy_summary.output_tokens, 20); + assert_eq!(legacy_summary.reasoning_tokens, None); + assert_eq!(legacy_cache.days[&day]["gpt-5"], vec![100, 0, 20]); + assert!( + (summary.total_cost_usd - legacy_summary.total_cost_usd).abs() < 1e-12, + "reasoning metadata must not change cost" + ); +} + +fn write_codex_fork_session_fixture( + sessions_root: &Path, + name: &str, + session_id: &str, + parent_id: Option<&str>, + fork_timestamp: DateTime, + token_start: DateTime, + totals: &[i64], +) -> PathBuf { + let today = Local::now().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 mut body = format!( + "{{\"type\":\"session_meta\",\"timestamp\":\"{}\",\"payload\":{{\"session_id\":\"{}\"", + fork_timestamp.to_rfc3339(), + session_id + ); + if let Some(parent_id) = parent_id { + body.push_str(&format!(",\"forked_from_id\":\"{parent_id}\"")); + } + body.push_str("}}\n"); + + for (index, total) in totals.iter().enumerate() { + let timestamp = (token_start + + Duration::seconds(i64::try_from(index).expect("fixture index fits i64"))) + .to_rfc3339(); + let line = serde_json::json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "model": "gpt-5", + "total_token_usage": { + "input_tokens": total, + "cached_input_tokens": 0, + "output_tokens": 5 + } + } + } + }); + body.push_str(&line.to_string()); + body.push('\n'); + } + + let path = day_dir.join(name); + std::fs::write(&path, body).unwrap(); + path +} + +fn cached_input_total(usage: &CostUsageFileUsage) -> i32 { + usage + .days + .values() + .flat_map(|models| models.values()) + .map(|tokens| tokens.first().copied().unwrap_or_default()) + .sum() +} + +#[test] +fn ordinary_non_fork_session_keeps_cumulative_accounting() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let path = write_codex_session_fixture_with_inputs(&sessions, "ordinary.jsonl", &[100, 140]); + + let mut options = CostScanOptions::app_driven(); + options.prefer_newest_codex_sessions_first = false; + let scanner = CostScanner::new(7) + .with_options(options) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + + assert_eq!(summary.input_tokens, 140); + let usage = cache + .files + .get(&path.to_string_lossy().to_string()) + .unwrap(); + assert_eq!(cached_input_total(usage), 140); + assert_eq!(usage.codex_forked_from_id, None); + assert!(!usage.codex_unresolved_fork_parent); +} + +#[test] +fn fork_child_counts_only_growth_above_parent_baseline() { + 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 fork = base + Duration::seconds(2); + let parent = write_codex_fork_session_fixture( + &sessions, + "parent.jsonl", + "parent-id", + None, + base, + base, + &[1_000_000], + ); + let child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("parent-id"), + fork, + base + Duration::seconds(3), + &[1_000_000, 1_000_140], + ); + + let now = std::time::SystemTime::now(); + File::options() + .write(true) + .open(&parent) + .unwrap() + .set_modified(now - std::time::Duration::from_secs(10)) + .unwrap(); + File::options() + .write(true) + .open(&child) + .unwrap() + .set_modified(now - std::time::Duration::from_secs(5)) + .unwrap(); + + let mut options = CostScanOptions::app_driven(); + options.prefer_newest_codex_sessions_first = false; + let scanner = CostScanner::new(7) + .with_options(options) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + + assert_eq!(summary.input_tokens, 1_000_140); + assert_eq!(summary.sessions_count, 2); + let child_usage = cache + .files + .get(&child.to_string_lossy().to_string()) + .unwrap(); + assert_eq!(cached_input_total(child_usage), 140); + assert!(!child_usage.codex_unresolved_fork_parent); + assert!(cache.codex_pending_paths.is_empty()); + assert!( + cache + .files + .contains_key(&parent.to_string_lossy().to_string()) + ); +} + +#[test] +fn replaced_fork_child_does_not_reuse_cached_identity() { + 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 fork = base + Duration::seconds(2); + let _parent = write_codex_fork_session_fixture( + &sessions, + "parent.jsonl", + "parent-id", + None, + base, + base, + &[1_000_000], + ); + let child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("parent-id"), + fork, + base + Duration::seconds(3), + &[1_000_000, 1_000_140], + ); + + let scanner = CostScanner::new(7) + .with_options({ + let mut options = CostScanOptions::app_driven(); + options.prefer_newest_codex_sessions_first = false; + options + }) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + let child_key = child.to_string_lossy().to_string(); + let (_, _, first_cache) = scanner.scan_codex_detailed_with_cache(None); + let first_child = first_cache.files.get(&child_key).unwrap(); + assert_eq!(first_child.codex_session_id.as_deref(), Some("child-id")); + assert_eq!( + first_child.codex_forked_from_id.as_deref(), + Some("parent-id") + ); + + let old_size = std::fs::metadata(&child).unwrap().len(); + write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "replacement-id", + None, + base + Duration::seconds(4), + base + Duration::seconds(5), + &[77], + ); + let new_size = std::fs::metadata(&child).unwrap().len(); + assert_ne!(old_size, new_size); + + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + let child_usage = cache.files.get(&child_key).unwrap(); + assert_eq!( + child_usage.codex_session_id.as_deref(), + Some("replacement-id") + ); + assert_eq!(child_usage.codex_forked_from_id, None); + assert!(!child_usage.codex_unresolved_fork_parent); + assert_eq!(cached_input_total(child_usage), 77); + assert_eq!(summary.input_tokens, 1_000_077); +} + +#[test] +fn missing_fork_parent_fails_closed_and_persists_pending() { + 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 child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("missing-parent"), + base + Duration::seconds(1), + base + Duration::seconds(2), + &[1_000_000, 1_000_140], + ); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + + let child_key = child.to_string_lossy().to_string(); + let child_usage = cache.files.get(&child_key).unwrap(); + assert_eq!(summary.input_tokens, 0); + assert_eq!(summary.sessions_count, 0); + assert!(child_usage.days.is_empty()); + assert!(child_usage.codex_unresolved_fork_parent); + assert!(cache.codex_pending_paths.contains(&child_key)); + assert!(!summary.history_coverage_established); + assert!(!summary.known_zero); +} + +#[test] +fn fork_child_resolves_after_parent_is_cached() { + 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 fork = base + Duration::seconds(2); + let child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("late-parent"), + fork, + base + Duration::seconds(3), + &[1_000_000, 1_000_140], + ); + let scanner = CostScanner::new(7) + .with_options({ + let mut options = CostScanOptions::app_driven(); + options.prefer_newest_codex_sessions_first = false; + options + }) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (first, _, first_cache) = scanner.scan_codex_detailed_with_cache(None); + assert_eq!(first.input_tokens, 0); + assert!( + first_cache + .codex_pending_paths + .contains(&child.to_string_lossy().to_string()) + ); + + let _parent = write_codex_fork_session_fixture( + &sessions, + "parent.jsonl", + "late-parent", + None, + base, + base, + &[1_000_000], + ); + + let mut resolved = None; + for _ in 0..3 { + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + if !cache.codex_scan_incomplete { + resolved = Some((summary, cache)); + break; + } + } + let (summary, cache) = resolved.expect("later bounded pass resolves the child"); + let child_usage = cache + .files + .get(&child.to_string_lossy().to_string()) + .unwrap(); + assert_eq!(summary.input_tokens, 1_000_140); + assert_eq!(cached_input_total(child_usage), 140); + assert!(!child_usage.codex_unresolved_fork_parent); + assert!(cache.codex_pending_paths.is_empty()); +} + +#[test] +fn parent_last_token_after_fork_keeps_child_unresolved() { + 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 parent = write_codex_fork_session_fixture( + &sessions, + "parent.jsonl", + "parent-id", + None, + base + Duration::seconds(10), + base + Duration::seconds(10), + &[1_000_000], + ); + let child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("parent-id"), + base + Duration::seconds(1), + base + Duration::seconds(2), + &[1_000_000, 1_000_140], + ); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + + let child_usage = cache + .files + .get(&child.to_string_lossy().to_string()) + .unwrap(); + assert_eq!(summary.input_tokens, 1_000_000); + assert_eq!(cached_input_total(child_usage), 0); + assert!(child_usage.codex_unresolved_fork_parent); + assert!( + cache + .codex_pending_paths + .contains(&child.to_string_lossy().to_string()) + ); + assert!( + cache + .files + .contains_key(&parent.to_string_lossy().to_string()) + ); +} + +#[test] +fn deleted_unresolved_child_is_pruned_without_resurrection() { + 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 child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("missing-parent"), + base + Duration::seconds(1), + base + Duration::seconds(2), + &[1_000_000, 1_000_140], + ); + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (_, _, first_cache) = scanner.scan_codex_detailed_with_cache(None); + assert!( + first_cache + .codex_pending_paths + .contains(&child.to_string_lossy().to_string()) + ); + + std::fs::remove_file(&child).unwrap(); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + assert!(summary.history_coverage_established); + assert!(summary.known_zero); + assert!(cache.codex_pending_paths.is_empty()); + assert!( + !cache + .files + .contains_key(&child.to_string_lossy().to_string()) + ); +} + +#[test] +fn fork_baseline_reset_fails_closed_instead_of_billing_fresh_usage() { + 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 _parent = write_codex_fork_session_fixture( + &sessions, + "parent.jsonl", + "parent-id", + None, + base, + base, + &[1_000_000], + ); + let child = write_codex_fork_session_fixture( + &sessions, + "child.jsonl", + "child-id", + Some("parent-id"), + base + Duration::seconds(1), + base + Duration::seconds(2), + &[1_000_000, 999_900, 1_000_140], + ); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, _, cache) = scanner.scan_codex_detailed_with_cache(None); + let child_usage = cache + .files + .get(&child.to_string_lossy().to_string()) + .unwrap(); + + assert_eq!(summary.input_tokens, 1_000_000); + assert_eq!(cached_input_total(child_usage), 0); + assert!(child_usage.codex_unresolved_fork_parent); + assert!(!summary.history_coverage_established); +} + +#[test] +fn cost_scan_second_pass_skips_unchanged_files_via_cache() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + write_codex_session_fixture(&sessions, "b.jsonl", 200); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary1, stats1) = scanner.scan_codex_detailed(None); + assert_eq!(stats1.files_parsed, 2, "first pass parses both files"); + assert_eq!(stats1.files_skipped, 0); + assert!(summary1.total_cost_usd > 0.0); + assert_eq!(summary1.sessions_count, 2); + + // Second pass with default debounce still inspects files but skips re-parse. + // Use app_driven so we exercise per-file mtime skip rather than whole-scan debounce. + let (summary2, stats2) = scanner.scan_codex_detailed(None); + assert_eq!(stats2.files_seen, 2); + assert_eq!(stats2.files_skipped, 2, "cache hit skips re-parse"); + assert_eq!(stats2.files_parsed, 0); + assert_eq!(summary2.input_tokens, summary1.input_tokens); + assert!((summary2.total_cost_usd - summary1.total_cost_usd).abs() < 1e-9); + + // Force path already used above; confirm debounce short-circuit with default options. + let debounced = CostScanner::new(7) + .with_options(CostScanOptions::default()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + let (summary3, stats3) = debounced.scan_codex_detailed(None); + assert!( + stats3.used_cache_debounce, + "default options debounce within 60s" + ); + assert_eq!(stats3.files_seen, 0); + assert_eq!(summary3.input_tokens, summary1.input_tokens); + + // app_driven after debounce still re-reads (skip via mtime, not full re-parse). + let forced = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (_, stats4) = forced.scan_codex_detailed(None); + assert!(!stats4.used_cache_debounce); + assert_eq!(stats4.files_skipped, 2); + assert_eq!(stats4.files_parsed, 0); +} + +#[test] +fn cancelled_fresh_cache_hit_is_not_authoritative() { + let root = tempfile::tempdir().unwrap(); + let cache_root = root.path().join("cache"); + let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); + let usage = HashMap::from([( + today.clone(), + HashMap::from([("gpt-5.6-sol".to_string(), vec![100, 0, 10])]), + )]); + let mut cache = CostUsageCache { + last_scan_unix_ms: unix_now_ms(), + files: HashMap::from([( + "cached.jsonl".to_string(), + CostUsageFileUsage { + mtime_unix_ms: 0, + size: 100, + days: usage.clone(), + parsed_bytes: Some(100), + last_model: Some("gpt-5.6-sol".to_string()), + last_totals: None, + codex_token_timestamps_monotonic: Some(true), + codex_last_token_timestamp: None, + codex_session_id: None, + codex_forked_from_id: None, + codex_fork_timestamp: None, + codex_unresolved_fork_parent: false, + }, + )]), + days: usage, + ..Default::default() + }; + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); + + let cancel = AtomicBool::new(true); + let scanner = CostScanner::new(7) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![root.path().join("sessions")]); + let (summary, stats) = scanner.scan_codex_detailed(Some(&cancel)); + + assert!( + stats.used_cache_debounce, + "fresh cache should use debounce path" + ); + assert_eq!(summary.sessions_count, 1, "cached usage is still visible"); + assert!( + !summary.history_coverage_established, + "cancelled cache publication must not claim complete history" + ); + assert!(!summary.known_zero); +} + +#[test] +fn cost_scan_cancel_stops_between_files() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + write_codex_session_fixture(&sessions, "b.jsonl", 200); + write_codex_session_fixture(&sessions, "c.jsonl", 300); + + let cancel = AtomicBool::new(true); + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(cache_root) + .with_sessions_dirs(vec![sessions]); + let (summary, stats) = scanner.scan_codex_detailed(Some(&cancel)); + assert_eq!(stats.files_seen, 0, "cancel before first file stops walk"); + assert_eq!(summary.sessions_count, 0); +} + +#[test] +fn cost_scan_reconciles_deleted_file_to_known_zero() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let path = write_codex_session_fixture(&sessions, "deleted.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (first, _) = scanner.scan_codex_detailed(None); + assert_eq!(first.sessions_count, 1); + assert!(first.total_cost_usd > 0.0); + + std::fs::remove_file(&path).unwrap(); + let (second, _) = scanner.scan_codex_detailed(None); + + assert_eq!(second.sessions_count, 0); + assert_eq!(second.total_cost_usd, 0.0); + assert!(second.history_coverage_established); + assert!(second.known_zero); + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!(cache.files.is_empty(), "deleted JSONL row must be removed"); + assert!(cache.days.is_empty(), "stale daily totals must disappear"); +} + +#[test] +fn cost_scan_reconciliation_preserves_sibling_totals_once() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let deleted = write_codex_session_fixture(&sessions, "deleted.jsonl", 100); + let sibling = write_codex_session_fixture(&sessions, "sibling.jsonl", 200); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (first, _) = scanner.scan_codex_detailed(None); + assert_eq!(first.sessions_count, 2); + + std::fs::remove_file(&deleted).unwrap(); + let (second, _) = scanner.scan_codex_detailed(None); + + assert_eq!(second.sessions_count, 1); + assert_eq!(second.input_tokens, 200); + assert_eq!(second.output_tokens, 5); + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert_eq!(cache.files.len(), 1); + assert!( + !cache + .files + .contains_key(&deleted.to_string_lossy().to_string()) + ); + assert!( + cache + .files + .contains_key(&sibling.to_string_lossy().to_string()) + ); +} + +#[test] +fn cancelled_scan_after_deletion_preserves_stale_cache_row() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let path = write_codex_session_fixture(&sessions, "deleted.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (first, _) = scanner.scan_codex_detailed(None); + assert_eq!(first.sessions_count, 1); + std::fs::remove_file(&path).unwrap(); + + let cancel = AtomicBool::new(true); + let (cancelled, stats) = scanner.scan_codex_detailed(Some(&cancel)); + + assert_eq!(stats.files_seen, 0); + assert_eq!(cancelled.sessions_count, 0); + assert!(!cancelled.history_coverage_established); + assert!(!cancelled.known_zero); + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + cache + .files + .contains_key(&path.to_string_lossy().to_string()) + ); +} + +#[test] +fn cost_scan_resumes_appended_bytes() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let path = write_codex_session_fixture(&sessions, "grow.jsonl", 50); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + let (s1, st1) = scanner.scan_codex_detailed(None); + assert_eq!(st1.files_parsed, 1); + assert_eq!(st1.token_timestamp_comparisons, 0); + assert_eq!(s1.input_tokens, 50); + + // Append another cumulative token_count event (100 total => +50 delta). + let ts = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let extra = format!( + r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":100,"cached_input_tokens":0,"output_tokens":10}}}}}}}} +"# + ); + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + f.write_all(extra.as_bytes()).unwrap(); + drop(f); + + // Bump mtime/size visibly on some FS by rewriting metadata via reopen. + let (s2, st2) = scanner.scan_codex_detailed(None); + assert_eq!(st2.files_resumed, 1, "grown file resumes from offset"); + assert_eq!(st2.files_parsed, 0); + assert_eq!( + st2.token_timestamp_comparisons, 1, + "resume validates only the cached-prefix boundary and appended event" + ); + assert_eq!(s2.input_tokens, 100); + + // The append-only path must publish the same aggregate as a fresh + // full parse; the optimization is allowed to change work, not data. + let full_scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(root.path().join("fresh-cache")) + .with_sessions_dirs(vec![sessions]); + let (full, full_stats) = full_scanner.scan_codex_detailed(None); + assert_eq!(full_stats.files_parsed, 1); + assert_eq!(s2.input_tokens, full.input_tokens); + assert_eq!(s2.cached_tokens, full.cached_tokens); + assert_eq!(s2.output_tokens, full.output_tokens); + assert_eq!(s2.sessions_count, full.sessions_count); + assert_eq!(s2.by_model_tokens, full.by_model_tokens); + assert_eq!(s2.by_model.len(), full.by_model.len()); + for (model, resumed_cost) in &s2.by_model { + let full_cost = full.by_model.get(model).copied().expect("full model row"); + assert!((resumed_cost - full_cost).abs() < 1e-12); + } + assert!((s2.total_cost_usd - full.total_cost_usd).abs() < 1e-12); + + let cached = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + let cached_file = cached + .files + .get(&path.to_string_lossy().to_string()) + .expect("resumed file cache entry"); + assert_eq!(cached_file.codex_token_timestamps_monotonic, Some(true)); + assert!(cached_file.codex_last_token_timestamp.is_some()); +} + +#[test] +fn cost_scan_midline_rewrite_forces_full_parse_not_resume() { + // F2 (upstream 0.48.0 #2648): when a file is rewritten/truncated so the + // cached resume offset is now mid-line (byte before offset is not \n), + // the scanner must fall through to a full re-parse from offset 0 rather + // than resuming from the stale mid-line offset. + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let _path = write_codex_session_fixture(&sessions, "a.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + let (s1, st1) = scanner.scan_codex_detailed(None); + assert_eq!(st1.files_parsed, 1); + assert_eq!(s1.input_tokens, 100); + + // Rewrite the file with a shorter body at the same path so the cached + // parsed_bytes offset now points mid-line in the new content. + let today = Local::now().date_naive(); + let day_dir = sessions + .join(today.format("%Y").to_string()) + .join(today.format("%m").to_string()) + .join(today.format("%d").to_string()); + let ts = (Utc::now() - Duration::minutes(30)) + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + // Shorter content with different token count — the cached offset will + // be past EOF or mid-line in this new content. + let body = format!( + r#"{{"timestamp":"{ts}","type":"event_msg","payload":{{"type":"token_count","info":{{"model":"gpt-5","total_token_usage":{{"input_tokens":50,"cached_input_tokens":0,"output_tokens":5}}}}}}}} +"# + ); + std::fs::write(day_dir.join("a.jsonl"), body).unwrap(); + + let (s2, st2) = scanner.scan_codex_detailed(None); + // The scanner must full-parse (not resume) because the cached offset + // no longer sits on a line boundary in the rewritten content. + assert!( + st2.files_parsed >= 1 || st2.files_resumed == 0, + "midline rewrite forces full parse, not resume (parsed={}, resumed={})", + st2.files_parsed, + st2.files_resumed + ); + assert_eq!(s2.input_tokens, 50, "full parse picks up new token count"); +} + +#[test] +fn previous_report_clears_after_successful_full_scan() { + // F8 (upstream 0.48.0): a completed full scan clears previous_report so + // the refreshing indicator does not stay permanently on. + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + // First scan: builds cache fresh; no previous_report expected. + let (summary1, _) = scanner.scan_codex_detailed(None); + assert!(summary1.history_coverage_established); + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + cache.previous_report.is_none(), + "first scan clears previous_report" + ); + + // Inject a previous_report to simulate trim-set catch-up. + let mut cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + cache.previous_report = Some(crate::core::CachedCostReport { + total_cost_usd: 0.0, + input_tokens: 0, + cached_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + sessions_count: 0, + updated_at: None, + partial: false, + }); + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); + + // Verify the cache now has previous_report set. + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + cache.previous_report.is_some(), + "injected previous_report persists" + ); + + // Full scan with app_driven clears previous_report on success. + let (summary2, _) = scanner.scan_codex_detailed(None); + assert!( + summary2.history_coverage_established, + "after full scan coverage is established" + ); + + let cache = JsonlScanner::load_cache(ProviderId::Codex, Some(&cache_root)); + assert!( + cache.previous_report.is_none(), + "full scan clears previous_report" + ); +} + +// ── Upstream 0.50.1 #2932: known-zero history ──────────────────────────── + +#[test] +fn known_zero_is_set_when_scan_completes_with_no_sessions() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + std::fs::create_dir_all(&sessions).unwrap(); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established, "scan completed"); + assert_eq!(summary.sessions_count, 0, "no sessions"); + assert!(summary.known_zero, "completed scan with zero = known-zero"); +} + +#[test] +fn known_zero_is_not_set_when_scan_has_results() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture(&sessions, "a.jsonl", 100); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (summary, _) = scanner.scan_codex_detailed(None); + assert!(summary.history_coverage_established); + assert_eq!(summary.sessions_count, 1); + assert!(!summary.known_zero, "scan with results is not known-zero"); +} + +#[test] +fn tiny_candidate_limit_prefers_newest_dirty_file_and_persists_older_pending() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let older = write_codex_session_fixture(&sessions, "a-older.jsonl", 100); + let newer = write_codex_session_fixture(&sessions, "z-newer.jsonl", 200); + + let mut options = CostScanOptions::app_driven(); + options.codex_candidate_limit = 1; + let scanner = CostScanner::new(7) + .with_options(options) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + + let (first, first_stats, first_cache) = scanner.scan_codex_detailed_with_cache(None); + assert_eq!(first_stats.files_parsed, 1); + assert_eq!( + first.input_tokens, 200, + "newest dirty file is processed first" + ); + assert!(first_cache.codex_scan_incomplete); + assert_eq!( + first_cache.codex_pending_paths, + vec![older.to_string_lossy().to_string()] + ); + assert!( + !first_cache + .codex_pending_paths + .contains(&newer.to_string_lossy().to_string()) + ); + + let (second, _, second_cache) = scanner.scan_codex_detailed_with_cache(None); + assert_eq!(second.input_tokens, 300); + assert!(!second_cache.codex_scan_incomplete); + assert!(second_cache.codex_pending_paths.is_empty()); +} + +#[test] +fn tiny_byte_limit_resumes_and_drains_to_unbounded_totals() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("bounded-cache"); + let path = write_codex_session_fixture_with_inputs(&sessions, "multi.jsonl", &[100, 200, 300]); + let first_line_bytes = i64::try_from( + std::fs::read(&path) + .unwrap() + .split(|byte| *byte == b'\n') + .next() + .unwrap() + .len(), + ) + .expect("fixture line length fits i64") + + 1; + + let mut bounded_options = CostScanOptions::app_driven(); + bounded_options.codex_max_session_file_bytes = first_line_bytes; + bounded_options.codex_max_scan_bytes_per_refresh = first_line_bytes; + let bounded = CostScanner::new(7) + .with_options(bounded_options) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions.clone()]); + + let (first, first_stats, first_cache) = bounded.scan_codex_detailed_with_cache(None); + assert_eq!(first_stats.codex_bytes_read, first_line_bytes as u64); + assert!(first_stats.files_deferred > 0); + assert!(first_cache.codex_scan_incomplete); + assert!( + first_cache + .files + .get(&path.to_string_lossy().to_string()) + .expect("partial cache entry") + .parsed_bytes + .unwrap_or(0) + < i64::try_from(std::fs::metadata(&path).unwrap().len()) + .expect("fixture file length fits i64") + ); + assert!(!first.history_coverage_established); + + let mut final_bounded = None; + let mut saw_resume = false; + for _ in 0..8 { + let (summary, stats, cache) = bounded.scan_codex_detailed_with_cache(None); + saw_resume |= stats.files_resumed > 0; + if !cache.codex_scan_incomplete { + final_bounded = Some(summary); + break; + } + } + let final_bounded = final_bounded.expect("bounded passes drain"); + assert!(saw_resume, "later passes resume the cached prefix"); + + let full = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(root.path().join("full-cache")) + .with_sessions_dirs(vec![sessions]); + let (full_summary, _, full_cache) = full.scan_codex_detailed_with_cache(None); + assert!(!full_cache.codex_scan_incomplete); + assert_eq!(final_bounded.input_tokens, full_summary.input_tokens); + assert_eq!(final_bounded.cached_tokens, full_summary.cached_tokens); + assert_eq!(final_bounded.output_tokens, full_summary.output_tokens); + assert_eq!(final_bounded.sessions_count, full_summary.sessions_count); + assert_eq!(final_bounded.by_model_tokens, full_summary.by_model_tokens); + assert!((final_bounded.total_cost_usd - full_summary.total_cost_usd).abs() < 1e-12); +} + +#[test] +fn incomplete_summary_preserves_previous_report_and_marks_it_non_authoritative() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + write_codex_session_fixture_with_inputs(&sessions, "multi.jsonl", &[100, 200]); + + let report = CachedCostReport { + total_cost_usd: 42.5, + input_tokens: 11, + cached_tokens: 2, + output_tokens: 3, + reasoning_tokens: Some(7), + sessions_count: 7, + updated_at: Some("2026-09-06T00:00:00Z".to_string()), + partial: false, + }; + let mut cache = CostUsageCache { + previous_report: Some(report.clone()), + codex_scan_incomplete: true, + ..Default::default() + }; + JsonlScanner::save_cache(ProviderId::Codex, &mut cache, Some(&cache_root)); + + let scanner = CostScanner::new(7) + .with_options(CostScanOptions::app_driven()) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let cancel = AtomicBool::new(true); + let (summary, _, saved) = scanner.scan_codex_detailed_with_cache(Some(&cancel)); + + assert_eq!(summary.total_cost_usd, report.total_cost_usd); + assert_eq!(summary.input_tokens, report.input_tokens as u64); + assert_eq!(summary.cached_tokens, report.cached_tokens as u64); + assert_eq!(summary.output_tokens, report.output_tokens as u64); + assert_eq!(summary.reasoning_tokens, Some(7)); + assert_eq!(summary.sessions_count, report.sessions_count as u32); + assert!(!summary.history_coverage_established); + assert!(!summary.known_zero); + assert!(summary.model_pricing_completeness.is_partial()); + assert_eq!( + saved.previous_report.map(|saved| saved.total_cost_usd), + Some(42.5) + ); + assert!(saved.codex_scan_incomplete); +} + +#[test] +fn pending_and_incomplete_round_trip_through_cache_json() { + let cache = CostUsageCache { + codex_pending_paths: vec!["C:\\sessions\\pending.jsonl".to_string()], + codex_scan_incomplete: true, + ..Default::default() + }; + + let encoded = serde_json::to_string(&cache).unwrap(); + let decoded: CostUsageCache = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.codex_pending_paths, cache.codex_pending_paths); + assert!(decoded.codex_scan_incomplete); +} + +#[test] +fn deleted_pending_path_is_pruned_after_complete_discovery() { + let root = tempfile::tempdir().unwrap(); + let sessions = root.path().join("sessions"); + let cache_root = root.path().join("cache"); + let path = write_codex_session_fixture_with_inputs(&sessions, "partial.jsonl", &[100, 200]); + let first_line_bytes = i64::try_from( + std::fs::read(&path) + .unwrap() + .split(|byte| *byte == b'\n') + .next() + .unwrap() + .len(), + ) + .expect("fixture line length fits i64") + + 1; + let mut options = CostScanOptions::app_driven(); + options.codex_max_session_file_bytes = first_line_bytes; + options.codex_max_scan_bytes_per_refresh = first_line_bytes; + let scanner = CostScanner::new(7) + .with_options(options) + .with_cache_root(&cache_root) + .with_sessions_dirs(vec![sessions]); + let (_, _, first_cache) = scanner.scan_codex_detailed_with_cache(None); + assert!(first_cache.codex_scan_incomplete); + assert_eq!(first_cache.codex_pending_paths.len(), 1); + + std::fs::remove_file(&path).unwrap(); + let (summary, _, second_cache) = scanner.scan_codex_detailed_with_cache(None); + assert_eq!(summary.sessions_count, 0); + assert!(summary.history_coverage_established); + assert!(second_cache.codex_pending_paths.is_empty()); + assert!(!second_cache.codex_scan_incomplete); + assert!( + !second_cache + .files + .contains_key(&path.to_string_lossy().to_string()) + ); + assert!(second_cache.days.is_empty()); +} + +#[test] +fn legacy_cache_json_defaults_bounded_scan_state() { + let legacy = r#"{"last_scan_unix_ms":0,"files":{},"days":{}}"#; + let cache: CostUsageCache = serde_json::from_str(legacy).unwrap(); + assert!(cache.codex_pending_paths.is_empty()); + assert!(!cache.codex_scan_incomplete); +} diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index f065ea79c8..9d98b4552b 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -93,68 +93,8 @@ pub(super) enum DelayedDecision { Discard, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResetDiagnosticReason { - CandidateCreated, - SourceNotExactOAuth, - MissingPreviousSnapshot, - MissingWeeklyWindow, - ResetThresholdMismatch, - InvalidResetBoundary, - InconsistentResetBoundary, - UnsupportedResetBoundary, - PlanMismatch, - MissingCreditInventory, - ChangedCreditInventory, - EvidenceVersionMismatch, - FutureCandidate, - ExpiredCandidate, - StaleObservation, - MinimumDelay, - ConfirmedObservation, - StoreUnavailable, - StoreRequested, -} - -impl ResetDiagnosticReason { - const fn code(self) -> &'static str { - match self { - Self::CandidateCreated => "candidateCreated", - Self::SourceNotExactOAuth => "sourceNotExactOAuth", - Self::MissingPreviousSnapshot => "missingPreviousSnapshot", - Self::MissingWeeklyWindow => "missingWeeklyWindow", - Self::ResetThresholdMismatch => "resetThresholdMismatch", - Self::InvalidResetBoundary => "invalidResetBoundary", - Self::InconsistentResetBoundary => "inconsistentResetBoundary", - Self::UnsupportedResetBoundary => "unsupportedResetBoundary", - Self::PlanMismatch => "planMismatch", - Self::MissingCreditInventory => "missingCreditInventory", - Self::ChangedCreditInventory => "changedCreditInventory", - Self::EvidenceVersionMismatch => "evidenceVersionMismatch", - Self::FutureCandidate => "futureCandidate", - Self::ExpiredCandidate => "expiredCandidate", - Self::StaleObservation => "staleObservation", - Self::MinimumDelay => "minimumDelay", - Self::ConfirmedObservation => "confirmedObservation", - Self::StoreUnavailable => "storeUnavailable", - Self::StoreRequested => "storeRequested", - } - } -} - -fn log_reset_diagnostic( - stage: &'static str, - decision: &'static str, - reason: ResetDiagnosticReason, -) { - tracing::debug!( - target: "codex_weekly_reset", - stage, - decision, - reason = reason.code(), - "Codex weekly-reset decision" - ); -} +mod diagnostics; +use diagnostics::{ResetDiagnosticReason, log_reset_diagnostic}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ResetCreditEvidence { @@ -585,14 +525,6 @@ fn delayed_candidate_decision( ); return DelayedDecision::Discard; } - if !plans_match(state.plan.as_deref(), current, current) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::PlanMismatch, - ); - return DelayedDecision::Discard; - } let Some(previous_weekly) = state.published_weekly.as_ref() else { log_reset_diagnostic( "delayedCandidate", @@ -602,6 +534,9 @@ fn delayed_candidate_decision( return DelayedDecision::Discard; }; let Some(current_weekly) = weekly(current) else { + // Credits-only refreshes do not carry the weekly window (or necessarily + // the plan/inventory fields). Preserve the candidate and let the next + // complete usage observation validate it. log_reset_diagnostic( "delayedCandidate", "retain", @@ -609,6 +544,14 @@ fn delayed_candidate_decision( ); return DelayedDecision::Retain; }; + if !plans_match(state.plan.as_deref(), current, current) { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::PlanMismatch, + ); + return DelayedDecision::Discard; + } if previous_weekly.used_percent <= RESET_THRESHOLD || current_weekly.used_percent > RESET_THRESHOLD { @@ -767,253 +710,4 @@ fn reset_credit_evidence( } #[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - #[test] - fn reset_diagnostic_codes_are_fixed_and_redacted() { - let codes = [ - ResetDiagnosticReason::CandidateCreated.code(), - ResetDiagnosticReason::SourceNotExactOAuth.code(), - ResetDiagnosticReason::ExpiredCandidate.code(), - ResetDiagnosticReason::ChangedCreditInventory.code(), - ResetDiagnosticReason::StoreRequested.code(), - ]; - assert_eq!( - codes, - [ - "candidateCreated", - "sourceNotExactOAuth", - "expiredCandidate", - "changedCreditInventory", - "storeRequested", - ] - ); - assert!(codes.iter().all(|code| { - !code.contains('@') - && !code.contains(':') - && !code.contains('/') - && !code.contains('\\') - })); - } - - fn now() -> DateTime { - Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap() - } - - fn snapshot(used: f64, reset_days: i64, captured_minutes: i64) -> UsageSnapshot { - let captured = now() + chrono::Duration::minutes(captured_minutes); - let weekly = RateWindow::with_details( - used, - Some(7 * 24 * 60), - Some(now() + chrono::Duration::days(reset_days)), - None, - ); - let mut snapshot = UsageSnapshot::new(RateWindow::new(20.0)).with_secondary(weekly); - snapshot.updated_at = captured; - snapshot.login_method = Some("ChatGPT Pro".to_string()); - snapshot - } - - fn inventory(id: &str) -> CreditInventory { - CreditInventory { - available_count: 1, - credits: vec![CreditIdentity { - id: id.to_string(), - reset_type: "weekly".to_string(), - status: "available".to_string(), - expires_at: Some(now() + chrono::Duration::days(3)), - }], - } - } - - fn baseline() -> AccountState { - let previous = snapshot(45.0, 2, 0); - AccountState { - published_weekly: previous.secondary.clone(), - published_at: previous.updated_at, - plan: previous.login_method.clone(), - credit_inventory: Some(inventory("credit-a")), - candidate: None, - } - } - - #[test] - fn inventory_retains_consumed_status_rows_but_counts_only_available_credits() { - let reset = ResetCredits { - available_count: 1, - credits: vec![ - ResetCredit { - id: Some("available-a".into()), - reset_type: Some("weekly".into()), - status: Some("available".into()), - expires_at: None, - }, - ResetCredit { - id: Some("redeeming-b".into()), - reset_type: Some("weekly".into()), - status: Some("redeeming".into()), - expires_at: None, - }, - ResetCredit { - id: Some("redeemed-c".into()), - reset_type: Some("weekly".into()), - status: Some("redeemed".into()), - expires_at: None, - }, - ], - }; - let inventory = super::inventory(Some(&reset), now()).expect("credit inventory"); - assert_eq!(inventory.available_count, 1); - assert_eq!(inventory.credits.len(), 3); - assert!( - inventory - .credits - .iter() - .any(|credit| credit.status == "redeeming") - ); - assert!( - inventory - .credits - .iter() - .any(|credit| credit.status == "redeemed") - ); - } - #[test] - fn early_low_usage_requires_confirmation_without_spending_credit() { - let mut state = baseline(); - let initial = snapshot(0.0, 9, 1); - let inv = inventory("credit-a"); - assert_eq!( - initial_decision(&mut state, &initial, Some(&inv), true, now()), - InitialDecision::RequiresConfirmation - ); - let confirmation = snapshot(0.0, 9, 2); - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), - ConfirmationDecision::Preserve - ); - assert!(state.candidate.is_some()); - assert_eq!(state.credit_inventory.as_ref().unwrap().available_count, 1); - } - - #[test] - fn delayed_candidate_publishes_after_sixty_seconds_and_expires_after_thirty_minutes() { - let mut state = baseline(); - let initial = snapshot(0.0, 9, 1); - let confirmation = snapshot(0.0, 9, 2); - let inv = inventory("credit-a"); - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), - ConfirmationDecision::Preserve - ); - let current = snapshot(0.0, 9, 3); - let candidate = state.candidate.clone().unwrap(); - assert_eq!( - delayed_candidate_decision( - &state, - &candidate, - ¤t, - Some(&inv), - true, - now() + chrono::Duration::seconds(59), - ), - DelayedDecision::Retain - ); - assert_eq!( - delayed_candidate_decision( - &state, - &candidate, - ¤t, - Some(&inv), - true, - now() + chrono::Duration::seconds(60), - ), - DelayedDecision::Publish - ); - assert_eq!( - delayed_candidate_decision( - &state, - &candidate, - ¤t, - Some(&inv), - true, - now() + chrono::Duration::minutes(31), - ), - DelayedDecision::Discard - ); - } - - #[test] - fn credits_only_refresh_retains_candidate_and_account_scope_hashes_differ() { - let mut state = baseline(); - state.candidate = Some(DelayedCandidate { - evidence_version: EVIDENCE_VERSION, - first_observed_at: now(), - created_at: now(), - snapshot_updated_at: now(), - weekly: snapshot(0.0, 9, 1).secondary.unwrap(), - plan: Some("ChatGPT Pro".to_string()), - inventory: inventory("credit-a"), - }); - let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); - credits_only.updated_at = now() + chrono::Duration::minutes(1); - credits_only.login_method = Some("ChatGPT Pro".to_string()); - let candidate = state.candidate.clone().unwrap(); - assert_eq!( - delayed_candidate_decision( - &state, - &candidate, - &credits_only, - Some(&inventory("credit-a")), - true, - now() + chrono::Duration::minutes(1), - ), - DelayedDecision::Retain - ); - assert_ne!( - scope_key(Some("account-a"), Path::new("C:/a/auth.json")), - scope_key(Some("account-b"), Path::new("C:/b/auth.json")) - ); - } - - #[test] - fn consumed_credit_allows_immediate_confirmation() { - let mut state = baseline(); - let initial = snapshot(0.0, 2, 1); - let confirmation = snapshot(0.0, 2, 2); - let consumed = CreditInventory { - available_count: 0, - credits: Vec::new(), - }; - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&consumed), - &confirmation, - Some(&consumed), - true, - now(), - ), - ConfirmationDecision::Publish - ); - } -} +mod tests; diff --git a/rust/src/providers/codex/weekly_reset/diagnostics.rs b/rust/src/providers/codex/weekly_reset/diagnostics.rs new file mode 100644 index 0000000000..9ce305e8e3 --- /dev/null +++ b/rust/src/providers/codex/weekly_reset/diagnostics.rs @@ -0,0 +1,62 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResetDiagnosticReason { + CandidateCreated, + SourceNotExactOAuth, + MissingPreviousSnapshot, + MissingWeeklyWindow, + ResetThresholdMismatch, + InvalidResetBoundary, + InconsistentResetBoundary, + UnsupportedResetBoundary, + PlanMismatch, + MissingCreditInventory, + ChangedCreditInventory, + EvidenceVersionMismatch, + FutureCandidate, + ExpiredCandidate, + StaleObservation, + MinimumDelay, + ConfirmedObservation, + StoreUnavailable, + StoreRequested, +} + +impl ResetDiagnosticReason { + pub(super) const fn code(self) -> &'static str { + match self { + Self::CandidateCreated => "candidateCreated", + Self::SourceNotExactOAuth => "sourceNotExactOAuth", + Self::MissingPreviousSnapshot => "missingPreviousSnapshot", + Self::MissingWeeklyWindow => "missingWeeklyWindow", + Self::ResetThresholdMismatch => "resetThresholdMismatch", + Self::InvalidResetBoundary => "invalidResetBoundary", + Self::InconsistentResetBoundary => "inconsistentResetBoundary", + Self::UnsupportedResetBoundary => "unsupportedResetBoundary", + Self::PlanMismatch => "planMismatch", + Self::MissingCreditInventory => "missingCreditInventory", + Self::ChangedCreditInventory => "changedCreditInventory", + Self::EvidenceVersionMismatch => "evidenceVersionMismatch", + Self::FutureCandidate => "futureCandidate", + Self::ExpiredCandidate => "expiredCandidate", + Self::StaleObservation => "staleObservation", + Self::MinimumDelay => "minimumDelay", + Self::ConfirmedObservation => "confirmedObservation", + Self::StoreUnavailable => "storeUnavailable", + Self::StoreRequested => "storeRequested", + } + } +} + +pub(super) fn log_reset_diagnostic( + stage: &'static str, + decision: &'static str, + reason: ResetDiagnosticReason, +) { + tracing::debug!( + target: "codex_weekly_reset", + stage, + decision, + reason = reason.code(), + "Codex weekly-reset decision" + ); +} diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs new file mode 100644 index 0000000000..9859fd9834 --- /dev/null +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -0,0 +1,319 @@ +use super::*; +use chrono::TimeZone; + +#[test] +fn reset_diagnostic_codes_are_fixed_and_redacted() { + let codes = [ + ResetDiagnosticReason::CandidateCreated.code(), + ResetDiagnosticReason::SourceNotExactOAuth.code(), + ResetDiagnosticReason::ExpiredCandidate.code(), + ResetDiagnosticReason::ChangedCreditInventory.code(), + ResetDiagnosticReason::StoreRequested.code(), + ]; + assert_eq!( + codes, + [ + "candidateCreated", + "sourceNotExactOAuth", + "expiredCandidate", + "changedCreditInventory", + "storeRequested", + ] + ); + assert!(codes.iter().all(|code| { + !code.contains('@') && !code.contains(':') && !code.contains('/') && !code.contains('\\') + })); +} + +fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap() +} + +fn snapshot(used: f64, reset_days: i64, captured_minutes: i64) -> UsageSnapshot { + let captured = now() + chrono::Duration::minutes(captured_minutes); + let weekly = RateWindow::with_details( + used, + Some(7 * 24 * 60), + Some(now() + chrono::Duration::days(reset_days)), + None, + ); + let mut snapshot = UsageSnapshot::new(RateWindow::new(20.0)).with_secondary(weekly); + snapshot.updated_at = captured; + snapshot.login_method = Some("ChatGPT Pro".to_string()); + snapshot +} + +fn inventory(id: &str) -> CreditInventory { + CreditInventory { + available_count: 1, + credits: vec![CreditIdentity { + id: id.to_string(), + reset_type: "weekly".to_string(), + status: "available".to_string(), + expires_at: Some(now() + chrono::Duration::days(3)), + }], + } +} + +fn baseline() -> AccountState { + let previous = snapshot(45.0, 2, 0); + AccountState { + published_weekly: previous.secondary.clone(), + published_at: previous.updated_at, + plan: previous.login_method.clone(), + credit_inventory: Some(inventory("credit-a")), + candidate: None, + } +} + +#[test] +fn inventory_retains_consumed_status_rows_but_counts_only_available_credits() { + let reset = ResetCredits { + available_count: 1, + credits: vec![ + ResetCredit { + id: Some("available-a".into()), + reset_type: Some("weekly".into()), + status: Some("available".into()), + expires_at: None, + }, + ResetCredit { + id: Some("redeeming-b".into()), + reset_type: Some("weekly".into()), + status: Some("redeeming".into()), + expires_at: None, + }, + ResetCredit { + id: Some("redeemed-c".into()), + reset_type: Some("weekly".into()), + status: Some("redeemed".into()), + expires_at: None, + }, + ], + }; + let inventory = super::inventory(Some(&reset), now()).expect("credit inventory"); + assert_eq!(inventory.available_count, 1); + assert_eq!(inventory.credits.len(), 3); + assert!( + inventory + .credits + .iter() + .any(|credit| credit.status == "redeeming") + ); + assert!( + inventory + .credits + .iter() + .any(|credit| credit.status == "redeemed") + ); +} +#[test] +fn early_low_usage_requires_confirmation_without_spending_credit() { + let mut state = baseline(); + let initial = snapshot(0.0, 9, 1); + let inv = inventory("credit-a"); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + let confirmation = snapshot(0.0, 9, 2); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); + assert!(state.candidate.is_some()); + assert_eq!(state.credit_inventory.as_ref().unwrap().available_count, 1); +} + +#[test] +fn delayed_candidate_publishes_after_sixty_seconds_and_expires_after_thirty_minutes() { + let mut state = baseline(); + let initial = snapshot(0.0, 9, 1); + let confirmation = snapshot(0.0, 9, 2); + let inv = inventory("credit-a"); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); + let current = snapshot(0.0, 9, 3); + let candidate = state.candidate.clone().unwrap(); + assert_eq!( + delayed_candidate_decision( + &state, + &candidate, + ¤t, + Some(&inv), + true, + now() + chrono::Duration::seconds(59), + ), + DelayedDecision::Retain + ); + assert_eq!( + delayed_candidate_decision( + &state, + &candidate, + ¤t, + Some(&inv), + true, + now() + chrono::Duration::seconds(60), + ), + DelayedDecision::Publish + ); + assert_eq!( + delayed_candidate_decision( + &state, + &candidate, + ¤t, + Some(&inv), + true, + now() + chrono::Duration::minutes(31), + ), + DelayedDecision::Discard + ); +} + +#[test] +fn credits_only_refresh_retains_candidate_and_account_scope_hashes_differ() { + let mut state = baseline(); + state.candidate = Some(DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly: snapshot(0.0, 9, 1).secondary.unwrap(), + plan: Some("ChatGPT Pro".to_string()), + inventory: inventory("credit-a"), + }); + let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); + credits_only.updated_at = now() + chrono::Duration::minutes(1); + // A credits-only refresh has no weekly window and may omit both plan and + // reset-credit inventory. It must not consume the pending evidence. + let candidate_before = serde_json::to_value(&state.candidate).unwrap(); + assert_eq!( + initial_decision( + &mut state, + &credits_only, + None, + true, + now() + chrono::Duration::minutes(1), + ), + InitialDecision::Preserve + ); + assert_eq!( + serde_json::to_value(&state.candidate).unwrap(), + candidate_before + ); + assert_ne!( + scope_key(Some("account-a"), Path::new("C:/a/auth.json")), + scope_key(Some("account-b"), Path::new("C:/b/auth.json")) + ); +} + +#[test] +fn credits_only_refresh_candidate_survives_state_reload_until_full_usage() { + let mut state = baseline(); + state.candidate = Some(DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly: snapshot(0.0, 9, 1).secondary.unwrap(), + plan: Some("ChatGPT Pro".to_string()), + inventory: inventory("credit-a"), + }); + let candidate_before = serde_json::to_value(&state.candidate).unwrap(); + let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); + credits_only.updated_at = now() + chrono::Duration::minutes(1); + + assert_eq!( + initial_decision( + &mut state, + &credits_only, + None, + true, + now() + chrono::Duration::minutes(1), + ), + InitialDecision::Preserve + ); + + // Model the StateFile envelope used by save/load without touching the + // user's real LocalAppData during a unit test. + let encoded = serde_json::to_vec(&StateFile { + version: STATE_VERSION, + accounts: HashMap::from([(String::from("scope"), state)]), + }) + .unwrap(); + let mut reloaded_file: StateFile = serde_json::from_slice(&encoded).unwrap(); + let mut reloaded = reloaded_file.accounts.remove("scope").unwrap(); + assert_eq!( + serde_json::to_value(&reloaded.candidate).unwrap(), + candidate_before + ); + + let mut incompatible = reloaded.clone(); + let mut incompatible_usage = snapshot(0.0, 9, 3); + incompatible_usage.login_method = Some("ChatGPT Plus".to_string()); + assert_eq!( + initial_decision( + &mut incompatible, + &incompatible_usage, + Some(&inventory("credit-a")), + true, + now() + chrono::Duration::seconds(60), + ), + InitialDecision::RequiresConfirmation + ); + assert!(incompatible.candidate.is_none()); + + let full_usage = snapshot(0.0, 9, 3); + assert_eq!( + initial_decision( + &mut reloaded, + &full_usage, + Some(&inventory("credit-a")), + true, + now() + chrono::Duration::seconds(60), + ), + InitialDecision::Publish + ); + assert!(reloaded.candidate.is_none()); +} + +#[test] +fn consumed_credit_allows_immediate_confirmation() { + let mut state = baseline(); + let initial = snapshot(0.0, 2, 1); + let confirmation = snapshot(0.0, 2, 2); + let consumed = CreditInventory { + available_count: 0, + credits: Vec::new(), + }; + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&consumed), + &confirmation, + Some(&consumed), + true, + now(), + ), + ConfirmationDecision::Publish + ); +} diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 60f104b389..5777706864 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -114,7 +114,8 @@ impl LocalUsageSnapshot { // Upstream 0.51 (#2982): local SQLite quota reconstruction is useful // but it is not server-confirmed authority. Keep that distinction in // the data contract so CLI/React can present it without guessing. - ProviderFetchResult::new(snap, "local estimate") + ProviderFetchResult::new(snap, super::LOCAL_ESTIMATE_SOURCE_LABEL) + .with_non_authoritative_pace() } } @@ -605,6 +606,30 @@ mod tests { use chrono::Weekday; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn local_fetch_result_keeps_estimate_source_and_reset_windows() { + let result = LocalUsageSnapshot { + rolling_usage_percent: 12.0, + weekly_usage_percent: 23.0, + monthly_usage_percent: 34.0, + rolling_reset_in_sec: 300, + weekly_reset_in_sec: 1_000, + monthly_reset_in_sec: 2_000, + } + .to_fetch_result(); + + assert_eq!( + result.source_label, + super::super::LOCAL_ESTIMATE_SOURCE_LABEL + ); + assert_eq!(result.usage.primary.used_percent, 12.0); + assert_eq!(result.usage.secondary.as_ref().unwrap().used_percent, 23.0); + assert_eq!(result.usage.tertiary.as_ref().unwrap().used_percent, 34.0); + assert!(result.usage.primary.resets_at.is_some()); + assert!(result.usage.secondary.as_ref().unwrap().resets_at.is_some()); + assert!(result.usage.tertiary.as_ref().unwrap().resets_at.is_some()); + } + fn temp_db_path(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 984ea07264..62d0ca09d8 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -21,6 +21,8 @@ use crate::core::{ const BASE_URL: &str = "https://opencode.ai"; const SERVER_URL: &str = "https://opencode.ai/_server"; +/// Source label for quota values reconstructed from the device-local SQLite history. +pub const LOCAL_ESTIMATE_SOURCE_LABEL: &str = "local estimate"; const WORKSPACES_SERVER_ID: &str = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f"; const BILLING_SERVER_ID: &str = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d"; diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 0691fecd9a..457e209b57 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -122,7 +122,7 @@ impl OpenRouterProvider { supports_credits: true, default_enabled: false, is_primary: false, - dashboard_url: Some("https://openrouter.ai/settings/credits"), + dashboard_url: Some("https://openrouter.ai/activity"), status_page_url: Some("https://status.openrouter.ai"), }, } @@ -474,6 +474,14 @@ mod tests { assert_eq!(url, "https://openrouter.ai/api/v1/key"); } + #[test] + fn usage_dashboard_opens_activity_history() { + assert_eq!( + OpenRouterProvider::new().metadata().dashboard_url, + Some("https://openrouter.ai/activity") + ); + } + // ── F14: server-reported current-period remaining drives the key meter ── fn key_data( diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index 038cc147aa..9a2e928b34 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -24,6 +24,52 @@ pub enum CostProvenance { Unknown, } +impl CostProvenance { + /// Narrow snapshot provenance to the costs actually present in a window. + /// + /// This mirrors upstream 0.56.2 `CostProvenance.forWindow`: a vendor source + /// remains vendor-metered when it has window costs, while a mixed source is + /// mixed only when both its list-price and metered sides are present. + pub(crate) fn for_window( + snapshot: Self, + has_window_costs: bool, + includes_metered: bool, + ) -> Self { + match snapshot { + Self::VendorMetered => { + if includes_metered || has_window_costs { + Self::VendorMetered + } else { + Self::Unknown + } + } + Self::Mixed => match (includes_metered, has_window_costs) { + (true, true) => Self::Mixed, + (true, false) => Self::VendorMetered, + (false, true) => Self::ListPriceEstimate, + (false, false) => Self::Unknown, + }, + Self::ListPriceEstimate => { + if has_window_costs { + Self::ListPriceEstimate + } else { + Self::Unknown + } + } + Self::Unknown => Self::Unknown, + } + } + + fn from_source_kinds(includes_vendor: bool, includes_list: bool) -> Self { + match (includes_vendor, includes_list) { + (true, true) => Self::Mixed, + (true, false) => Self::VendorMetered, + (false, true) => Self::ListPriceEstimate, + (false, false) => Self::Unknown, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocalHistoryCoverage { @@ -74,12 +120,20 @@ pub struct CostCoverageCounts { impl CostCoverageCounts { pub fn total(&self) -> u32 { - self.priced + self.unpriced + self.unmetered + self.estimated + self.checked_total().unwrap_or(u32::MAX) + } + + fn checked_total(&self) -> Option { + self.priced + .checked_add(self.unpriced)? + .checked_add(self.unmetered)? + .checked_add(self.estimated) } pub fn coverage_ratio(&self) -> Option { - let denominator = self.total(); - (denominator > 0).then(|| (self.priced + self.estimated) as f64 / denominator as f64) + let denominator = self.checked_total()?; + let covered = self.priced.checked_add(self.estimated)?; + (denominator > 0).then(|| covered as f64 / denominator as f64) } } @@ -91,6 +145,11 @@ pub struct SpendTokenMix { pub cache_read_tokens: Option, pub cache_creation_tokens: Option, pub reasoning_tokens: Option, + /// Keeps arithmetic overflow distinct from an ordinary missing class while + /// the report is merged in memory. It is deliberately not part of the + /// wire contract: both cases are exposed as unknown (`None`). + #[serde(skip)] + pub(crate) overflowed_classes: u8, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -131,6 +190,7 @@ pub struct ImportedSpendSource { pub request_count: u32, pub conversation_count: u32, pub known_cost_usd: Option, + pub provenance: CostProvenance, pub token_mix: SpendTokenMix, pub coverage: CostCoverageCounts, pub models: Vec, @@ -148,7 +208,9 @@ struct NativeSpendData { struct ResolvedSpendData { known_cost_usd: Option, + provenance: CostProvenance, price_coverage: CostCoverageCounts, + price_coverage_exact: bool, token_mix: SpendTokenMix, models: Vec, daily: Vec, @@ -311,12 +373,19 @@ pub fn build_local_spend_contract_from_summary( let native_models = model_rows(provider_id, &summary, &custom); let native_coverage = coverage_for_models(&native_models); let native_cost = known_subtotal(&native_models, &summary); + let native_has_window_costs = native_models.iter().any(|model| model.cost_usd.is_some()); + let native_provenance = CostProvenance::for_window( + CostProvenance::ListPriceEstimate, + native_has_window_costs, + false, + ); let native_token_mix = SpendTokenMix { input_tokens: Some(summary.input_tokens), output_tokens: Some(summary.output_tokens), cache_read_tokens: Some(summary.cached_tokens), cache_creation_tokens: None, - reasoning_tokens: None, + reasoning_tokens: summary.reasoning_tokens, + ..SpendTokenMix::default() }; let native = load_native_spend(provider_id, history_days, hide_personal_info); @@ -332,6 +401,8 @@ pub fn build_local_spend_contract_from_summary( provider_id == "codex" && hide_native_codex_when_opencodex_present && imported.is_some(); let resolved = resolve_spend( native_cost, + native_provenance, + native_has_window_costs, native_coverage, native_token_mix, native_models, @@ -370,12 +441,12 @@ pub fn build_local_spend_contract_from_summary( history_days, known_cost_usd: resolved.known_cost_usd, known_zero, - provenance: if resolved.known_cost_usd.is_some() { - CostProvenance::ListPriceEstimate + provenance: resolved.provenance, + price_coverage_ratio: if resolved.price_coverage_exact { + resolved.price_coverage.coverage_ratio() } else { - CostProvenance::Unknown + None }, - price_coverage_ratio: resolved.price_coverage.coverage_ratio(), price_coverage: resolved.price_coverage, history_coverage_established: summary.history_coverage_established, token_mix: resolved.token_mix, @@ -444,6 +515,8 @@ fn load_native_spend( )] fn resolve_spend( native_cost: Option, + native_provenance: CostProvenance, + native_has_window_costs: bool, native_coverage: CostCoverageCounts, native_token_mix: SpendTokenMix, native_models: Vec, @@ -455,22 +528,37 @@ fn resolve_spend( match imported { Some(imported) if replace_native => ResolvedSpendData { known_cost_usd: imported.known_cost_usd, + provenance: imported.provenance, price_coverage: imported.coverage.clone(), + price_coverage_exact: imported.coverage.checked_total().is_some(), token_mix: imported.token_mix.clone(), models: imported.models.clone(), daily: imported.daily.clone(), hourly_activity: imported.hourly_activity.clone(), }, - Some(imported) => ResolvedSpendData { - known_cost_usd: sum_optional_cost(native_cost, imported.known_cost_usd), - price_coverage: merge_coverage(native_coverage, &imported.coverage), - token_mix: merge_token_mix(native_token_mix, &imported.token_mix), - models: merge_models(native_models, &imported.models), - daily: merge_daily(native_daily, &imported.daily), - hourly_activity: merge_activity(native_activity, &imported.hourly_activity), - }, + Some(imported) => { + let (price_coverage, price_coverage_exact) = + merge_coverage(native_coverage, &imported.coverage); + ResolvedSpendData { + known_cost_usd: sum_optional_cost(native_cost, imported.known_cost_usd), + provenance: merge_provenance( + native_provenance, + native_has_window_costs, + imported.provenance, + imported.known_cost_usd.is_some(), + ), + price_coverage, + price_coverage_exact, + token_mix: merge_token_mix(native_token_mix, &imported.token_mix), + models: merge_models(native_models, &imported.models), + daily: merge_daily(native_daily, &imported.daily), + hourly_activity: merge_activity(native_activity, &imported.hourly_activity), + } + } None => ResolvedSpendData { known_cost_usd: native_cost, + provenance: native_provenance, + price_coverage_exact: native_coverage.checked_total().is_some(), price_coverage: native_coverage, token_mix: native_token_mix, models: native_models, @@ -480,6 +568,45 @@ fn resolve_spend( } } +fn merge_provenance( + left: CostProvenance, + left_has_window_costs: bool, + right: CostProvenance, + right_has_window_costs: bool, +) -> CostProvenance { + let mut merged = None; + for (provenance, has_window_costs) in [ + (left, left_has_window_costs), + (right, right_has_window_costs), + ] { + if !has_window_costs { + continue; + } + merged = Some(match merged { + None => provenance, + Some(existing) => combine_provenance(existing, provenance), + }); + } + merged.unwrap_or(CostProvenance::Unknown) +} + +fn combine_provenance(left: CostProvenance, right: CostProvenance) -> CostProvenance { + match (left, right) { + (CostProvenance::Unknown, _) | (_, CostProvenance::Unknown) => CostProvenance::Unknown, + (CostProvenance::Mixed, _) | (_, CostProvenance::Mixed) => CostProvenance::Mixed, + (CostProvenance::ListPriceEstimate, CostProvenance::ListPriceEstimate) => { + CostProvenance::ListPriceEstimate + } + (CostProvenance::VendorMetered, CostProvenance::VendorMetered) => { + CostProvenance::VendorMetered + } + (CostProvenance::ListPriceEstimate, CostProvenance::VendorMetered) + | (CostProvenance::VendorMetered, CostProvenance::ListPriceEstimate) => { + CostProvenance::Mixed + } + } +} + fn model_rows( provider_id: &str, summary: &CostSummary, @@ -604,31 +731,96 @@ fn activity_from_sessions(sessions: &[SessionUsage]) -> Vec { .collect() } fn sum_optional_cost(left: Option, right: Option) -> Option { + let valid = |value: f64| value.is_finite() && value >= 0.0; match (left, right) { - (Some(left), Some(right)) => (left + right).is_finite().then_some(left + right), - (Some(value), None) | (None, Some(value)) => Some(value), + (Some(left), Some(right)) if valid(left) && valid(right) => { + let total = left + right; + valid(total).then_some(total) + } + (Some(value), None) | (None, Some(value)) if valid(value) => Some(value), (None, None) => None, + _ => None, } } -fn merge_coverage(mut left: CostCoverageCounts, right: &CostCoverageCounts) -> CostCoverageCounts { - left.priced = left.priced.saturating_add(right.priced); - left.unpriced = left.unpriced.saturating_add(right.unpriced); - left.unmetered = left.unmetered.saturating_add(right.unmetered); - left.estimated = left.estimated.saturating_add(right.estimated); - left +fn merge_coverage( + left: CostCoverageCounts, + right: &CostCoverageCounts, +) -> (CostCoverageCounts, bool) { + let priced = left.priced.checked_add(right.priced); + let unpriced = left.unpriced.checked_add(right.unpriced); + let unmetered = left.unmetered.checked_add(right.unmetered); + let estimated = left.estimated.checked_add(right.estimated); + let exact = + priced.is_some() && unpriced.is_some() && unmetered.is_some() && estimated.is_some(); + let merged = CostCoverageCounts { + priced: priced.unwrap_or(u32::MAX), + unpriced: unpriced.unwrap_or(u32::MAX), + unmetered: unmetered.unwrap_or(u32::MAX), + estimated: estimated.unwrap_or(u32::MAX), + }; + let exact = exact && merged.checked_total().is_some(); + (merged, exact) } fn merge_token_mix(mut left: SpendTokenMix, right: &SpendTokenMix) -> SpendTokenMix { - left.input_tokens = add_optional(left.input_tokens, right.input_tokens); - left.output_tokens = add_optional(left.output_tokens, right.output_tokens); - left.cache_read_tokens = add_optional(left.cache_read_tokens, right.cache_read_tokens); - left.cache_creation_tokens = - add_optional(left.cache_creation_tokens, right.cache_creation_tokens); - left.reasoning_tokens = add_optional(left.reasoning_tokens, right.reasoning_tokens); + left.overflowed_classes |= right.overflowed_classes; + left.input_tokens = merge_token_class( + left.input_tokens, + right.input_tokens, + &mut left.overflowed_classes, + 1 << 0, + ); + left.output_tokens = merge_token_class( + left.output_tokens, + right.output_tokens, + &mut left.overflowed_classes, + 1 << 1, + ); + left.cache_read_tokens = merge_token_class( + left.cache_read_tokens, + right.cache_read_tokens, + &mut left.overflowed_classes, + 1 << 2, + ); + left.cache_creation_tokens = merge_token_class( + left.cache_creation_tokens, + right.cache_creation_tokens, + &mut left.overflowed_classes, + 1 << 3, + ); + left.reasoning_tokens = merge_token_class( + left.reasoning_tokens, + right.reasoning_tokens, + &mut left.overflowed_classes, + 1 << 4, + ); left } +fn merge_token_class( + left: Option, + right: Option, + overflowed_classes: &mut u8, + bit: u8, +) -> Option { + if *overflowed_classes & bit != 0 { + return None; + } + match (left, right) { + (Some(left), Some(right)) => match left.checked_add(right) { + Some(total) => Some(total), + None => { + *overflowed_classes |= bit; + None + } + }, + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} + fn add_optional(left: Option, right: Option) -> Option { match (left, right) { (Some(left), Some(right)) => left.checked_add(right), @@ -709,149 +901,4 @@ fn merge_activity( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn coverage_ratio_counts_estimated_as_covered() { - let coverage = CostCoverageCounts { - priced: 1, - unpriced: 1, - unmetered: 0, - estimated: 2, - }; - assert_eq!(coverage.coverage_ratio(), Some(0.75)); - } - - #[test] - fn explicit_zero_custom_rate_is_known_free_but_missing_rate_is_unknown() { - let counts = ModelTokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - cached_tokens: 0, - }; - let free = CustomRates { - input: Some(0.0), - ..CustomRates::default() - }; - let missing = CustomRates::default(); - assert_eq!(free.cost(&counts), Some(0.0)); - assert_eq!(missing.cost(&counts), None); - } - - #[test] - fn sum_optional_cost_propagates_unknown_and_rejects_non_finite() { - assert_eq!(sum_optional_cost(Some(1.5), Some(2.25)), Some(3.75)); - assert_eq!(sum_optional_cost(Some(1.5), None), Some(1.5)); - assert_eq!(sum_optional_cost(None, Some(2.0)), Some(2.0)); - assert_eq!(sum_optional_cost(None, None), None); - assert_eq!(sum_optional_cost(Some(f64::INFINITY), Some(1.0)), None); - } - - #[test] - fn add_optional_token_counts_guard_against_overflow() { - assert_eq!(add_optional(Some(2), Some(3)), Some(5)); - assert_eq!(add_optional(Some(2), None), Some(2)); - assert_eq!(add_optional(None, None), None); - assert_eq!(add_optional(Some(u64::MAX), Some(1)), None); - } - - #[test] - fn merge_models_combines_duplicate_models_and_sorts_priced_first() { - let make_row = |model: &str, cost: Option, input: u64, custom: bool| SpendModelRow { - model: model.to_string(), - cost_usd: cost, - input_tokens: input, - output_tokens: 0, - cache_read_tokens: 0, - total_tokens: input, - custom_pricing: custom, - }; - let merged = merge_models( - vec![ - make_row("beta", Some(1.0), 10, false), - make_row("alpha", None, 5, false), - make_row("zzz", Some(1.0), 1, false), - make_row("aaa", Some(1.0), 1, false), - ], - &[make_row("beta", Some(2.0), 7, true)], - ); - let names: Vec<&str> = merged.iter().map(|row| row.model.as_str()).collect(); - assert_eq!( - names, - ["beta", "aaa", "zzz", "alpha"], - "cost desc, then name asc for ties, unknown cost last" - ); - let beta = &merged[0]; - assert_eq!(beta.cost_usd, Some(3.0)); - assert_eq!(beta.input_tokens, 17); - assert_eq!(beta.total_tokens, 17); - assert!(beta.custom_pricing, "custom pricing flags are OR-ed"); - } - - #[test] - fn merge_daily_sums_matching_days_and_keeps_iso_day_ordering() { - let make_point = |day: &str, cost: Option, tokens: Option| SpendDailyPoint { - day: day.to_string(), - cost_usd: cost, - total_tokens: tokens, - }; - let merged = merge_daily( - vec![ - make_point("2026-08-02", Some(1.0), Some(10)), - make_point("2026-08-01", None, None), - ], - &[ - make_point("2026-08-02", Some(2.5), Some(15)), - make_point("2026-08-03", Some(4.0), None), - ], - ); - let days: Vec<&str> = merged.iter().map(|point| point.day.as_str()).collect(); - assert_eq!(days, ["2026-08-01", "2026-08-02", "2026-08-03"]); - assert_eq!(merged[1].cost_usd, Some(3.5)); - assert_eq!(merged[1].total_tokens, Some(25)); - assert_eq!(merged[0].cost_usd, None, "unknown stays unknown"); - assert_eq!(merged[0].total_tokens, None); - assert_eq!(merged[2].total_tokens, None); - } - - #[test] - fn known_subtotal_sums_known_costs_only_and_needs_known_zero_for_empty() { - let make_row = |cost: Option| SpendModelRow { - model: String::new(), - cost_usd: cost, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - total_tokens: 0, - custom_pricing: false, - }; - let mut summary = CostSummary::default(); - assert_eq!(known_subtotal(&[], &summary), None); - summary.known_zero = true; - assert_eq!(known_subtotal(&[], &summary), Some(0.0)); - summary.known_zero = false; - let mixed = [make_row(Some(1.5)), make_row(None), make_row(Some(2.25))]; - assert_eq!(known_subtotal(&mixed, &summary), Some(3.75)); - let all_unknown = [make_row(None)]; - assert_eq!(known_subtotal(&all_unknown, &summary), None); - } - - #[test] - fn coverage_for_models_counts_priced_rows_as_estimated() { - let make_row = |cost: Option| SpendModelRow { - model: String::new(), - cost_usd: cost, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - total_tokens: 0, - custom_pricing: false, - }; - let coverage = - coverage_for_models(&[make_row(Some(0.0)), make_row(None), make_row(Some(3.0))]); - assert_eq!(coverage.estimated, 2); - assert_eq!(coverage.unpriced, 1); - assert_eq!(coverage.total(), 3); - } -} +mod tests; diff --git a/rust/src/spend_contract/opencodex.rs b/rust/src/spend_contract/opencodex.rs index 8358131f25..6298889ba3 100644 --- a/rust/src/spend_contract/opencodex.rs +++ b/rust/src/spend_contract/opencodex.rs @@ -8,8 +8,8 @@ use serde_json::Value; use crate::core::CostUsagePricing; use super::{ - CostCoverageCounts, CustomPricing, ImportedSpendSource, SpendActivityCell, SpendDailyPoint, - SpendModelRow, SpendTokenMix, + CostCoverageCounts, CostProvenance, CustomPricing, ImportedSpendSource, SpendActivityCell, + SpendDailyPoint, SpendModelRow, SpendTokenMix, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -141,6 +141,9 @@ fn aggregate( let mut daily: BTreeMap = BTreeMap::new(); let mut known_cost = 0.0; let mut saw_known_cost = false; + let mut saw_vendor_provenance = false; + let mut saw_list_provenance = false; + let mut saw_metered_cost = false; // Upstream 0.55.0 #3136: resolve the dynamic pricing catalog once per // aggregate instead of re-checking its cache metadata for every usage row. let pricing_snapshot = crate::core::pricing_snapshot(); @@ -159,6 +162,11 @@ fn aggregate( add_optional(token_mix.reasoning_tokens, entry.reasoning_tokens); let cost = entry_cost(entry, custom, &pricing_snapshot); + match entry.usage_status.as_str() { + "reported" => saw_vendor_provenance = true, + "estimated" => saw_list_provenance = true, + _ => {} + } match entry.usage_status.as_str() { "reported" if cost.is_some() => coverage.priced = coverage.priced.saturating_add(1), "estimated" if cost.is_some() => { @@ -170,6 +178,9 @@ fn aggregate( if let Some(cost) = cost { known_cost += cost; saw_known_cost = true; + if entry.usage_status == "reported" { + saw_metered_cost = true; + } } let local = entry.timestamp.with_timezone(&Local); @@ -257,12 +268,18 @@ fn aggregate( )] let conversation_count = conversations.len().min(u32::MAX as usize) as u32; + let snapshot_provenance = + CostProvenance::from_source_kinds(saw_vendor_provenance, saw_list_provenance); + let provenance = + CostProvenance::for_window(snapshot_provenance, saw_known_cost, saw_metered_cost); + Some(ImportedSpendSource { source_id: "opencodex".to_string(), display_name: "OpenCodex".to_string(), request_count, conversation_count, known_cost_usd: saw_known_cost.then_some(known_cost), + provenance, token_mix, coverage, models: model_rows, @@ -485,6 +502,7 @@ fn add_optional(left: Option, right: Option) -> Option { #[cfg(test)] mod tests { + use super::super::CustomRates; use super::cache::{load_entries_with_cache, read_cache}; use super::*; use std::fs; @@ -526,6 +544,61 @@ mod tests { assert_eq!(source.token_mix.input_tokens, Some(20)); assert_eq!(source.coverage.priced, 1); assert!(source.known_cost_usd.is_some()); + assert_eq!(source.provenance, CostProvenance::VendorMetered); + } + + #[test] + fn aggregate_preserves_list_and_mixed_provenance() { + let now = DateTime::parse_from_rfc3339("2026-08-19T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut estimated = entry("openai", "gpt-5"); + estimated.request_id = "estimated".to_string(); + estimated.usage_status = "estimated".to_string(); + let list_only = aggregate(vec![estimated.clone()], now, 30, &CustomPricing::default()) + .expect("list-price source"); + assert_eq!(list_only.provenance, CostProvenance::ListPriceEstimate); + + let mut reported = entry("openai", "gpt-5"); + reported.request_id = "reported".to_string(); + let mixed = aggregate( + vec![reported, estimated], + now, + 30, + &CustomPricing::default(), + ) + .expect("mixed source"); + assert_eq!(mixed.provenance, CostProvenance::Mixed); + } + + #[test] + fn aggregate_preserves_zero_cost_authoritative_provenance() { + let now = DateTime::parse_from_rfc3339("2026-08-19T12:00:00Z") + .unwrap() + .with_timezone(&Utc); + let custom = CustomPricing { + entries: std::collections::HashMap::from([( + "openai/gpt-5".to_string(), + CustomRates { + input: Some(0.0), + output: Some(0.0), + cache_read: Some(0.0), + cache_write: Some(0.0), + }, + )]), + }; + + let reported = aggregate(vec![entry("openai", "gpt-5")], now, 30, &custom) + .expect("zero-cost vendor source"); + assert_eq!(reported.known_cost_usd, Some(0.0)); + assert_eq!(reported.provenance, CostProvenance::VendorMetered); + + let mut estimated_entry = entry("openai", "gpt-5"); + estimated_entry.usage_status = "estimated".to_string(); + let estimated = + aggregate(vec![estimated_entry], now, 30, &custom).expect("zero-cost list source"); + assert_eq!(estimated.known_cost_usd, Some(0.0)); + assert_eq!(estimated.provenance, CostProvenance::ListPriceEstimate); } fn entry(provider: &str, model: &str) -> OpenCodexEntry { diff --git a/rust/src/spend_contract/tests.rs b/rust/src/spend_contract/tests.rs new file mode 100644 index 0000000000..fb74ff09de --- /dev/null +++ b/rust/src/spend_contract/tests.rs @@ -0,0 +1,520 @@ +use super::*; + +#[test] +fn coverage_ratio_counts_estimated_as_covered() { + let coverage = CostCoverageCounts { + priced: 1, + unpriced: 1, + unmetered: 0, + estimated: 2, + }; + assert_eq!(coverage.coverage_ratio(), Some(0.75)); +} + +#[test] +fn coverage_overflow_is_unknown_instead_of_a_fake_ratio() { + let coverage = CostCoverageCounts { + priced: u32::MAX, + unpriced: 1, + unmetered: 0, + estimated: 0, + }; + assert_eq!(coverage.total(), u32::MAX); + assert_eq!(coverage.checked_total(), None); + assert_eq!(coverage.coverage_ratio(), None); + + let (merged, exact) = merge_coverage( + CostCoverageCounts { + priced: u32::MAX, + ..CostCoverageCounts::default() + }, + &CostCoverageCounts { + priced: 1, + ..CostCoverageCounts::default() + }, + ); + assert!(!exact); + assert_eq!(merged.priced, u32::MAX); +} + +#[test] +fn explicit_zero_custom_rate_is_known_free_but_missing_rate_is_unknown() { + let counts = ModelTokenCounts { + input_tokens: 1_000_000, + output_tokens: 0, + cached_tokens: 0, + reasoning_tokens: None, + }; + let free = CustomRates { + input: Some(0.0), + ..CustomRates::default() + }; + let missing = CustomRates::default(); + assert_eq!(free.cost(&counts), Some(0.0)); + assert_eq!(missing.cost(&counts), None); +} + +#[test] +fn local_spend_contract_exposes_reasoning_tokens_and_preserves_unknown() { + let known_summary = CostSummary { + reasoning_tokens: Some(7), + ..CostSummary::default() + }; + let known = build_local_spend_contract_from_summary( + "unknown-provider", + 30, + false, + false, + false, + known_summary, + ); + assert_eq!(known.token_mix.reasoning_tokens, Some(7)); + let known_json = serde_json::to_value(&known).expect("known spend contract serializes"); + assert_eq!(known_json["tokenMix"]["reasoningTokens"], 7); + + let unknown = build_local_spend_contract_from_summary( + "unknown-provider", + 30, + false, + false, + false, + CostSummary::default(), + ); + assert_eq!(unknown.token_mix.reasoning_tokens, None); + let unknown_json = serde_json::to_value(&unknown).expect("unknown spend contract serializes"); + assert!(unknown_json["tokenMix"]["reasoningTokens"].is_null()); +} + +#[test] +fn sum_optional_cost_propagates_unknown_and_rejects_non_finite() { + assert_eq!(sum_optional_cost(Some(1.5), Some(2.25)), Some(3.75)); + assert_eq!(sum_optional_cost(Some(1.5), None), Some(1.5)); + assert_eq!(sum_optional_cost(None, Some(2.0)), Some(2.0)); + assert_eq!(sum_optional_cost(None, None), None); + assert_eq!(sum_optional_cost(Some(f64::INFINITY), Some(1.0)), None); + assert_eq!(sum_optional_cost(Some(-1.0), None), None); + assert_eq!(sum_optional_cost(Some(1.0), Some(f64::NAN)), None); +} + +#[test] +fn add_optional_token_counts_guard_against_overflow() { + assert_eq!(add_optional(Some(2), Some(3)), Some(5)); + assert_eq!(add_optional(Some(2), None), Some(2)); + assert_eq!(add_optional(None, None), None); + assert_eq!(add_optional(Some(u64::MAX), Some(1)), None); +} + +#[test] +fn merge_token_mix_preserves_optional_reasoning_and_unknown_classes() { + let merged = merge_token_mix( + SpendTokenMix { + input_tokens: None, + reasoning_tokens: Some(2), + ..SpendTokenMix::default() + }, + &SpendTokenMix { + input_tokens: Some(5), + reasoning_tokens: Some(3), + ..SpendTokenMix::default() + }, + ); + assert_eq!(merged.input_tokens, Some(5)); + assert_eq!(merged.reasoning_tokens, Some(5)); + assert_eq!(merged.output_tokens, None); +} + +#[test] +fn merge_token_mix_keeps_overflow_unknown_across_later_sources() { + let overflowed = merge_token_mix( + SpendTokenMix { + input_tokens: Some(u64::MAX), + ..SpendTokenMix::default() + }, + &SpendTokenMix { + input_tokens: Some(1), + ..SpendTokenMix::default() + }, + ); + assert_eq!(overflowed.input_tokens, None); + + let merged = merge_token_mix( + overflowed, + &SpendTokenMix { + input_tokens: Some(2), + ..SpendTokenMix::default() + }, + ); + assert_eq!(merged.input_tokens, None); +} + +#[test] +fn merge_models_combines_duplicate_models_and_sorts_priced_first() { + let make_row = |model: &str, cost: Option, input: u64, custom: bool| SpendModelRow { + model: model.to_string(), + cost_usd: cost, + input_tokens: input, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: input, + custom_pricing: custom, + }; + let merged = merge_models( + vec![ + make_row("beta", Some(1.0), 10, false), + make_row("alpha", None, 5, false), + make_row("zzz", Some(1.0), 1, false), + make_row("aaa", Some(1.0), 1, false), + ], + &[make_row("beta", Some(2.0), 7, true)], + ); + let names: Vec<&str> = merged.iter().map(|row| row.model.as_str()).collect(); + assert_eq!( + names, + ["beta", "aaa", "zzz", "alpha"], + "cost desc, then name asc for ties, unknown cost last" + ); + let beta = &merged[0]; + assert_eq!(beta.cost_usd, Some(3.0)); + assert_eq!(beta.input_tokens, 17); + assert_eq!(beta.total_tokens, 17); + assert!(beta.custom_pricing, "custom pricing flags are OR-ed"); +} + +#[test] +fn merge_daily_sums_matching_days_and_keeps_iso_day_ordering() { + let make_point = |day: &str, cost: Option, tokens: Option| SpendDailyPoint { + day: day.to_string(), + cost_usd: cost, + total_tokens: tokens, + }; + let merged = merge_daily( + vec![ + make_point("2026-08-02", Some(1.0), Some(10)), + make_point("2026-08-01", None, None), + ], + &[ + make_point("2026-08-02", Some(2.5), Some(15)), + make_point("2026-08-03", Some(4.0), None), + ], + ); + let days: Vec<&str> = merged.iter().map(|point| point.day.as_str()).collect(); + assert_eq!(days, ["2026-08-01", "2026-08-02", "2026-08-03"]); + assert_eq!(merged[1].cost_usd, Some(3.5)); + assert_eq!(merged[1].total_tokens, Some(25)); + assert_eq!(merged[0].cost_usd, None, "unknown stays unknown"); + assert_eq!(merged[0].total_tokens, None); + assert_eq!(merged[2].total_tokens, None); +} + +#[test] +fn resolve_spend_preserves_merged_report_details() { + let native_models = vec![SpendModelRow { + model: "gpt-5".to_string(), + cost_usd: Some(1.0), + input_tokens: 10, + output_tokens: 2, + cache_read_tokens: 0, + total_tokens: 12, + custom_pricing: false, + }]; + let imported = ImportedSpendSource { + source_id: "fixture".to_string(), + display_name: "Fixture".to_string(), + request_count: 2, + conversation_count: 1, + known_cost_usd: Some(2.0), + provenance: CostProvenance::VendorMetered, + token_mix: SpendTokenMix { + input_tokens: Some(5), + cache_read_tokens: Some(4), + reasoning_tokens: Some(1), + ..SpendTokenMix::default() + }, + coverage: CostCoverageCounts { + priced: 2, + unpriced: 1, + unmetered: 0, + estimated: 1, + }, + models: vec![SpendModelRow { + model: "gpt-5".to_string(), + cost_usd: Some(2.0), + input_tokens: 5, + output_tokens: 0, + cache_read_tokens: 4, + total_tokens: 9, + custom_pricing: true, + }], + daily: vec![ + SpendDailyPoint { + day: "2026-08-01".to_string(), + cost_usd: Some(2.0), + total_tokens: Some(9), + }, + SpendDailyPoint { + day: "2026-08-02".to_string(), + cost_usd: None, + total_tokens: None, + }, + ], + hourly_activity: vec![SpendActivityCell { + weekday: 1, + hour: 2, + conversations: 4, + }], + }; + + let resolved = resolve_spend( + Some(1.0), + CostProvenance::ListPriceEstimate, + true, + CostCoverageCounts { + priced: 1, + unpriced: 0, + unmetered: 1, + estimated: 0, + }, + SpendTokenMix { + input_tokens: Some(10), + output_tokens: Some(2), + ..SpendTokenMix::default() + }, + native_models, + vec![SpendDailyPoint { + day: "2026-08-01".to_string(), + cost_usd: Some(1.0), + total_tokens: Some(12), + }], + vec![SpendActivityCell { + weekday: 1, + hour: 2, + conversations: 3, + }], + Some(&imported), + false, + ); + + assert_eq!(resolved.known_cost_usd, Some(3.0)); + assert_eq!(resolved.provenance, CostProvenance::Mixed); + assert_eq!(resolved.token_mix.input_tokens, Some(15)); + assert_eq!(resolved.token_mix.output_tokens, Some(2)); + assert_eq!(resolved.token_mix.cache_read_tokens, Some(4)); + assert_eq!(resolved.token_mix.reasoning_tokens, Some(1)); + assert_eq!(resolved.price_coverage.priced, 3); + assert_eq!(resolved.price_coverage.unpriced, 1); + assert_eq!(resolved.price_coverage.unmetered, 1); + assert_eq!(resolved.price_coverage.estimated, 1); + assert!(resolved.price_coverage_exact); + + let model = &resolved.models[0]; + assert_eq!(model.cost_usd, Some(3.0)); + assert_eq!(model.input_tokens, 15); + assert_eq!(model.cache_read_tokens, 4); + assert!(model.custom_pricing); + assert_eq!(resolved.daily[0].cost_usd, Some(3.0)); + assert_eq!(resolved.daily[0].total_tokens, Some(21)); + assert_eq!(resolved.daily[1].cost_usd, None); + assert_eq!(resolved.daily[1].total_tokens, None); + assert_eq!(resolved.hourly_activity[0].conversations, 7); +} + +#[test] +fn cost_provenance_for_window_matches_upstream_truth_table() { + let cases = [ + ( + CostProvenance::ListPriceEstimate, + false, + false, + CostProvenance::Unknown, + ), + ( + CostProvenance::ListPriceEstimate, + true, + false, + CostProvenance::ListPriceEstimate, + ), + ( + CostProvenance::VendorMetered, + false, + false, + CostProvenance::Unknown, + ), + ( + CostProvenance::VendorMetered, + true, + false, + CostProvenance::VendorMetered, + ), + ( + CostProvenance::VendorMetered, + false, + true, + CostProvenance::VendorMetered, + ), + (CostProvenance::Mixed, false, false, CostProvenance::Unknown), + ( + CostProvenance::Mixed, + true, + false, + CostProvenance::ListPriceEstimate, + ), + ( + CostProvenance::Mixed, + false, + true, + CostProvenance::VendorMetered, + ), + (CostProvenance::Mixed, true, true, CostProvenance::Mixed), + (CostProvenance::Unknown, true, true, CostProvenance::Unknown), + ]; + + for (snapshot, has_window_costs, includes_metered, expected) in cases { + assert_eq!( + CostProvenance::for_window(snapshot, has_window_costs, includes_metered), + expected, + "snapshot={snapshot:?}, has_window_costs={has_window_costs}, includes_metered={includes_metered}" + ); + } +} + +#[test] +fn zero_cost_authoritative_sources_use_presence_not_positive_value() { + assert_eq!( + CostProvenance::for_window(CostProvenance::ListPriceEstimate, true, false), + CostProvenance::ListPriceEstimate + ); + assert_eq!( + CostProvenance::for_window(CostProvenance::VendorMetered, true, false), + CostProvenance::VendorMetered + ); + + let resolved = resolve_spend( + Some(0.0), + CostProvenance::ListPriceEstimate, + true, + CostCoverageCounts::default(), + SpendTokenMix::default(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + false, + ); + assert_eq!(resolved.known_cost_usd, Some(0.0)); + assert_eq!(resolved.provenance, CostProvenance::ListPriceEstimate); +} + +#[test] +fn provenance_merge_keeps_unknown_conservative_and_mixes_vendor_with_list() { + assert_eq!( + merge_provenance( + CostProvenance::ListPriceEstimate, + true, + CostProvenance::VendorMetered, + true, + ), + CostProvenance::Mixed + ); + assert_eq!( + merge_provenance( + CostProvenance::ListPriceEstimate, + true, + CostProvenance::ListPriceEstimate, + true, + ), + CostProvenance::ListPriceEstimate + ); + assert_eq!( + merge_provenance( + CostProvenance::VendorMetered, + true, + CostProvenance::VendorMetered, + true, + ), + CostProvenance::VendorMetered + ); + assert_eq!( + merge_provenance( + CostProvenance::Unknown, + true, + CostProvenance::ListPriceEstimate, + true, + ), + CostProvenance::Unknown + ); + assert_eq!( + merge_provenance( + CostProvenance::Unknown, + false, + CostProvenance::ListPriceEstimate, + true, + ), + CostProvenance::ListPriceEstimate + ); +} + +#[test] +fn spend_contract_serializes_provenance_for_tauri_and_cli() { + let contract = SpendContract { + provider_id: "codex".to_string(), + history_days: 30, + known_cost_usd: Some(0.0), + known_zero: false, + provenance: CostProvenance::VendorMetered, + price_coverage: CostCoverageCounts::default(), + price_coverage_ratio: None, + history_coverage_established: true, + token_mix: SpendTokenMix::default(), + conversation_count: 0, + models: Vec::new(), + projects: Vec::new(), + conversations: Vec::new(), + daily: Vec::new(), + hourly_activity: Vec::new(), + project_source_status: None, + custom_pricing_active: false, + imports: Vec::new(), + }; + + let json = serde_json::to_value(contract).expect("spend contract serializes"); + assert_eq!(json["provenance"], "vendorMetered"); +} + +#[test] +fn known_subtotal_sums_known_costs_only_and_needs_known_zero_for_empty() { + let make_row = |cost: Option| SpendModelRow { + model: String::new(), + cost_usd: cost, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + custom_pricing: false, + }; + let mut summary = CostSummary::default(); + assert_eq!(known_subtotal(&[], &summary), None); + summary.known_zero = true; + assert_eq!(known_subtotal(&[], &summary), Some(0.0)); + summary.known_zero = false; + let mixed = [make_row(Some(1.5)), make_row(None), make_row(Some(2.25))]; + assert_eq!(known_subtotal(&mixed, &summary), Some(3.75)); + let all_unknown = [make_row(None)]; + assert_eq!(known_subtotal(&all_unknown, &summary), None); +} + +#[test] +fn coverage_for_models_counts_priced_rows_as_estimated() { + let make_row = |cost: Option| SpendModelRow { + model: String::new(), + cost_usd: cost, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + custom_pricing: false, + }; + let coverage = coverage_for_models(&[make_row(Some(0.0)), make_row(None), make_row(Some(3.0))]); + assert_eq!(coverage.estimated, 2); + assert_eq!(coverage.unpriced, 1); + assert_eq!(coverage.total(), 3); +}