diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-huggingface.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-huggingface.svg
new file mode 100644
index 0000000000..5a5e3cd181
--- /dev/null
+++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-huggingface.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts
index 955ed3a941..58db5e5fed 100644
--- a/apps/desktop-tauri/src/components/providers/providerIcons.ts
+++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts
@@ -33,6 +33,7 @@ import factory from "./icons/ProviderIcon-factory.svg?raw";
import gemini from "./icons/ProviderIcon-gemini.svg?raw";
import grok from "./icons/ProviderIcon-grok.svg?raw";
import groq from "./icons/ProviderIcon-groq.svg?raw";
+import huggingface from "./icons/ProviderIcon-huggingface.svg?raw";
import jetbrains from "./icons/ProviderIcon-jetbrains.svg?raw";
import kilo from "./icons/ProviderIcon-kilo.svg?raw";
import kimi from "./icons/ProviderIcon-kimi.svg?raw";
@@ -118,6 +119,7 @@ const RAW: Record = {
gemini: tint(gemini),
grok: tint(grok),
groq: tint(groq),
+ huggingface: tint(huggingface),
jetbrains: tint(jetbrains),
kilo: tint(kilo),
kimi: tint(kimi),
@@ -180,6 +182,7 @@ export const PROVIDER_ICON_REGISTRY: Record = {
gemini: { id: "gemini", brandColor: "#ab87ea", fallbackLetter: "✦", svgPath: RAW.gemini },
grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok },
groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq },
+ huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface },
jetbrains: { id: "jetbrains", brandColor: "#ff3399", fallbackLetter: "J", svgPath: RAW.jetbrains },
kilo: { id: "kilo", brandColor: "#5d87ff", fallbackLetter: "K", svgPath: RAW.kilo },
bedrock: { id: "bedrock", brandColor: "#ff9900", fallbackLetter: "B", svgPath: RAW.bedrock },
diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts
index 75f71c8e28..b281964282 100644
--- a/apps/desktop-tauri/src/test/providerCatalog.ts
+++ b/apps/desktop-tauri/src/test/providerCatalog.ts
@@ -55,6 +55,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [
["elevenlabs", "ElevenLabs"],
["deepgram", "Deepgram"],
["groq", "Groq"],
+ ["huggingface", "Hugging Face"],
["llmproxy", "LLM Proxy"],
["chutes", "Chutes"],
["litellm", "LiteLLM"],
diff --git a/rust/src/cli/serve/dashboard/icons.rs b/rust/src/cli/serve/dashboard/icons.rs
index c1896fcbb4..611feb6653 100644
--- a/rust/src/cli/serve/dashboard/icons.rs
+++ b/rust/src/cli/serve/dashboard/icons.rs
@@ -169,6 +169,10 @@ static ICONS: &[(&str, &[u8])] = &[
"ProviderIcon-groq",
include_bytes!("icons/ProviderIcon-groq.svg"),
),
+ (
+ "ProviderIcon-huggingface",
+ include_bytes!("icons/ProviderIcon-huggingface.svg"),
+ ),
(
"ProviderIcon-jetbrains",
include_bytes!("icons/ProviderIcon-jetbrains.svg"),
diff --git a/rust/src/cli/serve/dashboard/icons/ProviderIcon-huggingface.svg b/rust/src/cli/serve/dashboard/icons/ProviderIcon-huggingface.svg
new file mode 100644
index 0000000000..5a5e3cd181
--- /dev/null
+++ b/rust/src/cli/serve/dashboard/icons/ProviderIcon-huggingface.svg
@@ -0,0 +1,7 @@
+
diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs
index 444462c677..f922e27726 100755
--- a/rust/src/core/provider.rs
+++ b/rust/src/core/provider.rs
@@ -62,6 +62,7 @@ pub enum ProviderId {
ElevenLabs,
Deepgram,
Groq,
+ HuggingFace,
LLMProxy,
Chutes,
LiteLLM,
@@ -142,6 +143,7 @@ impl ProviderId {
ProviderId::ElevenLabs,
ProviderId::Deepgram,
ProviderId::Groq,
+ ProviderId::HuggingFace,
ProviderId::LLMProxy,
ProviderId::Chutes,
ProviderId::LiteLLM,
@@ -224,6 +226,7 @@ impl ProviderId {
ProviderId::ElevenLabs => "elevenlabs",
ProviderId::Deepgram => "deepgram",
ProviderId::Groq => "groq",
+ ProviderId::HuggingFace => "huggingface",
ProviderId::LLMProxy => "llmproxy",
ProviderId::Chutes => "chutes",
ProviderId::LiteLLM => "litellm",
@@ -304,6 +307,7 @@ impl ProviderId {
ProviderId::ElevenLabs => "ElevenLabs",
ProviderId::Deepgram => "Deepgram",
ProviderId::Groq => "Groq",
+ ProviderId::HuggingFace => "Hugging Face",
ProviderId::LLMProxy => "LLM Proxy",
ProviderId::Chutes => "Chutes",
ProviderId::LiteLLM => "LiteLLM",
@@ -393,6 +397,7 @@ impl ProviderId {
ProviderId::ElevenLabs => None,
ProviderId::Deepgram => None,
ProviderId::Groq => None,
+ ProviderId::HuggingFace => None,
ProviderId::LLMProxy => None,
ProviderId::Chutes => None,
ProviderId::LiteLLM => None,
@@ -479,6 +484,7 @@ impl ProviderId {
"elevenlabs" | "eleven-labs" | "11labs" => Some(ProviderId::ElevenLabs),
"deepgram" | "dg" => Some(ProviderId::Deepgram),
"groq" | "groqcloud" | "groq-cloud" | "groq cloud" => Some(ProviderId::Groq),
+ "huggingface" | "hugging-face" | "hugging face" | "hf" => Some(ProviderId::HuggingFace),
"llmproxy" | "llm-proxy" | "llm proxy" => Some(ProviderId::LLMProxy),
"chutes" | "chutes-ai" | "chutes ai" => Some(ProviderId::Chutes),
"litellm" | "lite-llm" | "lite llm" => Some(ProviderId::LiteLLM),
@@ -524,12 +530,17 @@ impl std::fmt::Display for ProviderId {
}
/// Data source mode for fetching usage
+///
+/// Conventions for providers whose transport is not an OAuth flow: they
+/// reuse `OAuth` as the persisted token/API lane (an API key, hub token, or
+/// other credential), because the source enum is shared with the settings
+/// UI. `Auto` may dispatch to that lane as well.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceMode {
/// Automatically choose the best available source
#[default]
Auto,
- /// Use OAuth API
+ /// Use OAuth API; also the token/API lane for non-OAuth providers
OAuth,
/// Use web API with browser cookies
Web,
@@ -928,6 +939,8 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> {
map.insert("dg", ProviderId::Deepgram);
map.insert("groqcloud", ProviderId::Groq);
map.insert("groq-cloud", ProviderId::Groq);
+ map.insert("hugging-face", ProviderId::HuggingFace);
+ map.insert("hf", ProviderId::HuggingFace);
map.insert("chutes-ai", ProviderId::Chutes);
map.insert("lite-llm", ProviderId::LiteLLM);
map.insert("zed-ai", ProviderId::Zed);
@@ -995,6 +1008,7 @@ pub fn brand_color(id: ProviderId) -> &'static str {
ProviderId::ElevenLabs => "#111827",
ProviderId::Deepgram => "#13EF93",
ProviderId::Groq => "#F55036",
+ ProviderId::HuggingFace => "#FFD21E",
ProviderId::LLMProxy => "#4F46E5",
ProviderId::Chutes => "#FF5C35",
ProviderId::LiteLLM => "#0EA5E9",
@@ -1032,7 +1046,7 @@ mod tests {
#[test]
fn test_provider_id_all() {
let all = ProviderId::all();
- assert_eq!(all.len(), 73);
+ assert_eq!(all.len(), 74);
assert!(all.contains(&ProviderId::Claude));
assert!(all.contains(&ProviderId::Codex));
assert!(all.contains(&ProviderId::Fireworks));
@@ -1064,6 +1078,7 @@ mod tests {
assert!(all.contains(&ProviderId::ElevenLabs));
assert!(all.contains(&ProviderId::Deepgram));
assert!(all.contains(&ProviderId::Groq));
+ assert!(all.contains(&ProviderId::HuggingFace));
assert!(all.contains(&ProviderId::LLMProxy));
assert!(all.contains(&ProviderId::Chutes));
assert!(all.contains(&ProviderId::LiteLLM));
@@ -1121,6 +1136,7 @@ mod tests {
assert_eq!(ProviderId::Codex.cli_name(), "codex");
assert_eq!(ProviderId::Factory.cli_name(), "factory");
assert_eq!(ProviderId::Zai.cli_name(), "zai");
+ assert_eq!(ProviderId::HuggingFace.cli_name(), "huggingface");
assert_eq!(ProviderId::CodeRabbit.cli_name(), "coderabbit");
}
@@ -1129,6 +1145,7 @@ mod tests {
assert_eq!(ProviderId::Claude.display_name(), "Claude");
assert_eq!(ProviderId::Factory.display_name(), "Factory");
assert_eq!(ProviderId::Zai.display_name(), "z.ai");
+ assert_eq!(ProviderId::HuggingFace.display_name(), "Hugging Face");
assert_eq!(ProviderId::CodeRabbit.display_name(), "CodeRabbit");
}
@@ -1147,6 +1164,10 @@ mod tests {
Some(ProviderId::Claude)
);
assert_eq!(ProviderId::from_cli_name("codex"), Some(ProviderId::Codex));
+ assert_eq!(
+ ProviderId::from_cli_name("hf"),
+ Some(ProviderId::HuggingFace)
+ );
assert_eq!(ProviderId::from_cli_name("openai"), Some(ProviderId::Codex));
assert_eq!(
ProviderId::from_cli_name("factory"),
@@ -1233,6 +1254,7 @@ mod tests {
assert_eq!(ProviderId::Zai.cookie_domain(), None);
assert_eq!(ProviderId::VertexAI.cookie_domain(), None);
assert_eq!(ProviderId::JetBrains.cookie_domain(), None);
+ assert_eq!(ProviderId::HuggingFace.cookie_domain(), None);
assert_eq!(ProviderId::CodeRabbit.cookie_domain(), None);
}
diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs
index 4d495b0f06..a9cf6beccb 100644
--- a/rust/src/core/provider_factory.rs
+++ b/rust/src/core/provider_factory.rs
@@ -13,14 +13,15 @@ use crate::providers::{
CodexProvider, CommandCodeProvider, CopilotProvider, CrofProvider, CrossModelProvider,
CursorProvider, DeepInfraProvider, DeepSeekProvider, DeepgramProvider, DevinProvider,
DoubaoProvider, ElevenLabsProvider, FactoryProvider, FireworksProvider, GeminiProvider,
- GrokProvider, GroqProvider, InfiniProvider, JetBrainsProvider, KiloProvider, KimiK2Provider,
- KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider, ManusProvider,
- MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, MuseProvider, NanoGPTProvider,
- NeuralwattProvider, NotionProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider,
- OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider,
- QwenCloudProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider,
- VeniceProvider, VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider,
- XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider,
+ GrokProvider, GroqProvider, HuggingFaceProvider, InfiniProvider, JetBrainsProvider,
+ KiloProvider, KimiK2Provider, KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider,
+ LongCatProvider, ManusProvider, MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider,
+ MuseProvider, NanoGPTProvider, NeuralwattProvider, NotionProvider, OllamaProvider,
+ OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider,
+ PerplexityProvider, PoeProvider, QoderProvider, QwenCloudProvider, SakanaProvider,
+ StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider, VertexAIProvider,
+ WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider,
+ ZenMuxProvider, ZoomMateProvider,
};
/// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`].
@@ -79,6 +80,7 @@ pub fn instantiate(id: ProviderId) -> Box {
ProviderId::ElevenLabs => Box::new(ElevenLabsProvider::new()),
ProviderId::Deepgram => Box::new(DeepgramProvider::new()),
ProviderId::Groq => Box::new(GroqProvider::new()),
+ ProviderId::HuggingFace => Box::new(HuggingFaceProvider::new()),
ProviderId::LLMProxy => Box::new(LLMProxyProvider::new()),
ProviderId::Chutes => Box::new(ChutesProvider::new()),
ProviderId::LiteLLM => Box::new(LiteLLMProvider::new()),
diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs
index f913f8bcf9..b2fab33d12 100755
--- a/rust/src/core/token_accounts.rs
+++ b/rust/src/core/token_accounts.rs
@@ -233,6 +233,16 @@ impl TokenAccountSupport {
requires_manual_cookie_source: false,
cookie_name: None,
}),
+ ProviderId::HuggingFace => Some(TokenAccountSupport {
+ title: "API tokens",
+ subtitle: "Store multiple Hugging Face access tokens.",
+ placeholder: "Paste a Hugging Face access token",
+ injection: TokenInjection::Environment {
+ key: "CODEXBAR_HUGGINGFACE_API_KEY".to_string(),
+ },
+ requires_manual_cookie_source: false,
+ cookie_name: None,
+ }),
ProviderId::AiAnd => Some(TokenAccountSupport {
title: "API keys",
subtitle: "Store multiple ai& API keys.",
diff --git a/rust/src/providers/huggingface/mod.rs b/rust/src/providers/huggingface/mod.rs
new file mode 100644
index 0000000000..3b3f5b3d16
--- /dev/null
+++ b/rust/src/providers/huggingface/mod.rs
@@ -0,0 +1,762 @@
+//! Hugging Face billing provider.
+//!
+//! Hugging Face exposes inference billing and optional ZeroGPU usage through
+//! authenticated JSON endpoints. The billing data is presented as cost and
+//! transient detail rows; it is deliberately not converted into a quota
+//! window or a persisted identity record.
+
+use async_trait::async_trait;
+use chrono::{DateTime, Datelike, TimeZone, Utc};
+use futures::StreamExt;
+use reqwest::{Client, StatusCode, Url};
+use serde_json::Value;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use crate::core::{
+ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError,
+ ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot,
+};
+
+const BILLING_URL: &str = "https://huggingface.co/api/settings/billing/usage-v2";
+const WHOAMI_URL: &str = "https://huggingface.co/api/whoami-v2";
+const ZEROGPU_URL: &str = "https://huggingface.co/api/spaces/zero-gpu/quota";
+const CREDENTIAL_TARGET: &str = "codexbar-huggingface";
+const USER_AGENT: &str = "CodexBar";
+const PRIMARY_TIMEOUT: Duration = Duration::from_secs(15);
+const OPTIONAL_TIMEOUT: Duration = Duration::from_secs(2);
+const MAX_RESPONSE_BYTES: usize = 512 * 1024;
+const NANO_UNITS_PER_DOLLAR: f64 = 1_000_000_000.0;
+
+#[derive(Debug, Clone, PartialEq)]
+struct BillingSnapshot {
+ used_usd: f64,
+ included_usd: f64,
+ billable_usd: f64,
+ limit_usd: Option,
+ requests: Option,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ZeroGpuSnapshot {
+ used_minutes: f64,
+ remaining_minutes: f64,
+ total_minutes: f64,
+ resets_at: Option>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct IdentitySnapshot {
+ name: Option,
+ email: Option,
+ plan: Option,
+}
+
+#[derive(Debug, Clone, Default)]
+struct TokenEnvironment {
+ config_api_key: Option,
+ hf_token: Option,
+ hub_token: Option,
+ token_path: Option,
+ hf_home: Option,
+ xdg_cache_home: Option,
+ default_cache_dir: Option,
+ home_dir: Option,
+}
+
+impl TokenEnvironment {
+ fn from_process() -> Self {
+ Self {
+ config_api_key: std::env::var("CODEXBAR_HUGGINGFACE_API_KEY").ok(),
+ hf_token: std::env::var("HF_TOKEN").ok(),
+ hub_token: std::env::var("HUGGING_FACE_HUB_TOKEN").ok(),
+ token_path: std::env::var_os("HF_TOKEN_PATH").map(PathBuf::from),
+ hf_home: std::env::var_os("HF_HOME").map(PathBuf::from),
+ xdg_cache_home: std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from),
+ default_cache_dir: dirs::cache_dir(),
+ home_dir: dirs::home_dir(),
+ }
+ }
+
+ fn resolve(&self) -> Option {
+ for candidate in [
+ self.config_api_key.as_deref(),
+ self.hf_token.as_deref(),
+ self.hub_token.as_deref(),
+ ] {
+ if let Some(token) = candidate.and_then(clean_token) {
+ return Some(token);
+ }
+ }
+
+ self.file_candidates()
+ .into_iter()
+ .find_map(|path| read_token_file(&path))
+ }
+
+ fn file_candidates(&self) -> Vec {
+ let mut candidates = Vec::new();
+ let mut push_unique = |path: PathBuf| {
+ if !candidates.contains(&path) {
+ candidates.push(path);
+ }
+ };
+
+ if let Some(path) = self.token_path.as_deref() {
+ push_unique(expand_tilde(path, self.home_dir.as_deref()));
+ }
+ if let Some(path) = self.hf_home.as_deref() {
+ push_unique(expand_tilde(path, self.home_dir.as_deref()).join("token"));
+ }
+ if let Some(path) = self.xdg_cache_home.as_deref() {
+ push_unique(
+ expand_tilde(path, self.home_dir.as_deref())
+ .join("huggingface")
+ .join("token"),
+ );
+ }
+
+ let fallback_cache = self
+ .default_cache_dir
+ .clone()
+ .or_else(|| self.home_dir.as_deref().map(|home| home.join(".cache")));
+ if let Some(path) = fallback_cache {
+ push_unique(path.join("huggingface").join("token"));
+ }
+
+ candidates
+ }
+}
+
+pub struct HuggingFaceProvider {
+ metadata: ProviderMetadata,
+ client: Client,
+}
+
+impl HuggingFaceProvider {
+ pub fn new() -> Self {
+ Self {
+ metadata: ProviderMetadata {
+ id: ProviderId::HuggingFace,
+ display_name: "Hugging Face",
+ session_label: "Credits",
+ weekly_label: "ZeroGPU",
+ supports_opus: false,
+ supports_credits: false,
+ default_enabled: false,
+ is_primary: false,
+ dashboard_url: Some("https://huggingface.co/settings/billing"),
+ status_page_url: Some("https://status.huggingface.co"),
+ tertiary_label_key: None,
+ },
+ client: crate::core::credentialed_http_client_builder()
+ .timeout(PRIMARY_TIMEOUT)
+ .build()
+ .unwrap_or_else(|_| Client::new()),
+ }
+ }
+
+ async fn fetch_api(&self, ctx: &FetchContext) -> Result {
+ let token = resolve_token(ctx)?;
+ let now = Utc::now();
+ let billing_url = billing_url(now)?;
+ let whoami_url = Url::parse(WHOAMI_URL)
+ .map_err(|_| ProviderError::Other("Invalid Hugging Face whoami URL.".to_string()))?;
+ let zerogpu_url = Url::parse(ZEROGPU_URL)
+ .map_err(|_| ProviderError::Other("Invalid Hugging Face ZeroGPU URL.".to_string()))?;
+
+ let (billing, identity, zerogpu) = tokio::join!(
+ self.fetch_json(billing_url, &token, PRIMARY_TIMEOUT),
+ self.fetch_optional_json(whoami_url, &token),
+ self.fetch_optional_json(zerogpu_url, &token),
+ );
+ let billing = parse_billing(billing?)?;
+ let identity = identity.and_then(|value| parse_identity(&value));
+ let zerogpu = zerogpu.and_then(|value| parse_zerogpu(&value));
+
+ Ok(build_result(billing, identity, zerogpu))
+ }
+
+ async fn fetch_optional_json(&self, url: Url, token: &str) -> Option {
+ self.fetch_json(url, token, OPTIONAL_TIMEOUT).await.ok()
+ }
+
+ async fn fetch_json(
+ &self,
+ url: Url,
+ token: &str,
+ timeout: Duration,
+ ) -> Result {
+ // Wrapper timeout, not just the client's PRIMARY_TIMEOUT: the
+ // optional-fetch path (fetch_optional_json) overrides this with
+ // OPTIONAL_TIMEOUT (2s) so enrichment cannot stall the main fetch.
+ tokio::time::timeout(timeout, async {
+ let response = self
+ .client
+ .get(url)
+ .bearer_auth(token)
+ .header(reqwest::header::USER_AGENT, USER_AGENT)
+ .header(reqwest::header::ACCEPT, "application/json")
+ .send()
+ .await?;
+ let status = response.status();
+ if !status.is_success() {
+ return Err(classify_status(status));
+ }
+
+ let mut body = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|_| {
+ ProviderError::Parse(
+ "Hugging Face returned an unreadable JSON body.".to_string(),
+ )
+ })?;
+ body.extend_from_slice(&chunk);
+ if body.len() > MAX_RESPONSE_BYTES {
+ return Err(ProviderError::Parse(
+ "Hugging Face returned an oversized JSON body.".to_string(),
+ ));
+ }
+ }
+ serde_json::from_slice(&body).map_err(|_| {
+ ProviderError::Parse("Hugging Face returned invalid JSON.".to_string())
+ })
+ })
+ .await
+ .map_err(|_| ProviderError::Timeout)?
+ }
+}
+
+impl Default for HuggingFaceProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Provider for HuggingFaceProvider {
+ fn id(&self) -> ProviderId {
+ ProviderId::HuggingFace
+ }
+
+ fn metadata(&self) -> &ProviderMetadata {
+ &self.metadata
+ }
+
+ async fn fetch_usage(&self, ctx: &FetchContext) -> Result {
+ match ctx.source_mode {
+ // The shared source enum uses OAuth as the persisted token/API
+ // lane for providers whose transport is not an OAuth flow.
+ SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await,
+ SourceMode::Web | SourceMode::Cli => {
+ Err(ProviderError::UnsupportedSource(ctx.source_mode))
+ }
+ }
+ }
+
+ fn available_sources(&self) -> Vec {
+ vec![SourceMode::Auto, SourceMode::OAuth]
+ }
+}
+
+fn resolve_token(ctx: &FetchContext) -> Result {
+ if let Ok(raw) =
+ crate::providers::resolve_api_key(ctx.api_key.as_deref(), CREDENTIAL_TARGET, &[])
+ && let Some(token) = clean_token(&raw)
+ {
+ return Ok(token);
+ }
+
+ TokenEnvironment::from_process().resolve().ok_or_else(|| {
+ ProviderError::NotInstalled(
+ "Missing Hugging Face token. Add one in Settings, set HF_TOKEN, or run hf auth login."
+ .to_string(),
+ )
+ })
+}
+
+fn clean_token(raw: &str) -> Option {
+ let mut value = raw.trim();
+ if value.len() >= 2
+ && ((value.starts_with('"') && value.ends_with('"'))
+ || (value.starts_with('\'') && value.ends_with('\'')))
+ {
+ value = value[1..value.len() - 1].trim();
+ }
+ (!value.is_empty()).then_some(value.to_string())
+}
+
+fn expand_tilde(path: &Path, home: Option<&Path>) -> PathBuf {
+ let raw = path.to_string_lossy();
+ let Some(home) = home else {
+ return path.to_path_buf();
+ };
+ if raw == "~" {
+ return home.to_path_buf();
+ }
+ if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) {
+ return home.join(rest);
+ }
+ path.to_path_buf()
+}
+
+fn read_token_file(path: &Path) -> Option {
+ std::fs::read_to_string(path)
+ .ok()?
+ .lines()
+ .find_map(clean_token)
+}
+
+fn billing_url(now: DateTime) -> Result {
+ let mut url = Url::parse(BILLING_URL)
+ .map_err(|_| ProviderError::Other("Invalid Hugging Face billing URL.".to_string()))?;
+ let start = month_start(now).timestamp().to_string();
+ let end = now.timestamp().to_string();
+ url.query_pairs_mut()
+ .append_pair("startDate", &start)
+ .append_pair("endDate", &end);
+ Ok(url)
+}
+
+fn month_start(now: DateTime) -> DateTime {
+ Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
+ .single()
+ .unwrap_or_else(Utc::now)
+}
+
+fn parse_billing(value: Value) -> Result {
+ let inference = value
+ .get("usage")
+ .and_then(|usage| usage.get("inferenceProviders"))
+ .ok_or_else(|| invalid_billing("inferenceProviders"))?;
+ let used_nano = required_nonnegative_number(inference, "usedNanoUsd")?;
+ let included_nano = required_nonnegative_number(inference, "includedNanoUsd")?;
+ let limit_usd = optional_nonnegative_number(inference, "limitNanoUsd")
+ .map(|value| value / NANO_UNITS_PER_DOLLAR)
+ .filter(|value| *value > 0.0);
+ let requests = inference.get("numRequests").and_then(Value::as_u64);
+
+ let used_usd = used_nano / NANO_UNITS_PER_DOLLAR;
+ let included_usd = included_nano / NANO_UNITS_PER_DOLLAR;
+ let billable_usd = (used_nano - included_nano).max(0.0) / NANO_UNITS_PER_DOLLAR;
+ Ok(BillingSnapshot {
+ used_usd,
+ included_usd,
+ billable_usd,
+ limit_usd,
+ requests,
+ })
+}
+
+fn required_nonnegative_number(object: &Value, field: &str) -> Result {
+ let Some(value) = object.get(field).and_then(Value::as_f64) else {
+ return Err(invalid_billing(field));
+ };
+ if !value.is_finite() || value < 0.0 {
+ return Err(invalid_billing(field));
+ }
+ Ok(value)
+}
+
+fn optional_nonnegative_number(object: &Value, field: &str) -> Option {
+ object
+ .get(field)
+ .and_then(Value::as_f64)
+ .filter(|value| value.is_finite() && *value >= 0.0)
+}
+
+fn invalid_billing(field: &str) -> ProviderError {
+ ProviderError::Parse(format!("Hugging Face billing field '{field}' was invalid."))
+}
+
+fn parse_zerogpu(value: &Value) -> Option {
+ let total_minutes = optional_nonnegative_number(value, "base")?;
+ if total_minutes <= 0.0 {
+ return None;
+ }
+ let current_minutes = optional_nonnegative_number(value, "current")?;
+ let used_minutes = (total_minutes - current_minutes).max(0.0);
+ let remaining_minutes = current_minutes.min(total_minutes);
+ let resets_at = value.get("resetsAt").and_then(parse_timestamp);
+ Some(ZeroGpuSnapshot {
+ used_minutes,
+ remaining_minutes,
+ total_minutes,
+ resets_at,
+ })
+}
+
+fn parse_timestamp(value: &Value) -> Option> {
+ value
+ .as_i64()
+ .and_then(|seconds| Utc.timestamp_opt(seconds, 0).single())
+ .or_else(|| {
+ value
+ .as_str()
+ .and_then(|text| DateTime::parse_from_rfc3339(text).ok())
+ .map(|date| date.with_timezone(&Utc))
+ })
+}
+
+fn parse_identity(value: &Value) -> Option {
+ let name = safe_text(value.get("name").and_then(Value::as_str));
+ let email = safe_text(value.get("email").and_then(Value::as_str));
+ let plan = value
+ .get("isPro")
+ .and_then(Value::as_bool)
+ .map(|is_pro| if is_pro { "Pro" } else { "Free" }.to_string());
+ (name.is_some() || email.is_some() || plan.is_some()).then_some(IdentitySnapshot {
+ name,
+ email,
+ plan,
+ })
+}
+
+fn safe_text(value: Option<&str>) -> Option {
+ let value = value?.trim();
+ if value.is_empty() || value.chars().count() > 256 || value.chars().any(char::is_control) {
+ return None;
+ }
+ Some(value.to_string())
+}
+
+fn build_result(
+ billing: BillingSnapshot,
+ identity: Option,
+ zerogpu: Option,
+) -> ProviderFetchResult {
+ let mut result = ProviderFetchResult::new(
+ UsageSnapshot::new(RateWindow::informational("Hugging Face billing"))
+ .with_primary_label("Credits"),
+ "api",
+ )
+ .with_non_authoritative_pace();
+
+ let mut cost = CostSnapshot::new(billing.billable_usd, "USD", "Current month");
+ if let Some(limit) = billing.limit_usd {
+ cost = cost.with_limit(limit);
+ }
+ result = result.with_cost(cost);
+
+ let mut details: Vec<(&str, &str, String)> = vec![
+ (
+ "billable-usage",
+ "Billable inference usage",
+ format_usd(billing.billable_usd),
+ ),
+ (
+ "gross-inference-usage",
+ "Gross inference usage",
+ format_usd(billing.used_usd),
+ ),
+ (
+ "included-inference-amount",
+ "Included inference amount",
+ format_usd(billing.included_usd),
+ ),
+ ];
+ if let Some(limit) = billing.limit_usd {
+ details.push(("spending-limit", "Spending limit", format_usd(limit)));
+ }
+ if let Some(requests) = billing.requests {
+ details.push(("inference-requests", "Requests", requests.to_string()));
+ }
+
+ let mut rows: Vec