From 6cf69ba546375ed41cda5f4c4919fc9f113c86db Mon Sep 17 00:00:00 2001 From: Avi Flombaum <4515+aviflombaum@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:38:11 -0500 Subject: [PATCH 1/2] feat(llm): add Featherless AI provider Add Featherless AI as a native LLM provider, giving access to 17,000+ open-source models via their OpenAI-compatible API. Registers featherless_key as a named key that auto-maps to a ProviderConfig with ApiType::OpenAiCompletions, following the same pattern as anthropic, openai, and openrouter. The generic provider dispatch handles routing without any model.rs changes. Changes: - config: featherless_key field, FEATHERLESS_API_KEY env var, FEATHERLESS_PROVIDER_BASE_URL constant, auto-mapping to ProviderConfig in both load_from_env and from_toml, onboarding menu entry, needs_onboarding env check - llm/routing: default model (Qwen/Qwen3-32B), provider_to_prefix - api/providers: get/update/delete endpoints, routing auto-switch - api/models: configured_providers discovery --- src/api/models.rs | 1 + src/api/providers.rs | 12 +++++++++-- src/config.rs | 48 ++++++++++++++++++++++++++++++++++++++++++-- src/llm/routing.rs | 4 ++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/api/models.rs b/src/api/models.rs index f5a384940..b393a76b8 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -274,6 +274,7 @@ pub(super) async fn configured_providers(config_path: &std::path::Path) -> Vec<& if has_key("xai_key", "XAI_API_KEY") { providers.push("xai"); } if has_key("mistral_key", "MISTRAL_API_KEY") { providers.push("mistral"); } if has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY") { providers.push("opencode-zen"); } + if has_key("featherless_key", "FEATHERLESS_API_KEY") { providers.push("featherless"); } providers } diff --git a/src/api/providers.rs b/src/api/providers.rs index 76f602b84..b2f38e99f 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -19,6 +19,7 @@ pub(super) struct ProviderStatus { xai: bool, mistral: bool, opencode_zen: bool, + featherless: bool, } #[derive(Serialize)] @@ -44,7 +45,7 @@ pub(super) async fn get_providers( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); - let (anthropic, openai, openrouter, zhipu, groq, together, fireworks, deepseek, xai, mistral, opencode_zen) = if config_path.exists() { + let (anthropic, openai, openrouter, zhipu, groq, together, fireworks, deepseek, xai, mistral, opencode_zen, featherless) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -78,6 +79,7 @@ pub(super) async fn get_providers( has_key("xai_key", "XAI_API_KEY"), has_key("mistral_key", "MISTRAL_API_KEY"), has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY"), + has_key("featherless_key", "FEATHERLESS_API_KEY"), ) } else { ( @@ -92,6 +94,7 @@ pub(super) async fn get_providers( std::env::var("XAI_API_KEY").is_ok(), std::env::var("MISTRAL_API_KEY").is_ok(), std::env::var("OPENCODE_ZEN_API_KEY").is_ok(), + std::env::var("FEATHERLESS_API_KEY").is_ok(), ) }; @@ -107,6 +110,7 @@ pub(super) async fn get_providers( xai, mistral, opencode_zen, + featherless, }; let has_any = providers.anthropic || providers.openai @@ -118,7 +122,8 @@ pub(super) async fn get_providers( || providers.deepseek || providers.xai || providers.mistral - || providers.opencode_zen; + || providers.opencode_zen + || providers.featherless; Ok(Json(ProvidersResponse { providers, has_any })) } @@ -139,6 +144,7 @@ pub(super) async fn update_provider( "xai" => "xai_key", "mistral" => "mistral_key", "opencode-zen" => "opencode_zen_key", + "featherless" => "featherless_key", _ => { return Ok(Json(ProviderUpdateResponse { success: false, @@ -208,6 +214,7 @@ pub(super) async fn update_provider( "xai" => has_provider_key("xai_key", "XAI_API_KEY"), "mistral" => has_provider_key("mistral_key", "MISTRAL_API_KEY"), "opencode-zen" => has_provider_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY"), + "featherless" => has_provider_key("featherless_key", "FEATHERLESS_API_KEY"), _ => false, }; @@ -273,6 +280,7 @@ pub(super) async fn delete_provider( "xai" => "xai_key", "mistral" => "mistral_key", "opencode-zen" => "opencode_zen_key", + "featherless" => "featherless_key", _ => { return Ok(Json(ProviderUpdateResponse { success: false, diff --git a/src/config.rs b/src/config.rs index 81f141a16..4013a4ab0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,7 @@ pub struct LlmConfig { pub ollama_base_url: Option, pub opencode_zen_key: Option, pub nvidia_key: Option, + pub featherless_key: Option, pub providers: HashMap, } @@ -165,6 +166,7 @@ impl LlmConfig { || self.ollama_base_url.is_some() || self.opencode_zen_key.is_some() || self.nvidia_key.is_some() + || self.featherless_key.is_some() || !self.providers.is_empty() } } @@ -172,6 +174,7 @@ impl LlmConfig { const ANTHROPIC_PROVIDER_BASE_URL: &str = "https://api.anthropic.com"; const OPENAI_PROVIDER_BASE_URL: &str = "https://api.openai.com"; const OPENROUTER_PROVIDER_BASE_URL: &str = "https://openrouter.ai/api"; +const FEATHERLESS_PROVIDER_BASE_URL: &str = "https://api.featherless.ai/v1"; /// Defaults inherited by all agents. Individual agents can override any field. #[derive(Debug, Clone)] @@ -1087,6 +1090,7 @@ struct TomlLlmConfigFields { ollama_base_url: Option, opencode_zen_key: Option, nvidia_key: Option, + featherless_key: Option, #[serde(default)] providers: HashMap, #[serde(default)] @@ -1106,7 +1110,11 @@ struct TomlLlmConfig { deepseek_key: Option, xai_key: Option, mistral_key: Option, + ollama_key: Option, + ollama_base_url: Option, opencode_zen_key: Option, + nvidia_key: Option, + featherless_key: Option, providers: HashMap, } @@ -1151,7 +1159,11 @@ impl<'de> Deserialize<'de> for TomlLlmConfig { deepseek_key: fields.deepseek_key, xai_key: fields.xai_key, mistral_key: fields.mistral_key, + ollama_key: fields.ollama_key, + ollama_base_url: fields.ollama_base_url, opencode_zen_key: fields.opencode_zen_key, + nvidia_key: fields.nvidia_key, + featherless_key: fields.featherless_key, providers: fields.providers, }) } @@ -1481,8 +1493,9 @@ impl Config { || std::env::var("NVIDIA_API_KEY").is_ok() || std::env::var("OLLAMA_API_KEY").is_ok() || std::env::var("OLLAMA_BASE_URL").is_ok() - || std::env::var("OPENCODE_ZEN_API_KEY").is_ok(); - + || std::env::var("OPENCODE_ZEN_API_KEY").is_ok() + || std::env::var("FEATHERLESS_API_KEY").is_ok(); + // If we have any legacy keys, no onboarding needed if has_legacy_keys { return false; @@ -1543,6 +1556,7 @@ impl Config { ollama_base_url: std::env::var("OLLAMA_BASE_URL").ok(), opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), + featherless_key: std::env::var("FEATHERLESS_API_KEY").ok(), providers: HashMap::new(), }; @@ -1580,6 +1594,17 @@ impl Config { }); } + if let Some(featherless_key) = llm.featherless_key.clone() { + llm.providers + .entry("featherless".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: FEATHERLESS_PROVIDER_BASE_URL.to_string(), + api_key: featherless_key, + name: None, + }); + } + // Note: We allow boot without provider keys now. System starts in setup mode. // Agents are initialized later when keys are added via API. @@ -1756,6 +1781,12 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("NVIDIA_API_KEY").ok()), + featherless_key: toml + .llm + .featherless_key + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("FEATHERLESS_API_KEY").ok()), providers: toml .llm .providers @@ -1808,6 +1839,17 @@ impl Config { }); } + if let Some(featherless_key) = llm.featherless_key.clone() { + llm.providers + .entry("featherless".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: FEATHERLESS_PROVIDER_BASE_URL.to_string(), + api_key: featherless_key, + name: None, + }); + } + // Note: We allow boot without provider keys now. System starts in setup mode. // Agents are initialized later when keys are added via API. @@ -2838,6 +2880,7 @@ pub fn run_onboarding() -> anyhow::Result> { "Mistral AI", "Ollama", "OpenCode Zen", + "Featherless AI", ]; let provider_idx = Select::new() .with_prompt("Which LLM provider do you want to use?") @@ -2858,6 +2901,7 @@ pub fn run_onboarding() -> anyhow::Result> { 9 => ("Mistral AI API key", "mistral_key", "mistral"), 10 => ("Ollama base URL", "ollama_base_url", "ollama"), 11 => ("OpenCode Zen API key", "opencode_zen_key", "opencode-zen"), + 12 => ("Featherless AI API key", "featherless_key", "featherless"), _ => unreachable!(), }; let is_secret = provider_id != "ollama"; diff --git a/src/llm/routing.rs b/src/llm/routing.rs index db12ff24c..f1f5541a6 100644 --- a/src/llm/routing.rs +++ b/src/llm/routing.rs @@ -144,6 +144,9 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { "mistral" => RoutingConfig::for_model("mistral/mistral-large-latest".into()), "nvidia" => RoutingConfig::for_model("nvidia/meta/llama-3.1-405b-instruct".into()), "opencode-zen" => RoutingConfig::for_model("opencode-zen/kimi-k2.5".into()), + "featherless" => RoutingConfig::for_model( + "featherless/Qwen/Qwen3-32B".into(), + ), _ => RoutingConfig::default(), } } @@ -163,6 +166,7 @@ pub fn provider_to_prefix(provider: &str) -> &str { "mistral" => "mistral/", "nvidia" => "nvidia/", "opencode-zen" => "opencode-zen/", + "featherless" => "featherless/", _ => "", } } From 4d7ae6c9c8ff372032f13d186b3aaec750198e43 Mon Sep 17 00:00:00 2001 From: Avi Flombaum <4515+aviflombaum@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:09:44 -0500 Subject: [PATCH 2/2] fix: use correct base URL for Featherless provider (no /v1 suffix) The call_openai() function appends /v1/chat/completions to the base URL, so including /v1 in the constant caused double /v1 in the final URL (https://api.featherless.ai/v1/v1/chat/completions), resulting in 404. --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 4013a4ab0..289d6379c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -174,7 +174,7 @@ impl LlmConfig { const ANTHROPIC_PROVIDER_BASE_URL: &str = "https://api.anthropic.com"; const OPENAI_PROVIDER_BASE_URL: &str = "https://api.openai.com"; const OPENROUTER_PROVIDER_BASE_URL: &str = "https://openrouter.ai/api"; -const FEATHERLESS_PROVIDER_BASE_URL: &str = "https://api.featherless.ai/v1"; +const FEATHERLESS_PROVIDER_BASE_URL: &str = "https://api.featherless.ai"; /// Defaults inherited by all agents. Individual agents can override any field. #[derive(Debug, Clone)]