From a855b41d91b1d550124ecb294dfc12eb661f7d15 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:32:41 -0700 Subject: [PATCH 1/2] docs: align Hugging Face provider docs, errors, and tests with shipped route --- config.example.toml | 4 +- crates/config/src/lib.rs | 146 ++++++++++++++++++++++++++++- crates/tui/src/config.rs | 96 ++++++++++++++++++- docs/MODEL_LAB.md | 33 ++++--- docs/PROVIDERS.md | 19 +++- scripts/check-provider-registry.py | 92 ++++++++++++++++++ 6 files changed, 370 insertions(+), 20 deletions(-) diff --git a/config.example.toml b/config.example.toml index b5343535..3bc8d59f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -248,7 +248,8 @@ max_subagents = 10 # optional (1-20) # SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY # Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY -# Hugging Face: HUGGINGFACE_API_KEY (or HF_TOKEN), HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL +# Hugging Face: HUGGINGFACE_API_KEY (or HF_TOKEN), HUGGINGFACE_BASE_URL (or HF_BASE_URL), +# HUGGINGFACE_MODEL (or HF_MODEL) # # Custom DeepSeek-compatible APIs usually do not need a new provider table: # set `provider = "deepseek"` and override [providers.deepseek].base_url/model. @@ -385,6 +386,7 @@ max_subagents = 10 # optional (1-20) # model = "deepseek-coder:1.3b" # or any local Ollama tag # Hugging Face Inference Providers (https://huggingface.co/docs/api-inference) +# Provider aliases: huggingface, hugging-face, hugging_face, hf [providers.huggingface] # api_key = "YOUR_HF_TOKEN" # base_url = "https://router.huggingface.co/v1" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7135300d..1c1eee0a 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -131,6 +131,39 @@ pub enum ProviderKind { } impl ProviderKind { + #[must_use] + pub fn all() -> &'static [Self] { + &[ + Self::Deepseek, + Self::NvidiaNim, + Self::Openai, + Self::Atlascloud, + Self::WanjieArk, + Self::Volcengine, + Self::Openrouter, + Self::XiaomiMimo, + Self::Novita, + Self::Fireworks, + Self::Siliconflow, + Self::SiliconflowCN, + Self::Arcee, + Self::Moonshot, + Self::Sglang, + Self::Vllm, + Self::Ollama, + Self::Huggingface, + ] + } + + #[must_use] + pub fn names_hint() -> String { + Self::all() + .iter() + .map(|provider| provider.as_str()) + .collect::>() + .join(", ") + } + #[must_use] pub fn as_str(self) -> &'static str { match self { @@ -698,8 +731,12 @@ impl ConfigToml { pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> { match key { "provider" => { - self.provider = ProviderKind::parse(value) - .with_context(|| format!("unknown provider '{value}'"))?; + self.provider = ProviderKind::parse(value).with_context(|| { + format!( + "unknown provider '{value}': expected {}", + ProviderKind::names_hint() + ) + })?; } "api_key" => self.api_key = Some(value.to_string()), "base_url" => self.base_url = Some(value.to_string()), @@ -1418,7 +1455,7 @@ impl ConfigToml { } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { - match codewhale_secrets::env_for(provider.as_str()) { + match env_api_key_for_provider(provider) { Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)), None => (None, None), } @@ -1431,7 +1468,10 @@ impl ConfigToml { }; (Some(value), Some(source)) } - None => (None, None), + None => match env_api_key_for_provider(provider) { + Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)), + None => (None, None), + }, } }; @@ -1909,6 +1949,17 @@ fn should_skip_secret_store_for_provider( ) || base_url_uses_local_host(base_url) } +fn env_api_key_for_provider(provider: ProviderKind) -> Option { + if provider == ProviderKind::Huggingface { + return std::env::var("HUGGINGFACE_API_KEY") + .or_else(|_| std::env::var("HF_TOKEN")) + .ok() + .filter(|value| !value.trim().is_empty()); + } + + codewhale_secrets::env_for(provider.as_str()) +} + fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool { matches!( auth_mode @@ -2855,6 +2906,12 @@ mod tests { vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + huggingface_api_key: Option, + huggingface_token: Option, + huggingface_base_url: Option, + hf_base_url: Option, + huggingface_model: Option, + hf_model: Option, codewhale_provider: Option, codewhale_model: Option, codewhale_base_url: Option, @@ -2928,6 +2985,12 @@ mod tests { vllm_base_url: env::var_os("VLLM_BASE_URL"), ollama_api_key: env::var_os("OLLAMA_API_KEY"), ollama_base_url: env::var_os("OLLAMA_BASE_URL"), + huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"), + huggingface_token: env::var_os("HF_TOKEN"), + huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"), + hf_base_url: env::var_os("HF_BASE_URL"), + huggingface_model: env::var_os("HUGGINGFACE_MODEL"), + hf_model: env::var_os("HF_MODEL"), }; // Safety: test-only environment mutation guarded by a module mutex. unsafe { @@ -2996,6 +3059,12 @@ mod tests { env::remove_var("VLLM_BASE_URL"); env::remove_var("OLLAMA_API_KEY"); env::remove_var("OLLAMA_BASE_URL"); + env::remove_var("HUGGINGFACE_API_KEY"); + env::remove_var("HF_TOKEN"); + env::remove_var("HUGGINGFACE_BASE_URL"); + env::remove_var("HF_BASE_URL"); + env::remove_var("HUGGINGFACE_MODEL"); + env::remove_var("HF_MODEL"); } guard } @@ -3084,6 +3153,12 @@ mod tests { Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); + Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); + Self::restore_var("HF_TOKEN", self.huggingface_token.take()); + Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); + Self::restore_var("HF_BASE_URL", self.hf_base_url.take()); + Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); + Self::restore_var("HF_MODEL", self.hf_model.take()); } } } @@ -3987,6 +4062,13 @@ unix_socket_path = "/tmp/cw-hooks.sock" ProviderKind::parse("ark_wanjie"), Some(ProviderKind::WanjieArk) ); + for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface)); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias"); + assert_eq!(parsed.provider, ProviderKind::Huggingface); + } let parsed: ConfigToml = toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias"); @@ -3997,6 +4079,17 @@ unix_socket_path = "/tmp/cw-hooks.sock" assert_eq!(parsed.provider, ProviderKind::Siliconflow); } + #[test] + fn unknown_provider_error_lists_huggingface() { + let mut config = ConfigToml::default(); + let err = config + .set_value("provider", "not-a-provider") + .expect_err("unknown provider should fail"); + let message = err.to_string(); + assert!(message.contains("unknown provider 'not-a-provider'")); + assert!(message.contains("huggingface")); + } + #[test] fn provider_kind_accepts_legacy_deepseek_cn_aliases() { for alias in [ @@ -4687,6 +4780,51 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL); } + #[test] + fn huggingface_env_precedence_prefers_documented_names() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "hf"); + env::set_var("HUGGINGFACE_API_KEY", "hf-full-key"); + env::set_var("HF_TOKEN", "hf-token-fallback"); + env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1"); + env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); + env::set_var("HUGGINGFACE_MODEL", "org/full-model"); + env::set_var("HF_MODEL", "org/short-model"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key")); + assert_eq!(resolved.base_url, "https://hf-full.example/v1"); + assert_eq!(resolved.model, "org/full-model"); + } + + #[test] + fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HF_TOKEN", "hf-token-fallback"); + env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); + env::set_var("HF_MODEL", "org/short-model"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); + assert_eq!(resolved.base_url, "https://hf-short.example/v1"); + assert_eq!(resolved.model, "org/short-model"); + } + #[test] fn siliconflow_cn_base_url_env_normalizes_model_aliases() { let _lock = env_lock(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f71400b7..fcc3877f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -3461,7 +3461,8 @@ fn apply_env_overrides(config: &mut Config) { .base_url = Some(value); } if matches!(config.api_provider(), ApiProvider::Huggingface) - && let Ok(value) = std::env::var("HUGGINGFACE_BASE_URL") + && let Ok(value) = + std::env::var("HUGGINGFACE_BASE_URL").or_else(|_| std::env::var("HF_BASE_URL")) && !value.trim().is_empty() { config @@ -3674,7 +3675,7 @@ fn apply_env_overrides(config: &mut Config) { .model = Some(value); } if matches!(config.api_provider(), ApiProvider::Huggingface) - && let Ok(value) = std::env::var("HUGGINGFACE_MODEL") + && let Ok(value) = std::env::var("HUGGINGFACE_MODEL").or_else(|_| std::env::var("HF_MODEL")) && !value.trim().is_empty() { config @@ -5763,7 +5764,9 @@ mod tests { huggingface_api_key: Option, huggingface_token: Option, huggingface_base_url: Option, + hf_base_url: Option, huggingface_model: Option, + hf_model: Option, } impl EnvGuard { @@ -5860,7 +5863,9 @@ mod tests { let huggingface_api_key_prev = env::var_os("HUGGINGFACE_API_KEY"); let huggingface_token_prev = env::var_os("HF_TOKEN"); let huggingface_base_url_prev = env::var_os("HUGGINGFACE_BASE_URL"); + let hf_base_url_prev = env::var_os("HF_BASE_URL"); let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL"); + let hf_model_prev = env::var_os("HF_MODEL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -5952,7 +5957,9 @@ mod tests { env::remove_var("HUGGINGFACE_API_KEY"); env::remove_var("HF_TOKEN"); env::remove_var("HUGGINGFACE_BASE_URL"); + env::remove_var("HF_BASE_URL"); env::remove_var("HUGGINGFACE_MODEL"); + env::remove_var("HF_MODEL"); } Self { home: home_prev, @@ -6044,7 +6051,9 @@ mod tests { huggingface_api_key: huggingface_api_key_prev, huggingface_token: huggingface_token_prev, huggingface_base_url: huggingface_base_url_prev, + hf_base_url: hf_base_url_prev, huggingface_model: huggingface_model_prev, + hf_model: hf_model_prev, } } } @@ -6154,7 +6163,9 @@ mod tests { Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); Self::restore_var("HF_TOKEN", self.huggingface_token.take()); Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); + Self::restore_var("HF_BASE_URL", self.hf_base_url.take()); Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); + Self::restore_var("HF_MODEL", self.hf_model.take()); } } } @@ -10038,6 +10049,25 @@ model = "deepseek-ai/deepseek-v4-pro" assert_eq!(tui.status_items, None); } + #[test] + fn huggingface_provider_aliases_parse() { + for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] { + assert_eq!(ApiProvider::parse(alias), Some(ApiProvider::Huggingface)); + } + } + + #[test] + fn invalid_provider_error_lists_huggingface() { + let config = Config { + provider: Some("not-a-provider".to_string()), + ..Default::default() + }; + let err = config.validate().expect_err("unknown provider should fail"); + let message = err.to_string(); + assert!(message.contains("Invalid provider 'not-a-provider'")); + assert!(message.contains("huggingface")); + } + #[test] fn huggingface_provider_uses_direct_defaults() -> Result<()> { let _lock = lock_test_env(); @@ -10092,6 +10122,35 @@ model = "deepseek-ai/deepseek-v4-pro" Ok(()) } + #[test] + fn huggingface_missing_key_error_mentions_env_fallbacks() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-huggingface-missing-key-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("huggingface".to_string()), + ..Default::default() + }; + + config.validate()?; + let err = config.deepseek_api_key().expect_err("missing key"); + let message = err.to_string(); + assert!(message.contains("Hugging Face API key not found")); + assert!(message.contains("HUGGINGFACE_API_KEY")); + assert!(message.contains("HF_TOKEN")); + Ok(()) + } + #[test] fn huggingface_env_overrides_key_base_url_and_model() -> Result<()> { let _lock = lock_test_env(); @@ -10110,8 +10169,11 @@ model = "deepseek-ai/deepseek-v4-pro" unsafe { env::set_var("CODEWHALE_PROVIDER", "huggingface"); env::set_var("HUGGINGFACE_API_KEY", "hf-env-key"); + env::set_var("HF_TOKEN", "hf-token-fallback"); env::set_var("HUGGINGFACE_BASE_URL", "https://custom-hf.example/v1"); + env::set_var("HF_BASE_URL", "https://fallback-hf.example/v1"); env::set_var("HUGGINGFACE_MODEL", "meta-llama/Llama-3-70B"); + env::set_var("HF_MODEL", "fallback/model"); } let config = Config::load(None, None)?; @@ -10121,4 +10183,34 @@ model = "deepseek-ai/deepseek-v4-pro" assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); Ok(()) } + + #[test] + fn huggingface_short_env_fallbacks_configure_route() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-huggingface-short-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "hf"); + env::set_var("HF_TOKEN", "hf-token-value"); + env::set_var("HF_BASE_URL", "https://short-hf.example/v1"); + env::set_var("HF_MODEL", "org/short-model"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-token-value"); + assert_eq!(config.deepseek_base_url(), "https://short-hf.example/v1"); + assert_eq!(config.default_model(), "org/short-model"); + Ok(()) + } } diff --git a/docs/MODEL_LAB.md b/docs/MODEL_LAB.md index f7213e6a..ca34564f 100644 --- a/docs/MODEL_LAB.md +++ b/docs/MODEL_LAB.md @@ -7,8 +7,7 @@ those models become discoverable, evaluable, routable, servable, and exportable without weakening the current terminal-agent contract: local workspace control, explicit provider auth, approval gates, and clear privacy boundaries. -This document is roadmap language. It does not mean every workset below is -implemented today. +This document is roadmap language. Some worksets below are roadmap-only. ## Implemented Today @@ -19,6 +18,10 @@ implemented today. OpenAI-compatible endpoints, SGLang, vLLM, and Ollama are supported provider paths where their IDs appear in `/provider`, `codewhale --provider`, or `codewhale models`. +- Hugging Face Inference Providers are available through the + OpenAI-compatible router at `https://router.huggingface.co/v1`. Select the + route with `huggingface`, `hugging-face`, `hugging_face`, or `hf`; configure + `HUGGINGFACE_API_KEY` or `HF_TOKEN` for auth. - Model auto-routing chooses a concrete DeepSeek model and thinking level per turn. It is not a TUI mode. - Fin is the fast `deepseek-v4-flash` thinking-off path for routing, @@ -27,11 +30,10 @@ implemented today. - Self-hosted OpenAI-compatible endpoints can be used through SGLang, vLLM, Ollama, or the generic `openai` provider configuration. -## Not Implemented Yet +## Still Planned -- A native Hugging Face provider or Hub browser. -- Built-in Hugging Face model card, dataset, adapter, safetensors, or Jobs - workflows. +- Hugging Face Hub browsing, upload/export, model card, dataset, adapter, + safetensors, or Jobs workflows. - Native Unsloth, NeMo, or Arcee integrations. - A dedicated Model Lab UI tab. - Built-in benchmark suites, eval leaderboards, hosted observability, or @@ -57,18 +59,24 @@ describe a model as available before CodeWhale can actually route to it. ## Hugging Face Workset +Implemented today: + +- Hugging Face Inference Providers as an explicit OpenAI-compatible router + provider, selected with `huggingface`, `hugging-face`, `hugging_face`, or + `hf`. +- Model IDs are sent to the router exactly as selected, including + org-prefixed Hugging Face model IDs. + Planned scope: - Hub API auth and model discovery. - Model cards, licenses, tags, safetensors metadata, adapters, and dataset links surfaced in a terminal-friendly way. -- Inference Providers as explicit provider choices when the user configures - them. - Hugging Face Jobs as an optional remote execution path for user-approved experiments. -Non-goal for now: claiming a native Hugging Face provider exists before it is -implemented in code. +Non-goal for now: treating the router route as Hub browsing/export, or +inferring Hub upload/export auth from the inference-provider API key. ## Unsloth Workset @@ -138,8 +146,9 @@ Planned scope: - Local files, prompts, transcripts, traces, model outputs, eval results, adapters, datasets, and checkpoints should remain local unless the user explicitly chooses a provider or export destination. -- Provider auth must remain explicit. `DEEPSEEK_*`, OpenRouter, Hugging Face, - and self-hosted credentials should not be inferred from unrelated config. +- Provider auth must remain explicit. `DEEPSEEK_*`, OpenRouter, + `HUGGINGFACE_API_KEY` / `HF_TOKEN`, and self-hosted credentials should not be + inferred from unrelated config. - Exportable artifacts should include provenance: source model, provider, route, tool policy, eval inputs, and redaction status. - Public sharing, hosted telemetry, sponsorship badges, and external branding diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 4c99e882..6d3398ee 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -44,6 +44,11 @@ Use any of these surfaces to select a provider: as legacy aliases for `deepseek`. They do not select a different official host; DeepSeek uses the same official API host worldwide. +`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the +Hugging Face Inference Providers route. This is the OpenAI-compatible router +path for chat/inference, not Hub browsing, model-card inspection, uploads, or +artifact export. + Fresh shared config writes to `~/.codewhale/config.toml`. Existing `~/.deepseek/config.toml` files are still read for compatibility. @@ -128,7 +133,7 @@ endpoint. | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | | `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. | | `ollama` | `[providers.ollama]` | Optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | `deepseek-coder:1.3b`; provider-hinted custom tags pass through | Self-hosted Ollama OpenAI-compatible route. Localhost deployments commonly omit auth. `OLLAMA_MODEL` is accepted. | -| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible route. Org-prefixed model IDs pass through. | +| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. | ### Xiaomi MiMo Notes @@ -223,6 +228,18 @@ the endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom OpenAI-compatible or local endpoint can still reject tool calls even if CodeWhale can send the schema. +### Hugging Face Inference Providers Notes + +The shipped Hugging Face route targets the OpenAI-compatible Inference Providers +router at `https://router.huggingface.co/v1`. Configure auth with +`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint +with `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the +model with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback. + +This route does not imply Hub browsing, model-card metadata, dataset access, +Jobs, uploads, or export. Those remain explicit Model Lab work items so +provider auth and artifact movement stay separate. + ### When a Local Model Prints Tool JSON CodeWhale only executes tools when the provider returns Chat Completions diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 85d7eea6..b49a356a 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -30,6 +30,10 @@ API_PROVIDER_ONLY_IDS = {"deepseek-cn"} SHARED_PROVIDER_TABLES = { "siliconflow-CN": "siliconflow", } +HUGGINGFACE_ALIASES = {"huggingface", "hugging-face", "hugging_face", "hf"} +HUGGINGFACE_API_KEY_ENV_ORDER = ["HUGGINGFACE_API_KEY", "HF_TOKEN"] +HUGGINGFACE_BASE_URL_ENV_ORDER = ["HUGGINGFACE_BASE_URL", "HF_BASE_URL"] +HUGGINGFACE_MODEL_ENV_ORDER = ["HUGGINGFACE_MODEL", "HF_MODEL"] def read(path: Path) -> str: @@ -68,6 +72,23 @@ def extract_match_block( raise ValueError(f"could not parse match block after {signature!r}") +def parse_aliases_for_variant(source: str, enum_name: str, variant: str, context: str) -> set[str]: + impl_start = require_index(source, f"impl {enum_name}", context) + block = extract_match_block( + source, + "pub fn parse(value: &str) -> Option", + context, + impl_start, + ) + match_arm = re.search( + rf'((?:"[^"]+"\s*\|\s*)*"[^"]+")\s*=>\s*Some\(Self::{variant}\)', + block, + ) + if not match_arm: + raise ValueError(f"{context}: missing parse arm for {variant}") + return set(re.findall(r'"([^"]+)"', match_arm.group(1))) + + def provider_kind_ids(config_rs: str) -> dict[str, str]: impl_start = require_index( config_rs, "impl ProviderKind", "crates/config/src/lib.rs" @@ -198,6 +219,76 @@ def report_provider_enum_drift( return errors +def report_huggingface_coverage( + config_rs: str, tui_config_rs: str, providers_md: str +) -> list[str]: + errors = [] + + config_aliases = parse_aliases_for_variant( + config_rs, "ProviderKind", "Huggingface", "crates/config/src/lib.rs" + ) + tui_aliases = parse_aliases_for_variant( + tui_config_rs, "ApiProvider", "Huggingface", "crates/tui/src/config.rs" + ) + errors += report_set( + "ProviderKind Hugging Face aliases", + HUGGINGFACE_ALIASES, + config_aliases & HUGGINGFACE_ALIASES, + ) + errors += report_set( + "ApiProvider Hugging Face aliases", + HUGGINGFACE_ALIASES, + tui_aliases & HUGGINGFACE_ALIASES, + ) + + inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL) + code_spans = set(re.findall(r"`([^`]+)`", inline_source)) + errors += report_set( + "documented Hugging Face aliases", + HUGGINGFACE_ALIASES, + code_spans & HUGGINGFACE_ALIASES, + ) + + for label, env_order in [ + ("Hugging Face API key env precedence", HUGGINGFACE_API_KEY_ENV_ORDER), + ("Hugging Face base URL env precedence", HUGGINGFACE_BASE_URL_ENV_ORDER), + ("Hugging Face model env precedence", HUGGINGFACE_MODEL_ENV_ORDER), + ]: + errors += report_env_lookup_order( + label, config_rs, env_order, "crates/config/src/lib.rs" + ) + errors += report_env_lookup_order( + label, tui_config_rs, env_order, "crates/tui/src/config.rs" + ) + errors += report_string_order(label, providers_md, env_order, "docs/PROVIDERS.md") + + return errors + + +def report_env_lookup_order( + label: str, source: str, expected_order: list[str], context: str +) -> list[str]: + lookup_needles = [f'std::env::var("{name}")' for name in expected_order] + return report_string_order(label, source, lookup_needles, context) + + +def report_string_order( + label: str, source: str, expected_order: list[str], context: str +) -> list[str]: + positions = [] + for needle in expected_order: + index = source.find(needle) + if index == -1: + return [f"{label} missing {needle!r} in {context}"] + positions.append(index) + if positions != sorted(positions): + return [ + f"{label} has wrong order in {context}: expected " + + " before ".join(expected_order) + ] + return [] + + def provider_table_name(provider_id: str) -> str: return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_")) @@ -216,6 +307,7 @@ def main() -> int: errors: list[str] = [] errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids) + errors += report_huggingface_coverage(config_rs, tui_config_rs, providers_md) errors += report_set( "shipped provider rows", canonical_ids, From 89cb6c55c81c7d8cc0c07816f215f5d00fe2d414 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:51:27 -0700 Subject: [PATCH 2/2] fix: address self-review findings --- crates/config/src/lib.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 1c1eee0a..af255c17 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1952,9 +1952,13 @@ fn should_skip_secret_store_for_provider( fn env_api_key_for_provider(provider: ProviderKind) -> Option { if provider == ProviderKind::Huggingface { return std::env::var("HUGGINGFACE_API_KEY") - .or_else(|_| std::env::var("HF_TOKEN")) .ok() - .filter(|value| !value.trim().is_empty()); + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var("HF_TOKEN") + .ok() + .filter(|value| !value.trim().is_empty()) + }); } codewhale_secrets::env_for(provider.as_str()) @@ -4825,6 +4829,24 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, "org/short-model"); } + #[test] + fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HUGGINGFACE_API_KEY", " "); + env::set_var("HF_TOKEN", "hf-token-fallback"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); + } + #[test] fn siliconflow_cn_base_url_env_normalizes_model_aliases() { let _lock = env_lock();