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/styles.css b/apps/desktop-tauri/src/styles.css index 6e4cd60151..94ba18ebba 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4267,14 +4267,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/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/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/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/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&",