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/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/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/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&",