Files
tkmind_go/crates/goose/src/providers/huggingface.rs
T
Douwe Osinga 4bb968fadb Improve ACP provider setup and unify setup metadata (#11100)
Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
Signed-off-by: Douwe Osinga <douwe.osinga@gmail.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
2026-08-14 16:25:20 +00:00

552 lines
18 KiB
Rust

use super::api_client::{ApiClient, AuthMethod, AuthProvider};
use super::base::{
ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata,
DEFAULT_PROVIDER_TIMEOUT_SECS,
};
use super::huggingface_auth;
use super::openai_compatible::OpenAiCompatibleProvider;
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::config::{Config, ConfigError};
use crate::conversation::message::Message;
use anyhow::{anyhow, Result};
use futures::future::BoxFuture;
use goose_providers::errors::ProviderError;
use goose_providers::model::ModelConfig;
use rmcp::model::Tool;
pub const HUGGINGFACE_API_HOST: &str = "https://router.huggingface.co/v1";
pub const HUGGINGFACE_DOC_URL: &str = "https://huggingface.co/docs/inference-providers";
pub const HUGGINGFACE_DEFAULT_MODEL: &str = "Qwen/Qwen3-Coder-480B-A35B-Instruct";
pub const HUGGINGFACE_KNOWN_MODELS: &[&str] = &[
"MiniMaxAI/MiniMax-M2.1",
"MiniMaxAI/MiniMax-M2.5",
"MiniMaxAI/MiniMax-M2.7",
"Qwen/Qwen3-235B-A22B-Thinking",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-Coder-Next",
"Qwen/Qwen3-Embedding-4B",
"Qwen/Qwen3-Embedding-8B",
"Qwen/Qwen3-Next-80B-A3B-Instruct",
"Qwen/Qwen3-Next-80B-A3B-Thinking",
"Qwen/Qwen3.5-397B-A17B",
"XiaomiMiMo/MiMo-V2-Flash",
"deepseek-ai/DeepSeek-R1",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-V4-Pro",
"moonshotai/Kimi-K2-Instruct",
"moonshotai/Kimi-K2-Thinking",
"moonshotai/Kimi-K2.5",
"moonshotai/Kimi-K2.6",
"zai-org/GLM-4.7",
"zai-org/GLM-4.7-Flash",
"zai-org/GLM-5",
"zai-org/GLM-5.1",
];
type QueryParams = Vec<(String, String)>;
type EndpointParts = (String, String, QueryParams);
pub struct HuggingFaceProvider {
inner: OpenAiCompatibleProvider,
custom_models: Option<Vec<String>>,
dynamic_models: Option<bool>,
}
struct HuggingFaceAuthProvider;
#[async_trait::async_trait]
impl AuthProvider for HuggingFaceAuthProvider {
async fn get_auth_header(&self) -> Result<(String, String)> {
let token = huggingface_auth::resolve_token_async()
.await?
.ok_or_else(missing_token_error)?;
Ok(("Authorization".to_string(), format!("Bearer {}", token)))
}
}
impl HuggingFaceProvider {
pub fn matches_declarative_config(config: &DeclarativeProviderConfig) -> bool {
config.name == huggingface_auth::HUGGINGFACE_PROVIDER_NAME
|| config.catalog_provider_id.as_deref()
== Some(huggingface_auth::HUGGINGFACE_PROVIDER_NAME)
}
pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<Self> {
let custom_models = static_model_names(&config);
if config.dynamic_models == Some(false) && custom_models.is_none() {
return Err(anyhow!(
"Provider '{}' has dynamic_models: false but no static models listed; \
at least one entry in `models` is required.",
config.name
));
}
let auth_method = custom_auth_method(&config)?;
let (host, completions_prefix, query_params) =
openai_compatible_endpoint_parts(&config.base_url, config.base_path.as_deref())?;
let timeout_secs = config
.timeout_seconds
.unwrap_or(DEFAULT_PROVIDER_TIMEOUT_SECS);
let mut api_client = ApiClient::with_timeout_and_tls(
host,
auth_method,
std::time::Duration::from_secs(timeout_secs),
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder())
.with_query(query_params);
if let Some(headers) = &config.headers {
let mut header_map = reqwest::header::HeaderMap::new();
for (key, value) in headers {
let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?;
let header_value = reqwest::header::HeaderValue::from_str(value)?;
header_map.insert(header_name, header_value);
}
api_client = api_client.with_headers(header_map)?;
}
Ok(Self {
inner: OpenAiCompatibleProvider::new(
config.name.clone(),
api_client,
completions_prefix,
)
.with_supports_streaming(config.supports_streaming.unwrap_or(true)),
custom_models,
dynamic_models: config.dynamic_models,
})
}
pub async fn cleanup() -> Result<()> {
huggingface_auth::clear_oauth_token()
}
}
#[async_trait::async_trait]
impl Provider for HuggingFaceProvider {
fn get_name(&self) -> &str {
self.inner.get_name()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
if let Some(custom_models) = &self.custom_models {
if self.dynamic_models == Some(false) {
return Ok(custom_models.clone());
}
match self.inner.fetch_supported_models().await {
Ok(models) => return Ok(models),
Err(e) if e.is_endpoint_not_found() => {
tracing::debug!(
"Models endpoint not implemented for Hugging Face provider '{}' ({}), using predefined list",
self.inner.get_name(),
e
);
return Ok(custom_models.clone());
}
Err(e) => return Err(e),
}
}
self.inner.fetch_supported_models().await
}
async fn stream(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
self.inner
.stream(model_config, system, messages, tools)
.await
}
}
impl goose_providers::base::ProviderDescriptor for HuggingFaceProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
huggingface_auth::HUGGINGFACE_PROVIDER_NAME,
huggingface_auth::HUGGINGFACE_DISPLAY_NAME,
"Hugging Face Inference Providers via the Hugging Face Router",
HUGGINGFACE_DEFAULT_MODEL,
HUGGINGFACE_KNOWN_MODELS.to_vec(),
HUGGINGFACE_DOC_URL,
vec![
ConfigKey::new(
huggingface_auth::HUGGINGFACE_TOKEN_SECRET_KEY,
true,
true,
None,
true,
),
ConfigKey::new("HF_HOST", false, false, Some(HUGGINGFACE_API_HOST), false),
],
)
.with_setup(
crate::providers::catalog::ProviderSetupMetadata::api_key(
crate::providers::catalog::ProviderSetupGroup::Default,
)
.with_docs_url("https://huggingface.co/docs/inference-providers")
.with_aliases(&["huggingface", "hf"]),
)
}
}
impl ProviderDef for HuggingFaceProvider {
type Provider = Self;
fn from_env(
_extensions: Vec<crate::config::ExtensionConfig>,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(async move {
let config = Config::global();
let auth_method =
refreshable_huggingface_auth_method(huggingface_auth::has_configured_token)?;
let host: String = config
.get_param("HF_HOST")
.unwrap_or_else(|_| HUGGINGFACE_API_HOST.to_string());
let api_client = ApiClient::new_with_tls(host, auth_method, tls_config)?
.with_request_builder(crate::session_context::session_id_request_builder());
Ok(Self {
inner: OpenAiCompatibleProvider::new(
huggingface_auth::HUGGINGFACE_PROVIDER_NAME.to_string(),
api_client,
String::new(),
),
custom_models: None,
dynamic_models: None,
})
})
}
}
fn missing_token_error() -> anyhow::Error {
anyhow!(
"Hugging Face token is not configured. Sign in from Settings > Auth or configure HF_TOKEN."
)
}
fn configured_api_key(config: &DeclarativeProviderConfig) -> Result<Option<String>> {
if config.api_key_env.is_empty() {
return Ok(None);
}
match Config::global().get_secret::<String>(&config.api_key_env) {
Ok(token) => Ok(Some(token)),
Err(ConfigError::NotFound(_)) => Ok(None),
Err(error) => Err(error.into()),
}
}
fn static_model_names(config: &DeclarativeProviderConfig) -> Option<Vec<String>> {
(!config.models.is_empty()).then(|| {
config
.models
.iter()
.map(|model| model.name.clone())
.collect()
})
}
fn custom_auth_method(config: &DeclarativeProviderConfig) -> Result<AuthMethod> {
let configured_key = if config.requires_auth {
configured_api_key(config)?
} else {
None
};
custom_auth_method_with_provider_token(config.requires_auth, configured_key)
}
fn custom_auth_method_with_provider_token(
requires_auth: bool,
provider_token: Option<String>,
) -> Result<AuthMethod> {
custom_auth_method_from_sources(
requires_auth,
provider_token,
huggingface_auth::has_configured_token,
)
}
fn custom_auth_method_from_sources(
requires_auth: bool,
provider_token: Option<String>,
has_global_token: impl FnOnce() -> Result<bool>,
) -> Result<AuthMethod> {
if !requires_auth {
return Ok(AuthMethod::NoAuth);
}
if let Some(token) = provider_token {
return Ok(AuthMethod::BearerToken(token));
}
refreshable_huggingface_auth_method(has_global_token)
}
fn refreshable_huggingface_auth_method(
has_configured_token: impl FnOnce() -> Result<bool>,
) -> Result<AuthMethod> {
if !has_configured_token()? {
return Err(missing_token_error());
}
Ok(AuthMethod::Custom(Box::new(HuggingFaceAuthProvider)))
}
fn openai_compatible_endpoint_parts(
base_url: &str,
base_path: Option<&str>,
) -> Result<EndpointParts> {
let url =
url::Url::parse(base_url).map_err(|e| anyhow!("Invalid base URL '{}': {}", base_url, e))?;
let mut host = if let Some(port) = url.port() {
format!(
"{}://{}:{}",
url.scheme(),
url.host_str().unwrap_or_default(),
port
)
} else {
format!("{}://{}", url.scheme(), url.host_str().unwrap_or_default())
};
let query_params = url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
if let Some(path) = base_path {
return Ok((host, completions_prefix(path), query_params));
}
let path = url.path().trim_matches('/');
if path.is_empty() {
return Ok((host, String::new(), query_params));
}
if let Some(parent) = path
.strip_suffix("/chat/completions")
.or_else(|| (path == "chat/completions").then_some(""))
{
if !parent.is_empty() {
host.push('/');
host.push_str(parent);
}
return Ok((host, String::new(), query_params));
}
host.push('/');
host.push_str(path);
Ok((host, String::new(), query_params))
}
fn completions_prefix(path: &str) -> String {
let path = path.trim_matches('/');
if path.is_empty() {
return String::new();
}
let parent = path
.strip_suffix("/chat/completions")
.or_else(|| (path == "chat/completions").then_some(""))
.unwrap_or(path);
if parent.is_empty() {
String::new()
} else {
format!("{}/", parent)
}
}
#[cfg(test)]
mod tests {
use goose_providers::base::ProviderDescriptor as _;
use super::*;
use crate::providers::base::ModelInfo;
#[test]
fn metadata_preserves_huggingface_id_and_token_key() {
let metadata = HuggingFaceProvider::metadata();
assert_eq!(metadata.name, "huggingface");
assert_eq!(metadata.display_name, "Hugging Face");
assert_eq!(metadata.default_model, HUGGINGFACE_DEFAULT_MODEL);
assert!(metadata
.config_keys
.iter()
.any(|key| key.name == "HF_TOKEN" && key.secret));
}
#[test]
fn declarative_matching_accepts_name_or_catalog_provider_id() {
let mut config = test_config();
assert!(!HuggingFaceProvider::matches_declarative_config(&config));
config.name = "huggingface".to_string();
assert!(HuggingFaceProvider::matches_declarative_config(&config));
config.name = "custom_hugging_face".to_string();
config.catalog_provider_id = Some("huggingface".to_string());
assert!(HuggingFaceProvider::matches_declarative_config(&config));
}
#[test]
fn endpoint_parts_use_base_url_path_as_api_host() {
let (host, prefix, query) =
openai_compatible_endpoint_parts("https://router.huggingface.co/v1?beta=1", None)
.unwrap();
assert_eq!(host, "https://router.huggingface.co/v1");
assert_eq!(prefix, "");
assert_eq!(query, vec![("beta".to_string(), "1".to_string())]);
}
#[test]
fn endpoint_parts_strip_chat_completions_suffix() {
let (host, prefix, query) = openai_compatible_endpoint_parts(
"https://router.huggingface.co/v1/chat/completions",
None,
)
.unwrap();
assert_eq!(host, "https://router.huggingface.co/v1");
assert_eq!(prefix, "");
assert!(query.is_empty());
}
#[test]
fn endpoint_parts_respect_explicit_base_path() {
let (host, prefix, query) = openai_compatible_endpoint_parts(
"https://router.huggingface.co",
Some("v1/chat/completions"),
)
.unwrap();
assert_eq!(host, "https://router.huggingface.co");
assert_eq!(prefix, "v1/");
assert!(query.is_empty());
}
#[tokio::test]
async fn custom_provider_returns_static_models_when_dynamic_models_disabled() {
let mut config = test_config();
config.requires_auth = false;
config.dynamic_models = Some(false);
config.models = vec![
ModelInfo::new("static-a".to_string(), 128000),
ModelInfo::new("static-b".to_string(), 128000),
];
let provider = HuggingFaceProvider::from_custom_config(config, None).unwrap();
assert_eq!(
provider.fetch_supported_models().await.unwrap(),
vec!["static-a".to_string(), "static-b".to_string()]
);
}
#[test]
fn custom_provider_requires_static_models_when_dynamic_models_disabled() {
let mut config = test_config();
config.requires_auth = false;
config.dynamic_models = Some(false);
let error = match HuggingFaceProvider::from_custom_config(config, None) {
Ok(_) => panic!("expected dynamic_models: false without static models to fail"),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"Provider 'custom_provider' has dynamic_models: false but no static models listed; at least one entry in `models` is required."
);
}
#[test]
fn custom_auth_method_respects_no_auth_config() {
let auth_method =
custom_auth_method_with_provider_token(false, Some("provider-token".to_string()))
.unwrap();
assert!(matches!(auth_method, AuthMethod::NoAuth));
}
#[test]
fn custom_auth_method_uses_provider_token_when_auth_is_required() {
let auth_method =
custom_auth_method_with_provider_token(true, Some("provider-token".to_string()))
.unwrap();
match auth_method {
AuthMethod::BearerToken(token) => assert_eq!(token, "provider-token"),
other => panic!("expected bearer token auth, got {other:?}"),
}
}
#[test]
fn custom_auth_method_uses_refresh_capable_auth_for_global_token() {
let auth_method = custom_auth_method_from_sources(true, None, || Ok(true)).unwrap();
assert!(matches!(auth_method, AuthMethod::Custom(_)));
}
#[test]
fn refreshable_huggingface_auth_method_uses_refresh_capable_auth() {
let auth_method = refreshable_huggingface_auth_method(|| Ok(true)).unwrap();
assert!(matches!(auth_method, AuthMethod::Custom(_)));
}
#[test]
fn refreshable_huggingface_auth_method_requires_configured_token() {
let error = refreshable_huggingface_auth_method(|| Ok(false)).unwrap_err();
assert_eq!(
error.to_string(),
"Hugging Face token is not configured. Sign in from Settings > Auth or configure HF_TOKEN."
);
}
#[test]
fn custom_auth_method_requires_global_token_when_auth_is_required() {
let error = custom_auth_method_from_sources(true, None, || Ok(false)).unwrap_err();
assert_eq!(
error.to_string(),
"Hugging Face token is not configured. Sign in from Settings > Auth or configure HF_TOKEN."
);
}
fn test_config() -> DeclarativeProviderConfig {
DeclarativeProviderConfig {
name: "custom_provider".to_string(),
engine: crate::config::declarative_providers::ProviderEngine::OpenAI,
display_name: "Custom Provider".to_string(),
description: None,
api_key_env: "CUSTOM_API_KEY".to_string(),
base_url: HUGGINGFACE_API_HOST.to_string(),
models: Vec::new(),
headers: None,
timeout_seconds: None,
supports_streaming: Some(true),
requires_auth: true,
catalog_provider_id: None,
base_path: None,
env_vars: None,
dynamic_models: None,
skip_canonical_filtering: false,
model_doc_link: None,
setup_steps: vec![],
fast_model: None,
preserves_thinking: true,
setup: None,
}
}
}