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