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,