From 5211bd60675975ee5ac8d434fb846714cf5d4687 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:37:53 +0700 Subject: [PATCH] Port upstream 0.60.5: mark Claude incomplete-request usage --- .../src-tauri/src/commands/chart.rs | 47 +++++- .../src-tauri/src/commands/tests.rs | 3 + apps/desktop-tauri/src-tauri/src/powertoys.rs | 1 + .../src/components/MenuCardDetails.tsx | 9 ++ .../src/components/MiniBarChart.test.tsx | 20 +++ .../src/components/MiniBarChart.tsx | 8 +- .../src/components/charts/BarChart.tsx | 12 +- apps/desktop-tauri/src/i18n/keys.ts | 2 + .../src/lib/incompleteRequests.test.ts | 16 ++ .../src/lib/incompleteRequests.ts | 16 ++ .../sections/charts/ChartsSection.tsx | 1 + .../sections/charts/CostHistoryChart.tsx | 10 +- apps/desktop-tauri/src/types/bridge.ts | 4 + rust/src/cli/cost.rs | 66 +++++++- rust/src/cli/serve/data.rs | 58 +++++-- rust/src/cost_scanner.rs | 77 ++++++++- rust/src/cost_scanner/claude_incomplete.rs | 148 ++++++++++++++++++ rust/src/cost_scanner/tests.rs | 67 ++++++++ rust/src/locale.rs | 2 + rust/src/locale/en-US.ftl | 2 + rust/src/providers/chart.rs | 3 + 21 files changed, 544 insertions(+), 28 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/incompleteRequests.test.ts create mode 100644 apps/desktop-tauri/src/lib/incompleteRequests.ts create mode 100644 rust/src/cost_scanner/claude_incomplete.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 4561dd9129..388e28b689 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -30,6 +30,10 @@ const LOCAL_USAGE_TTL: Duration = Duration::from_secs(30); pub struct DailyCostPoint { pub date: String, pub value: Option, + /// Claude requests that only produced a preliminary proxy usage row that + /// day (upstream 0.60.5 #3688). Omitted when zero or not applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete_request_count: Option, } /// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0 @@ -69,6 +73,10 @@ pub struct ProviderLocalUsageSummary { pub top_model: Option, pub estimate_note: String, pub token_cost_updated_at_ms: i64, + /// Incomplete Claude requests excluded from the 30-day totals. Omitted + /// when zero or not applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete_request_count: Option, } /// One display-only quota-window history row. Completeness is tracked per @@ -186,7 +194,18 @@ fn build_provider_chart_data_with_cancel( .daily_cost .iter() .cloned() - .map(|(date, value)| DailyCostPoint { date, value }) + .map(|(date, value)| { + let incomplete_request_count = snapshot + .daily_incomplete + .iter() + .find(|(day, _)| *day == date) + .map(|(_, count)| *count); + DailyCostPoint { + date, + value, + incomplete_request_count, + } + }) .collect(); let tokens_history = snapshot .daily_tokens @@ -233,7 +252,11 @@ fn build_provider_chart_data_with_cancel( let raw_cost = get_daily_cost_history(&provider_id, 30); let cost_history: Vec = raw_cost .into_iter() - .map(|(date, value)| DailyCostPoint { date, value }) + .map(|(date, value)| DailyCostPoint { + date, + value, + incomplete_request_count: None, + }) .collect(); let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30); @@ -336,7 +359,10 @@ fn local_usage_summary_from_cost_summary( summary: &CostSummary, ) -> Option { let total_tokens = total_tokens(summary); - let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || total_tokens > 0; + let has_usage = summary.sessions_count > 0 + || summary.total_cost_usd > 0.0 + || total_tokens > 0 + || summary.incomplete_request_count > 0; has_usage.then(|| ProviderLocalUsageSummary { today_cost: None, thirty_day_cost: non_zero_f64(summary.total_cost_usd), @@ -345,6 +371,7 @@ fn local_usage_summary_from_cost_summary( top_model: top_model(summary), estimate_note: localized_estimate_note(provider_id, locale::current_language()), token_cost_updated_at_ms: current_unix_ms(), + incomplete_request_count: non_zero_u32(summary.incomplete_request_count), }) } @@ -412,8 +439,10 @@ fn load_local_usage_summary_with_unknown_models( let thirty_day_tokens = total_tokens(&thirty_day); let latest_tokens = total_tokens(&today); - let has_usage = - thirty_day.sessions_count > 0 || thirty_day.total_cost_usd > 0.0 || thirty_day_tokens > 0; + let has_usage = thirty_day.sessions_count > 0 + || thirty_day.total_cost_usd > 0.0 + || thirty_day_tokens > 0 + || thirty_day.incomplete_request_count > 0; if !has_usage { return (None, unknown_models); } @@ -428,6 +457,7 @@ fn load_local_usage_summary_with_unknown_models( top_model: top_model(&thirty_day), estimate_note: localized_estimate_note(provider_id, lang), token_cost_updated_at_ms: current_unix_ms(), + incomplete_request_count: non_zero_u32(thirty_day.incomplete_request_count), }), unknown_models, ) @@ -449,6 +479,7 @@ fn muse_local_usage_summary( top_model: report.top_model.clone(), estimate_note: locale::get_text(lang, LocaleKey::PanelEstimatedFromLocalLogsMuse), token_cost_updated_at_ms: current_unix_ms(), + incomplete_request_count: None, }) } @@ -662,6 +693,10 @@ fn non_zero_u64(value: u64) -> Option { (value > 0).then_some(value) } +fn non_zero_u32(value: u32) -> Option { + (value > 0).then_some(value) +} + fn top_model(summary: &CostSummary) -> Option { summary .by_model_tokens @@ -712,6 +747,7 @@ fn load_openai_dashboard_chart_data( .map(|d| DailyCostPoint { date: d.day.clone(), value: Some(d.total_credits_used), + incomplete_request_count: None, }) .collect(); @@ -791,6 +827,7 @@ mod tests { top_model: Some("gpt-5".to_string()), estimate_note: "estimated".to_string(), token_cost_updated_at_ms: 1234, + incomplete_request_count: None, }; let json = serde_json::to_value(summary).expect("serialize summary"); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index d25a0a064a..28ad75675a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1595,15 +1595,18 @@ fn chart_data_serde_roundtrip_preserves_fields() { DailyCostPoint { date: "2025-01-01".into(), value: Some(1.25), + incomplete_request_count: None, }, DailyCostPoint { date: "2025-01-02".into(), value: Some(0.0), + incomplete_request_count: None, }, ], credits_history: vec![DailyCostPoint { date: "2025-01-01".into(), value: Some(42.0), + incomplete_request_count: None, }], usage_breakdown: vec![DailyUsageBreakdown { day: "2025-01-01".into(), diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..4f8006edc9 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -231,6 +231,7 @@ mod tests { top_model: Some("gpt-5".to_string()), estimate_note: "cached".to_string(), token_cost_updated_at_ms: 1234, + incomplete_request_count: None, }), ); diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..4e975884f6 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -172,6 +172,15 @@ function LocalUsageBlock({ )} + {summary.incompleteRequestCount != null && summary.incompleteRequestCount > 0 && ( +
+ {t("IncompleteRequestsLabel")} + + {t("IncompleteRequestsDetail").replace("{}", String(summary.incompleteRequestCount))} + +
+ )} +
{summary.topModel && {t("PanelTopModelPrefix")}: {summary.topModel}} diff --git a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx index d2703a7c95..d46c973ead 100644 --- a/apps/desktop-tauri/src/components/MiniBarChart.test.tsx +++ b/apps/desktop-tauri/src/components/MiniBarChart.test.tsx @@ -29,6 +29,26 @@ describe("MiniBarChart history axes", () => { expect((labels[1] as HTMLElement).style.transform).toBe(""); }); + it("marks days that only had incomplete requests without inventing a cost", () => { + const { container } = render( + , + ); + + const titles = Array.from(container.querySelectorAll("rect title")).map( + (node) => node.textContent, + ); + expect(titles[0]).toBe("2026-09-01 \u00b7 IncompleteRequestsLabel \u00b7 IncompleteRequestsDetail"); + expect(titles[1]).toContain("2026-09-02: 1.50"); + expect(titles[1]).toContain("IncompleteRequestsLabel"); + expect(container.querySelectorAll("rect")[0]).toHaveAttribute("opacity", "0"); + }); + it("keeps full endpoint dates for usage breakdown history", () => { const { container } = render( - {p.value == null ? p.date : `${p.date}: ${fmt(p.value)}`} + {[ + p.value == null ? p.date : `${p.date}: ${fmt(p.value)}`, + incompleteRequestsTooltip(t, p.incompleteRequestCount), + ] + .filter(Boolean) + .join(" \u00b7 ")} ); diff --git a/apps/desktop-tauri/src/components/charts/BarChart.tsx b/apps/desktop-tauri/src/components/charts/BarChart.tsx index 0151811f57..f92b13758b 100644 --- a/apps/desktop-tauri/src/components/charts/BarChart.tsx +++ b/apps/desktop-tauri/src/components/charts/BarChart.tsx @@ -23,6 +23,8 @@ import { export interface BarChartPoint { label: string; value: number | null; + /** Localized marker for requests excluded from this slot's value. */ + incompleteNote?: string; } export interface BarChartProps { @@ -127,7 +129,12 @@ export function BarChart({ onMouseLeave={onLeave} > - {p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`} + {[ + p.value == null ? p.label : `${p.label}: ${fmt(p.value)}`, + p.incompleteNote, + ] + .filter(Boolean) + .join(" \u00b7 ")} {isPeak && ( @@ -165,6 +172,9 @@ export function BarChart({ > {data[hover.i].label} {fmt(data[hover.i].value ?? 0)} + {data[hover.i].incompleteNote && ( + {data[hover.i].incompleteNote} + )}
)} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..e3245b4595 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -416,6 +416,8 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendHourlyActivity", "UsageSpendRequests", "UsageSpendTokens", + "IncompleteRequestsLabel", + "IncompleteRequestsDetail", "UsageSpendAllTimeHistory", "UsageSpendCustomPricing", "OverviewSpendTitle", diff --git a/apps/desktop-tauri/src/lib/incompleteRequests.test.ts b/apps/desktop-tauri/src/lib/incompleteRequests.test.ts new file mode 100644 index 0000000000..5fa0c70028 --- /dev/null +++ b/apps/desktop-tauri/src/lib/incompleteRequests.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { incompleteRequestsTooltip } from "./incompleteRequests"; + +const t = (key: string) => + key === "IncompleteRequestsDetail" ? "{} requests excluded" : "Incomplete"; + +describe("incompleteRequestsTooltip", () => { + it("returns null when nothing was excluded", () => { + expect(incompleteRequestsTooltip(t, undefined)).toBeNull(); + expect(incompleteRequestsTooltip(t, 0)).toBeNull(); + }); + + it("names the marker and the excluded request count", () => { + expect(incompleteRequestsTooltip(t, 3)).toBe("Incomplete · 3 requests excluded"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/incompleteRequests.ts b/apps/desktop-tauri/src/lib/incompleteRequests.ts new file mode 100644 index 0000000000..718a7a76b1 --- /dev/null +++ b/apps/desktop-tauri/src/lib/incompleteRequests.ts @@ -0,0 +1,16 @@ +import type { LocaleKey } from "../i18n/keys"; + +type Translate = (key: LocaleKey) => string; + +/** + * Tooltip text for Claude requests that only produced a preliminary proxy + * usage row (upstream 0.60.5 #3688). Returns null when there is nothing to + * flag so callers can skip the marker. + */ +export function incompleteRequestsTooltip( + t: Translate, + count: number | null | undefined, +): string | null { + if (count == null || count <= 0) return null; + return `${t("IncompleteRequestsLabel")} \u00b7 ${t("IncompleteRequestsDetail").replace("{}", String(count))}`; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 8257e3069c..852a5cf43e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -144,6 +144,7 @@ export function ChartsSection({ providerId, accountEmail, accentColor, t }: Prop providerId={providerId} animations={animations} emptyMessage={emptyMsg} + t={t} /> )} {current === "credits" && ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CostHistoryChart.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CostHistoryChart.tsx index 654f722e0a..a61bbe8870 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CostHistoryChart.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/CostHistoryChart.tsx @@ -1,5 +1,7 @@ import { BarChart } from "../../../../../components/charts/BarChart"; import { providerCostColor } from "../../../../../components/charts/chartPalette"; +import { incompleteRequestsTooltip } from "../../../../../lib/incompleteRequests"; +import type { LocaleKey } from "../../../../../i18n/keys"; import type { DailyCostPoint } from "../../../../../types/bridge"; interface Props { @@ -9,6 +11,7 @@ interface Props { providerId: string; animations: boolean; emptyMessage: string; + t: (key: LocaleKey) => string; } /** @@ -23,9 +26,14 @@ export function CostHistoryChart({ providerId, animations, emptyMessage, + t, }: Props) { const recent = data.slice(-30); - const points = recent.map((p) => ({ label: p.date, value: p.value })); + const points = recent.map((p) => ({ + label: p.date, + value: p.value, + incompleteNote: incompleteRequestsTooltip(t, p.incompleteRequestCount) ?? undefined, + })); return (
{title}
diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7d..434521342a 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -798,6 +798,8 @@ export interface AppInfoBridge { export interface DailyCostPoint { date: string; value: number | null; + /** Claude requests excluded from this day's totals (upstream 0.60.5 #3688). */ + incompleteRequestCount?: number; } /** Exact local token totals per day (upstream 0.50.0 #2930). */ @@ -825,6 +827,8 @@ export interface ProviderLocalUsageSummary { topModel: string | null; estimateNote: string; tokenCostUpdatedAtMs: number; + /** Claude requests excluded from the 30-day totals (upstream 0.60.5 #3688). */ + incompleteRequestCount?: number; } export interface QuotaWindowHistoryPoint { diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 8e9be16dc2..48eaac85cb 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -296,7 +296,11 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b println!(" Local cost scanning not available for this provider"); println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { - if result.summary.known_zero { + if result.summary.incomplete_request_count > 0 { + // Only preliminary proxy rows exist: usage is unavailable, not $0. + println!(" No completed usage data found"); + print_incomplete_note(result.summary.incomplete_request_count); + } else if result.summary.known_zero { println!(" No usage in the last {} days (scan complete)", days); } else { println!(" No usage data found"); @@ -304,13 +308,17 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } } else { // Total cost + let incomplete_suffix = incomplete_suffix(result.summary.incomplete_request_count); if use_color { println!( - " Total: \x1b[32m{}\x1b[0m", + " Total: \x1b[32m{}\x1b[0m{incomplete_suffix}", result.summary.format_total() ); } else { - println!(" Total: {}", result.summary.format_total()); + println!( + " Total: {}{incomplete_suffix}", + result.summary.format_total() + ); } // Token breakdown @@ -323,6 +331,7 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b // Sessions println!(" Sessions: {}", result.summary.sessions_count); + print_incomplete_note(result.summary.incomplete_request_count); // Cost by model if !result.summary.by_model.is_empty() { @@ -372,6 +381,20 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_b } } +/// " · Incomplete" marker for totals that exclude preliminary Claude proxy +/// rows (upstream 0.60.5 #3688). +fn incomplete_suffix(count: u32) -> &'static str { + if count > 0 { " · Incomplete" } else { "" } +} + +fn print_incomplete_note(count: u32) { + if count > 0 { + println!( + " Incomplete: {count} requests lacked final usage and were excluded from tokens and cost." + ); + } +} + fn print_local_token_history(history: crate::spend_contract::LocalTokenHistorySummary, days: u32) { use crate::spend_contract::LocalHistoryCoverage; match history.coverage { @@ -478,7 +501,7 @@ fn build_json_payloads(results: &[CostResult], days: u32) -> Vec Vec>(), "period": {"start": r.summary.period_start.map(|d| d.to_string()), "end": r.summary.period_end.map(|d| d.to_string())}, "spendContract": spend_contract - }) + }); + // Only emitted when a scan excluded preliminary Claude proxy rows. + if r.summary.incomplete_request_count > 0 + && let Some(object) = payload.as_object_mut() + { + object.insert( + "incompleteRequestCount".to_string(), + serde_json::json!(r.summary.incomplete_request_count), + ); + } + payload } }) .collect() @@ -616,6 +649,29 @@ mod tests { assert!(s.contains("null"), "non-codex A16 is null"); } + #[test] + fn json_output_emits_incomplete_request_count_only_when_positive() { + let make = |incomplete_request_count| CostResult { + provider: "claude".to_string(), + display_name: "Claude".to_string(), + summary: CostSummary { + incomplete_request_count, + ..CostSummary::default() + }, + supported: true, + token_history: None, + }; + let payloads = build_json_payloads(&[make(3), make(0)], 30); + assert_eq!(payloads[0]["incompleteRequestCount"], 3); + assert!(payloads[1].get("incompleteRequestCount").is_none()); + } + + #[test] + fn incomplete_suffix_marks_only_positive_counts() { + assert_eq!(incomplete_suffix(0), ""); + assert_eq!(incomplete_suffix(2), " · Incomplete"); + } + #[test] fn antigravity_json_keeps_unknown_cost_distinct_from_zero() { use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index d1841db6bc..9c51b26b46 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -95,11 +95,10 @@ pub async fn cost_response(provider: Option<&str>) -> String { // Daily spend history for the dashboard bar charts. The debounced // helper reuses the cache the summary scan just warmed, so no // second disk walk happens per request. - let daily = daily_json(cost_scanner::get_daily_cost_history( - provider_id.cli_name(), - 30, - )); - results.push(json!({ + let (daily_cost, daily_incomplete) = + cost_scanner::get_daily_cost_and_incomplete_history(provider_id.cli_name(), 30); + let daily = daily_json_with_incomplete(daily_cost, &daily_incomplete); + let mut payload = json!({ "provider": provider_id.cli_name(), "supported": true, "days_scanned": 30, @@ -115,7 +114,17 @@ pub async fn cost_response(provider: Option<&str>) -> String { }, "sessions_count": summary.sessions_count, "by_model": summary.by_model, - })); + }); + // Only emitted when a scan excluded preliminary Claude proxy rows. + if summary.incomplete_request_count > 0 + && let Some(object) = payload.as_object_mut() + { + object.insert( + "incompleteRequestCount".to_string(), + json!(summary.incomplete_request_count), + ); + } + results.push(payload); } else { results.push(json!({ "provider": provider_id.cli_name(), @@ -128,11 +137,31 @@ pub async fn cost_response(provider: Option<&str>) -> String { } /// Dashboard-charts shape for one provider's daily spend: [{date, totalCost}]. +/// Days with incomplete Claude requests also carry `incompleteRequestCount`. +#[cfg(test)] fn daily_json(daily: Vec<(String, Option)>) -> serde_json::Value { + daily_json_with_incomplete(daily, &[]) +} + +fn daily_json_with_incomplete( + daily: Vec<(String, Option)>, + incomplete: &[(String, u32)], +) -> serde_json::Value { serde_json::Value::Array( daily .into_iter() - .map(|(date, cost_usd)| json!({ "date": date, "totalCost": cost_usd })) + .map(|(date, cost_usd)| { + let count = incomplete + .iter() + .find(|(day, _)| *day == date) + .map(|(_, count)| *count) + .filter(|count| *count > 0); + let mut row = json!({ "date": date, "totalCost": cost_usd }); + if let (Some(count), Some(object)) = (count, row.as_object_mut()) { + object.insert("incompleteRequestCount".to_string(), json!(count)); + } + row + }) .collect(), ) } @@ -143,12 +172,17 @@ mod tests { #[test] fn daily_array_shape_matches_dashboard_charts_contract() { - let daily = daily_json(vec![ - ("2026-08-07".to_string(), Some(0.0)), - ("2026-08-08".to_string(), Some(4.25)), - ("2026-08-09".to_string(), None), - ]); + let daily = daily_json_with_incomplete( + vec![ + ("2026-08-07".to_string(), Some(0.0)), + ("2026-08-08".to_string(), Some(4.25)), + ("2026-08-09".to_string(), None), + ], + &[("2026-08-09".to_string(), 2)], + ); let rows = daily.as_array().unwrap(); + assert!(rows[0].get("incompleteRequestCount").is_none()); + assert_eq!(rows[2]["incompleteRequestCount"], 2); assert_eq!(rows[0]["date"], "2026-08-07"); assert_eq!(rows[1]["totalCost"], 4.25); assert_eq!(rows[0]["totalCost"], 0.0); diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 619f9ba7b0..1fd0b8f85d 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -34,11 +34,14 @@ use crate::providers::claude::quota_history::{ }; use crate::providers::opencodego::local as opencodego_local; use crate::settings::Settings; +mod claude_incomplete; mod claude_pricing; mod claude_usage; mod codex; mod read_receipt; mod stats; +pub use claude_incomplete::ClaudeIncompleteReport; +use claude_incomplete::ClaudeIncompleteTracker; use claude_pricing::ClaudeScanPricingResolver; #[cfg(test)] use claude_pricing::{ClaudePricing, FALLBACK_CLAUDE_MODEL}; @@ -111,6 +114,12 @@ pub struct CostSummary { /// the scan found no sessions/tokens (upstream 0.50.1 #2932). Never /// fabricated on incomplete scans. pub known_zero: bool, + /// Claude preliminary proxy rows (null stop reason, input only) that were + /// excluded from cost and tokens and never superseded by a completed row + /// (upstream 0.60.5 #3688). Zero for every other provider. + pub incomplete_request_count: u32, + /// Incomplete Claude request counts per model. + pub incomplete_by_model: HashMap, /// Period start date pub period_start: Option, /// Period end date @@ -460,6 +469,8 @@ pub struct ClaudeChartSnapshot { pub summary: CostSummary, pub daily_cost: Vec<(String, Option)>, pub daily_tokens: Vec<(String, u64)>, + /// Incomplete request count per local day; only days with a count appear. + pub daily_incomplete: Vec<(String, u32)>, pub quota_history: ClaudeQuotaHistoryScan, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -587,6 +598,8 @@ impl CostScanner { // Walk through projects directory, de-duplicating usage records // that appear across multiple files. let mut claude_scan = ClaudeFileScanResult::default(); + let mut incomplete = ClaudeIncompleteTracker::default(); + let mut completed_keys = HashSet::new(); if projects_dir.exists() { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); @@ -597,6 +610,7 @@ impl CostScanner { &mut seen, cancel, &mut pricing, + &mut incomplete, |record| { add_claude_record_to_summary(&mut summary, record); }, @@ -607,7 +621,9 @@ impl CostScanner { claude_scan.absorb(file_result); }; self.walk_claude_files(&projects_dir, &cutoff, cancel, &mut handle_file); + completed_keys = seen; } + incomplete.resolve(&completed_keys).apply_to(&mut summary); // OMP / pi-compatible anthropic rows, deduped across shared files. if include_pi_sessions { @@ -662,6 +678,8 @@ impl CostScanner { let mut quota_records = Vec::new(); let mut scan_result = ClaudeFileScanResult::default(); let mut missing_timestamp = false; + let mut incomplete = ClaudeIncompleteTracker::default(); + let mut completed_keys = HashSet::new(); if projects_dir.exists() { let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); @@ -673,6 +691,7 @@ impl CostScanner { &mut seen, cancel, &mut pricing, + &mut incomplete, |record| { file_has_usage = true; add_claude_record_to_summary(&mut summary, record); @@ -690,7 +709,10 @@ impl CostScanner { } scan_result.absorb(file_result); }); + completed_keys = seen; } + let incomplete_report = incomplete.resolve(&completed_keys); + incomplete_report.apply_to(&mut summary); crate::pi_session_cost::scan_pi_compatible_into( &mut summary, @@ -726,6 +748,7 @@ impl CostScanner { summary, daily_cost, daily_tokens, + daily_incomplete: incomplete_report.daily_sorted(), quota_history: ClaudeQuotaHistoryScan { records: quota_records, history_coverage_established: complete, @@ -851,7 +874,17 @@ where F: FnMut(&ClaudeUsageRecord), { let mut pricing = ClaudeScanPricingResolver::default(); - scan_claude_file_with_pricing(path, cutoff, seen, cancel, &mut pricing, on_record).counted + let mut incomplete = ClaudeIncompleteTracker::default(); + scan_claude_file_with_pricing( + path, + cutoff, + seen, + cancel, + &mut pricing, + &mut incomplete, + on_record, + ) + .counted } fn for_each_claude_usage_record_with_pricing( @@ -865,7 +898,18 @@ fn for_each_claude_usage_record_with_pricing( where F: FnMut(&ClaudeUsageRecord), { - scan_claude_file_with_pricing(path, cutoff, seen, cancel, pricing, on_record).counted + // Token history ignores incomplete-request markers. + let mut incomplete = ClaudeIncompleteTracker::default(); + scan_claude_file_with_pricing( + path, + cutoff, + seen, + cancel, + pricing, + &mut incomplete, + on_record, + ) + .counted } fn scan_claude_file_with_pricing( @@ -874,6 +918,7 @@ fn scan_claude_file_with_pricing( seen: &mut HashSet, cancel: Option<&AtomicBool>, pricing: &mut ClaudeScanPricingResolver, + incomplete: &mut ClaudeIncompleteTracker, mut on_record: F, ) -> ClaudeFileScanResult where @@ -906,6 +951,18 @@ where } if is_preliminary_claude_usage(&event) { result.incomplete_requests = result.incomplete_requests.saturating_add(1); + if let Some(message) = event.message.as_ref() { + incomplete.record( + claude_usage_dedup_key( + message.id.as_deref(), + event.request_id.as_deref(), + event.session_id(), + ), + message.model.as_deref().unwrap_or("claude-3-5-sonnet"), + event.parsed_timestamp(), + cutoff, + ); + } return true; } if let Some(record) = claude_usage_record_from_event_with_pricing(&event, pricing) @@ -1129,6 +1186,17 @@ pub fn has_cost_usage_sources() -> bool { /// 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)> { + get_daily_cost_and_incomplete_history(provider, days).0 +} + +/// Daily cost series plus per-day incomplete request counts. +pub type DailyCostAndIncomplete = (Vec<(String, Option)>, Vec<(String, u32)>); + +/// Daily cost history plus, for Claude, the per-day count of incomplete proxy +/// requests (upstream 0.60.5 #3688). Days with no incomplete request are +/// absent from the second vector; it is empty for every other provider. +pub fn get_daily_cost_and_incomplete_history(provider: &str, days: u32) -> DailyCostAndIncomplete { + let mut daily_incomplete = Vec::new(); let scanner = CostScanner::new(days); let today = Local::now().date_naive(); let mut daily_costs: HashMap> = HashMap::new(); @@ -1188,6 +1256,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< let mut seen = HashSet::new(); let mut pricing = ClaudeScanPricingResolver::default(); let mut claude_scan = ClaudeFileScanResult::default(); + let mut incomplete = ClaudeIncompleteTracker::default(); let mut handle_file = |path: &Path| { let file_result = scan_claude_file_with_pricing( path, @@ -1195,6 +1264,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< &mut seen, None, &mut pricing, + &mut incomplete, |record| { add_claude_record_to_daily_costs(&mut daily_costs, record); }, @@ -1202,6 +1272,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< claude_scan.absorb(file_result); }; scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + daily_incomplete = incomplete.resolve(&seen).daily_sorted(); if claude_scan.is_complete() { for slot in daily_costs.values_mut() { if slot.is_none() { @@ -1241,7 +1312,7 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, Option< // Convert to sorted vector let mut result: Vec<(String, Option)> = daily_costs.into_iter().collect(); result.sort_by(|a, b| a.0.cmp(&b.0)); - result + (result, daily_incomplete) } /// Daily token totals (input + output) for the Tokens chart mode, plus diff --git a/rust/src/cost_scanner/claude_incomplete.rs b/rust/src/cost_scanner/claude_incomplete.rs new file mode 100644 index 0000000000..1a0b2e5d65 --- /dev/null +++ b/rust/src/cost_scanner/claude_incomplete.rs @@ -0,0 +1,148 @@ +//! Incomplete Claude request tracking (upstream 0.60.5 #3688). +//! +//! Claude Code proxies can emit a preliminary `message_start` usage row (null +//! stop reason, input tokens, no output, no cache fields). Those rows are +//! excluded from cost and tokens, but the scanner still counts them per day and +//! per model so the UI can say "Incomplete" instead of showing a silent $0. +//! A completed record with the same dedup key supersedes the preliminary row. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Local, Utc}; + +use super::claude_usage::ClaudeUsageDedupKey; + +/// Reconciled incomplete-request counts for one scan. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClaudeIncompleteReport { + pub total: u32, + /// Local calendar day (`YYYY-MM-DD`) -> count. + pub by_day: HashMap, + pub by_model: HashMap, +} + +#[derive(Debug)] +struct IncompleteRow { + key: Option, + model: String, + day: Option, +} + +/// Collects preliminary rows during a scan and reconciles them against the +/// completed dedup keys once the walk has finished. +#[derive(Debug, Default)] +pub(super) struct ClaudeIncompleteTracker { + rows: Vec, + keys: HashSet, +} + +impl ClaudeIncompleteTracker { + pub(super) fn record( + &mut self, + key: Option, + model: &str, + timestamp: Option>, + cutoff: &DateTime, + ) { + if timestamp.is_some_and(|timestamp| timestamp < *cutoff) { + return; + } + if let Some(key) = &key + && !self.keys.insert(key.clone()) + { + return; + } + let day = timestamp.map(|timestamp| { + timestamp + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string() + }); + self.rows.push(IncompleteRow { + key, + model: model.to_string(), + day, + }); + } + + /// Drop rows whose request also produced a completed record, then count. + pub(super) fn resolve( + self, + completed: &HashSet, + ) -> ClaudeIncompleteReport { + let mut report = ClaudeIncompleteReport::default(); + for row in self.rows { + if row.key.as_ref().is_some_and(|key| completed.contains(key)) { + continue; + } + report.total = report.total.saturating_add(1); + *report.by_model.entry(row.model).or_insert(0) += 1; + if let Some(day) = row.day { + *report.by_day.entry(day).or_insert(0) += 1; + } + } + report + } +} + +impl ClaudeIncompleteReport { + /// Copy the reconciled totals onto a summary. + pub(super) fn apply_to(&self, summary: &mut super::CostSummary) { + summary.incomplete_request_count = self.total; + summary.incomplete_by_model = self.by_model.clone(); + } + + /// Days with at least one incomplete request, sorted ascending. + pub(super) fn daily_sorted(&self) -> Vec<(String, u32)> { + let mut days: Vec<_> = self + .by_day + .iter() + .map(|(day, count)| (day.clone(), *count)) + .collect(); + days.sort_by(|left, right| left.0.cmp(&right.0)); + days + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(request: &str) -> Option { + Some(ClaudeUsageDedupKey::Request { + message_id: None, + request_id: request.to_string(), + }) + } + + #[test] + fn completed_key_supersedes_preliminary_row() { + let cutoff = Utc::now() - chrono::Duration::days(7); + let mut tracker = ClaudeIncompleteTracker::default(); + tracker.record(key("a"), "m", Some(Utc::now()), &cutoff); + tracker.record(key("b"), "m", Some(Utc::now()), &cutoff); + let completed: HashSet<_> = key("a").into_iter().collect(); + let report = tracker.resolve(&completed); + assert_eq!(report.total, 1); + assert_eq!(report.by_model.get("m"), Some(&1)); + } + + #[test] + fn duplicate_preliminary_rows_count_once_and_old_rows_are_skipped() { + let cutoff = Utc::now() - chrono::Duration::days(1); + let mut tracker = ClaudeIncompleteTracker::default(); + tracker.record(key("a"), "m", Some(Utc::now()), &cutoff); + tracker.record(key("a"), "m", Some(Utc::now()), &cutoff); + tracker.record( + key("old"), + "m", + Some(Utc::now() - chrono::Duration::days(5)), + &cutoff, + ); + tracker.record(None, "m", None, &cutoff); + let report = tracker.resolve(&HashSet::new()); + assert_eq!(report.total, 2); + assert_eq!(report.by_day.values().sum::(), 1); + } +} diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 3b16c92a1c..078ef6ba6a 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -455,6 +455,71 @@ fn excludes_preliminary_proxy_estimates_but_keeps_cache_aware_rows() { assert!(claude_usage_record_from_event(&cache_aware).is_some()); } +#[test] +fn claude_scan_counts_unreconciled_incomplete_requests_per_day_and_model() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("transcript.jsonl"); + let now = Utc::now() - Duration::hours(1); + let ts = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let day = now + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string(); + let row = |request: &str, message: &str, stop: &str, usage: &str| { + format!( + r#"{{"type":"assistant","timestamp":"{ts}","requestId":"{request}","message":{{"id":"{message}","model":"claude-sonnet-4-6","stop_reason":{stop},"usage":{usage}}}}}"# + ) + }; + let body = [ + // Superseded: a completed row with the same key exists. + row("req_done", "msg_done", "null", r#"{"input_tokens":1000}"#), + row( + "req_done", + "msg_done", + r#""end_turn""#, + r#"{"input_tokens":1000,"output_tokens":50}"#, + ), + // Never completed, duplicated preliminary rows count once. + row("req_open", "msg_open", "null", r#"{"input_tokens":500}"#), + row("req_open", "msg_open", "null", r#"{"input_tokens":500}"#), + ] + .join("\n"); + std::fs::write(&path, body).unwrap(); + + let cutoff = Utc::now() - Duration::days(1); + let mut seen = HashSet::new(); + let mut pricing = ClaudeScanPricingResolver::default(); + let mut tracker = ClaudeIncompleteTracker::default(); + let mut summary = CostSummary::default(); + let result = scan_claude_file_with_pricing( + &path, + &cutoff, + &mut seen, + None, + &mut pricing, + &mut tracker, + |record| add_claude_record_to_summary(&mut summary, record), + ); + assert_eq!(result.counted, 1); + assert!( + !result.is_complete(), + "preliminary rows keep coverage unknown" + ); + + let report = tracker.resolve(&seen); + report.apply_to(&mut summary); + assert_eq!(summary.incomplete_request_count, 1); + assert_eq!( + summary.incomplete_by_model.get("claude-sonnet-4-6"), + Some(&1) + ); + assert_eq!(report.by_day.get(&day), Some(&1)); + // Only the completed row contributes tokens. + assert_eq!(summary.input_tokens, 1000); + assert_eq!(summary.output_tokens, 50); +} + #[test] fn malformed_claude_history_stays_unknown_while_valid_empty_history_is_known_zero() { let root = tempfile::tempdir().unwrap(); @@ -470,6 +535,7 @@ fn malformed_claude_history_stays_unknown_while_valid_empty_history_is_known_zer &mut empty_seen, None, &mut empty_pricing, + &mut ClaudeIncompleteTracker::default(), |_| {}, ); let mut empty_summary = CostSummary::default(); @@ -486,6 +552,7 @@ fn malformed_claude_history_stays_unknown_while_valid_empty_history_is_known_zer &mut malformed_seen, None, &mut malformed_pricing, + &mut ClaudeIncompleteTracker::default(), |_| {}, ); assert_eq!(malformed_result.malformed_lines, 1); diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..ec9d6fe33a 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -660,6 +660,8 @@ locale_keys! { UsageSpendHourlyActivity, UsageSpendRequests, UsageSpendTokens, + IncompleteRequestsLabel, + IncompleteRequestsDetail, UsageSpendAllTimeHistory, UsageSpendCustomPricing, OverviewSpendTitle, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..b0518d631b 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -354,6 +354,8 @@ UsageSpendDefaultPricing = Default pricing UsageSpendHourlyActivity = Hourly activity UsageSpendRequests = requests UsageSpendTokens = tokens +IncompleteRequestsLabel = Incomplete +IncompleteRequestsDetail = { "{}" } requests lacked final usage and were excluded from tokens and cost UsageSpendAllTimeHistory = All-time is backed by the latest UsageSpendCustomPricing = custom pricing OverviewSpendTitle = Usage & Spend · 30d diff --git a/rust/src/providers/chart.rs b/rust/src/providers/chart.rs index df6a499789..0a002e39ed 100644 --- a/rust/src/providers/chart.rs +++ b/rust/src/providers/chart.rs @@ -19,6 +19,8 @@ use std::sync::atomic::AtomicBool; pub struct ProviderChartSnapshot { pub daily_cost: Vec<(String, Option)>, pub daily_tokens: Vec<(String, u64)>, + /// Claude incomplete proxy-request count per local day (upstream 0.60.5 #3688). + pub daily_incomplete: Vec<(String, u32)>, pub tokens_incomplete: bool, pub local_summary: Option, pub quota_window_history: Option, @@ -121,6 +123,7 @@ fn build_claude_chart_snapshot( ProviderChartSnapshot { daily_cost: scan.daily_cost, daily_tokens: scan.daily_tokens, + daily_incomplete: scan.daily_incomplete, tokens_incomplete: !scan.summary.history_coverage_established, local_summary: Some(scan.summary), quota_window_history,