diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index a7dd653bc1..bde16a93d0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -6,7 +6,9 @@ //! reads to the right cached bundle. use codexbar::core::OpenAIDashboardCacheStore; -use codexbar::cost_scanner::{CostScanner, CostSummary, get_daily_cost_history}; +use codexbar::cost_scanner::{ + CostScanner, CostSummary, get_daily_cost_history, get_daily_token_history, +}; use codexbar::locale::{self, LocaleKey}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -26,6 +28,15 @@ pub struct DailyCostPoint { pub value: f64, } +/// A single (date, tokens) point for the Tokens chart mode (upstream 0.50.0 +/// #2930 — exact local token totals). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyTokenPoint { + pub date: String, + pub tokens: u64, +} + /// A single service's usage within a day for the stacked usage breakdown chart. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -65,6 +76,10 @@ pub struct ProviderChartData { pub credits_history: Vec, pub usage_breakdown: Vec, pub local_usage: Option, + /// Daily exact local token totals for the Tokens mode; incomplete + /// backfill keeps the marker true so the UI can show "Refreshing". + pub tokens_history: Vec, + pub tokens_incomplete: bool, } #[tauri::command] @@ -117,6 +132,12 @@ fn build_provider_chart_data_with_cancel( .map(|(date, value)| DailyCostPoint { date, value }) .collect(); + let (raw_tokens, tokens_incomplete) = get_daily_token_history(&provider_id, 30); + let tokens_history: Vec = raw_tokens + .into_iter() + .map(|(date, tokens)| DailyTokenPoint { date, tokens }) + .collect(); + let (credits_history, usage_breakdown) = load_openai_dashboard_chart_data(&provider_id, account_email.as_deref()); let local_usage = if cancel @@ -134,6 +155,8 @@ fn build_provider_chart_data_with_cancel( credits_history, usage_breakdown, local_usage, + tokens_history, + tokens_incomplete, } } @@ -145,6 +168,8 @@ impl ProviderChartData { credits_history: Vec::new(), usage_breakdown: Vec::new(), local_usage: None, + tokens_history: Vec::new(), + tokens_incomplete: false, } } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 1709867ac8..36ef491c44 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1010,7 +1010,9 @@ fn non_claude_error_message_is_preserved() { #[test] fn chart_data_serde_roundtrip_preserves_fields() { - use super::{DailyCostPoint, DailyUsageBreakdown, ProviderChartData, ServiceUsagePoint}; + use super::{ + DailyCostPoint, DailyTokenPoint, DailyUsageBreakdown, ProviderChartData, ServiceUsagePoint, + }; let original = ProviderChartData { provider_id: "codex".into(), @@ -1043,6 +1045,11 @@ fn chart_data_serde_roundtrip_preserves_fields() { total_credits_used: 13.5, }], local_usage: None, + tokens_history: vec![DailyTokenPoint { + date: "2025-01-01".into(), + tokens: 123_456, + }], + tokens_incomplete: true, }; let json = serde_json::to_string(&original).expect("serialize"); @@ -1056,6 +1063,9 @@ fn chart_data_serde_roundtrip_preserves_fields() { assert!(json.contains("\"localUsage\":null")); assert!(json.contains("\"creditsUsed\":10.0")); assert!(json.contains("\"totalCreditsUsed\":13.5")); + assert!(json.contains("\"tokensHistory\"")); + assert!(json.contains("\"tokens\":123456")); + assert!(json.contains("\"tokensIncomplete\":true")); let back: ProviderChartData = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.provider_id, "codex"); @@ -1064,6 +1074,8 @@ fn chart_data_serde_roundtrip_preserves_fields() { assert_eq!(back.credits_history[0].value, 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); + assert!(back.tokens_incomplete); } #[test] diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-fireworks.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-fireworks.svg new file mode 100644 index 0000000000..5a25c09c25 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-fireworks.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index a8e063b50a..9bd27919c9 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -18,6 +18,7 @@ import crossmodel from "./icons/ProviderIcon-crossmodel.svg?raw"; import cursor from "./icons/ProviderIcon-cursor.svg?raw"; import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; import deepinfra from "./icons/ProviderIcon-deepinfra.svg?raw"; +import fireworks from "./icons/ProviderIcon-fireworks.svg?raw"; import aiand from "./icons/ProviderIcon-aiand.svg?raw"; import clinepass from "./icons/ProviderIcon-clinepass.svg?raw"; import longcat from "./icons/ProviderIcon-longcat.svg?raw"; @@ -99,6 +100,7 @@ const RAW: Record = { cursor: tint(cursor), deepgram: tint(deepgram), deepinfra: tint(deepinfra), + fireworks: tint(fireworks), aiand: tint(aiand), clinepass: tint(clinepass), longcat: tint(longcat), @@ -158,6 +160,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { cursor: { id: "cursor", brandColor: "#00bfa5", fallbackLetter: "▸", svgPath: RAW.cursor }, deepgram: { id: "deepgram", brandColor: "#13ef93", fallbackLetter: "D", svgPath: RAW.deepgram }, deepinfra: { id: "deepinfra", brandColor: "#2a3275", fallbackLetter: "D", svgPath: RAW.deepinfra }, + fireworks: { id: "fireworks", brandColor: "#f25b1c", fallbackLetter: "F", svgPath: RAW.fireworks }, aiand: { id: "aiand", brandColor: "#e25c2b", fallbackLetter: "&", svgPath: RAW.aiand }, clinepass: { id: "clinepass", brandColor: "#61a3fa", fallbackLetter: "C", svgPath: RAW.clinepass }, longcat: { id: "longcat", brandColor: "#ffd100", fallbackLetter: "L", svgPath: RAW.longcat }, @@ -246,8 +249,10 @@ const ALIASES: Record = { "deep seek": "deepseek", "deep-seek": "deepseek", "deep infra": "deepinfra", - "deep-infra": "deepinfra", - di: "deepinfra", + "deep-infra": "deepinfra", + di: "deepinfra", + "fireworks-ai": "fireworks", + fw: "fireworks", "ai&": "aiand", "ai-and": "aiand", "ai and": "aiand", diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index f4ff708a20..ff20e42a8d 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -536,6 +536,8 @@ export const ALL_LOCALE_KEYS = [ "DetailCostBalance", "DetailCostResets", "DetailChartCost", + "DetailChartTokens", + "DetailChartRefreshing", "DetailChartCredits", "DetailChartUsageBreakdown", "DetailChartEmpty", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 6e4cd60151..f71ed56925 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2683,6 +2683,15 @@ body:has(.tray-panel-reveal) { color: var(--provider-row-text-secondary); } +/* Tokens chart "Refreshing" marker while local history backfill is + incomplete (upstream 0.50.0 #2930). */ +.provider-detail-chart__refreshing { + margin-left: 8px; + font-size: 0.68rem; + color: var(--text-muted); + font-style: italic; +} + .chart { display: flex; flex-direction: column; @@ -4267,14 +4276,20 @@ html:has(.menu-surface--tray) { .menu-metric__reset { font-size: 11px; color: var(--text-secondary); - white-space: nowrap; - /* Long provider descriptions (credits remaining, etc.) used to overflow - the tray card because nowrap had no max-width/ellipsis. */ + /* Upstream 0.49.0 #2742 (refs #2182): long metric reset and pace details + wrap to a second line instead of truncating, so non-English locales keep + the full reset information. #2846: the percent value keeps its own line + slot (row only stacks when both cannot fit) and the two-line clamp keeps + cached card heights bounded. */ min-width: 0; max-width: 62%; - overflow: hidden; - text-overflow: ellipsis; text-align: right; + white-space: normal; + overflow-wrap: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } .menu-metric__exhausted { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 83c0675070..0f92bba552 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -24,7 +24,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", + "xai", "zai", "fireworks", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx new file mode 100644 index 0000000000..ade4cdcd3b --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { ChartsSection } from "./ChartsSection"; +import { getProviderChartData } from "../../../../../lib/tauri"; +import type { ProviderChartData } from "../../../../../types/bridge"; + +vi.mock("../../../../../lib/tauri", () => ({ + getProviderChartData: vi.fn(), + getSettingsSnapshot: vi.fn().mockResolvedValue({ enableAnimations: false }), +})); +vi.mock("../../../../../lib/providerCharts", () => ({ + providerSupportsChartData: () => true, +})); + +const mockChart = vi.mocked(getProviderChartData); + +function chartData(overrides: Partial): ProviderChartData { + return { + providerId: "codex", + costHistory: [{ date: "2026-08-16", value: 1.5 }], + creditsHistory: [], + usageBreakdown: [], + localUsage: null, + tokensHistory: [{ date: "2026-08-16", tokens: 9000 }], + tokensIncomplete: false, + ...overrides, + }; +} + +describe("ChartsSection tokens mode (upstream 0.50.0 #2930)", () => { + it("defaults Codex to the Tokens tab when exact token data exists", async () => { + mockChart.mockResolvedValue(chartData({})); + render( + key} + />, + ); + await waitFor(() => { + expect(screen.getByRole("tab", { selected: true }).textContent).toBe( + "DetailChartTokens", + ); + }); + }); + + it("keeps Cost as the default for non-Codex providers", async () => { + mockChart.mockResolvedValue(chartData({ providerId: "claude" })); + render( + key} + />, + ); + await waitFor(() => { + expect(screen.getByRole("tab", { selected: true }).textContent).toBe( + "DetailChartCost", + ); + }); + }); + + it("shows the Refreshing marker while local history backfill is incomplete", async () => { + mockChart.mockResolvedValue(chartData({ tokensIncomplete: true })); + render( + key} + />, + ); + await waitFor(() => { + expect(screen.getByText("DetailChartRefreshing")).toBeTruthy(); + }); + }); + + it("hides the Tokens tab when no day carries token data", async () => { + mockChart.mockResolvedValue( + chartData({ tokensHistory: [{ date: "2026-08-16", tokens: 0 }] }), + ); + render( + key} + />, + ); + await waitFor(() => { + expect(screen.getByRole("tab", { selected: true }).textContent).toBe( + "DetailChartCost", + ); + }); + expect(screen.queryByText("DetailChartTokens")).toBeNull(); + }); +}); 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 5528fc04e7..f295b123ce 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 @@ -5,6 +5,7 @@ import type { ProviderChartData, SettingsSnapshot } from "../../../../../types/b import type { useLocale } from "../../../../../hooks/useLocale"; import { CostHistoryChart } from "./CostHistoryChart"; import { CreditsHistoryChart } from "./CreditsHistoryChart"; +import { TokensHistoryChart } from "./TokensHistoryChart"; import { UsageBreakdownChart } from "./UsageBreakdownChart"; type T = ReturnType["t"]; @@ -15,7 +16,7 @@ interface Props { t: T; } -type TabKey = "cost" | "credits" | "usage"; +type TabKey = "tokens" | "cost" | "credits" | "usage"; /** * Charts tabs block for the Settings → Providers detail pane. @@ -73,18 +74,26 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) { const hasCost = data.costHistory.length > 0; const hasCredits = data.creditsHistory.length > 0; const hasUsage = data.usageBreakdown.length > 0; + // Tokens mode needs at least one day with exact local token data. + const hasTokens = data.tokensHistory.some((p) => p.tokens > 0); - if (!hasCost && !hasCredits && !hasUsage) return null; + if (!hasCost && !hasCredits && !hasUsage && !hasTokens) return null; const available: TabKey[] = []; if (hasCost) available.push("cost"); if (hasCredits) available.push("credits"); if (hasUsage) available.push("usage"); + if (hasTokens) available.push("tokens"); - const current: TabKey = active && available.includes(active) ? active : available[0]; + // Upstream 0.50.0 #2930: Codex defaults to exact local token totals. + const defaultTab: TabKey = + providerId === "codex" && hasTokens ? "tokens" : available[0]; + const current: TabKey = + active && available.includes(active) ? active : defaultTab; const emptyMsg = t("DetailChartEmpty"); const tabLabel = (k: TabKey): string => { + if (k === "tokens") return t("DetailChartTokens"); if (k === "cost") return t("DetailChartCost"); if (k === "credits") return t("DetailChartCredits"); return t("DetailChartUsageBreakdown"); @@ -108,6 +117,18 @@ export function ChartsSection({ providerId, accountEmail, t }: Props) { ))}
+ {current === "tokens" && ( + + )} {current === "cost" && ( string; +} + +/** + * Tokens chart mode (upstream 0.50.0 #2930): exact local token totals per + * day, defaulting Codex to this view. An incomplete backfill shows a + * "Refreshing" marker instead of silently missing days. + */ +export function TokensHistoryChart({ + data, + title, + ariaLabel, + providerId, + animations, + emptyMessage, + incomplete, + t, +}: Props) { + const recent = data.slice(-30); + const points = recent.map((p) => ({ label: p.date, value: p.tokens })); + return ( +
+
+ {title} + {incomplete && ( + + {t("DetailChartRefreshing")} + + )} +
+ Intl.NumberFormat().format(v)} + animations={animations} + emptyMessage={emptyMessage} + /> +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx index 47485a4205..3ed6842890 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx @@ -115,6 +115,7 @@ const WORKSPACE_EXTRA_IDS: Record = { zed: true, sub2api: true, xai: true, + fireworks: true, }; function extraConfig(providerId: string, t: Props["t"]) { @@ -168,6 +169,13 @@ function extraConfig(providerId: string, t: Props["t"]) { placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", help: "Required. Shown in the xAI Console URL and team settings. Or set XAI_TEAM_ID. Pair with a Management API key (not an inference key).", }; + case "fireworks": + return { + title: "Fireworks account", + label: "Account slug", + placeholder: "your-account-slug", + help: "From app.fireworks.ai/accounts/. Or set FIREWORKS_ACCOUNT_SLUG. Pair with a Fireworks API key to read 30-day rated billing spend.", + }; default: return null; } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index 9b28e52927..941db83a66 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -248,6 +248,7 @@ function providerSourceHintShort( case "groq": case "llmproxy": case "xai": + case "fireworks": return t("ProviderSourceApiShort"); case "kiro": return t("ProviderSourceKiroEnvShort"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 9197dd355c..c74a80ab40 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -34,6 +34,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["codebuff", "Codebuff"], ["deepseek", "DeepSeek"], ["deepinfra", "DeepInfra"], + ["fireworks", "Fireworks"], ["aiand", "ai&"], ["zenmux", "ZenMux"], ["clinepass", "ClinePass"], diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index e0bbe1bde5..cb3019d795 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -593,6 +593,12 @@ export interface DailyCostPoint { value: number; } +/** Exact local token totals per day (upstream 0.50.0 #2930). */ +export interface DailyTokenPoint { + date: string; + tokens: number; +} + export interface ServiceUsagePoint { service: string; creditsUsed: number; @@ -620,6 +626,8 @@ export interface ProviderChartData { creditsHistory: DailyCostPoint[]; usageBreakdown: DailyUsageBreakdown[]; localUsage: ProviderLocalUsageSummary | null; + tokensHistory: DailyTokenPoint[]; + tokensIncomplete: boolean; } // ── Token account types ────────────────────────────────────────────── diff --git a/rust/src/cli/tty_runner.rs b/rust/src/cli/tty_runner.rs index faf9d2de09..4dea2bb510 100755 --- a/rust/src/cli/tty_runner.rs +++ b/rust/src/cli/tty_runner.rs @@ -704,8 +704,9 @@ mod tests { fn test_run_sends_script_through_pty() { let runner = TtyCommandRunner::new(); let opts = TtyCommandOptions::new() - .with_timeout(5.0) - .with_idle_timeout(2.0) + .with_timeout(15.0) + .with_idle_timeout(6.0) + .with_initial_delay(1.0) .with_script_line_delay(0.1); #[cfg(windows)] diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 659cb1183e..326fc161ff 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -80,6 +80,7 @@ pub enum ProviderId { QwenCloud, Notion, Xai, + Fireworks, } impl ProviderId { @@ -155,6 +156,7 @@ impl ProviderId { ProviderId::QwenCloud, ProviderId::Notion, ProviderId::Xai, + ProviderId::Fireworks, ] } @@ -196,6 +198,7 @@ impl ProviderId { ProviderId::Codebuff => "codebuff", ProviderId::DeepSeek => "deepseek", ProviderId::DeepInfra => "deepinfra", + ProviderId::Fireworks => "fireworks", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -272,6 +275,7 @@ impl ProviderId { ProviderId::Codebuff => "Codebuff", ProviderId::DeepSeek => "DeepSeek", ProviderId::DeepInfra => "DeepInfra", + ProviderId::Fireworks => "Fireworks", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -361,6 +365,7 @@ impl ProviderId { ProviderId::Codebuff => None, ProviderId::DeepSeek => None, ProviderId::DeepInfra => None, + ProviderId::Fireworks => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -433,6 +438,7 @@ impl ProviderId { "codebuff" | "manicode" => Some(ProviderId::Codebuff), "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), + "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), "windsurf" | "codeium" => Some(ProviderId::Windsurf), "manus" => Some(ProviderId::Manus), @@ -681,6 +687,8 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("ds", ProviderId::DeepSeek); map.insert("deep-infra", ProviderId::DeepInfra); map.insert("di", ProviderId::DeepInfra); + map.insert("fireworks-ai", ProviderId::Fireworks); + map.insert("fw", ProviderId::Fireworks); map.insert("ai&", ProviderId::AiAnd); map.insert("ai-and", ProviderId::AiAnd); map.insert("codeium", ProviderId::Windsurf); @@ -740,9 +748,10 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 69); + assert_eq!(all.len(), 70); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); + assert!(all.contains(&ProviderId::Fireworks)); assert!(all.contains(&ProviderId::Kimi)); assert!(all.contains(&ProviderId::KimiK2)); assert!(all.contains(&ProviderId::Amp)); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 71582253aa..efba0661f9 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -12,9 +12,9 @@ use crate::providers::{ ClaudeProvider, ClinePassProvider, CodeBuddyProvider, CodebuffProvider, CodexProvider, CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider, CursorProvider, DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider, DoubaoProvider, - ElevenLabsProvider, FactoryProvider, GeminiProvider, GrokProvider, GroqProvider, - InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, KiroProvider, - LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MiMoProvider, + ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider, GrokProvider, + GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider, KimiProvider, + KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider, MiMoProvider, MiniMaxProvider, MistralProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, SakanaProvider, @@ -98,6 +98,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::QwenCloud => Box::new(QwenCloudProvider::new()), ProviderId::Notion => Box::new(NotionProvider::new()), ProviderId::Xai => Box::new(XaiProvider::new()), + ProviderId::Fireworks => Box::new(FireworksProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 72cbe2fc59..ac9edc1609 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -343,7 +343,8 @@ impl TokenAccountSupport { | ProviderId::CrossModel | ProviderId::LongCat | ProviderId::Wayfinder - | ProviderId::QwenCloud => None, + | ProviderId::QwenCloud + | ProviderId::Fireworks => None, } } diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 3cd1e458e9..74c00b3000 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -1008,6 +1008,101 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> { result } +/// Daily token totals (input + output) for the Tokens chart mode, plus +/// whether local history looks incomplete at the old edge of the window +/// (Codex backfill still in progress → the chart shows a "Refreshing" +/// marker; upstream 0.50.0 #2930). +pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)>, bool) { + let scanner = CostScanner::new(days); + let today = Local::now().date_naive(); + let mut daily_tokens: HashMap = HashMap::new(); + let mut covered_days: HashSet = HashSet::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_tokens.insert(date_str, 0); + } + + match provider { + "codex" => { + // Warm/refresh the disk cache, then read exact local token totals + // from packed days through the same summary path the cost chart + // uses. + let _ = scanner.scan_codex(); + let cache = JsonlScanner::load_cache(ProviderId::Codex, scanner.cache_root.as_deref()); + for (day_key, models) in &cache.days { + if !daily_tokens.contains_key(day_key) { + continue; + } + let Some(day) = CostUsageDayRange::parse_day_key(day_key) else { + continue; + }; + let day_range = CostUsageDayRange::new(day, day); + let mut one_day = HashMap::new(); + one_day.insert(day_key.clone(), models.clone()); + let mut scratch = CostSummary::default(); + add_codex_days_map_to_summary(&mut scratch, &one_day, &day_range); + if let Some(slot) = daily_tokens.get_mut(day_key) { + *slot = scratch.input_tokens + scratch.output_tokens; + } + covered_days.insert(day_key.clone()); + } + } + "claude" => { + // Per-day token breakdown from the same de-duplicated record walk + // as the cost chart. The full walk is authoritative, so the + // Refreshing marker never applies here. + let projects_dir = scanner.get_claude_projects_dir(); + if projects_dir.exists() { + let cutoff = Utc::now() - Duration::days(days as i64); + let mut seen = HashSet::new(); + let mut handle_file = |path: &Path| { + for_each_claude_usage_record(path, &cutoff, &mut seen, None, |record| { + add_claude_record_to_daily_tokens(&mut daily_tokens, record); + }); + }; + scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file); + } + } + _ => {} + } + + // Convert to sorted vector + let mut result: Vec<(String, u64)> = daily_tokens.into_iter().collect(); + result.sort_by(|a, b| a.0.cmp(&b.0)); + + // Codex only: the bounded catch-up may not have reached the requested + // depth yet. Incomplete = history exists but the oldest quarter of the + // window has no scanned day. + let incomplete = provider == "codex" + && !covered_days.is_empty() + && covered_days.len() < days as usize + && result[..(result.len() / 4).max(1)] + .iter() + .any(|(date, _)| !covered_days.contains(date)); + + (result, incomplete) +} + +fn add_claude_record_to_daily_tokens( + daily_tokens: &mut HashMap, + record: &ClaudeUsageRecord, +) { + let Some(timestamp) = record.timestamp else { + return; + }; + let date_str = timestamp + .with_timezone(&Local) + .date_naive() + .format("%Y-%m-%d") + .to_string(); + if let Some(slot) = daily_tokens.get_mut(&date_str) { + *slot += record.input + record.output; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 423bfb71b8..90a5573d6c 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -784,6 +784,8 @@ locale_keys! { DetailCostBalance, DetailCostResets, DetailChartCost, + DetailChartTokens, + DetailChartRefreshing, DetailChartCredits, DetailChartUsageBreakdown, DetailChartEmpty, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index cb0ab1ddbd..5788955e17 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -512,6 +512,8 @@ DetailCostRemaining = Remaining DetailCostBalance = Balance DetailCostResets = Resets DetailChartCost = Cost (30 days) +DetailChartTokens = Tokens (30 days) +DetailChartRefreshing = Refreshing… DetailChartCredits = Credits used (30 days) DetailChartUsageBreakdown = Usage by service (30 days) DetailChartEmpty = No chart data yet. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 88f5cc2e08..d6c86feff6 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -511,6 +511,8 @@ DetailCostRemaining = Restante DetailCostBalance = Saldo DetailCostResets = Reinicia DetailChartCost = Costo (30 días) +DetailChartTokens = Tokens (30 días) +DetailChartRefreshing = Actualizando… DetailChartCredits = Créditos usados (30 días) DetailChartUsageBreakdown = Uso por servicio (30 días) DetailChartEmpty = Sin datos de gráfico aún. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index d319fed896..5c9d697989 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -493,6 +493,8 @@ DetailCostRemaining = 残り DetailCostBalance = 残高 DetailCostResets = リセット DetailChartCost = コスト(30日間) +DetailChartTokens = トークン(30日間) +DetailChartRefreshing = 更新中… DetailChartCredits = 使用クレジット(30日間) DetailChartUsageBreakdown = サービス別使用量(30日間) DetailChartEmpty = まだチャートデータはありません。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 7f71c4d019..fb197295a2 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -498,6 +498,8 @@ DetailCostRemaining = 남음 DetailCostBalance = 잔액 DetailCostResets = 초기화 DetailChartCost = 비용 (30일) +DetailChartTokens = 토큰 (30일) +DetailChartRefreshing = 새로 고치는 중… DetailChartCredits = 사용 크레딧 (30일) DetailChartUsageBreakdown = 서비스별 사용량 (30일) DetailChartEmpty = 아직 차트 데이터가 없습니다. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 97d879d7ea..f1305358ba 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -477,6 +477,8 @@ DetailCostRemaining = Осталось DetailCostBalance = Баланс DetailCostResets = Сбрасывает DetailChartCost = Стоимость (30 дней) +DetailChartTokens = Токены (30 дней) +DetailChartRefreshing = Обновление… DetailChartCredits = Использовано кредитов (30 дней) DetailChartUsageBreakdown = Использование службой (30 дней) DetailChartEmpty = Данных диаграммы пока нет. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 3b866b9e56..024c406b96 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -492,6 +492,8 @@ DetailCostRemaining = 剩余 DetailCostBalance = 余额 DetailCostResets = 重置 DetailChartCost = 费用(30 天) +DetailChartTokens = Token(30 天) +DetailChartRefreshing = 刷新中… DetailChartCredits = 已用额度(30 天) DetailChartUsageBreakdown = 按服务划分的用量(30 天) DetailChartEmpty = 暂无图表数据。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 22c4142d3b..a4ccde3ff2 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -492,6 +492,8 @@ DetailCostRemaining = 剩餘 DetailCostBalance = 餘額 DetailCostResets = 重置 DetailChartCost = 費用(30 天) +DetailChartTokens = Token(30 天) +DetailChartRefreshing = 重新整理中… DetailChartCredits = 已用額度(30 天) DetailChartUsageBreakdown = 按服務劃分的用量(30 天) DetailChartEmpty = 暫無圖表資料。 diff --git a/rust/src/providers/amp/mod.rs b/rust/src/providers/amp/mod.rs index 313fa9f3a7..9270317d5a 100755 --- a/rust/src/providers/amp/mod.rs +++ b/rust/src/providers/amp/mod.rs @@ -313,16 +313,20 @@ pub fn parse_amp_free_percent_remaining(text: &str) -> Option { None } -/// Parse Amp subscription display text (Megawatt dual other/orb windows). +/// Parse Amp subscription display text (Megawatt/Gigawatt dual other/orb windows). /// /// Matches: /// `Subscription Megawatt: 42% other usage and 88% orb usage remaining - resets upon renewal in 12 days` +/// `Subscription Gigawatt: 10% other usage and 95% orb usage remaining - resets upon renewal in 2 months` +/// +/// Upstream 0.49.6 #2601: monthly (Gigawatt) renewals advance by calendar +/// month, not 30-day buckets. pub fn parse_amp_subscription_usage( text: &str, now: chrono::DateTime, ) -> Option { let re = regex_lite::Regex::new( - r"(?im)^\s*Subscription\s+(.+?):\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*%\s+other\s+usage\s+and\s+([0-9][0-9,]*(?:\.[0-9]+)?)\s*%\s+orb\s+usage\s+remaining\s*-\s*resets\s+upon\s+renewal\s+in\s+([0-9][0-9,]*)\s+days?(?:\s+-\s+https?://\S+)?\s*$", + r"(?im)^\s*Subscription\s+(.+?):\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*%\s+other\s+usage\s+and\s+([0-9][0-9,]*(?:\.[0-9]+)?)\s*%\s+orb\s+usage\s+remaining\s*-\s*resets\s+upon\s+renewal\s+in\s+([0-9][0-9,]*)\s+(days?|months?)(?:\s+-\s+https?://\S+)?\s*$", ) .ok()?; @@ -336,26 +340,46 @@ pub fn parse_amp_subscription_usage( } let other_remaining = parse_amp_number(caps.get(2)?.as_str())?; let orb_remaining = parse_amp_number(caps.get(3)?.as_str())?; - let renewal_days: i64 = caps.get(4)?.as_str().replace(',', "").parse().ok()?; - if renewal_days < 0 { + let renewal_value: i64 = caps.get(4)?.as_str().replace(',', "").parse().ok()?; + if renewal_value < 0 { continue; } - let reset_description = if renewal_days == 1 { - "renews in 1 day".to_string() + let unit = caps.get(5)?.as_str().to_ascii_lowercase(); + let resets_at = if unit.starts_with("month") { + add_calendar_months(now, renewal_value)? + } else { + now + chrono::Duration::days(renewal_value) + }; + let singular_unit = if unit.starts_with("month") { + "month" } else { - format!("renews in {renewal_days} days") + "day" + }; + let reset_description = if renewal_value == 1 { + format!("renews in 1 {singular_unit}") + } else { + format!("renews in {renewal_value} {singular_unit}s") }; return Some(AmpSubscriptionUsage { plan: plan.to_string(), other_used_percent: 100.0 - other_remaining.clamp(0.0, 100.0), orb_used_percent: 100.0 - orb_remaining.clamp(0.0, 100.0), - resets_at: now + chrono::Duration::days(renewal_days), + resets_at, reset_description, }); } None } +/// Add whole calendar months via chrono's calendar arithmetic, mirroring +/// upstream `Calendar.date(byAdding: .month:)` for monthly renewals. +fn add_calendar_months( + now: chrono::DateTime, + months: i64, +) -> Option> { + now.checked_add_months(chrono::Months::new(u32::try_from(months).ok()?)) +} + /// Build a [`UsageSnapshot`] from Amp Free / subscription display text. /// /// Subscription (Megawatt) wins for primary/secondary windows when present: @@ -392,15 +416,38 @@ pub fn usage_snapshot_from_amp_display_text( } let free_used = parse_amp_free_percent_remaining(text)?; + // Upstream 0.49.6 #2601: the Amp Free daily tier resets at 8:00 PM + // America/New_York, not local midnight. let primary = RateWindow::with_details( free_used, Some(24 * 60), - None, + next_free_tier_reset(now), Some("resets daily".to_string()), ); Some(UsageSnapshot::new(primary).with_login_method("Amp Free")) } +/// Next 8:00 PM America/New_York boundary strictly after `now`. +fn next_free_tier_reset( + now: chrono::DateTime, +) -> Option> { + use chrono::{Datelike, TimeZone}; + let tz = chrono_tz::America::New_York; + let local_now = now.with_timezone(&tz); + let today = local_now.date_naive(); + let today_reset = tz + .with_ymd_and_hms(today.year(), today.month(), today.day(), 20, 0, 0) + .single()? + .with_timezone(&chrono::Utc); + if today_reset > now { + return Some(today_reset); + } + let tomorrow = today + chrono::Duration::days(1); + tz.with_ymd_and_hms(tomorrow.year(), tomorrow.month(), tomorrow.day(), 20, 0, 0) + .single() + .map(|dt| dt.with_timezone(&chrono::Utc)) +} + fn parse_amp_number(raw: &str) -> Option { let value: f64 = raw.replace(',', "").parse().ok()?; value.is_finite().then_some(value) @@ -488,6 +535,46 @@ Subscription Megawatt: 42% other usage and 88% orb usage remaining - resets upon assert!((sub.orb_used_percent - 0.0).abs() < f64::EPSILON); } + #[test] + fn gigawatt_monthly_renewal_advances_calendar_months() { + // Upstream 0.49.6 #2601: monthly renewals (Gigawatt) use calendar + // months, not 30-day buckets. + let now = Utc.with_ymd_and_hms(2026, 8, 17, 12, 0, 0).unwrap(); + let text = "Subscription Gigawatt: 10% other usage and 95% orb usage remaining - resets upon renewal in 2 months"; + let sub = parse_amp_subscription_usage(text, now).expect("subscription"); + assert_eq!(sub.plan, "Gigawatt"); + assert_eq!(sub.reset_description, "renews in 2 months"); + assert_eq!( + sub.resets_at, + Utc.with_ymd_and_hms(2026, 10, 17, 12, 0, 0).unwrap() + ); + } + + #[test] + fn free_tier_resets_at_8pm_new_york() { + // Upstream 0.49.6 #2601: Amp Free resets at 8:00 PM America/New_York. + // 2026-08-17 18:00 UTC = 14:00 EDT → same-day 20:00 EDT = 00:00 UTC Aug 18. + let now = Utc.with_ymd_and_hms(2026, 8, 17, 18, 0, 0).unwrap(); + let snapshot = + usage_snapshot_from_amp_display_text("Amp Free: 72% remaining (resets daily)", now) + .expect("snapshot"); + assert_eq!( + snapshot.primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 8, 18, 0, 0, 0).unwrap()) + ); + + // 2026-08-18 00:30 UTC = 20:30 EDT Aug 17 (after the boundary) → the + // next reset is Aug 18 20:00 EDT = Aug 19 00:00 UTC. + let later = Utc.with_ymd_and_hms(2026, 8, 18, 0, 30, 0).unwrap(); + let snapshot = + usage_snapshot_from_amp_display_text("Amp Free: 72% remaining (resets daily)", later) + .expect("snapshot"); + assert_eq!( + snapshot.primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 8, 19, 0, 0, 0).unwrap()) + ); + } + #[test] fn free_path_still_builds_snapshot() { let now = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(); diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index bc7c730972..828de5308d 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -147,6 +147,16 @@ impl CodexApi { let auth_path = self.get_auth_path(); if !auth_path.exists() { + // Upstream 0.50.0 #2679: when the CLI targets Amazon Bedrock or + // another custom backend without ChatGPT auth, sign-in guidance + // is wrong — rate limits simply are not available there. + if self.uses_custom_backend() { + return Err(ProviderError::NotInstalled( + "Codex uses a custom backend (chatgpt_base_url / model_provider) without \ + ChatGPT auth. ChatGPT rate limits are unavailable for this setup." + .to_string(), + )); + } return Err(ProviderError::NotInstalled( "Codex auth.json not found. Run `codex login` in a terminal to sign in." .to_string(), @@ -271,6 +281,15 @@ impl CodexApi { DEFAULT_BASE_URL.to_string() } + /// Whether config.toml points the CLI at a backend that does not + /// authenticate against ChatGPT (Bedrock / other custom providers). + fn uses_custom_backend(&self) -> bool { + let Ok(content) = std::fs::read_to_string(self.codex_dir().join("config.toml")) else { + return false; + }; + parse_chatgpt_base_url(&content).is_some() || config_uses_non_chatgpt_provider(&content) + } + fn build_result_from_json( &self, json: &serde_json::Value, @@ -974,6 +993,21 @@ fn format_reset_countdown(reset_at: Option>) -> Option { } } +/// Whether config.toml selects a non-ChatGPT model provider (e.g. Bedrock), +/// meaning the CLI never authenticates against ChatGPT. +fn config_uses_non_chatgpt_provider(config_content: &str) -> bool { + config_content.lines().any(|line| { + let Some((key, value)) = line.trim().split_once('=') else { + return false; + }; + if !key.trim().eq_ignore_ascii_case("model_provider") { + return false; + } + let provider = value.trim().trim_matches('"').trim_matches('\''); + !provider.is_empty() && !provider.eq_ignore_ascii_case("openai") + }) +} + fn parse_chatgpt_base_url(config_content: &str) -> Option { for line in config_content.lines() { // Skip comments @@ -1035,6 +1069,24 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn non_chatgpt_model_provider_is_detected_for_guidance() { + // Upstream 0.50.0 #2679: Bedrock and other custom backends get + // rate-limit guidance instead of login instructions. + assert!(config_uses_non_chatgpt_provider( + "model_provider = \"bedrock\"\n" + )); + assert!(config_uses_non_chatgpt_provider( + "# relay\nmodel_provider = 'ollama'" + )); + assert!(!config_uses_non_chatgpt_provider( + "model_provider = \"openai\"" + )); + assert!(!config_uses_non_chatgpt_provider( + "model = \"gpt-5\"\napproval_policy = \"never\"" + )); + } + #[test] fn parses_codex_credentials_without_retaining_refresh_token() { let credentials = CodexApi::parse_credentials_json( diff --git a/rust/src/providers/cursor/app_auth.rs b/rust/src/providers/cursor/app_auth.rs new file mode 100644 index 0000000000..a123ebe68b --- /dev/null +++ b/rust/src/providers/cursor/app_auth.rs @@ -0,0 +1,220 @@ +//! Cursor desktop app auth session (upstream 0.50.0 #2398). +//! +//! Reads Cursor's own read-only local session database +//! (`%APPDATA%\Cursor\User\globalStorage\state.vscdb`) and rebuilds the +//! `WorkosCursorSessionToken` cookie from the stored access token, so +//! Automatic mode prefers the signed-in app over browser cookies. The +//! database is only ever opened read-only; an idle WAL database whose +//! sidecars vanished is retried in SQLite immutable mode (never while a WAL +//! exists — that would ignore live uncheckpointed Cursor state). + +use rusqlite::OpenFlags; + +/// Default `state.vscdb` location. Windows: `%APPDATA%\Cursor\…`; the +/// upstream macOS/Linux layouts differ per OS. +pub fn app_auth_db_path() -> Option { + let base = dirs::config_dir()?; + Some( + base.join("Cursor") + .join("User") + .join("globalStorage") + .join("state.vscdb"), + ) +} + +/// Read the stored `cursorAuth/accessToken` from Cursor's app database. +pub fn load_app_auth_access_token() -> Option { + let db_path = app_auth_db_path()?; + if !db_path.exists() { + return None; + } + match read_item_table_value(&db_path, "cursorAuth/accessToken", false) { + Ok(value) => value, + Err(err) => { + // Immutable retry only when both WAL sidecars are gone — an idle + // WAL database can retain WAL mode in its header after the + // sidecars disappear, and immutable mode reads the main file + // without recreating them. + let wal_missing = !wal_sidecar(&db_path).exists() && !shm_sidecar(&db_path).exists(); + if !wal_missing { + tracing::debug!("Cursor app auth read failed: {err}"); + return None; + } + read_item_table_value(&db_path, "cursorAuth/accessToken", true) + .ok() + .flatten() + .or_else(|| { + tracing::debug!("Cursor app auth immutable read failed: {err}"); + None + }) + } + } +} + +fn wal_sidecar(db_path: &std::path::Path) -> std::path::PathBuf { + let mut name = db_path.as_os_str().to_os_string(); + name.push("-wal"); + std::path::PathBuf::from(name) +} + +fn shm_sidecar(db_path: &std::path::Path) -> std::path::PathBuf { + let mut name = db_path.as_os_str().to_os_string(); + name.push("-shm"); + std::path::PathBuf::from(name) +} + +/// Rebuild the `WorkosCursorSessionToken` cookie header from the app's +/// access token (`{userID}::{token}`, URL-encoded separator). +pub fn app_session_cookie_header(access_token: &str) -> Option { + let token = access_token.trim(); + if token.is_empty() { + return None; + } + let user_id = crate::codex_accounts::api::jwt_payload(token) + .and_then(|payload| { + payload + .get("sub") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .unwrap_or_default(); + Some(format!("WorkosCursorSessionToken={user_id}%3A%3A{token}")) +} + +/// Read one `ItemTable` value from the app database. +fn read_item_table_value( + db_path: &std::path::Path, + key: &str, + immutable: bool, +) -> rusqlite::Result> { + let mut flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let opened_path = if immutable { + // URI + immutable=1: never recreates WAL sidecars on a cold database. + flags |= OpenFlags::SQLITE_OPEN_URI; + format!( + "file:{}?immutable=1", + db_path + .to_str() + .map(|p| p.replace('\\', "/")) + .unwrap_or_default() + ) + } else { + db_path.to_string_lossy().to_string() + }; + let conn = rusqlite::Connection::open_with_flags(&opened_path, flags)?; + conn.busy_timeout(std::time::Duration::from_millis(250))?; + let mut stmt = conn.prepare("SELECT value FROM ItemTable WHERE key = ? LIMIT 1;")?; + let mut rows = stmt.query([key])?; + match rows.next()? { + Some(row) => Ok(row.get_ref(0).ok().and_then(decode_sqlite_string)), + None => Ok(None), + } +} + +/// `ItemTable` values arrive as text or (sometimes) UTF-8/UTF-16LE blobs. +/// UTF-16LE bytes misread as UTF-8 decode "successfully" with interleaved +/// NULs, so a NUL-riddled result routes to the UTF-16LE decoder. +fn decode_sqlite_string(value: rusqlite::types::ValueRef<'_>) -> Option { + match value { + rusqlite::types::ValueRef::Text(bytes) => String::from_utf8(bytes.to_vec()).ok(), + rusqlite::types::ValueRef::Blob(bytes) => String::from_utf8(bytes.to_vec()) + .ok() + .filter(|decoded| !decoded.contains('\0')) + .or_else(|| decode_utf16_le(bytes)), + _ => None, + } +} + +fn decode_utf16_le(bytes: &[u8]) -> Option { + if !bytes.len().is_multiple_of(2) { + return None; + } + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + String::from_utf16(&units).ok() +} + +/// Local app session preference for Automatic mode: the validated header +/// persisted after a successful app-session fetch, else a fresh read of the +/// app database. +pub fn preferred_auto_cookie_header() -> Option { + if let Some(cached) = + crate::browser::cookie_cache::CookieHeaderCache::load(crate::core::ProviderId::Cursor) + && cached.source_label == "cursor-app" + && !cached.is_stale(APP_SESSION_MAX_AGE_SECS) + { + return Some(cached.cookie_header); + } + let access_token = load_app_auth_access_token()?; + app_session_cookie_header(&access_token) +} + +/// Persist a validated app-session header for reuse across refreshes. +pub fn store_validated_app_session(cookie_header: &str) { + if let Err(err) = crate::browser::cookie_cache::CookieHeaderCache::store( + crate::core::ProviderId::Cursor, + cookie_header, + "cursor-app", + ) { + tracing::debug!("Could not persist Cursor app session: {err}"); + } +} + +/// Validated app sessions go stale faster than browser imports: the app +/// rotates its token itself, so re-read the database every 15 minutes. +const APP_SESSION_MAX_AGE_SECS: i64 = 15 * 60; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cookie_header_rebuilds_from_jwt_subject() { + use base64::Engine; + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"user_123"}"#); + let token = format!("header.{payload}.signature"); + let header = app_session_cookie_header(&token).expect("cookie header"); + assert_eq!( + header, + format!("WorkosCursorSessionToken=user_123%3A%3A{token}") + ); + } + + #[test] + fn empty_or_malformed_token_yields_no_cookie() { + assert!(app_session_cookie_header(" ").is_none()); + // A non-JWT token still builds a header with an empty subject — the + // server rejects it and the caller falls back to browser cookies. + assert_eq!( + app_session_cookie_header("opaque-token").as_deref(), + Some("WorkosCursorSessionToken=%3A%3Aopaque-token") + ); + } + + #[test] + fn utf16_le_blob_decodes() { + let bytes: Vec = "WorkosCursorSessionToken" + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect(); + assert_eq!( + decode_sqlite_string(rusqlite::types::ValueRef::Blob(&bytes)).as_deref(), + Some("WorkosCursorSessionToken") + ); + // Unpaired surrogate (0xD800) is invalid UTF-16 → no value. + assert!( + decode_sqlite_string(rusqlite::types::ValueRef::Blob(&[0x00, 0xd8, 0x41, 0x00])) + .is_none() + ); + } + + #[test] + fn db_path_points_at_cursor_global_storage() { + let path = app_auth_db_path().expect("path"); + assert!(path.ends_with("state.vscdb")); + assert!(path.to_string_lossy().contains("Cursor")); + } +} diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 96fdd0ef3e..cef75060b2 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -3,6 +3,7 @@ //! Fetches usage data from Cursor's API using browser cookies mod api; +mod app_auth; mod token_cost; use async_trait::async_trait; @@ -53,18 +54,70 @@ impl CursorProvider { let cookie_header = if let Some(cookie_header) = ctx.manual_cookie_header.as_deref() { cookie_header.to_string() } else { + // Upstream 0.50.0 #2398: Automatic mode prefers the signed-in + // Cursor app's read-only local session over browser cookies. + // A rejected app session (stale token, account mismatch) + // surfaces in the log and falls back to the browser import. + if ctx.source_mode == SourceMode::Auto + && let Some(app_result) = self.fetch_via_app_session().await + { + return Ok(app_result); + } crate::providers::browser_cookie_header(&["cursor.com", "cursor.sh"])? }; + self.fetch_usage_and_token_report(&cookie_header).await + } + + /// One usage pass with the app's local session; `None` means the app + /// session was unavailable or rejected (caller falls back to cookies). + async fn fetch_via_app_session( + &self, + ) -> Option<( + api::CursorUsageResult, + Option, + )> { + let app_cookie = app_auth::preferred_auto_cookie_header()?; + let usage = match self.api.fetch_usage_with_cookie_header(&app_cookie).await { + Ok(usage) => usage, + Err(err) => { + tracing::debug!( + "Cursor app session rejected ({err}); falling back to browser cookies" + ); + return None; + } + }; + app_auth::store_validated_app_session(&app_cookie); + let token_report = self.fetch_token_report_best_effort(&app_cookie).await; + Some((usage, token_report)) + } + + async fn fetch_usage_and_token_report( + &self, + cookie_header: &str, + ) -> Result< + ( + api::CursorUsageResult, + Option, + ), + ProviderError, + > { let usage = self .api - .fetch_usage_with_cookie_header(&cookie_header) + .fetch_usage_with_cookie_header(cookie_header) .await?; + let token_report = self.fetch_token_report_best_effort(cookie_header).await; + Ok((usage, token_report)) + } - // Best-effort token-cost page; never fail the main usage fetch. - let token_report = match token_cost::fetch_token_cost_report( + /// Best-effort token-cost page; never fail the main usage fetch. + async fn fetch_token_report_best_effort( + &self, + cookie_header: &str, + ) -> Option { + match token_cost::fetch_token_cost_report( self.api.client(), - &cookie_header, + cookie_header, Some(token_cost::default_since()), Some(chrono::Utc::now()), ) @@ -75,9 +128,7 @@ impl CursorProvider { tracing::debug!("Cursor token-cost events unavailable: {err}"); None } - }; - - Ok((usage, token_report)) + } } fn build_usage_snapshot( diff --git a/rust/src/providers/fireworks/mod.rs b/rust/src/providers/fireworks/mod.rs new file mode 100644 index 0000000000..4dab3189c8 --- /dev/null +++ b/rust/src/providers/fireworks/mod.rs @@ -0,0 +1,383 @@ +//! Fireworks AI provider implementation. +//! +//! Fetches 30-day rated billing spend from the Fireworks billing API: +//! `GET https://api.fireworks.ai/v1/accounts/{slug}/billing/summary?startTime=&endTime=` +//! +//! Fireworks is prepaid with no quota windows and exposes no credit-balance +//! API, so rated spend is the only usable usage signal (upstream 0.49.0 +//! #2687). Ported from steipete/CodexBar `FireworksUsageFetcher`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; + +const BILLING_SUMMARY_URL: &str = "https://api.fireworks.ai/v1/accounts"; +const CREDENTIAL_TARGET: &str = "codexbar-fireworks"; +const ENV_KEYS: &[&str] = &["FIREWORKS_API_KEY"]; +const SLUG_ENV_KEYS: &[&str] = &["FIREWORKS_ACCOUNT_SLUG"]; +const LOOKBACK_DAYS: i64 = 30; +/// Characters permitted in a Fireworks account slug. Slugs are simple +/// lower-case ASCII path segments; restricting to this explicit ASCII set +/// means a misconfigured slug can never widen the request path or inject a +/// query (upstream `accountSlugAllowedCharacters`). +const SLUG_ALLOWED: fn(char) -> bool = + |c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BillingSummaryResponse { + #[serde(default)] + line_items: Vec, + #[serde(default)] + #[allow(dead_code)] + usage_buckets: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LineItem { + #[serde(default)] + #[allow(dead_code)] + category: Option, + #[serde(default)] + total_cost: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Money { + currency_code: Option, + nanos: Option, + /// Google-style money `units` serialized as a string. + units: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageBucket { + #[serde(default)] + #[allow(dead_code)] + bucket_start_time: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct FireworksSummary { + last_30_days_spend: Option, + currency_code: Option, +} + +impl FireworksSummary { + fn from_response(response: &BillingSummaryResponse) -> Self { + // Rated line items arrive grouped by category/model; the newest-rated + // currency decides the display currency and only rows in that + // currency are summed (upstream `parseSummary`). + let mut currency: Option = None; + let mut total = 0.0_f64; + for item in &response.line_items { + let Some(cost) = item.total_cost.as_ref() else { + continue; + }; + let Some(units) = cost + .units + .as_deref() + .and_then(|units| units.parse::().ok()) + else { + continue; + }; + let Some(code) = cost + .currency_code + .as_deref() + .map(str::trim) + .filter(|code| !code.is_empty()) + else { + continue; + }; + if currency.is_none() { + currency = Some(code.to_string()); + } + if currency.as_deref() != Some(code) { + continue; + } + total += units + cost.nanos.unwrap_or(0) as f64 / 1_000_000_000.0; + } + + Self { + last_30_days_spend: currency.as_ref().map(|_| total), + currency_code: currency, + } + } + + fn to_usage_snapshot(&self) -> UsageSnapshot { + // Fireworks is prepaid with no quota windows, so no RateWindows are + // synthesized; the spend text rides the primary description (upstream + // emits a cost-only snapshot). + let spend_text = self + .last_30_days_spend + .zip(self.currency_code.as_deref()) + .map(|(spend, _)| format_money(spend)); + let mut primary = RateWindow::new(0.0); + primary.reset_description = spend_text.clone(); + let mut snapshot = UsageSnapshot::new(primary); + if let Some(text) = spend_text { + snapshot = snapshot.with_login_method(text); + } + snapshot + } + + fn to_cost_snapshot(&self) -> Option { + let spend = self.last_30_days_spend?; + let currency = self.currency_code.as_deref().unwrap_or("USD"); + Some(CostSnapshot::new(spend, currency, "Last 30 days")) + } +} + +fn format_money(value: f64) -> String { + format!("${value:.2}") +} + +pub struct FireworksProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl FireworksProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Fireworks, + display_name: "Fireworks", + session_label: "Spend", + weekly_label: "Spend", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://app.fireworks.ai"), + status_page_url: None, + }, + client: crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + fn resolve_api_key(api_key: Option<&str>) -> Result { + let raw = crate::providers::resolve_api_key(api_key, CREDENTIAL_TARGET, ENV_KEYS)?; + let cleaned = raw.trim().to_string(); + if cleaned.is_empty() { + return Err(ProviderError::NotInstalled( + "Missing Fireworks API key. Add one in Settings or set FIREWORKS_API_KEY." + .to_string(), + )); + } + Ok(cleaned) + } + + /// Account slug from settings (provider workspace slot) or + /// `FIREWORKS_ACCOUNT_SLUG`. Validated against the upstream slug charset + /// so a bad slug surfaces as a config error, not a widened request path. + fn resolve_account_slug(ctx: &FetchContext) -> Result { + let from_env = SLUG_ENV_KEYS.iter().find_map(|key| std::env::var(key).ok()); + let raw = from_env + .or_else(|| ctx.workspace_id.as_deref().map(str::to_string)) + .unwrap_or_default(); + let slug = raw.trim().to_string(); + if slug.is_empty() { + return Err(ProviderError::NotInstalled( + "Fireworks needs the account slug from app.fireworks.ai/accounts/. Set FIREWORKS_ACCOUNT_SLUG or the slug field in Settings." + .to_string(), + )); + } + if !slug.chars().all(SLUG_ALLOWED) { + return Err(ProviderError::Other(format!( + "Invalid Fireworks account slug '{slug}'. Please double-check the account slug in Settings." + ))); + } + Ok(slug) + } + + fn summary_url(slug: &str, now: DateTime) -> String { + let start = now - chrono::Duration::days(LOOKBACK_DAYS); + format!( + "{BILLING_SUMMARY_URL}/{slug}/billing/summary?startTime={}&endTime={}", + start.to_rfc3339(), + now.to_rfc3339() + ) + } + + async fn fetch_usage_api( + &self, + ctx: &FetchContext, + ) -> Result { + let api_key = Self::resolve_api_key(ctx.api_key.as_deref())?; + let slug = Self::resolve_account_slug(ctx)?; + let url = Self::summary_url(&slug, Utc::now()); + + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .send() + .await?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::Other( + "Fireworks rejected the API key. Create a new key at app.fireworks.ai and update Settings." + .to_string(), + )); + } + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(ProviderError::Other( + "Fireworks rate limit exceeded. Usage will refresh on the next cycle.".to_string(), + )); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "Fireworks billing API returned HTTP {status}." + ))); + } + + let body = resp + .text() + .await + .map_err(|e| ProviderError::Parse(format!("Could not read Fireworks usage: {e}")))?; + let summary = parse_summary_for_testing(&body)?; + + let mut result = ProviderFetchResult::new(summary.to_usage_snapshot(), "api"); + if let Some(cost) = summary.to_cost_snapshot() { + result = result.with_cost(cost); + } + Ok(result) + } +} + +impl Default for FireworksProvider { + fn default() -> Self { + Self::new() + } +} + +fn parse_summary_for_testing(body: &str) -> Result { + let response: BillingSummaryResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("Could not parse Fireworks usage: {e}")))?; + Ok(FireworksSummary::from_response(&response)) +} + +#[async_trait] +impl Provider for FireworksProvider { + fn id(&self) -> ProviderId { + ProviderId::Fireworks + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sums_rated_line_items_in_first_currency() { + let summary = parse_summary_for_testing( + r#"{ + "lineItems": [ + {"category": "inference", "totalCost": {"currencyCode": "USD", "units": "12", "nanos": 500000000}}, + {"category": "fine-tuning", "totalCost": {"currencyCode": "USD", "units": "3", "nanos": 250000000}}, + {"category": "training", "totalCost": {"currencyCode": "EUR", "units": "1", "nanos": 0}}, + {"category": "unrated"} + ], + "usageBuckets": [] + }"#, + ) + .unwrap(); + + assert!((summary.last_30_days_spend.unwrap() - 15.75).abs() < 1e-9); + assert_eq!(summary.currency_code.as_deref(), Some("USD")); + + let cost = summary.to_cost_snapshot().unwrap(); + assert!((cost.used - 15.75).abs() < 1e-9); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "Last 30 days"); + + let usage = summary.to_usage_snapshot(); + assert_eq!(usage.primary.used_percent, 0.0); + assert_eq!(usage.primary.reset_description.as_deref(), Some("$15.75")); + } + + #[test] + fn unrated_summary_yields_no_spend() { + let summary = parse_summary_for_testing( + r#"{"lineItems": [{"category": "pending"}], "usageBuckets": []}"#, + ) + .unwrap(); + + assert!(summary.last_30_days_spend.is_none()); + assert!(summary.currency_code.is_none()); + assert!(summary.to_cost_snapshot().is_none()); + } + + #[test] + fn slug_validation_rejects_path_and_query_injection() { + let ctx = |slug: &str| FetchContext { + source_mode: SourceMode::OAuth, + workspace_id: Some(slug.to_string()), + ..FetchContext::default() + }; + + let err = FireworksProvider::resolve_account_slug(&ctx(" ")).unwrap_err(); + assert!(err.to_string().contains("account slug"), "{err}"); + + assert!(FireworksProvider::resolve_account_slug(&ctx("acme_corp.1-2")).is_ok()); + assert!(FireworksProvider::resolve_account_slug(&ctx("../etc")).is_err()); + assert!(FireworksProvider::resolve_account_slug(&ctx("a?x=1")).is_err()); + + let url = FireworksProvider::summary_url( + "acme", + DateTime::parse_from_rfc3339("2026-08-17T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + ); + assert!( + url.starts_with("https://api.fireworks.ai/v1/accounts/acme/billing/summary?startTime=") + ); + assert!(url.contains("&endTime=2026-08-17T00:00:00")); + } + + #[test] + fn metadata_matches_upstream_descriptor() { + let provider = FireworksProvider::new(); + assert_eq!(provider.id(), ProviderId::Fireworks); + assert_eq!(provider.metadata().display_name, "Fireworks"); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://app.fireworks.ai") + ); + assert_eq!(provider.metadata().status_page_url, None); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + } +} diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index eda6f4848a..9fecad7601 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -65,6 +65,12 @@ struct KimiSubscriptionStatsResponse { struct KimiSubscriptionBalance { amount_used_ratio: Option, expire_time: Option, + /// Pool scoping (upstream 0.49.0 #2741): only the omni/subscription pool + /// is the shared "Total usage" lane; feature-scoped balances are not. + #[serde(default)] + feature: Option, + #[serde(default, rename = "type")] + balance_type: Option, } #[derive(Debug, Deserialize)] @@ -296,21 +302,26 @@ fn kimi_window_minutes(window: &KimiWindow) -> Option { } } -/// Shared merge of the membership-pool windows (`Monthly` + `Code 7-day`) +/// Shared merge of the membership-pool windows (`Total usage` + `Code 7-day`) /// recovered from the subscription-stats endpoint — used by the web fetch and /// by the upstream 0.48.0 Code-API/CLI enrichment (#2622). fn apply_subscription_windows( mut usage: UsageSnapshot, subscription: &KimiSubscriptionStatsResponse, ) -> UsageSnapshot { + // Upstream 0.49.0 #2741: the membership pool is the official "Total usage" + // lane — the shared subscription pool (`amountUsedRatio`), not the + // Code-only ratio. Feature-scoped or non-subscription balances are skipped. if let Some(balance) = subscription.subscription_balance.as_ref() + && matches!(balance.feature.as_deref(), None | Some("FEATURE_OMNI")) + && matches!(balance.balance_type.as_deref(), None | Some("SUBSCRIPTION")) && let Some(ratio) = value_as_f64(balance.amount_used_ratio.as_ref()).filter(|value| value.is_finite()) { // Verified monthly sentinel (#2431 / #2566). usage = usage.with_extra_rate_window( "kimi-monthly", - "Monthly", + "Total usage", RateWindow::with_details( ratio * 100.0, Some(30 * 24 * 60), @@ -324,21 +335,42 @@ fn apply_subscription_windows( && limit.enabled.unwrap_or(true) && let Some(ratio) = value_as_f64(limit.ratio.as_ref()).filter(|value| value.is_finite()) { - usage = usage.with_extra_rate_window( - "kimi-code-7d", - "Code 7-day", - RateWindow::with_details( - ratio * 100.0, - Some(10080), - limit.reset_time.as_ref().and_then(parse_kimi_timestamp), - None, - ), + // Upstream 0.49.0 #2741: the membership 7-day Code ratio and the + // FEATURE_CODING weekly detail report the same quota through two + // endpoints — keep the row only where it genuinely diverges. + let window = RateWindow::with_details( + ratio * 100.0, + Some(10080), + limit.reset_time.as_ref().and_then(parse_kimi_timestamp), + None, ); + if !is_equivalent_to_weekly_window(&window, &usage.primary) { + usage = usage.with_extra_rate_window("kimi-code-7d", "Code 7-day", window); + } } usage } +/// Upstream `isEquivalentToWeeklyWindow` (#2741): suppress the Code 7-day row +/// only on positive evidence — the weekly counter must be reliable (window +/// minutes present), the percentages must agree within 1 point, and both lanes +/// need reset timestamps within 5 minutes of each other. +fn is_equivalent_to_weekly_window(window: &RateWindow, weekly: &RateWindow) -> bool { + if weekly.window_minutes.is_none() { + return false; + } + if (window.used_percent - weekly.used_percent).abs() > 1.0 { + return false; + } + match (window.resets_at, weekly.resets_at) { + (Some(code_reset), Some(weekly_reset)) => { + (code_reset - weekly_reset).num_seconds().abs() <= 5 * 60 + } + _ => false, + } +} + async fn kimi_web_post( client: &Client, url: &str, @@ -551,7 +583,8 @@ mod tests { .iter() .find(|window| window.id == "kimi-monthly") .unwrap(); - assert_eq!(monthly.title, "Monthly"); + // Upstream 0.49.0 #2741: official lane name for the shared pool. + assert_eq!(monthly.title, "Total usage"); assert_eq!(monthly.window.window_minutes, Some(30 * 24 * 60)); assert!((monthly.window.used_percent - 77.16).abs() < 0.0001); let code_7d = snapshot @@ -564,6 +597,103 @@ mod tests { assert!((code_7d.window.used_percent - 9.46).abs() < 0.0001); } + #[test] + fn feature_scoped_balance_is_not_the_total_usage_lane() { + // Upstream 0.49.0 #2741: only the omni/subscription pool maps to the + // "Total usage" lane; feature-scoped balances must not. + let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { "limit": "2048", "used": "375" } + }] + })) + .unwrap(); + let subscription: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ + "subscriptionBalance": { + "amountUsedRatio": 0.5, + "feature": "FEATURE_CODING", + "type": "SUBSCRIPTION" + } + })) + .unwrap(); + + let snapshot = web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap(); + assert!( + snapshot + .extra_rate_windows + .iter() + .all(|window| window.id != "kimi-monthly") + ); + } + + #[test] + fn duplicate_code_7d_row_is_hidden_when_matching_weekly() { + // Upstream 0.49.0 #2741: when the membership Code 7-day ratio and the + // primary weekly window agree (percent within 1 point, resets within + // 5 minutes, weekly counter reliable), the extra row is suppressed. + let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { + "limit": "1000", + "used": "420", + "resetTime": "2026-08-13T15:28:00Z" + } + }] + })) + .unwrap(); + let subscription_matching: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ + "ratelimitCode7d": { + "ratio": 0.421, + "enabled": true, + "resetTime": "2026-08-13T15:30:00Z" + } + })) + .unwrap(); + + let snapshot = + web::snapshot_from_web_usage_response(usage, Some(subscription_matching)).unwrap(); + + assert!((snapshot.primary.used_percent - 42.0).abs() < f64::EPSILON); + assert!( + snapshot + .extra_rate_windows + .iter() + .all(|window| window.id != "kimi-code-7d"), + "matching Code 7-day row should be suppressed" + ); + + // Diverging ratio (or missing reset evidence) keeps the row. + let subscription_diverging: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ + "ratelimitCode7d": { + "ratio": 0.9, + "enabled": true, + "resetTime": "2026-08-13T15:30:00Z" + } + })) + .unwrap(); + let usage_diverging: KimiWebUsageResponse = serde_json::from_value(json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { + "limit": "1000", + "used": "420", + "resetTime": "2026-08-13T15:28:00Z" + } + }] + })) + .unwrap(); + let snapshot = + web::snapshot_from_web_usage_response(usage_diverging, Some(subscription_diverging)) + .unwrap(); + assert!( + snapshot + .extra_rate_windows + .iter() + .any(|window| window.id == "kimi-code-7d") + ); + } + #[test] fn cleaned_env_strips_quotes() { assert_eq!(cleaned_owned(" \"token\" ").as_deref(), Some("token")); diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs index c067e9d6f8..a69038588e 100644 --- a/rust/src/providers/longcat/mod.rs +++ b/rust/src/providers/longcat/mod.rs @@ -16,6 +16,8 @@ const HOST: &str = "https://longcat.chat"; const USER_CURRENT: &str = "/api/v1/user-current"; const TOKEN_USAGE: &str = "/api/lc-platform/v1/tokenUsage"; const PENDING_FUEL: &str = "/api/lc-platform/v1/pending-fuel-packages"; +/// Live token-pack lot summary (upstream 0.49.4 #2670). +const TOKEN_PACKS_SUMMARY: &str = "/api/pay/quota/metering/token-packs/summary"; pub struct LongCatProvider { metadata: ProviderMetadata, @@ -68,6 +70,33 @@ impl LongCatProvider { .await .map_err(|e| ProviderError::Parse(format!("Failed to parse LongCat {path}: {e}"))) } + + /// POST variant of [`Self::get_json`] used by the token-packs summary + /// endpoint (upstream 0.49.4 #2670 posts an empty JSON body). + async fn post_json(&self, path: &str, cookie: &str) -> Result { + let url = format!("{HOST}{path}"); + let resp = self + .client + .post(&url) + .header("Cookie", cookie) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .body("{}") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "LongCat API {path} returned HTTP {status}" + ))); + } + resp.json() + .await + .map_err(|e| ProviderError::Parse(format!("Failed to parse LongCat {path}: {e}"))) + } } impl Default for LongCatProvider { @@ -100,13 +129,33 @@ impl Provider for LongCatProvider { { return Err(ProviderError::AuthRequired); } - let usage_raw = self.get_json(TOKEN_USAGE, &cookie).await?; + // Upstream 0.49.4 #2670: prefer the token-packs summary lot; + // only fall back to the legacy token-usage endpoint when no + // active lot is available. + let token_packs = match self.post_json(TOKEN_PACKS_SUMMARY, &cookie).await { + Ok(v) => Some(v), + Err(ProviderError::AuthRequired) => return Err(ProviderError::AuthRequired), + Err(err) => { + tracing::debug!("LongCat token-packs summary probe failed: {err}"); + None + } + }; + let usage_raw = if token_packs.as_ref().is_some_and(has_active_token_pack_lot) { + None + } else { + Some(self.get_json(TOKEN_USAGE, &cookie).await?) + }; let fuel = match self.get_json(PENDING_FUEL, &cookie).await { Ok(v) => Some(v), Err(ProviderError::AuthRequired) => return Err(ProviderError::AuthRequired), Err(_) => None, }; - let snap = build_snapshot(&account, &usage_raw, fuel.as_ref())?; + let snap = build_snapshot( + &account, + token_packs.as_ref(), + usage_raw.as_ref(), + fuel.as_ref(), + )?; Ok(ProviderFetchResult::new(snap, "web")) } SourceMode::Cli | SourceMode::OAuth => { @@ -157,22 +206,53 @@ fn json_str(value: &Value, key: &str) -> Option { .filter(|s| !s.is_empty()) } +/// Whether the token-packs summary carries an ACTIVE lot with a positive +/// total (upstream `activeTokenPackLot`). +fn has_active_token_pack_lot(summary: &Value) -> bool { + active_token_pack_lot(summary).is_some() +} + +fn active_token_pack_lot(summary: &Value) -> Option { + let lot = envelope_data(summary).get("currentLot")?; + if json_str(lot, "status")?.to_uppercase() != "ACTIVE" { + return None; + } + json_f64(lot, "totalToken").filter(|total| *total > 0.0)?; + Some(lot.clone()) +} + fn build_snapshot( account: &Value, - usage_raw: &Value, + token_packs: Option<&Value>, + usage_raw: Option<&Value>, fuel_raw: Option<&Value>, ) -> Result { let account_data = envelope_data(account); - let usage_outer = envelope_data(usage_raw); - let usage = usage_outer - .get("usage") - .filter(|u| u.is_object()) - .unwrap_or(usage_outer); - let total = json_f64(usage, "totalToken") - .ok_or_else(|| ProviderError::Parse("tokenUsage data was missing totalToken".into()))?; - let remaining = json_f64(usage, "availableToken"); - let used = remaining.map(|r| (total - r).max(0.0)).unwrap_or(0.0); + let (total, used) = if let Some(lot) = token_packs.and_then(active_token_pack_lot) { + // Active token-pack lot: consumed/total drive the quota directly. + let total = json_f64(&lot, "totalToken") + .ok_or_else(|| ProviderError::Parse("token-packs lot missing totalToken".into()))?; + let used = json_f64(&lot, "consumedToken").unwrap_or(0.0); + (total, used) + } else if let Some(usage_raw) = usage_raw { + // Legacy token quota: data.usage is the canonical aggregate. + let usage_outer = envelope_data(usage_raw); + let usage = usage_outer + .get("usage") + .filter(|u| u.is_object()) + .unwrap_or(usage_outer); + let total = json_f64(usage, "totalToken") + .ok_or_else(|| ProviderError::Parse("tokenUsage data was missing totalToken".into()))?; + let remaining = json_f64(usage, "availableToken"); + let used = remaining.map(|r| (total - r).max(0.0)).unwrap_or(0.0); + (total, used) + } else { + return Err(ProviderError::Parse( + "LongCat usage data was missing (no active token-pack lot and no tokenUsage payload)" + .into(), + )); + }; let primary = if total > 0.0 { let mut w = RateWindow::new(((used / total) * 100.0).clamp(0.0, 100.0)); @@ -282,13 +362,57 @@ mod tests { ] } }); - let snap = build_snapshot(&account, &usage, Some(&fuel)).unwrap(); + let snap = build_snapshot(&account, None, Some(&usage), Some(&fuel)).unwrap(); assert!((snap.primary.used_percent - 75.0).abs() < 0.01); assert_eq!(snap.account_organization.as_deref(), Some("cat")); let fuel_w = snap.secondary.unwrap(); assert!((fuel_w.used_percent - 75.0).abs() < 0.01); } + #[test] + fn active_token_pack_lot_wins_over_legacy_usage() { + // Upstream 0.49.4 #2670: the token-packs summary lot is the live + // usage source; the legacy endpoint is only a fallback. + let account = json!({ "code": 0, "data": { "name": "cat" } }); + let summary = json!({ + "code": 0, + "data": { + "currentLot": { + "status": "active", + "totalToken": 5000, + "consumedToken": 1250 + } + } + }); + let snap = build_snapshot(&account, Some(&summary), None, None).unwrap(); + assert!((snap.primary.used_percent - 25.0).abs() < 0.01); + assert_eq!(snap.primary.reset_description.as_deref(), Some("1250/5000")); + } + + #[test] + fn inactive_or_empty_lot_falls_back_to_legacy_usage() { + let account = json!({ "code": 0, "data": { "name": "cat" } }); + let inactive = json!({ + "code": 0, + "data": { + "currentLot": { "status": "EXPIRED", "totalToken": 5000, "consumedToken": 10 } + } + }); + let usage = json!({ + "code": 0, + "data": { "usage": { "totalToken": 1000, "availableToken": 900 } } + }); + let snap = build_snapshot(&account, Some(&inactive), Some(&usage), None).unwrap(); + assert!((snap.primary.used_percent - 10.0).abs() < 0.01); + + let no_total = json!({ "code": 0, "data": { "currentLot": { "status": "ACTIVE" } } }); + assert!(!has_active_token_pack_lot(&no_total)); + assert!(has_active_token_pack_lot(&json!({ + "code": 0, + "data": { "currentLot": { "status": "ACTIVE", "totalToken": 5 } } + }))); + } + #[test] fn normalizes_cookie() { assert_eq!( diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 288b2ee7c7..67bbf84b07 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -29,6 +29,7 @@ pub mod devin; pub mod doubao; pub mod elevenlabs; pub mod factory; +pub mod fireworks; pub mod gemini; pub mod grok; pub mod groq; @@ -101,6 +102,7 @@ pub use devin::DevinProvider; pub use doubao::DoubaoProvider; pub use elevenlabs::ElevenLabsProvider; pub use factory::FactoryProvider; +pub use fireworks::FireworksProvider; pub use gemini::GeminiProvider; pub use grok::GrokProvider; pub use groq::GroqProvider; diff --git a/rust/src/providers/opencode/billing.rs b/rust/src/providers/opencode/billing.rs new file mode 100644 index 0000000000..2ac874dcb0 --- /dev/null +++ b/rust/src/providers/opencode/billing.rs @@ -0,0 +1,325 @@ +//! OpenCode billing subsystem (pay-as-you-go workspaces). +//! +//! Extracted from `mod.rs` (upstream 0.49.5 #2504/#2697). The billing +//! server function carries the monthly spend fields pay-as-you-go +//! workspaces bill against; this module owns the DTO, JSON/SolidStart +//! parsing, the HTTP fallback, and the presentation mapping. + +use serde_json::Value; +use uuid::Uuid; + +use crate::core::{ProviderError, RateWindow, UsageSnapshot}; + +use super::{BASE_URL, OpenCodeProvider, SERVER_URL}; + +/// Customer/billing server function carrying the monthly spend fields +/// pay-as-you-go workspaces bill against (upstream 0.49.5 #2504/#2697). +pub(super) const BILLING_SERVER_ID: &str = + "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d"; + +/// Billing/customer payload for an OpenCode workspace (upstream +/// `OpenCodeZenBillingInfo`). `monthlyUsage` and `balance` arrive as +/// fixed-point integers scaled by 1e8; `monthlyLimit` is whole USD. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenCodeZenBilling { + /// Spend in the current monthly cycle, in USD. + pub monthly_usage_usd: f64, + /// Configured monthly spend limit, in USD (`None` when unset). + pub monthly_limit_usd: Option, + /// Remaining prepaid balance, in USD. + pub balance_usd: Option, + /// Whether the workspace still carries a subscription object (legacy + /// quota accounts). + pub has_subscription: bool, +} + +const ZEN_USD_SCALE: f64 = 100_000_000.0; + +/// Parse the customer/billing payload. The response may arrive as +/// SolidStart's `$R[...]` JavaScript payload rather than JSON, so the JSON +/// path is tried first and a tolerant field scan is the fallback. A +/// `customerID` must be present before any number is trusted (upstream +/// `OpenCodeZenBillingParser`). +pub(super) fn parse_zen_billing(text: &str) -> Option { + if let Ok(value) = serde_json::from_str::(text) + && let Some(customer) = find_customer_object(&value) + { + let raw_usage = json_number(customer.get("monthlyUsage")?)?; + return Some(OpenCodeZenBilling { + monthly_usage_usd: raw_usage / ZEN_USD_SCALE, + monthly_limit_usd: customer.get("monthlyLimit").and_then(json_number), + balance_usd: customer + .get("balance") + .and_then(json_number) + .map(|v| v / ZEN_USD_SCALE), + has_subscription: customer.get("subscription").is_some_and(|s| !s.is_null()), + }); + } + parse_zen_billing_payload(text) +} + +/// Find the object carrying a non-empty `customerID`, at the root or nested +/// one level under any key. +fn find_customer_object(value: &Value) -> Option<&serde_json::Map> { + let mut candidates: Vec<&serde_json::Map> = Vec::new(); + if let Some(object) = value.as_object() { + candidates.push(object); + for child in object.values() { + if let Some(child_object) = child.as_object() { + candidates.push(child_object); + } + } + } + candidates.into_iter().find(|object| { + object + .get("customerID") + .and_then(|id| id.as_str()) + .is_some_and(|id| !id.is_empty()) + }) +} + +fn json_number(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_i64().map(|i| i as f64)) + .or_else(|| value.as_str()?.parse().ok()) +} + +/// Tolerant `$R[...]` payload scan with the same field semantics. +fn parse_zen_billing_payload(text: &str) -> Option { + let customer_id = regex_lite::Regex::new(r#""?customerID"?\s*:\s*"[^"]+""#).ok()?; + if !customer_id.is_match(text) { + return None; + } + let raw_usage = payload_number("monthlyUsage", text)?; + Some(OpenCodeZenBilling { + monthly_usage_usd: raw_usage / ZEN_USD_SCALE, + monthly_limit_usd: payload_number("monthlyLimit", text), + balance_usd: payload_number("balance", text).map(|v| v / ZEN_USD_SCALE), + has_subscription: payload_has_subscription(text), + }) +} + +fn payload_number(field: &str, text: &str) -> Option { + let re = + regex_lite::Regex::new(&format!(r#""?{field}"?\s*:\s*(-?[0-9]+(?:\.[0-9]+)?)"#)).ok()?; + re.captures(text)?.get(1)?.as_str().parse().ok() +} + +/// A subscription is only considered present when the field exists and is +/// not `null`, so a pay-as-you-go payload never routes back into the retired +/// subscription path. +fn payload_has_subscription(text: &str) -> bool { + let Ok(present) = regex_lite::Regex::new(r#""?subscription"?\s*:\s*[^,}]+"#) else { + return false; + }; + let Ok(is_null) = regex_lite::Regex::new(r#""?subscription"?\s*:\s*null"#) else { + return false; + }; + present.is_match(text) && !is_null.is_match(text) +} + +impl OpenCodeProvider { + /// Only subscription-shaped failures are worth retrying against billing + /// (upstream `canFallBackToBilling`): credential and transport failures + /// would fail the same way on the billing call. + pub(super) fn can_fall_back_to_billing(err: &ProviderError) -> bool { + matches!(err, ProviderError::Parse(_) | ProviderError::Other(_)) + } + + /// Billing/customer payload fallback for pay-as-you-go workspaces. + /// + /// Returns `Err` only for an actionable signed-out diagnosis (which wins + /// over the original error, matching upstream), `Ok(None)` when the + /// billing payload does not carry monthly usage fields or still reports + /// a subscription. + pub(super) async fn fetch_pay_as_you_go_usage( + &self, + workspace_id: &str, + cookie_header: &str, + ) -> Result, ProviderError> { + let referer = format!("https://opencode.ai/workspace/{workspace_id}"); + let args = serde_json::json!([workspace_id]); + let encoded_args = Self::url_encode(&args.to_string()); + let url = format!( + "{}?id={}&args={}", + SERVER_URL, BILLING_SERVER_ID, encoded_args + ); + + let response = self + .client + .get(&url) + .header("Cookie", cookie_header) + .header("X-Server-Id", BILLING_SERVER_ID) + .header("X-Server-Instance", format!("server-fn:{}", Uuid::new_v4())) + .header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + ) + .header("Origin", BASE_URL) + .header("Referer", referer) + .header( + "Accept", + "text/javascript, application/json;q=0.9, */*;q=0.8", + ) + .send() + .await; + + let response = match response { + Ok(response) => response, + Err(err) => { + tracing::debug!("OpenCode billing fallback transport failed: {err}"); + return Ok(None); + } + }; + if response.status().as_u16() == 401 || response.status().as_u16() == 403 { + return Err(ProviderError::AuthRequired); + } + if !response.status().is_success() { + tracing::debug!("OpenCode billing fallback returned {}", response.status()); + return Ok(None); + } + let text = match response.text().await { + Ok(text) => text, + Err(err) => { + tracing::debug!("OpenCode billing fallback body read failed: {err}"); + return Ok(None); + } + }; + if self.looks_signed_out(&text) { + return Err(ProviderError::AuthRequired); + } + + let Some(billing) = parse_zen_billing(&text) else { + tracing::debug!("OpenCode billing payload missing monthly usage fields"); + return Ok(None); + }; + if billing.has_subscription { + tracing::debug!( + "OpenCode billing fallback still reports a subscription; preserving error" + ); + return Ok(None); + } + tracing::debug!( + "OpenCode billing usage resolved (limit {})", + if billing.monthly_limit_usd.is_some() { + "set" + } else { + "unset" + } + ); + Ok(Some(self.snapshot_from_pay_as_you_go(&billing))) + } + + /// Pay-as-you-go presentation: monthly spend against the configured + /// monthly limit (when set) with the prepaid balance in the login label. + pub(super) fn snapshot_from_pay_as_you_go( + &self, + billing: &OpenCodeZenBilling, + ) -> UsageSnapshot { + let used_percent = billing + .monthly_limit_usd + .filter(|limit| *limit > 0.0 && limit.is_finite()) + .map(|limit| ((billing.monthly_usage_usd / limit) * 100.0).clamp(0.0, 100.0)) + .unwrap_or(0.0); + let mut primary = RateWindow::with_details( + used_percent, + Some(30 * 24 * 60), + None, + Some(format!( + "${:.2} spent this month", + billing.monthly_usage_usd + )), + ); + primary.is_informational = billing.monthly_limit_usd.is_none(); + let mut usage = UsageSnapshot::new(primary); + let label = match billing.balance_usd { + Some(balance) => format!("Pay-as-you-go · ${balance:.2} prepaid"), + None => "Pay-as-you-go".to_string(), + }; + usage = usage.with_login_method(label); + usage + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ProviderError; + use crate::providers::opencode::OpenCodeProvider; + + #[test] + fn zen_billing_json_path_scales_usage_and_balance() { + // Upstream 0.49.5 #2504/#2697: monthlyUsage/balance are fixed-point + // 1e8 integers; monthlyLimit is whole USD; a null subscription marks + // a pay-as-you-go workspace. + let billing = parse_zen_billing( + r#"{ + "customerID": "cus_123", + "monthlyUsage": 1250000000, + "monthlyLimit": 50, + "balance": 200000000, + "subscription": null + }"#, + ) + .expect("zen billing"); + + assert!((billing.monthly_usage_usd - 12.5).abs() < 1e-9); + assert_eq!(billing.monthly_limit_usd, Some(50.0)); + assert!((billing.balance_usd.unwrap() - 2.0).abs() < 1e-9); + assert!(!billing.has_subscription); + + let snapshot = OpenCodeProvider::new().snapshot_from_pay_as_you_go(&billing); + assert!((snapshot.primary.used_percent - 25.0).abs() < 0.01); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("$12.50 spent this month") + ); + assert_eq!( + snapshot.login_method.as_deref(), + Some("Pay-as-you-go · $2.00 prepaid") + ); + } + + #[test] + fn zen_billing_payload_scan_and_guards() { + // $R[...] script payload fallback. + let billing = parse_zen_billing( + "$R[0]={\"customerID\":\"cus_9\",\"monthlyUsage\":500000000,\"balance\":null,\"subscription\":null}", + ) + .expect("payload scan"); + assert!((billing.monthly_usage_usd - 5.0).abs() < 1e-9); + assert!(billing.balance_usd.is_none()); + assert!(!billing.has_subscription); + + // No customerID → nothing is trusted. + assert!(parse_zen_billing("{\"monthlyUsage\": 500000000}").is_none()); + // No monthlyUsage → no snapshot. + assert!(parse_zen_billing("$R[0]={\"customerID\":\"cus_9\",\"balance\":1}").is_none()); + + // A present, non-null subscription object keeps the legacy path. + let subscribed = parse_zen_billing( + r#"{"customerID":"cus_9","monthlyUsage":1,"subscription":{"plan":"pro"}}"#, + ) + .expect("subscribed billing"); + assert!(subscribed.has_subscription); + assert!(payload_has_subscription( + "\"subscription\": {\"plan\": \"pro\"}" + )); + assert!(!payload_has_subscription("\"subscription\": null")); + assert!(!payload_has_subscription("no subscription field")); + } + + #[test] + fn only_subscription_shaped_errors_fall_back_to_billing() { + assert!(OpenCodeProvider::can_fall_back_to_billing( + &ProviderError::Parse("missing usage percent".into()) + )); + assert!(OpenCodeProvider::can_fall_back_to_billing( + &ProviderError::Other("OpenCode subscription API returned 500".into()) + )); + assert!(!OpenCodeProvider::can_fall_back_to_billing( + &ProviderError::AuthRequired + )); + } +} diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index 66a3c7c510..62b0e2a9a9 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -3,6 +3,7 @@ //! Fetches usage data from OpenCode (opencode.ai) //! Uses browser cookies for authentication +pub mod billing; pub mod scraper; // Re-exports for advanced scraping @@ -20,18 +21,17 @@ use crate::core::{ RateWindow, SourceMode, UsageSnapshot, }; -const BASE_URL: &str = "https://opencode.ai"; -const SERVER_URL: &str = "https://opencode.ai/_server"; +pub(super) const BASE_URL: &str = "https://opencode.ai"; +pub(super) const SERVER_URL: &str = "https://opencode.ai/_server"; const MAX_RESET_SECONDS: i64 = 366 * 24 * 60 * 60; const WORKSPACES_SERVER_ID: &str = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f"; const SUBSCRIPTION_SERVER_ID: &str = "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4"; - /// OpenCode provider pub struct OpenCodeProvider { metadata: ProviderMetadata, - client: Client, + pub(super) client: Client, } impl OpenCodeProvider { @@ -64,10 +64,24 @@ impl OpenCodeProvider { // First get workspace ID let workspace_id = self.fetch_workspace_id(cookie_header).await?; - // Then fetch subscription info - let subscription = self - .fetch_subscription(&workspace_id, cookie_header) - .await?; + // Then fetch subscription info. Pay-as-you-go workspaces have no + // subscription object, so the subscription server answers null or + // fails; their spend lives in the billing payload (upstream 0.49.5 + // #2504/#2697). + let subscription = match self.fetch_subscription(&workspace_id, cookie_header).await { + Ok(text) => text, + Err(err) if Self::can_fall_back_to_billing(&err) => { + match self + .fetch_pay_as_you_go_usage(&workspace_id, cookie_header) + .await + { + Err(auth_err) => return Err(auth_err), + Ok(Some(snapshot)) => return Ok(snapshot), + Ok(None) => return Err(err), + } + } + Err(err) => return Err(err), + }; // Parse the response self.parse_subscription(&subscription) @@ -521,13 +535,13 @@ impl OpenCodeProvider { } /// Check if response indicates user is signed out - fn looks_signed_out(&self, text: &str) -> bool { + pub(super) fn looks_signed_out(&self, text: &str) -> bool { let lower = text.to_lowercase(); lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") } /// URL encode a string for query parameters - fn url_encode(s: &str) -> String { + pub(super) fn url_encode(s: &str) -> String { let mut result = String::with_capacity(s.len() * 3); for c in s.chars() { match c { diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 8f70933a56..d2047b49e0 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -19,7 +19,10 @@ use crate::core::{ /// which turned the credits call into `/api/v1/auth/credits` -> 404. const OPENROUTER_API_BASE: &str = "https://openrouter.ai/api/v1"; const OPENROUTER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -const OPENROUTER_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); +/// Optional key-quota enrichment joins on a one-second fast deadline +/// (upstream 0.49.0 #2778) so a slow `/key` endpoint can never stall the +/// refresh; degraded enrichment is logged and skipped, never fatal. +const OPENROUTER_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); /// Windows Credential Manager target for OpenRouter API token const OPENROUTER_CREDENTIAL_TARGET: &str = "codexbar-openrouter"; @@ -203,13 +206,25 @@ impl OpenRouterProvider { async fn fetch_key_data(api_key: &str) -> Result, ProviderError> { let key_client = Self::build_client(OPENROUTER_KEY_TIMEOUT)?; - let resp = Self::send_key_request(&key_client, api_key).await; - - let Ok(key_resp) = resp else { - return Ok(None); + let key_resp = match Self::send_key_request(&key_client, api_key).await { + Ok(resp) => resp, + // Upstream 0.49.0 #2778: make the degraded fast join explicit — + // core usage stays authoritative, only the optional key meter is + // dropped. + Err(err) => { + tracing::debug!( + error = %err, + "OpenRouter key-quota fast join degraded; continuing without key meter" + ); + return Ok(None); + } }; if !key_resp.status().is_success() { + tracing::debug!( + status = %key_resp.status(), + "OpenRouter key-quota fast join degraded; continuing without key meter" + ); return Ok(None); } diff --git a/rust/src/providers/zai/mcp_details.rs b/rust/src/providers/zai/mcp_details.rs index d0b7455f7e..027dc6c36d 100755 --- a/rust/src/providers/zai/mcp_details.rs +++ b/rust/src/providers/zai/mcp_details.rs @@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize}; pub enum ZaiLimitType { /// Token-based limit TokensLimit, + /// Credit-based limit (credit Coding Plans, upstream 0.49.0 #2724) + CreditLimit, /// Time-based limit TimeLimit, } @@ -21,6 +23,7 @@ impl ZaiLimitType { pub fn from_string(s: &str) -> Option { match s { "TOKENS_LIMIT" => Some(ZaiLimitType::TokensLimit), + "CREDIT_LIMIT" => Some(ZaiLimitType::CreditLimit), "TIME_LIMIT" => Some(ZaiLimitType::TimeLimit), _ => None, } diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 75d6a48b91..07d540a2c9 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -324,11 +324,13 @@ impl ZaiProvider { }) .unwrap_or("z.ai"); - // Collect TOKENS_LIMIT entries (upstream uses "TOKENS_LIMIT", legacy uses "tokens") + // Collect token/credit limit entries (upstream 0.49.0 #2724: credit + // Coding Plans report `CREDIT_LIMIT` rows with the same shape as + // `TOKENS_LIMIT`; upstream uses "TOKENS_LIMIT", legacy uses "tokens"). let is_tokens = |l: &&ZaiLimit| { matches!( l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("tokens") + Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") ) }; let is_time = @@ -338,13 +340,28 @@ impl ZaiProvider { token_limits.sort_by_key(|l| Self::window_minutes(l).unwrap_or(u32::MAX)); let time_limit = limits.iter().find(is_time); - // Compute used percent for a limit entry + // Compute used percent for a limit entry (upstream 0.49.0 `parseLimit`): + // when the response carries a positive `usage` total, the absolute + // used signal (`usage - remaining`, or `currentValue`) wins over the + // API's own `percentage`; otherwise `percentage` is trusted, and + // legacy `limit`/`used` responses fall back to the old math. fn compute_percent(l: &ZaiLimit) -> f64 { + if let Some(usage) = l.usage.filter(|&usage| usage > 0.0) { + let used = if let Some(remaining) = l.remaining { + let from_remaining = usage - remaining; + let baseline = l.current_value.unwrap_or(from_remaining); + from_remaining.max(baseline) + } else { + l.current_value.unwrap_or(0.0) + }; + let clamped = used.clamp(0.0, usage); + return (clamped / usage * 100.0).clamp(0.0, 100.0); + } if let Some(percentage) = l.percentage { return percentage.clamp(0.0, 100.0); } - let limit = l.limit.or(l.usage).unwrap_or(0.0); + let limit = l.limit.unwrap_or(0.0); if limit <= 0.0 { return if l.used.unwrap_or(0.0) > 0.0 || l.current_value.unwrap_or(0.0) > 0.0 { 100.0 @@ -379,7 +396,7 @@ impl ZaiProvider { }); let is_tokens = matches!( l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("tokens") + Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") ); let window_mins = if is_tokens { ZaiProvider::window_minutes(l) @@ -452,7 +469,7 @@ fn rate_window_reset_description(l: &ZaiLimit, window_mins: Option) -> Opti } if matches!( l.limit_type.as_deref(), - Some("TOKENS_LIMIT") | Some("tokens") + Some("TOKENS_LIMIT") | Some("CREDIT_LIMIT") | Some("tokens") ) && window_mins == Some(300) { return Some("5-hour".to_string()); @@ -695,6 +712,80 @@ mod tests { assert!(usage.primary.resets_at.is_some()); } + #[test] + fn credit_limit_plan_drives_primary_and_weekly_windows() { + // Upstream 0.49.0 #2724/#2712: credit-based Coding Plans report + // CREDIT_LIMIT rows shaped like TOKENS_LIMIT. Without this, usage + // sticks at 0% used / 100% remaining. + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "planName": "GLM Coding Lite", + "limits": [ + { + "type": "CREDIT_LIMIT", + "unit": 3, + "number": 5, + "usage": 500, + "currentValue": 475, + "remaining": 25, + "percentage": 95, + "nextResetTime": 1770648402389_i64 + }, + { + "type": "CREDIT_LIMIT", + "unit": 6, + "number": 1, + "usage": 3000, + "currentValue": 1200, + "remaining": 1800, + "percentage": 40 + } + ] + } + })) + .unwrap(); + + let usage = provider.parse_quota_response("a).unwrap(); + + // Shortest window (5h credits) is the primary; longest (weekly) secondary. + assert!((usage.primary.used_percent - 95.0).abs() < f64::EPSILON); + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!(usage.primary.reset_description.as_deref(), Some("5-hour")); + assert!(usage.primary.resets_at.is_some()); + let secondary = usage.secondary.expect("weekly credit window"); + assert!((secondary.used_percent - 40.0).abs() < f64::EPSILON); + assert_eq!(secondary.window_minutes, Some(10080)); + } + + #[test] + fn usage_signal_overrides_stale_percentage() { + // Upstream 0.49.0 `parseLimit`: a positive `usage` total makes the + // absolute used signal authoritative; the API's `percentage` is only + // trusted without it. + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [{ + "type": "CREDIT_LIMIT", + "unit": 3, + "number": 5, + "usage": 500, + "currentValue": 25, + "remaining": 475, + "percentage": 95 + }] + } + })) + .unwrap(); + + let usage = provider.parse_quota_response("a).unwrap(); + + assert!((usage.primary.used_percent - 5.0).abs() < f64::EPSILON); + } + #[test] fn time_limit_primary_carries_mcp_label_without_duration() { // Upstream 0.48.0: TIME_LIMIT (MCP) windows no longer keep explicit diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 9d40e74237..07ba5b1c8d 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -310,6 +310,18 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://deepinfra.com/dash"), }, + ProviderConfigInfo { + id: ProviderId::Fireworks, + name: "Fireworks", + requires_api_key: true, + api_key_env_var: Some("FIREWORKS_API_KEY"), + api_key_help: Some( + "Get your API key from app.fireworks.ai. Also set the account slug from \ + app.fireworks.ai/accounts/ (FIREWORKS_ACCOUNT_SLUG).", + ), + config_file_path: None, + dashboard_url: Some("https://app.fireworks.ai"), + }, ProviderConfigInfo { id: ProviderId::AiAnd, name: "ai&", diff --git a/rust/src/status/mod.rs b/rust/src/status/mod.rs index 79f4331a5c..af53b90880 100755 --- a/rust/src/status/mod.rs +++ b/rust/src/status/mod.rs @@ -187,20 +187,52 @@ pub async fn fetch_statuspage_io_components(url: &str) -> Result Option { let url = get_status_page_url(provider)?; // Try the simple status endpoint first - match fetch_statuspage_io(url).await { + let fresh = match fetch_statuspage_io(url).await { Ok(status) => Some(status), Err(_) => { // Fall back to components endpoint fetch_statuspage_io_components(url).await.ok() } + }; + match fresh { + Some(status) => { + cache_last_success(provider, &status); + Some(status) + } + None => last_cached_success(provider), + } +} + +/// Process-wide cache of the last successful status per provider. +fn status_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +fn cache_last_success(provider: &str, status: &ProviderStatus) { + if let Ok(mut cache) = status_cache().lock() { + cache.insert(provider.to_string(), status.clone()); } } +fn last_cached_success(provider: &str) -> Option { + status_cache() + .lock() + .ok() + .and_then(|cache| cache.get(provider).cloned()) +} + /// Fetch status for all providers in parallel pub async fn fetch_all_statuses(providers: &[&str]) -> HashMap { let futures: Vec<_> = providers