From a91c88374e50738630dbac5552b8da0ea494c50d Mon Sep 17 00:00:00 2001
From: Finesssee <90105158+Finesssee@users.noreply.github.com>
Date: Mon, 17 Aug 2026 04:26:15 +0700
Subject: [PATCH 1/3] Port upstream 0.49.0-0.49.2: Fireworks provider, z.ai
credit quotas, Kimi lanes, menu-card wrap
---
.../icons/ProviderIcon-fireworks.svg | 12 +
.../src/components/providers/providerIcons.ts | 9 +-
apps/desktop-tauri/src/styles.css | 16 +-
apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +-
.../sections/credentials/OpenAiExtras.tsx | 8 +
.../surfaces/settings/tabs/ProvidersTab.tsx | 1 +
.../desktop-tauri/src/test/providerCatalog.ts | 1 +
rust/src/core/provider.rs | 11 +-
rust/src/core/provider_factory.rs | 7 +-
rust/src/core/token_accounts.rs | 3 +-
rust/src/providers/fireworks/mod.rs | 383 ++++++++++++++++++
rust/src/providers/kimi/mod.rs | 154 ++++++-
rust/src/providers/mod.rs | 2 +
rust/src/providers/openrouter/mod.rs | 25 +-
rust/src/providers/zai/mcp_details.rs | 3 +
rust/src/providers/zai/mod.rs | 103 ++++-
rust/src/settings/api_keys.rs | 12 +
17 files changed, 716 insertions(+), 36 deletions(-)
create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-fireworks.svg
create mode 100644 rust/src/providers/fireworks/mod.rs
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