From 4d3d79ff38acddb3800727349e64887013e632e8 Mon Sep 17 00:00:00 2001
From: NessZerra <90105158+Finesssee@users.noreply.github.com>
Date: Sun, 20 Sep 2026 00:31:35 +0700
Subject: [PATCH] Port Hugging Face billing usage
---
.../src-tauri/src/commands/tests.rs | 26 +
.../icons/ProviderIcon-huggingface.svg | 7 +
.../src/components/providers/providerIcons.ts | 3 +
.../desktop-tauri/src/test/providerCatalog.ts | 1 +
rust/src/cli/serve/dashboard/icons.rs | 4 +
.../icons/ProviderIcon-huggingface.svg | 7 +
rust/src/core/provider.rs | 19 +-
rust/src/core/provider_factory.rs | 17 +-
rust/src/core/token_accounts.rs | 10 +
rust/src/providers/huggingface/mod.rs | 771 ++++++++++++++++++
rust/src/providers/mod.rs | 2 +
rust/src/settings/api_keys.rs | 13 +
rust/src/settings/tests.rs | 1 +
13 files changed, 872 insertions(+), 9 deletions(-)
create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-huggingface.svg
create mode 100644 rust/src/cli/serve/dashboard/icons/ProviderIcon-huggingface.svg
create mode 100644 rust/src/providers/huggingface/mod.rs
diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs
index a81fd4e41f..0fcc04119e 100644
--- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs
+++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs
@@ -304,6 +304,32 @@ fn fetch_context_defaults_to_manual_cookies_without_browser_import() {
assert_eq!(ctx.source_mode, SourceMode::Web);
}
+#[test]
+fn fetch_context_huggingface_uses_api_token_lane() {
+ let settings = Settings::default();
+ let cookies = ManualCookies::default();
+ let api_keys = ApiKeys::default();
+ let token_accounts = HashMap::new();
+
+ let ctx = super::build_fetch_context(
+ ProviderId::HuggingFace,
+ &settings,
+ &cookies,
+ &api_keys,
+ &token_accounts,
+ );
+
+ assert_eq!(ctx.source_mode, SourceMode::Auto);
+ let provider = instantiate_provider(ProviderId::HuggingFace);
+ assert_eq!(
+ provider.available_sources(),
+ vec![SourceMode::Auto, SourceMode::OAuth]
+ );
+ assert!(!provider.supports_web());
+ assert!(!provider.supports_cli());
+ assert_eq!(provider.metadata().display_name, "Hugging Face");
+}
+
#[test]
fn fetch_context_cursor_cookie_off_stays_cli() {
let mut settings = Settings::default();
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 0b554eba9b..6cfe04d552 100644
--- a/apps/desktop-tauri/src/components/providers/providerIcons.ts
+++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts
@@ -32,6 +32,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";
@@ -115,6 +116,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),
@@ -175,6 +177,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 3281d8c070..627c135aac 100644
--- a/apps/desktop-tauri/src/test/providerCatalog.ts
+++ b/apps/desktop-tauri/src/test/providerCatalog.ts
@@ -54,6 +54,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 36fc7c3326..f7f3860c4b 100644
--- a/rust/src/cli/serve/dashboard/icons.rs
+++ b/rust/src/cli/serve/dashboard/icons.rs
@@ -165,6 +165,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 55ab70d391..96c591b740 100755
--- a/rust/src/core/provider.rs
+++ b/rust/src/core/provider.rs
@@ -61,6 +61,7 @@ pub enum ProviderId {
ElevenLabs,
Deepgram,
Groq,
+ HuggingFace,
LLMProxy,
Chutes,
LiteLLM,
@@ -139,6 +140,7 @@ impl ProviderId {
ProviderId::ElevenLabs,
ProviderId::Deepgram,
ProviderId::Groq,
+ ProviderId::HuggingFace,
ProviderId::LLMProxy,
ProviderId::Chutes,
ProviderId::LiteLLM,
@@ -218,6 +220,7 @@ impl ProviderId {
ProviderId::ElevenLabs => "elevenlabs",
ProviderId::Deepgram => "deepgram",
ProviderId::Groq => "groq",
+ ProviderId::HuggingFace => "huggingface",
ProviderId::LLMProxy => "llmproxy",
ProviderId::Chutes => "chutes",
ProviderId::LiteLLM => "litellm",
@@ -296,6 +299,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",
@@ -383,6 +387,7 @@ impl ProviderId {
ProviderId::ElevenLabs => None,
ProviderId::Deepgram => None,
ProviderId::Groq => None,
+ ProviderId::HuggingFace => None,
ProviderId::LLMProxy => None,
ProviderId::Chutes => None,
ProviderId::LiteLLM => None,
@@ -467,6 +472,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),
@@ -888,6 +894,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);
@@ -954,6 +962,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",
@@ -990,7 +999,7 @@ mod tests {
#[test]
fn test_provider_id_all() {
let all = ProviderId::all();
- assert_eq!(all.len(), 71);
+ assert_eq!(all.len(), 72);
assert!(all.contains(&ProviderId::Claude));
assert!(all.contains(&ProviderId::Codex));
assert!(all.contains(&ProviderId::Fireworks));
@@ -1021,6 +1030,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));
@@ -1077,6 +1087,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");
}
#[test]
@@ -1084,6 +1095,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");
}
#[test]
@@ -1101,6 +1113,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"),
@@ -1183,6 +1199,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);
}
#[test]
diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs
index 093b5cf089..da0f9a4784 100644
--- a/rust/src/core/provider_factory.rs
+++ b/rust/src/core/provider_factory.rs
@@ -13,14 +13,14 @@ use crate::providers::{
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, 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,
+ GroqProvider, HuggingFaceProvider, InfiniProvider, JetBrainsProvider, KiloProvider,
+ KimiK2Provider, KimiProvider, KiroProvider, LLMProxyProvider, LiteLLMProvider, LongCatProvider,
+ ManusProvider, MetaProvider, MiMoProvider, MiniMaxProvider, MistralProvider, 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`].
@@ -78,6 +78,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 a8ae172b17..38d8ca46d7 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..b776cd7da5
--- /dev/null
+++ b/rust/src/providers/huggingface/mod.rs
@@ -0,0 +1,771 @@
+//! 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 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 ENV_KEYS: &[&str] = &[
+ "CODEXBAR_HUGGINGFACE_API_KEY",
+ "HF_TOKEN",
+ "HUGGING_FACE_HUB_TOKEN",
+];
+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;
+const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
+
+#[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"),
+ },
+ 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 {
+ 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 body = response.bytes().await.map_err(|_| {
+ ProviderError::Parse("Hugging Face returned an unreadable JSON body.".to_string())
+ })?;
+ 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()
+ .expect("valid UTC calendar month start")
+}
+
+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)
+ .filter(|value| *value <= MAX_SAFE_INTEGER);
+
+ 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> {
+ if let Some(seconds) = value
+ .as_i64()
+ .and_then(|seconds| Utc.timestamp_opt(seconds, 0).single())
+ {
+ return Some(seconds);
+ }
+ value
+ .as_u64()
+ .and_then(|seconds| i64::try_from(seconds).ok())
+ .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);
+ result = result
+ .with_display_detail(ProviderDisplayDetail::new(
+ "billable-usage",
+ "Billable inference usage",
+ format_usd(billing.billable_usd),
+ ))
+ .with_display_detail(ProviderDisplayDetail::new(
+ "gross-inference-usage",
+ "Gross inference usage",
+ format_usd(billing.used_usd),
+ ))
+ .with_display_detail(ProviderDisplayDetail::new(
+ "included-inference-amount",
+ "Included inference amount",
+ format_usd(billing.included_usd),
+ ));
+ if let Some(limit) = billing.limit_usd {
+ result = result.with_display_detail(ProviderDisplayDetail::new(
+ "spending-limit",
+ "Spending limit",
+ format_usd(limit),
+ ));
+ }
+ if let Some(requests) = billing.requests {
+ result = result.with_display_detail(ProviderDisplayDetail::new(
+ "inference-requests",
+ "Requests",
+ requests.to_string(),
+ ));
+ }
+ if let Some(zerogpu) = zerogpu {
+ let reset = zerogpu
+ .resets_at
+ .map(|date| format!(" · resets {}", date.to_rfc3339()))
+ .unwrap_or_default();
+ result = result.with_display_detail(
+ ProviderDisplayDetail::new(
+ "zerogpu-quota",
+ "ZeroGPU quota",
+ format!("{:.0} minutes used", zerogpu.used_minutes),
+ )
+ .with_secondary_value(format!(
+ "{:.0} minutes remaining{reset}",
+ zerogpu.remaining_minutes
+ ))
+ .with_progress(zerogpu.used_minutes, zerogpu.total_minutes),
+ );
+ }
+ if let Some(identity) = identity {
+ if let Some(name) = identity.name {
+ result = result.with_display_detail(ProviderDisplayDetail::new(
+ "account-name",
+ "Account",
+ name,
+ ));
+ }
+ if let Some(email) = identity.email {
+ result = result.with_display_detail(ProviderDisplayDetail::new(
+ "account-email",
+ "Email",
+ email,
+ ));
+ }
+ if let Some(plan) = identity.plan {
+ result = result.with_display_detail(ProviderDisplayDetail::new(
+ "account-plan",
+ "Plan",
+ plan,
+ ));
+ }
+ }
+ result
+}
+
+fn format_usd(value: f64) -> String {
+ format!("${value:.2}")
+}
+
+fn classify_status(status: StatusCode) -> ProviderError {
+ match status {
+ StatusCode::UNAUTHORIZED => ProviderError::AuthRequired,
+ StatusCode::FORBIDDEN => ProviderError::Other(
+ "Hugging Face token cannot access billing data (HTTP 403).".to_string(),
+ ),
+ StatusCode::TOO_MANY_REQUESTS => {
+ ProviderError::Other("Hugging Face API rate limited (HTTP 429).".to_string())
+ }
+ status if status.is_server_error() => {
+ ProviderError::Other("Hugging Face service unavailable (HTTP 5xx).".to_string())
+ }
+ status => ProviderError::Other(format!("Hugging Face API request failed (HTTP {status}).")),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+ use tempfile::tempdir;
+
+ fn fixture_environment() -> TokenEnvironment {
+ TokenEnvironment {
+ default_cache_dir: None,
+ home_dir: None,
+ ..TokenEnvironment::default()
+ }
+ }
+
+ #[test]
+ fn provider_is_api_only_and_disabled_by_default() {
+ let provider = HuggingFaceProvider::new();
+ assert_eq!(provider.id(), ProviderId::HuggingFace);
+ assert_eq!(
+ provider.available_sources(),
+ vec![SourceMode::Auto, SourceMode::OAuth]
+ );
+ assert!(!provider.metadata().default_enabled);
+ assert_eq!(provider.metadata().session_label, "Credits");
+ }
+
+ #[test]
+ fn token_environment_precedence_and_quote_cleanup_are_deterministic() {
+ let mut environment = fixture_environment();
+ environment.config_api_key = Some(" \"configured\" ".to_string());
+ environment.hf_token = Some("hf_env".to_string());
+ environment.hub_token = Some("hf_legacy".to_string());
+ assert_eq!(environment.resolve().as_deref(), Some("configured"));
+
+ environment.config_api_key = Some(" \"\" ".to_string());
+ assert_eq!(environment.resolve().as_deref(), Some("hf_env"));
+ environment.hf_token = Some(" ".to_string());
+ assert_eq!(environment.resolve().as_deref(), Some("hf_legacy"));
+ }
+
+ #[test]
+ fn token_files_follow_explicit_home_xdg_and_default_order() {
+ let root = tempdir().unwrap();
+ let explicit = root.path().join("explicit-token");
+ let hf_home = root.path().join("hf-home");
+ let xdg = root.path().join("xdg");
+ std::fs::write(&explicit, "\n 'explicit-token' \nsecond").unwrap();
+ std::fs::create_dir_all(&hf_home).unwrap();
+ std::fs::write(hf_home.join("token"), "hf-home-token").unwrap();
+ std::fs::create_dir_all(xdg.join("huggingface")).unwrap();
+ std::fs::write(xdg.join("huggingface/token"), "xdg-token").unwrap();
+
+ let environment = TokenEnvironment {
+ token_path: Some(explicit),
+ hf_home: Some(hf_home),
+ xdg_cache_home: Some(xdg),
+ ..fixture_environment()
+ };
+ assert_eq!(environment.resolve().as_deref(), Some("explicit-token"));
+ }
+
+ #[test]
+ fn token_file_reads_first_nonempty_line_and_expands_tilde() {
+ let root = tempdir().unwrap();
+ let file = root.path().join("hf/token");
+ std::fs::create_dir_all(file.parent().unwrap()).unwrap();
+ std::fs::write(&file, "\n\"from-file\"\nignored").unwrap();
+ let environment = TokenEnvironment {
+ token_path: Some(PathBuf::from("~/hf/token")),
+ home_dir: Some(root.path().to_path_buf()),
+ ..fixture_environment()
+ };
+ assert_eq!(environment.resolve().as_deref(), Some("from-file"));
+ }
+
+ #[test]
+ fn billing_url_uses_utc_month_start_and_current_end() {
+ let now = DateTime::parse_from_rfc3339("2026-09-19T16:47:00Z")
+ .unwrap()
+ .with_timezone(&Utc);
+ let url = billing_url(now).unwrap();
+ let query = url.query_pairs().collect::>();
+ assert_eq!(query[0].0, "startDate");
+ assert_eq!(query[0].1, month_start(now).timestamp().to_string());
+ assert_eq!(query[1].0, "endDate");
+ assert_eq!(query[1].1, now.timestamp().to_string());
+ }
+
+ #[test]
+ fn billing_parser_converts_nano_usd_and_preserves_optional_fields() {
+ let parsed = parse_billing(json!({
+ "usage": {"inferenceProviders": {
+ "usedNanoUsd": 2_450_000_000_u64,
+ "includedNanoUsd": 2_000_000_000_u64,
+ "limitNanoUsd": 10_000_000_000_u64,
+ "numRequests": 7_u64
+ }}
+ }))
+ .unwrap();
+ assert!((parsed.used_usd - 2.45).abs() < f64::EPSILON);
+ assert!((parsed.included_usd - 2.0).abs() < f64::EPSILON);
+ assert!((parsed.billable_usd - 0.45).abs() < f64::EPSILON);
+ assert_eq!(parsed.limit_usd, Some(10.0));
+ assert_eq!(parsed.requests, Some(7));
+ }
+
+ #[test]
+ fn billing_parser_clamps_included_amount_above_gross_to_zero() {
+ let parsed = parse_billing(json!({
+ "usage": {"inferenceProviders": {
+ "usedNanoUsd": 1_000_000_u64,
+ "includedNanoUsd": 2_000_000_u64
+ }}
+ }))
+ .unwrap();
+ assert_eq!(parsed.billable_usd, 0.0);
+ }
+
+ #[test]
+ fn billing_parser_rejects_missing_wrong_type_and_negative_required_values() {
+ for payload in [
+ json!({"usage": {"inferenceProviders": {"usedNanoUsd": 1}}}),
+ json!({"usage": {"inferenceProviders": {
+ "usedNanoUsd": "1", "includedNanoUsd": 1
+ }}}),
+ json!({"usage": {"inferenceProviders": {
+ "usedNanoUsd": -1, "includedNanoUsd": 1
+ }}}),
+ ] {
+ assert!(matches!(
+ parse_billing(payload),
+ Err(ProviderError::Parse(_))
+ ));
+ }
+ }
+
+ #[test]
+ fn invalid_optional_billing_fields_are_omitted() {
+ let parsed = parse_billing(json!({
+ "usage": {"inferenceProviders": {
+ "usedNanoUsd": 1_000_000_u64,
+ "includedNanoUsd": 0_u64,
+ "limitNanoUsd": "bad",
+ "numRequests": 9_007_199_254_740_992_u64
+ }}
+ }))
+ .unwrap();
+ assert_eq!(parsed.limit_usd, None);
+ assert_eq!(parsed.requests, None);
+ }
+
+ #[test]
+ fn invalid_fractional_or_negative_request_counts_are_omitted() {
+ for requests in [json!(-1), json!(1.5)] {
+ let parsed = parse_billing(json!({
+ "usage": {"inferenceProviders": {
+ "usedNanoUsd": 1, "includedNanoUsd": 0, "numRequests": requests
+ }}
+ }))
+ .unwrap();
+ assert_eq!(parsed.requests, None);
+ }
+ }
+
+ #[test]
+ fn zerogpu_parser_requires_a_valid_positive_total_and_keeps_reset_optional() {
+ let parsed =
+ parse_zerogpu(&json!({"base": 1500, "current": 900, "resetsAt": 1_800_000_000}))
+ .unwrap();
+ assert_eq!(parsed.used_minutes, 600.0);
+ assert_eq!(parsed.remaining_minutes, 900.0);
+ assert_eq!(parsed.total_minutes, 1500.0);
+ assert_eq!(parsed.resets_at.unwrap().timestamp(), 1_800_000_000);
+ assert!(parse_zerogpu(&json!({"base": "bad", "current": 900})).is_none());
+ assert!(parse_zerogpu(&json!({"base": 0, "current": 0})).is_none());
+ }
+
+ #[test]
+ fn identity_parser_is_optional_and_sanitized() {
+ let identity =
+ parse_identity(&json!({"name": "ness", "email": "n@example.test", "isPro": true}))
+ .unwrap();
+ assert_eq!(identity.name.as_deref(), Some("ness"));
+ assert_eq!(identity.email.as_deref(), Some("n@example.test"));
+ assert_eq!(identity.plan.as_deref(), Some("Pro"));
+ assert!(parse_identity(&json!({"email": "bad\nemail"})).is_none());
+ }
+
+ #[test]
+ fn status_errors_are_classified_without_response_body_or_token() {
+ let token = "hf_secret_fixture";
+ for status in [
+ StatusCode::FORBIDDEN,
+ StatusCode::TOO_MANY_REQUESTS,
+ StatusCode::INTERNAL_SERVER_ERROR,
+ StatusCode::BAD_REQUEST,
+ ] {
+ let error = classify_status(status).to_string();
+ assert!(!error.contains(token));
+ assert!(!error.contains("response body"));
+ }
+ assert!(matches!(
+ classify_status(StatusCode::UNAUTHORIZED),
+ ProviderError::AuthRequired
+ ));
+ }
+
+ #[test]
+ fn result_uses_cost_and_transient_details_without_quota_windows() {
+ let result = build_result(
+ BillingSnapshot {
+ used_usd: 2.45,
+ included_usd: 2.0,
+ billable_usd: 0.45,
+ limit_usd: Some(10.0),
+ requests: Some(7),
+ },
+ None,
+ Some(ZeroGpuSnapshot {
+ used_minutes: 600.0,
+ remaining_minutes: 900.0,
+ total_minutes: 1500.0,
+ resets_at: None,
+ }),
+ );
+ assert_eq!(result.source_label, "api");
+ assert_eq!(result.cost.as_ref().and_then(|cost| cost.limit), Some(10.0));
+ assert_eq!(result.display_details().count(), 6);
+ assert!(result.usage.primary.is_informational);
+ assert!(result.usage.secondary.is_none());
+ assert!(!result.pace_authoritative);
+ }
+}
diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs
index 1e32aaecd5..e4690d1aff 100755
--- a/rust/src/providers/mod.rs
+++ b/rust/src/providers/mod.rs
@@ -36,6 +36,7 @@ pub mod fireworks;
pub mod gemini;
pub mod grok;
pub mod groq;
+pub mod huggingface;
pub mod infini;
pub mod jetbrains;
pub mod kilo;
@@ -110,6 +111,7 @@ pub use fireworks::FireworksProvider;
pub use gemini::GeminiProvider;
pub use grok::GrokProvider;
pub use groq::GroqProvider;
+pub use huggingface::HuggingFaceProvider;
pub use infini::InfiniProvider;
pub use jetbrains::JetBrainsProvider;
pub use kilo::KiloProvider;
diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs
index 36402a93e6..7535b77af2 100644
--- a/rust/src/settings/api_keys.rs
+++ b/rust/src/settings/api_keys.rs
@@ -323,6 +323,19 @@ pub fn get_api_key_providers() -> Vec {
config_file_path: None,
dashboard_url: Some("https://deepinfra.com/dash"),
},
+ ProviderConfigInfo {
+ id: ProviderId::HuggingFace,
+ name: "Hugging Face",
+ requires_api_key: true,
+ api_key_env_var: Some(
+ "CODEXBAR_HUGGINGFACE_API_KEY / HF_TOKEN / HUGGING_FACE_HUB_TOKEN",
+ ),
+ api_key_help: Some(
+ "Add a Hugging Face access token here, set HF_TOKEN, or run `hf auth login`.",
+ ),
+ config_file_path: None,
+ dashboard_url: Some("https://huggingface.co/settings/billing"),
+ },
ProviderConfigInfo {
id: ProviderId::Fireworks,
name: "Fireworks",
diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs
index f4a3f72d43..f28b69139f 100644
--- a/rust/src/settings/tests.rs
+++ b/rust/src/settings/tests.rs
@@ -510,6 +510,7 @@ fn test_api_key_provider_catalog_includes_token_providers() {
ProviderId::Codebuff,
ProviderId::DeepSeek,
ProviderId::DeepInfra,
+ ProviderId::HuggingFace,
ProviderId::AiAnd,
ProviderId::ElevenLabs,
ProviderId::Deepgram,