diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 17ed64cca7..09a5088cba 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -25,7 +25,7 @@ const LOCAL_USAGE_TTL: Duration = Duration::from_secs(30); #[serde(rename_all = "camelCase")] pub struct DailyCostPoint { pub date: String, - pub value: f64, + pub value: Option, } /// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0 @@ -494,7 +494,7 @@ fn load_openai_dashboard_chart_data( .iter() .map(|d| DailyCostPoint { date: d.day.clone(), - value: d.total_credits_used, + value: Some(d.total_credits_used), }) .collect(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs index 8eb0e7f940..a3e3637f12 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs @@ -33,6 +33,7 @@ pub async fn get_spend_contract( history_days, include_import, settings.hide_native_codex_cost_when_open_codex_present && provider == "codex", + settings.hide_personal_info, summary, ) }) diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index f22d0a8cd8..e520f325ee 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1117,16 +1117,16 @@ fn chart_data_serde_roundtrip_preserves_fields() { cost_history: vec![ DailyCostPoint { date: "2025-01-01".into(), - value: 1.25, + value: Some(1.25), }, DailyCostPoint { date: "2025-01-02".into(), - value: 0.0, + value: Some(0.0), }, ], credits_history: vec![DailyCostPoint { date: "2025-01-01".into(), - value: 42.0, + value: Some(42.0), }], usage_breakdown: vec![DailyUsageBreakdown { day: "2025-01-01".into(), @@ -1169,7 +1169,7 @@ fn chart_data_serde_roundtrip_preserves_fields() { assert_eq!(back.provider_id, "codex"); assert_eq!(back.cost_history.len(), 2); assert_eq!(back.cost_history[0].date, "2025-01-01"); - assert_eq!(back.credits_history[0].value, 42.0); + assert_eq!(back.credits_history[0].value, Some(42.0)); assert_eq!(back.usage_breakdown[0].services.len(), 2); assert_eq!(back.usage_breakdown[0].total_credits_used, 13.5); assert_eq!(back.tokens_history[0].tokens, 123_456); diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 28c74bab5b..db2d49458a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -113,7 +113,8 @@ fn build_usage_spend_summary_cached( selected_days: u32, force_refresh: bool, ) -> Result { - let key = usage_spend_cache_key(cached, selected_days); + let settings = codexbar::settings::Settings::load(); + let key = usage_spend_cache_key(cached, selected_days, &settings); let mut guard = usage_spend_summary_cache() .lock() .map_err(|error| error.to_string())?; @@ -125,7 +126,7 @@ fn build_usage_spend_summary_cached( } // Hold the cache mutex while building: callers for the same app revision // coalesce behind this single scan instead of starting parallel rescans. - let summary = build_usage_spend_summary(cached, selected_days); + let summary = build_usage_spend_summary(cached, selected_days, &settings); *guard = Some(CachedUsageSpendSummary { key, summary: summary.clone(), @@ -133,8 +134,27 @@ fn build_usage_spend_summary_cached( Ok(summary) } -fn usage_spend_cache_key(cached: &[ProviderUsageSnapshot], selected_days: u32) -> String { - let settings = codexbar::settings::Settings::load(); +fn usage_spend_cache_key( + cached: &[ProviderUsageSnapshot], + selected_days: u32, + settings: &codexbar::settings::Settings, +) -> String { + usage_spend_cache_key_with_privacy( + cached, + selected_days, + settings.open_codex_usage_logs_enabled, + settings.hide_native_codex_cost_when_open_codex_present, + settings.hide_personal_info, + ) +} + +fn usage_spend_cache_key_with_privacy( + cached: &[ProviderUsageSnapshot], + selected_days: u32, + include_opencodex: bool, + hide_native: bool, + hide_personal_info: bool, +) -> String { let mut revisions: Vec = cached .iter() .map(|snapshot| { @@ -162,11 +182,12 @@ fn usage_spend_cache_key(cached: &[ProviderUsageSnapshot], selected_days: u32) - .collect(); revisions.sort(); format!( - "{}|{}|{}|{}|{}", + "{}|{}|{}|{}|{}|{}", chrono::Local::now().date_naive(), selected_days, - settings.open_codex_usage_logs_enabled, - settings.hide_native_codex_cost_when_open_codex_present, + include_opencodex, + hide_native, + hide_personal_info, revisions.join(";") ) } @@ -174,23 +195,11 @@ fn usage_spend_cache_key(cached: &[ProviderUsageSnapshot], selected_days: u32) - fn build_usage_spend_summary( cached: &[ProviderUsageSnapshot], selected_days: u32, + settings: &codexbar::settings::Settings, ) -> UsageSpendSummary { - let settings = codexbar::settings::Settings::load(); let include_opencodex = settings.open_codex_usage_logs_enabled; let hide_native = settings.hide_native_codex_cost_when_open_codex_present; - let codex_cache_status = - codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None); - let codex_stale = codex_cache_status.has_days && codex_cache_status.previous_report.is_some(); - let codex_stale_updated_at = codex_stale - .then(|| { - codex_cache_status - .previous_report - .as_ref() - .and_then(|report| report.updated_at.clone()) - }) - .flatten(); - // Upstream 0.55.0 #3105: independent provider baselines load in parallel. // Keep each provider's 7d/30d scans serial so they can safely share that // provider's incremental cache, while Codex and Claude run concurrently. @@ -214,11 +223,21 @@ fn build_usage_spend_summary( ) }); + let codex_stale = !codex_30_summary.history_coverage_established; + let codex_stale_updated_at = codex_stale + .then(|| { + codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) + .previous_report + .and_then(|report| report.updated_at) + }) + .flatten(); + let codex_7_contract = build_local_spend_contract_from_summary( "codex", 7, include_opencodex, hide_native, + settings.hide_personal_info, codex_7_summary.clone(), ); let codex_30_contract = build_local_spend_contract_from_summary( @@ -226,6 +245,7 @@ fn build_usage_spend_summary( 30, include_opencodex, hide_native, + settings.hide_personal_info, codex_30_summary.clone(), ); @@ -415,6 +435,7 @@ fn build_usage_spend_summary( history_days, include_opencodex, hide_native, + settings.hide_personal_info, selected_summary, ); UsageSpendSummary { rows, contract } @@ -507,3 +528,15 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { stale_updated_at: None, } } + +#[cfg(test)] +mod cache_key_tests { + use super::*; + + #[test] + fn privacy_mode_is_part_of_usage_spend_cache_identity() { + let public = usage_spend_cache_key_with_privacy(&[], 30, false, false, false); + let private = usage_spend_cache_key_with_privacy(&[], 30, false, false, true); + assert_ne!(public, private); + } +} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 0e1ed9f738..14d7fced8a 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -108,10 +108,11 @@ function LocalUsageBlock({ }) { const { t } = useLocale(); const isCodex = providerId === "codex"; - const visibleHistory = costHistory - .slice(-30) - .filter((point) => point.value > 0); - const maxCost = Math.max(...visibleHistory.map((point) => point.value), 0); + const visibleHistory = costHistory.slice(-30); + const maxCost = Math.max( + ...visibleHistory.flatMap((point) => (point.value == null ? [] : [point.value])), + 0, + ); return (
@@ -148,9 +149,10 @@ function LocalUsageBlock({ ))} @@ -443,7 +445,7 @@ export function describeCard( showPace = true, ): MenuCardPresence { const hasCostHistory = - chartData !== null && chartData.costHistory.some((point) => point.value > 0); + chartData !== null && chartData.costHistory.some((point) => point.value != null); const hasCreditsHistory = chartData !== null && chartData.creditsHistory.length > 0; const hasUsageBreakdown = diff --git a/apps/desktop-tauri/src/components/MiniBarChart.tsx b/apps/desktop-tauri/src/components/MiniBarChart.tsx index df9ca989c4..5b5f8eff9b 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.tsx @@ -31,7 +31,8 @@ export function SimpleBarChart({ ); } - const max = Math.max(...points.map((p) => p.value), 0.0001); + 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)); @@ -55,7 +56,7 @@ export function SimpleBarChart({ aria-label={label ?? t("BarChartAriaLabel")} > {visible.map((p, i) => { - const barH = Math.max(1, (p.value / max) * (height - 4)); + const barH = p.value == null ? 1 : Math.max(1, (p.value / max) * (height - 4)); const x = i * (barWidth + BAR_GAP); const y = height - barH; return ( @@ -66,11 +67,11 @@ export function SimpleBarChart({ width={barWidth} height={barH} fill={color} - opacity={p.value === 0 ? 0.25 : 0.9} + opacity={p.value == null ? 0 : p.value === 0 ? 0.25 : 0.9} rx={1} > - {p.date}: {fmt(p.value)} + {p.value == null ? p.date : `${p.date}: ${fmt(p.value)}`} ); diff --git a/apps/desktop-tauri/src/components/charts/BarChart.test.tsx b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx new file mode 100644 index 0000000000..95403690e9 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/BarChart.test.tsx @@ -0,0 +1,25 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { BarChart } from "./BarChart"; + +describe("BarChart calendar slots", () => { + it("keeps unknown and known-zero slots distinct", () => { + const { container } = render( + , + ); + const bars = container.querySelectorAll(".chart__bar"); + expect(bars).toHaveLength(3); + expect(bars[0]).toHaveAttribute("opacity", "0"); + expect(bars[1]).toHaveAttribute("opacity", "0.25"); + expect(container).toHaveTextContent("unknown"); + expect(container).toHaveTextContent("zero: 0.00"); + }); +}); \ No newline at end of file diff --git a/apps/desktop-tauri/src/components/charts/BarChart.tsx b/apps/desktop-tauri/src/components/charts/BarChart.tsx index 0a0cc4c744..4813543220 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.tsx @@ -15,7 +15,7 @@ import { useChartAnimation } from "./useChartAnimation"; export interface BarChartPoint { label: string; - value: number; + value: number | null; } export interface BarChartProps { @@ -59,7 +59,7 @@ export function BarChart({ let p = -1; for (let i = 0; i < data.length; i++) { const v = data[i].value; - if (v > m) { + if (v != null && v > m) { m = v; p = i; } @@ -101,7 +101,7 @@ export function BarChart({ aria-label={ariaLabel} > {data.map((p, i) => { - const base = p.value === 0 ? 1 : Math.max(3, (p.value / max) * plotHeight); + const base = p.value == null ? 1 : p.value === 0 ? 1 : Math.max(3, (p.value / max) * plotHeight); const eased = anim.barProgress(i); const barH = base * eased; const x = i * (barWidth + BAR_GAP); @@ -119,14 +119,14 @@ export function BarChart({ width={barWidth} height={bodyH} fill={color} - opacity={p.value === 0 ? 0.25 : isHovered ? 1 : 0.9} + opacity={p.value == null ? 0 : p.value === 0 ? 0.25 : isHovered ? 1 : 0.9} rx={1} className="chart__bar" - onMouseMove={(e) => onMove(e, i)} + onMouseMove={p.value == null ? undefined : (e) => onMove(e, i)} onMouseLeave={onLeave} > - {p.label}: {fmt(p.value)} + {p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`} {isPeak && ( @@ -157,7 +157,7 @@ export function BarChart({ role="tooltip" > {data[hover.i].label} - {fmt(data[hover.i].value)} + {fmt(data[hover.i].value ?? 0)} )} diff --git a/apps/desktop-tauri/src/components/charts/LineChart.test.tsx b/apps/desktop-tauri/src/components/charts/LineChart.test.tsx new file mode 100644 index 0000000000..54fdac4980 --- /dev/null +++ b/apps/desktop-tauri/src/components/charts/LineChart.test.tsx @@ -0,0 +1,26 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { LineChart } from "./LineChart"; + +describe("LineChart unknown values", () => { + it("renders gaps for unknown values while preserving known zero", () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll(".chart__point")).toHaveLength(4); + expect(container.querySelectorAll(".chart__line")).toHaveLength(2); + expect(container).toHaveTextContent("2026-09-02: 0.00"); + expect(container).not.toHaveTextContent("2026-09-03: 0.00"); + }); +}); \ No newline at end of file diff --git a/apps/desktop-tauri/src/components/charts/LineChart.tsx b/apps/desktop-tauri/src/components/charts/LineChart.tsx index 24ccb5cb5f..a8a1a1bda2 100644 --- a/apps/desktop-tauri/src/components/charts/LineChart.tsx +++ b/apps/desktop-tauri/src/components/charts/LineChart.tsx @@ -12,7 +12,7 @@ import { useChartAnimation } from "./useChartAnimation"; export interface LineChartPoint { label: string; - value: number; + value: number | null; } export interface LineChartProps { @@ -58,9 +58,10 @@ export function LineChart({ ); } - const values = data.map((p) => p.value); - const max = Math.max(...values, 0.0001); - const min = Math.min(...values, 0); + const values = data.flatMap((p) => (p.value == null ? [] : [p.value])); + const hasKnownValues = values.length > 0; + const max = hasKnownValues ? Math.max(...values, 0.0001) : 0.0001; + const min = hasKnownValues ? Math.min(...values, 0) : 0; const range = Math.max(max - min, 0.0001); const plotHeight = Math.max(1, height - 4); @@ -74,26 +75,29 @@ export function LineChart({ const step = data.length > 1 ? usableWidth / (data.length - 1) : 0; const coords = data.map((p, i) => { const x = pad + i * step; + if (p.value == null) return null; const finalY = pad + plotHeight - ((p.value - min) / range) * plotHeight; const t = anim.barProgress(i); const y = baselineY + (finalY - baselineY) * t; - return { x, y, finalY }; + return { x, y }; }); - if (coords.length === 1) { - coords.push({ x: pad + usableWidth, y: coords[0].y, finalY: coords[0].finalY }); + const segments: Array> = []; + let segment: Array<{ x: number; y: number }> = []; + for (const coord of coords) { + if (coord) { + segment.push(coord); + } else if (segment.length > 0) { + segments.push(segment); + segment = []; + } } + if (segment.length > 0) segments.push(segment); - const polyline = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" "); - - const areaPath = area - ? [ - `M ${coords[0].x.toFixed(1)} ${baselineY.toFixed(1)}`, - ...coords.map((c) => `L ${c.x.toFixed(1)} ${c.y.toFixed(1)}`), - `L ${coords[coords.length - 1].x.toFixed(1)} ${baselineY.toFixed(1)}`, - "Z", - ].join(" ") - : null; + if (data.length === 1 && segments[0]?.length === 1) { + const point = segments[0][0]; + segments[0].push({ x: pad + usableWidth, y: point.y }); + } const onPointMove = (e: React.MouseEvent, i: number) => { const host = containerRef.current; @@ -102,6 +106,7 @@ export function LineChart({ setHover({ i, x: e.clientX - rect.left, y: e.clientY - rect.top }); }; const onLeave = () => setHover(null); + const hoveredPoint = hover ? data[hover.i] : null; return (
@@ -113,49 +118,76 @@ export function LineChart({ role="img" aria-label={ariaLabel} > - {areaPath && ( - + {area && + segments.map((points, i) => { + if (points.length < 2) return null; + const path = [ + `M ${points[0].x.toFixed(1)} ${baselineY.toFixed(1)}`, + ...points.map((point) => `L ${point.x.toFixed(1)} ${point.y.toFixed(1)}`), + `L ${points[points.length - 1].x.toFixed(1)} ${baselineY.toFixed(1)}`, + "Z", + ].join(" "); + return ( + + ); + })} + {segments.map((points, i) => + points.length < 2 ? null : ( + `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" ")} + fill="none" + stroke={color} + strokeWidth={1.5} + strokeLinejoin="round" + strokeLinecap="round" + opacity={0.95} + className="chart__line" + /> + ), )} - - {data.map((p, i) => ( - onPointMove(e, i)} - onMouseLeave={onLeave} - > - - {p.label}: {fmt(p.value)} - - - ))} + {data.map((p, i) => { + const coord = coords[i]; + if (p.value == null || !coord) return null; + return ( + onPointMove(e, i)} + onMouseLeave={onLeave} + > + + {p.label}: {fmt(p.value)} + + + ); + })}
{data[0].label.slice(-5)} - {fmt(max)} + + {hasKnownValues ? fmt(max) : ""} + {data[data.length - 1].label.slice(-5)}
- {hover && !anim.running && ( + {hover && hoveredPoint?.value != null && !anim.running && (
- {data[hover.i].label} - {fmt(data[hover.i].value)} + {hoveredPoint.label} + {fmt(hoveredPoint.value)}
)}
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CreditsHistoryChart.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CreditsHistoryChart.test.tsx new file mode 100644 index 0000000000..0a0b2e3660 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CreditsHistoryChart.test.tsx @@ -0,0 +1,26 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CreditsHistoryChart } from "./CreditsHistoryChart"; + +describe("CreditsHistoryChart", () => { + it("preserves unknown history values instead of fabricating zero", () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll(".chart__point")).toHaveLength(2); + expect(container).toHaveTextContent("2026-09-03: 0.0"); + expect(container).not.toHaveTextContent("2026-09-02: 0.0"); + }); +}); \ No newline at end of file diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 0ffa3196bc..a668392a23 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -723,7 +723,7 @@ export interface AppInfoBridge { export interface DailyCostPoint { date: string; - value: number; + value: number | null; } /** Exact local token totals per day (upstream 0.50.0 #2930). */ diff --git a/docs/CLI.md b/docs/CLI.md index 182651cb25..6e0e15ea59 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -64,7 +64,9 @@ codexbar cost codexbar cost -p codex -f json --pretty ``` -Claude/Codex costs come from local session logs. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. +Claude/Codex costs come from local session logs. Antigravity exposes local **token history only** through `cost`; dollar cost remains unknown rather than becoming a false `$0`. Other providers may differ; do not assume upstream Cursor dashboard cost behavior unless implemented in this tree. + +Codex local-history scans use a 60-second scanner-side debounce for ordinary disk-cache reads. This is separate from the desktop provider refresh setting. With Adaptive refresh off, **Manual** (`refresh_interval_secs = 0`) disables the recurring desktop refresh timer, but it does not forbid startup/stale-aware reads, explicit refreshes, or pending Codex catch-up scans. Low Power Mode floors recurring automatic refreshes to 30 minutes; explicit/manual work remains immediate. ### Guard diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7053d48beb..206969d599 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -65,6 +65,16 @@ Desktop tab id: `usageSpend`. The desktop and Overview consume one shared spend Custom pricing overlays are exact-match overrides used only where the local spend contract has matching provider/model token evidence. Explicit zero rates mean free; omitted rate fields stay unknown. The Usage & Spend surface keeps provenance/coverage visible, preserves cost-only model rows when token coverage is partial, and can Copy JSON or save the same JSON contract through the native file picker. +### OpenCode, Codex quota, and local cost boundaries + +OpenCode-held OpenAI/Codex OAuth can be reused for **remote Codex account quota** only when the Codex provider's `External OAuth sources` setting is explicitly enabled. Native Codex credentials still take precedence, an explicit `CODEX_HOME` stays isolated, and external credentials remain read-only. This does **not** import ordinary OpenCode sessions into Codex token or spend totals. OpenCode Go's local SQLite reader remains scoped to its own `opencode-go` assistant records; OpenAI API-platform usage is a separate provider. + +### z.ai Coding Plan quotas + +z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shortest known Coding Plan window becomes primary and the longest becomes secondary; `TIME_LIMIT` is the separate MCP lane. When absolute usage/remaining counts are available they determine the used percentage, otherwise the provider percentage is used, always clamped to 0–100%. This behavior is shared by the tray, provider detail, CLI, and other Windows surfaces. + +Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces. + ## Upstream doc warning Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows: diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index e4facef949..2d3e201854 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -13,7 +13,7 @@ use crate::spend_contract::build_local_spend_contract_from_summary; /// Arguments for the cost command #[derive(Args, Debug, Default)] pub struct CostArgs { - /// Provider to query (codex, claude, cursor, gemini, copilot, all, both) + /// Provider to query (codex, claude, antigravity, cursor, gemini, copilot, all, both) #[arg(short, long)] pub provider: Option, @@ -102,6 +102,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { display_name: provider.display_name().to_string(), summary, supported: true, + token_history: None, }); } ProviderId::Claude => { @@ -111,6 +112,18 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { display_name: provider.display_name().to_string(), summary, supported: true, + token_history: None, + }); + } + ProviderId::Antigravity => { + results.push(CostResult { + provider: provider.cli_name().to_string(), + display_name: provider.display_name().to_string(), + summary: CostSummary::default(), + supported: true, + token_history: Some(crate::providers::antigravity::local_sessions::summarize( + args.days, + )), }); } _ => { @@ -120,6 +133,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { display_name: provider.display_name().to_string(), summary: CostSummary::default(), supported: false, + token_history: None, }); } } @@ -143,21 +157,26 @@ struct CostResult { display_name: String, summary: CostSummary, supported: bool, + token_history: Option, } /// Print text output fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_by: CostGroupBy) { for (i, result) in results.iter().enumerate() { + let title = if result.token_history.is_some() { + format!("{} Token History (last {} days)", result.display_name, days) + } else { + format!("{} Cost (last {} days)", result.display_name, days) + }; if use_color { - println!( - "\x1b[1m{} Cost (last {} days)\x1b[0m", - result.display_name, days - ); + println!("\x1b[1m{title}\x1b[0m"); } else { - println!("{} Cost (last {} days)", result.display_name, days); + println!("{title}"); } - if group_by == CostGroupBy::Session && result.provider == "codex" { + if let Some(history) = result.token_history { + print_local_token_history(history, days); + } else if group_by == CostGroupBy::Session && result.provider == "codex" { print_codex_session_output(result, days); } else if group_by == CostGroupBy::Session { println!(" Session grouping is only available for Codex local conversations"); @@ -241,6 +260,23 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } } +fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySummary, days: u32) { + use crate::spend_contract::LocalHistoryCoverage; + match history.coverage { + LocalHistoryCoverage::Complete if history.total_tokens == 0 => { + println!(" No token usage in the last {days} days (scan complete)"); + } + LocalHistoryCoverage::Complete => { + println!(" Tokens: {} total", format_number(history.total_tokens)); + println!(" Sessions: {}", history.session_count); + } + LocalHistoryCoverage::Partial | LocalHistoryCoverage::Unavailable => { + println!(" Local token history is unavailable or incomplete"); + } + } + println!(" Local token history; dollar costs unavailable"); +} + fn print_codex_session_output(result: &CostResult, days: u32) { let index = crate::codex_workspaces::CodexWorkspacesIndex::new(days); let snapshot = match index.load_snapshot(false, |_| {}) { @@ -311,6 +347,9 @@ fn build_json_payloads(results: &[CostResult], days: u32) -> Vec Vec HashMap { cost_scanner::get_daily_cost_history(provider, 30) .into_iter() .find(|(day, _)| day == &today) - .map(|(_, cost)| cost) + .and_then(|(_, cost)| cost) }; let mut costs = HashMap::new(); costs.insert( diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 3df77b31d0..c3ffa29f83 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -65,6 +65,15 @@ pub async fn cost_response(provider: Option<&str>) -> String { let scanner = CostScanner::new(30).with_options(CostScanOptions::app_driven()); let mut results = Vec::new(); for provider_id in selection.as_list() { + if provider_id == ProviderId::Antigravity { + let history = crate::providers::antigravity::local_sessions::summarize(30); + results.push(crate::spend_contract::local_token_history_json( + "antigravity", + history, + 30, + )); + continue; + } let (supported, summary) = match provider_id { ProviderId::Codex => (true, scanner.scan_codex()), ProviderId::Claude => (true, scanner.scan_claude()), @@ -107,7 +116,7 @@ pub async fn cost_response(provider: Option<&str>) -> String { } /// Dashboard-charts shape for one provider's daily spend: [{date, totalCost}]. -fn daily_json(daily: Vec<(String, f64)>) -> serde_json::Value { +fn daily_json(daily: Vec<(String, Option)>) -> serde_json::Value { serde_json::Value::Array( daily .into_iter() @@ -123,20 +132,51 @@ mod tests { #[test] fn daily_array_shape_matches_dashboard_charts_contract() { let daily = daily_json(vec![ - ("2026-08-07".to_string(), 0.0), - ("2026-08-08".to_string(), 4.25), + ("2026-08-07".to_string(), Some(0.0)), + ("2026-08-08".to_string(), Some(4.25)), + ("2026-08-09".to_string(), None), ]); let rows = daily.as_array().unwrap(); assert_eq!(rows[0]["date"], "2026-08-07"); assert_eq!(rows[1]["totalCost"], 4.25); assert_eq!(rows[0]["totalCost"], 0.0); + assert!(rows[2]["totalCost"].is_null()); } + #[test] + fn antigravity_cost_payload_is_token_only_and_preserves_partial_unknown() { + use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; + let complete = crate::spend_contract::local_token_history_json( + "antigravity", + LocalTokenHistorySummary { + total_tokens: 42, + session_count: 1, + coverage: LocalHistoryCoverage::Complete, + }, + 30, + ); + assert!(complete["cost"]["total_usd"].is_null()); + assert_eq!(complete["tokens"]["total"], 42); + assert_eq!(complete["historyCoverage"], "complete"); + + let partial = crate::spend_contract::local_token_history_json( + "antigravity", + LocalTokenHistorySummary { + total_tokens: 42, + session_count: 1, + coverage: LocalHistoryCoverage::Partial, + }, + 30, + ); + assert!(partial["tokens"]["total"].is_null()); + assert_eq!(partial["historyCoverage"], "partial"); + } #[test] fn daily_rows_use_upstream_total_cost_key_only() { let daily = daily_json(vec![ - ("2026-08-07".to_string(), 0.0), - ("2026-08-08".to_string(), 4.25), + ("2026-08-07".to_string(), Some(0.0)), + ("2026-08-08".to_string(), Some(4.25)), + ("2026-08-09".to_string(), None), ]); let serialized = daily.to_string(); assert!( @@ -157,7 +197,7 @@ mod tests { #[test] fn daily_zero_values_are_preserved_not_filtered() { - let daily = daily_json(vec![("2026-08-07".to_string(), 0.0)]); + let daily = daily_json(vec![("2026-08-07".to_string(), Some(0.0))]); let rows = daily.as_array().unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0]["totalCost"], 0.0); diff --git a/rust/src/core/claude_routed_pricing.rs b/rust/src/core/claude_routed_pricing.rs index 4f120fed0c..8d5a35347a 100644 --- a/rust/src/core/claude_routed_pricing.rs +++ b/rust/src/core/claude_routed_pricing.rs @@ -41,7 +41,11 @@ pub fn models_dev_target(model: &str, normalized: String) -> Option<(&'static st .any(|prefix| lower.starts_with(prefix)) { "google" - } else if lower == "kimi-for-coding" || lower == "k3" || lower.starts_with("k3-") { + } else if lower == "kimi-for-coding" + || lower == "k3" + || lower == "k3[1m]" + || lower.starts_with("k3-") + { "kimi-for-coding" } else if lower.starts_with("kimi-") || lower.starts_with("moonshot-") { "moonshot" @@ -56,6 +60,17 @@ pub fn models_dev_target(model: &str, normalized: String) -> Option<(&'static st Some((provider, normalized)) } +fn models_dev_targets(model: &str, normalized: String) -> Vec<(&'static str, String)> { + let Some(primary) = models_dev_target(model, normalized) else { + return Vec::new(); + }; + let mut targets = vec![primary.clone()]; + if primary.0 == "kimi-for-coding" && primary.1.eq_ignore_ascii_case("k3[1m]") { + targets.push(("kimi-for-coding", "k3".to_string())); + } + targets +} + pub fn cost_usd( model: &str, normalized: String, @@ -64,8 +79,9 @@ pub fn cost_usd( cache_write: i32, output: i32, ) -> Option { - let (provider, lookup_model) = models_dev_target(model, normalized)?; - let pricing = models_dev_pricing::lookup(provider, &lookup_model)?; + let pricing = models_dev_targets(model, normalized) + .into_iter() + .find_map(|(provider, lookup_model)| models_dev_pricing::lookup(provider, &lookup_model))?; let input = input.max(0); let cache_read = cache_read.max(0); let cache_write = cache_write.max(0); @@ -118,6 +134,38 @@ pub fn cost_usd( } pub fn input_cost_per_token(model: &str, normalized: String) -> Option { - let (provider, lookup_model) = models_dev_target(model, normalized)?; - models_dev_pricing::lookup(provider, &lookup_model).map(|pricing| pricing.input_cost_per_token) + models_dev_targets(model, normalized) + .into_iter() + .find_map(|(provider, lookup_model)| models_dev_pricing::lookup(provider, &lookup_model)) + .map(|pricing| pricing.input_cost_per_token) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kimi_context_alias_falls_back_only_inside_kimi_vendor() { + assert_eq!( + models_dev_targets("k3[1m]", "k3[1m]".to_string()), + vec![ + ("kimi-for-coding", "k3[1m]".to_string()), + ("kimi-for-coding", "k3".to_string()), + ] + ); + assert_eq!( + models_dev_targets( + "kimi-for-coding/k3[1m]", + "kimi-for-coding/k3[1m]".to_string() + ), + vec![ + ("kimi-for-coding", "k3[1m]".to_string()), + ("kimi-for-coding", "k3".to_string()), + ] + ); + assert_eq!( + models_dev_targets("moonshot/k3[1m]", "moonshot/k3[1m]".to_string()), + vec![("moonshot", "k3[1m]".to_string())] + ); + } } diff --git a/rust/src/core/jsonl_scanner.rs b/rust/src/core/jsonl_scanner.rs index 56032b8d4d..77be5ee62e 100755 --- a/rust/src/core/jsonl_scanner.rs +++ b/rust/src/core/jsonl_scanner.rs @@ -133,10 +133,10 @@ pub struct CostUsageCache { pub scan_since_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub scan_until_key: Option, - /// Last validated cost report, kept so spend surfaces can keep showing - /// totals while a (re)scan catches up after the cache was trimmed or the - /// debounce window expired (upstream 0.48.0 #2628). `None` once a scan - /// completes for the current window. + /// Last validated cost report retained when the persisted cache needs future + /// catch-up after trimming or expiry. A completed in-memory scan may still + /// leave this populated when persistence-budget pruning follows; current + /// publication completeness is carried separately on `CostSummary`. #[serde(default, skip_serializing_if = "Option::is_none")] pub previous_report: Option, } @@ -1189,7 +1189,14 @@ impl JsonlScanner { } } - let sessions_count = i32::try_from(cache.files.len()).unwrap_or(i32::MAX); + let sessions_count = i32::try_from( + cache + .files + .values() + .filter(|usage| !usage.days.is_empty()) + .count(), + ) + .unwrap_or(i32::MAX); CachedCostReport { total_cost_usd, input_tokens, @@ -1865,12 +1872,26 @@ line2 CostUsageFileUsage { mtime_unix_ms: 0, size: 100, - days: HashMap::new(), + 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])]), diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index bbb3921c2f..4ba65aa86e 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -398,9 +398,9 @@ impl CostScanner { { 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 (previous_report set - // means entries were trimmed for budget → re-scan may be needed). - summary.history_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; @@ -429,6 +429,8 @@ impl CostScanner { &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 = @@ -465,14 +467,6 @@ impl CostScanner { JsonlScanner::save_cache(ProviderId::Codex, &mut cache, cache_root); } - // A16 (upstream 0.48.0): after a completed scan, coverage IS established - // unless cache pruning during save marked a catch-up pending. - summary.history_coverage_established = cache.previous_report.is_none(); - // 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; - // 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. @@ -487,6 +481,17 @@ impl CostScanner { ); } + // 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) } @@ -954,7 +959,7 @@ fn add_claude_record_to_summary(summary: &mut CostSummary, record: &ClaudeUsageR /// own timestamp in the local timezone. Records outside the initialized /// date range (or without a timestamp) are ignored. fn add_claude_record_to_daily_costs( - daily_costs: &mut HashMap, + daily_costs: &mut HashMap>, record: &ClaudeUsageRecord, ) { let Some(timestamp) = record.timestamp else { @@ -966,7 +971,7 @@ fn add_claude_record_to_daily_costs( .format("%Y-%m-%d") .to_string(); if let Some(cost) = daily_costs.get_mut(&date_str) { - *cost += record.cost; + *cost = Some(cost.unwrap_or(0.0) + record.cost); } } @@ -988,24 +993,42 @@ pub fn has_cost_usage_sources() -> bool { } /// Get daily cost history for the last N days -/// Returns Vec of (date_string, cost_usd) sorted by date -pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> { +/// Returns calendar-preserving daily costs sorted by date. `None` means the day +/// is unscanned or contains unpriced Codex usage; `Some(0)` is a known zero. +pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option)> { let scanner = CostScanner::new(days); let today = Local::now().date_naive(); - let mut daily_costs: HashMap = HashMap::new(); + let mut daily_costs: HashMap> = HashMap::new(); // Initialize all days with 0 for days_ago in 0..days { let date = today - Duration::days(days_ago as i64); let date_str = date.format("%Y-%m-%d").to_string(); - daily_costs.insert(date_str, 0.0); + daily_costs.insert(date_str, (provider != "codex").then_some(0.0)); } match provider { "codex" => { - // Warm/refresh the disk cache (honors debounce), then price from packed days. - let _ = scanner.scan_codex(); + // 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() { + for (day_key, slot) in &mut daily_costs { + if cache + .scan_since_key + .as_deref() + .is_some_and(|since| day_key.as_str() >= since) + && cache + .scan_until_key + .as_deref() + .is_some_and(|until| day_key.as_str() <= until) + { + *slot = Some(0.0); + } + } + } for (day_key, models) in &cache.days { let Some(slot) = daily_costs.get_mut(day_key) else { continue; @@ -1018,7 +1041,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> { one_day.insert(day_key.clone(), models.clone()); let mut scratch = CostSummary::default(); let (cost, _) = add_codex_days_map_to_summary(&mut scratch, &one_day, &day_range); - *slot = cost; + *slot = (!scratch.model_pricing_completeness.is_partial()).then_some(cost); } } "claude" => { @@ -1041,7 +1064,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> { // Rows are grouped by local calendar day to match Codex/Claude keying. for (day_key, cost) in opencodego_local::daily_cost_series(Utc::now(), days) { if let Some(slot) = daily_costs.get_mut(&day_key) { - *slot += cost; + *slot = Some(slot.unwrap_or(0.0) + cost); } } } @@ -1049,7 +1072,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> { } // Convert to sorted vector - let mut result: Vec<(String, f64)> = daily_costs.into_iter().collect(); + let mut result: Vec<(String, Option)> = daily_costs.into_iter().collect(); result.sort_by(|a, b| a.0.cmp(&b.0)); result } @@ -1434,8 +1457,8 @@ mod tests { .to_string() }; let mut daily_costs = HashMap::new(); - daily_costs.insert(day_key(&day_one), 0.0); - daily_costs.insert(day_key(&day_two), 0.0); + 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(); @@ -1445,8 +1468,8 @@ mod tests { }); } - let day_one_cost = daily_costs[&day_key(&day_one)]; - let day_two_cost = daily_costs[&day_key(&day_two)]; + 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). @@ -1550,6 +1573,51 @@ mod tests { 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(); diff --git a/rust/src/providers/antigravity/local_sessions.rs b/rust/src/providers/antigravity/local_sessions.rs index 650da25b86..26d906a216 100644 --- a/rust/src/providers/antigravity/local_sessions.rs +++ b/rust/src/providers/antigravity/local_sessions.rs @@ -11,20 +11,8 @@ const MAX_SESSION_FILE_BYTES: usize = 32 * 1024 * 1024; const MAX_SESSION_FILE_BYTES_U64: u64 = 32 * 1024 * 1024; const MAX_JSONL_LINE_BYTES: usize = 1024 * 1024; -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum LocalHistoryCoverage { - Complete, - Partial, - #[default] - Unavailable, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct LocalSessionSummary { - pub total_tokens: u64, - pub session_count: usize, - pub coverage: LocalHistoryCoverage, -} +pub use crate::spend_contract::LocalHistoryCoverage; +pub type LocalSessionSummary = crate::spend_contract::LocalTokenHistorySummary; #[derive(Debug, Clone, PartialEq, Eq)] struct ScanContext { diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 8a86f52de2..d36c545d4e 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -284,6 +284,7 @@ impl CodexApi { .ok() .and_then(|metadata| metadata.modified().ok()); if let Some(cached) = Self::cached_credentials(&auth_path, modified) { + Self::enforce_external_oauth_gate(&cached)?; return Ok(cached); } diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index 8ef0b4924b..f065ea79c8 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -93,6 +93,69 @@ 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" + ); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ResetCreditEvidence { None, @@ -128,6 +191,11 @@ pub(super) fn load(scope: &str) -> AccountState { pub(super) fn save(scope: &str, state: &AccountState) { let Some(path) = state_path() else { + log_reset_diagnostic( + "candidatePersistence", + "skipped", + ResetDiagnosticReason::StoreUnavailable, + ); return; }; let mut file = crate::secure_file::read_string(&path) @@ -140,13 +208,28 @@ pub(super) fn save(scope: &str, state: &AccountState) { }); file.accounts.insert(scope.to_string(), state.clone()); let Some(parent) = path.parent() else { + log_reset_diagnostic( + "candidatePersistence", + "skipped", + ResetDiagnosticReason::StoreUnavailable, + ); return; }; if std::fs::create_dir_all(parent).is_err() { + log_reset_diagnostic( + "candidatePersistence", + "skipped", + ResetDiagnosticReason::StoreUnavailable, + ); return; } if let Ok(raw) = serde_json::to_string_pretty(&file) { let _written = crate::secure_file::write_string(&path, &raw); + log_reset_diagnostic( + "candidatePersistence", + "requested", + ResetDiagnosticReason::StoreRequested, + ); } } @@ -359,32 +442,88 @@ fn maybe_store_delayed_candidate( exact_oauth: bool, observed_at: DateTime, ) { - if !exact_oauth || !plans_match(state.plan.as_deref(), initial, confirmation) { + if !exact_oauth { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::SourceNotExactOAuth, + ); + return; + } + if !plans_match(state.plan.as_deref(), initial, confirmation) { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::PlanMismatch, + ); return; } let Some(previous_weekly) = state.published_weekly.as_ref() else { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::MissingPreviousSnapshot, + ); return; }; let Some(initial_weekly) = weekly(initial) else { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::MissingWeeklyWindow, + ); return; }; let Some(confirmation_weekly) = weekly(confirmation) else { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::MissingWeeklyWindow, + ); return; }; let Some(previous_inventory) = state.credit_inventory.as_ref() else { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::MissingCreditInventory, + ); return; }; let Some(confirmation_inventory) = confirmation_inventory else { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::MissingCreditInventory, + ); return; }; if previous_inventory.available_count == 0 || previous_inventory != confirmation_inventory { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::ChangedCreditInventory, + ); return; } if !supported_delayed_boundary(previous_weekly, initial_weekly) || !supported_delayed_boundary(previous_weekly, confirmation_weekly) - || boundary_distance_seconds(initial_weekly, confirmation_weekly).abs() - >= RESET_TOLERANCE_SECONDS { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::UnsupportedResetBoundary, + ); + return; + } + if boundary_distance_seconds(initial_weekly, confirmation_weekly).abs() + >= RESET_TOLERANCE_SECONDS + { + log_reset_diagnostic( + "candidateCreation", + "rejected", + ResetDiagnosticReason::InconsistentResetBoundary, + ); return; } state.candidate = Some(DelayedCandidate { @@ -396,6 +535,11 @@ fn maybe_store_delayed_candidate( plan: confirmation.login_method.clone(), inventory: confirmation_inventory.clone(), }); + log_reset_diagnostic( + "candidateCreation", + "created", + ResetDiagnosticReason::CandidateCreated, + ); } fn delayed_candidate_decision( @@ -409,36 +553,126 @@ fn delayed_candidate_decision( let age = observed_at .signed_duration_since(candidate.created_at) .num_seconds(); - if candidate.evidence_version != EVIDENCE_VERSION - || !(0..=CANDIDATE_MAXIMUM_AGE_SECONDS).contains(&age) - { + if candidate.evidence_version != EVIDENCE_VERSION { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::EvidenceVersionMismatch, + ); return DelayedDecision::Discard; } - if !exact_oauth || !plans_match(state.plan.as_deref(), current, current) { + if age < 0 { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::FutureCandidate, + ); + return DelayedDecision::Discard; + } + if age > CANDIDATE_MAXIMUM_AGE_SECONDS { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::ExpiredCandidate, + ); + return DelayedDecision::Discard; + } + if !exact_oauth { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::SourceNotExactOAuth, + ); + 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", + "discard", + ResetDiagnosticReason::MissingPreviousSnapshot, + ); return DelayedDecision::Discard; }; let Some(current_weekly) = weekly(current) else { - // Later v0.56.2 explicitly protects candidate evidence through credits-only - // refreshes. Keeping it here makes that follow-up an invariant, not a fork. + log_reset_diagnostic( + "delayedCandidate", + "retain", + ResetDiagnosticReason::MissingWeeklyWindow, + ); return DelayedDecision::Retain; }; if previous_weekly.used_percent <= RESET_THRESHOLD || current_weekly.used_percent > RESET_THRESHOLD - || current.updated_at <= candidate.snapshot_updated_at - || !is_valid_boundary(current_weekly, current.updated_at) - || boundary_distance_seconds(&candidate.weekly, current_weekly).abs() - >= RESET_TOLERANCE_SECONDS - || !supported_delayed_boundary(previous_weekly, current_weekly) - || current_inventory != Some(&candidate.inventory) { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::ResetThresholdMismatch, + ); + return DelayedDecision::Discard; + } + if current.updated_at <= candidate.snapshot_updated_at { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::StaleObservation, + ); + return DelayedDecision::Discard; + } + if !is_valid_boundary(current_weekly, current.updated_at) { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::InvalidResetBoundary, + ); + return DelayedDecision::Discard; + } + if boundary_distance_seconds(&candidate.weekly, current_weekly).abs() >= RESET_TOLERANCE_SECONDS + { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::InconsistentResetBoundary, + ); + return DelayedDecision::Discard; + } + if !supported_delayed_boundary(previous_weekly, current_weekly) { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::UnsupportedResetBoundary, + ); + return DelayedDecision::Discard; + } + if current_inventory != Some(&candidate.inventory) { + log_reset_diagnostic( + "delayedCandidate", + "discard", + ResetDiagnosticReason::ChangedCreditInventory, + ); return DelayedDecision::Discard; } if age >= CANDIDATE_MINIMUM_AGE_SECONDS { + log_reset_diagnostic( + "delayedCandidate", + "publish", + ResetDiagnosticReason::ConfirmedObservation, + ); DelayedDecision::Publish } else { + log_reset_diagnostic( + "delayedCandidate", + "retain", + ResetDiagnosticReason::MinimumDelay, + ); DelayedDecision::Retain } } @@ -537,6 +771,33 @@ 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() } diff --git a/rust/src/spend_contract.rs b/rust/src/spend_contract.rs index a79719323b..038cc147aa 100644 --- a/rust/src/spend_contract.rs +++ b/rust/src/spend_contract.rs @@ -24,6 +24,45 @@ pub enum CostProvenance { Unknown, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalHistoryCoverage { + Complete, + Partial, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LocalTokenHistorySummary { + pub total_tokens: u64, + pub session_count: usize, + pub coverage: LocalHistoryCoverage, +} + +pub fn local_token_history_json( + provider: &str, + history: LocalTokenHistorySummary, + days: u32, +) -> serde_json::Value { + let complete = history.coverage == LocalHistoryCoverage::Complete; + serde_json::json!({ + "provider": provider, + "supported": true, + "days_scanned": days, + "cost": {"total_usd": serde_json::Value::Null, "currency": serde_json::Value::Null}, + "daily": [], + "tokens": {"total": complete.then_some(history.total_tokens)}, + "sessions_count": complete.then_some(history.session_count), + "historyCoverage": match history.coverage { + LocalHistoryCoverage::Complete => "complete", + LocalHistoryCoverage::Partial => "partial", + LocalHistoryCoverage::Unavailable => "unavailable", + }, + "knownZero": complete && history.total_tokens == 0, + "note": "Local token history; dollar costs unavailable" + }) +} #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CostCoverageCounts { @@ -253,6 +292,7 @@ pub fn build_local_spend_contract( history_days, include_opencodex, false, + crate::settings::Settings::load().hide_personal_info, summary, ) } @@ -263,6 +303,7 @@ pub fn build_local_spend_contract_from_summary( history_days: u32, include_opencodex: bool, hide_native_codex_when_opencodex_present: bool, + hide_personal_info: bool, summary: CostSummary, ) -> SpendContract { let history_days = history_days.clamp(1, 365); @@ -278,7 +319,7 @@ pub fn build_local_spend_contract_from_summary( reasoning_tokens: None, }; - let native = load_native_spend(provider_id, history_days); + let native = load_native_spend(provider_id, history_days, hide_personal_info); let imports: Vec<_> = if include_opencodex { opencodex::load_for_subscription(provider_id, history_days, &custom) .into_iter() @@ -350,7 +391,11 @@ pub fn build_local_spend_contract_from_summary( } } -fn load_native_spend(provider_id: &str, history_days: u32) -> NativeSpendData { +fn load_native_spend( + provider_id: &str, + history_days: u32, + hide_personal_info: bool, +) -> NativeSpendData { if provider_id != "codex" { return NativeSpendData { projects: Vec::new(), @@ -361,7 +406,10 @@ fn load_native_spend(provider_id: &str, history_days: u32) -> NativeSpendData { }; } match CodexWorkspacesIndex::new(history_days).load_snapshot(false, |_| {}) { - Ok(snapshot) => { + Ok(mut snapshot) => { + if hide_personal_info { + snapshot.redact_for_privacy(); + } let activity = activity_from_sessions(&snapshot.sessions); let daily = snapshot .daily @@ -513,14 +561,14 @@ fn known_subtotal(models: &[SpendModelRow], summary: &CostSummary) -> Option Vec { - let costs: HashMap = get_daily_cost_history(provider_id, days) + let costs: HashMap> = get_daily_cost_history(provider_id, days) .into_iter() .collect(); let (tokens, incomplete) = get_daily_token_history(provider_id, days); tokens .into_iter() .map(|(day, total_tokens)| SpendDailyPoint { - cost_usd: costs.get(&day).copied().filter(|_| !incomplete), + cost_usd: costs.get(&day).copied().flatten().filter(|_| !incomplete), day, total_tokens: (!incomplete).then_some(total_tokens), })