diff --git a/providers.json b/providers.json index a9398a87..f7574f4c 100644 --- a/providers.json +++ b/providers.json @@ -9,8 +9,9 @@ "api_key_required": true, "base_url_env": "OPENAI_BASE_URL", "model_env": "OPENAI_MODEL", - "default_model": "gpt-4o", + "default_model": "gpt-5-mini", "description": "OpenAI GPT models (direct API)", + "unsupported_params": ["temperature"], "setup": { "kind": "api_key", "secret_name": "llm_openai_api_key", @@ -86,6 +87,7 @@ "model_env": "TINFOIL_MODEL", "default_model": "kimi-k2-5", "description": "Tinfoil private inference (hardware-attested TEE)", + "unsupported_params": ["temperature"], "setup": { "kind": "api_key", "secret_name": "llm_tinfoil_api_key", diff --git a/src/config/llm.rs b/src/config/llm.rs index 08a59866..cc02cd31 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -209,6 +209,7 @@ impl LlmConfig { extra_headers_env, api_key_required, base_url_required, + unsupported_params, ) = if let Some(def) = def { ( def.id.as_str(), @@ -221,6 +222,7 @@ impl LlmConfig { def.extra_headers_env.as_deref(), def.api_key_required, def.base_url_required, + def.unsupported_params.clone(), ) } else { // Absolute fallback: treat as generic openai_completions @@ -235,6 +237,7 @@ impl LlmConfig { Some("LLM_EXTRA_HEADERS"), false, true, + Vec::new(), ) }; @@ -338,6 +341,7 @@ impl LlmConfig { extra_headers, oauth_token, cache_retention, + unsupported_params, }) } } @@ -624,6 +628,12 @@ mod tests { let provider = cfg.provider.expect("provider config should be present"); assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1"); assert_eq!(provider.model, "kimi-k2-5"); + assert!( + provider + .unsupported_params + .contains(&"temperature".to_string()), + "tinfoil should propagate unsupported_params from registry" + ); } #[test] diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs index 104778ad..0badda93 100644 --- a/src/llm/anthropic_oauth.rs +++ b/src/llm/anthropic_oauth.rs @@ -6,6 +6,8 @@ //! //! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`. +use std::collections::HashSet; + use async_trait::async_trait; use reqwest::Client; use rust_decimal::Decimal; @@ -35,6 +37,8 @@ pub struct AnthropicOAuthProvider { model: String, base_url: Option, active_model: std::sync::RwLock, + /// Parameter names that this provider does not support. + unsupported_params: HashSet, } impl AnthropicOAuthProvider { @@ -61,15 +65,45 @@ impl AnthropicOAuthProvider { Some(config.base_url.clone()) }; + let unsupported_params: HashSet = + config.unsupported_params.iter().cloned().collect(); + Ok(Self { client, token, model: config.model.clone(), base_url, active_model, + unsupported_params, }) } + /// Strip unsupported fields from a `CompletionRequest` in place. + fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) { + if self.unsupported_params.is_empty() { + return; + } + if self.unsupported_params.contains("temperature") { + req.temperature = None; + } + if self.unsupported_params.contains("max_tokens") { + req.max_tokens = None; + } + } + + /// Strip unsupported fields from a `ToolCompletionRequest` in place. + fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) { + if self.unsupported_params.is_empty() { + return; + } + if self.unsupported_params.contains("temperature") { + req.temperature = None; + } + if self.unsupported_params.contains("max_tokens") { + req.max_tokens = None; + } + } + fn api_url(&self) -> String { if let Some(ref base) = self.base_url { let base = base.trim_end_matches('/'); @@ -197,8 +231,9 @@ impl AnthropicOAuthProvider { #[async_trait] impl LlmProvider for AnthropicOAuthProvider { - async fn complete(&self, req: CompletionRequest) -> Result { - let model = req.model.unwrap_or_else(|| self.active_model_name()); + async fn complete(&self, mut req: CompletionRequest) -> Result { + let model = req.model.take().unwrap_or_else(|| self.active_model_name()); + self.strip_unsupported_completion_params(&mut req); let (system, messages) = convert_messages(req.messages); let request = AnthropicRequest { @@ -233,9 +268,10 @@ impl LlmProvider for AnthropicOAuthProvider { async fn complete_with_tools( &self, - req: ToolCompletionRequest, + mut req: ToolCompletionRequest, ) -> Result { - let model = req.model.unwrap_or_else(|| self.active_model_name()); + let model = req.model.take().unwrap_or_else(|| self.active_model_name()); + self.strip_unsupported_tool_params(&mut req); let (system, messages) = convert_messages(req.messages); let tools: Vec = req diff --git a/src/llm/config.rs b/src/llm/config.rs index c36280c2..1902f128 100644 --- a/src/llm/config.rs +++ b/src/llm/config.rs @@ -87,6 +87,10 @@ pub struct RegistryProviderConfig { pub oauth_token: Option, /// Prompt cache retention (Anthropic-specific). pub cache_retention: CacheRetention, + /// Parameter names that this provider does not support (e.g., `["temperature"]`). + /// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`. + /// Listed parameters are stripped from requests before sending to avoid 400 errors. + pub unsupported_params: Vec, } /// Configuration for AWS Bedrock (native Converse API). diff --git a/src/llm/mod.rs b/src/llm/mod.rs index a800eb6a..c992f89c 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -228,7 +228,9 @@ fn create_openai_compat_from_registry( "Using OpenAI-compatible provider" ); - Ok(Arc::new(RigAdapter::new(model, &config.model))) + let adapter = RigAdapter::new(model, &config.model) + .with_unsupported_params(config.unsupported_params.clone()); + Ok(Arc::new(adapter)) } fn create_anthropic_from_registry( @@ -296,7 +298,9 @@ fn create_anthropic_from_registry( ); Ok(Arc::new( - RigAdapter::new(model, &config.model).with_cache_retention(cache_retention), + RigAdapter::new(model, &config.model) + .with_cache_retention(cache_retention) + .with_unsupported_params(config.unsupported_params.clone()), )) } @@ -324,7 +328,9 @@ fn create_ollama_from_registry( "Using Ollama provider" ); - Ok(Arc::new(RigAdapter::new(model, &config.model))) + let adapter = RigAdapter::new(model, &config.model) + .with_unsupported_params(config.unsupported_params.clone()); + Ok(Arc::new(adapter)) } /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). diff --git a/src/llm/registry.rs b/src/llm/registry.rs index 273690a1..36cb7001 100644 --- a/src/llm/registry.rs +++ b/src/llm/registry.rs @@ -152,6 +152,11 @@ pub struct ProviderDefinition { /// Setup wizard hints. #[serde(default)] pub setup: Option, + /// Parameter names that this provider does not support (e.g., `["temperature"]`). + /// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`. + /// Listed parameters are stripped from requests before sending to avoid 400 errors. + #[serde(default)] + pub unsupported_params: Vec, } /// Registry of known LLM providers. @@ -378,6 +383,7 @@ mod tests { description: "Custom tinfoil".to_string(), extra_headers_env: None, setup: None, + unsupported_params: vec![], }); let registry = ProviderRegistry::new(all); let tf = registry.find("tinfoil").expect("tinfoil should exist"); @@ -517,6 +523,7 @@ mod tests { description: "No setup".to_string(), extra_headers_env: None, setup: None, // no setup hint + unsupported_params: vec![], }]; let registry = ProviderRegistry::new(providers.clone()); @@ -546,6 +553,7 @@ mod tests { can_list_models: false, models_filter: None, }), + unsupported_params: vec![], }); let registry = ProviderRegistry::new(providers); @@ -587,6 +595,7 @@ mod tests { can_list_models: false, models_filter: None, }), + unsupported_params: vec![], }, // User override removes setup ProviderDefinition { @@ -603,6 +612,7 @@ mod tests { description: "No setup now".to_string(), extra_headers_env: None, setup: None, + unsupported_params: vec![], }, ]; @@ -640,6 +650,7 @@ mod tests { display_name: "A".to_string(), can_list_models: false, }), + unsupported_params: vec![], }, ProviderDefinition { id: "bbb".to_string(), @@ -658,6 +669,7 @@ mod tests { display_name: "B".to_string(), can_list_models: false, }), + unsupported_params: vec![], }, ProviderDefinition { id: "ccc".to_string(), @@ -676,6 +688,7 @@ mod tests { display_name: "C".to_string(), can_list_models: false, }), + unsupported_params: vec![], }, // User override for B ProviderDefinition { @@ -695,6 +708,7 @@ mod tests { display_name: "B".to_string(), can_list_models: false, }), + unsupported_params: vec![], }, ]; @@ -708,6 +722,48 @@ mod tests { ); } + #[test] + fn test_unsupported_params_deserialized() { + let providers: Vec = + serde_json::from_str(include_str!("../../providers.json")).unwrap(); + + // Tinfoil should have temperature in unsupported_params + let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap(); + assert!( + tinfoil + .unsupported_params + .contains(&"temperature".to_string()), + "tinfoil should have 'temperature' in unsupported_params" + ); + + // OpenAI should also have temperature in unsupported_params + let openai = providers.iter().find(|p| p.id == "openai").unwrap(); + assert!( + openai + .unsupported_params + .contains(&"temperature".to_string()), + "openai should have 'temperature' in unsupported_params" + ); + + // Providers without the field in JSON should deserialize to empty vec + let groq = providers.iter().find(|p| p.id == "groq").unwrap(); + assert!( + groq.unsupported_params.is_empty(), + "groq should have empty unsupported_params (field absent in JSON)" + ); + + // Every non-empty entry should contain valid param names + for def in &providers { + for param in &def.unsupported_params { + assert!( + !param.is_empty(), + "{}: unsupported_params contains empty string", + def.id + ); + } + } + } + #[test] fn test_all_builtin_api_key_providers_have_api_key_env() { // Every built-in provider with SetupHint::ApiKey must have api_key_env diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 87a0b65c..5b835536 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -42,6 +42,9 @@ pub struct RigAdapter { /// via `additional_params` for Anthropic automatic caching. Also controls /// the cost multiplier for cache-creation tokens. cache_retention: CacheRetention, + /// Parameter names that this provider does not support (e.g., `"temperature"`). + /// These are stripped from requests before sending to avoid 400 errors. + unsupported_params: HashSet, } impl RigAdapter { @@ -56,6 +59,7 @@ impl RigAdapter { input_cost, output_cost, cache_retention: CacheRetention::None, + unsupported_params: HashSet::new(), } } @@ -84,6 +88,44 @@ impl RigAdapter { } self } + + /// Set the list of unsupported parameter names for this provider. + /// + /// Parameters in this set are stripped from requests before sending. + /// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`. + pub fn with_unsupported_params(mut self, params: Vec) -> Self { + self.unsupported_params = params.into_iter().collect(); + self + } + + /// Strip unsupported fields from a `CompletionRequest` in place. + fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) { + if self.unsupported_params.is_empty() { + return; + } + if self.unsupported_params.contains("temperature") { + req.temperature = None; + } + if self.unsupported_params.contains("max_tokens") { + req.max_tokens = None; + } + if self.unsupported_params.contains("stop_sequences") { + req.stop_sequences = None; + } + } + + /// Strip unsupported fields from a `ToolCompletionRequest` in place. + fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) { + if self.unsupported_params.is_empty() { + return; + } + if self.unsupported_params.contains("temperature") { + req.temperature = None; + } + if self.unsupported_params.contains("max_tokens") { + req.max_tokens = None; + } + } } // -- Type conversion helpers -- @@ -539,7 +581,10 @@ where } } - async fn complete(&self, request: CompletionRequest) -> Result { + async fn complete( + &self, + mut request: CompletionRequest, + ) -> Result { if let Some(requested_model) = request.model.as_deref() && requested_model != self.model_name.as_str() { @@ -550,6 +595,8 @@ where ); } + self.strip_unsupported_completion_params(&mut request); + let mut messages = request.messages; crate::llm::provider::sanitize_tool_messages(&mut messages); let (preamble, history) = convert_messages(&messages); @@ -599,7 +646,7 @@ where async fn complete_with_tools( &self, - request: ToolCompletionRequest, + mut request: ToolCompletionRequest, ) -> Result { if let Some(requested_model) = request.model.as_deref() && requested_model != self.model_name.as_str() @@ -611,6 +658,8 @@ where ); } + self.strip_unsupported_tool_params(&mut request); + let known_tool_names: HashSet = request.tools.iter().map(|t| t.name.clone()).collect(); @@ -1156,4 +1205,97 @@ mod tests { assert!(!supports_prompt_cache("gpt-4o")); assert!(!supports_prompt_cache("llama3")); } + + #[test] + fn test_with_unsupported_params_populates_set() { + use rig::client::CompletionClient; + use rig::providers::openai; + + let client: openai::Client = openai::Client::builder() + .api_key("test-key") + .base_url("http://localhost:0") + .build() + .unwrap(); + let client = client.completions_api(); + let model = client.completion_model("test-model"); + let adapter = RigAdapter::new(model, "test-model") + .with_unsupported_params(vec!["temperature".to_string()]); + + assert!(adapter.unsupported_params.contains("temperature")); + assert!(!adapter.unsupported_params.contains("max_tokens")); + } + + #[test] + fn test_strip_unsupported_completion_params() { + use rig::client::CompletionClient; + use rig::providers::openai; + + let client: openai::Client = openai::Client::builder() + .api_key("test-key") + .base_url("http://localhost:0") + .build() + .unwrap(); + let client = client.completions_api(); + let model = client.completion_model("test-model"); + let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![ + "temperature".to_string(), + "stop_sequences".to_string(), + ]); + + let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]); + req.temperature = Some(0.7); + req.max_tokens = Some(100); + req.stop_sequences = Some(vec!["STOP".to_string()]); + + adapter.strip_unsupported_completion_params(&mut req); + + assert!(req.temperature.is_none(), "temperature should be stripped"); + assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved"); + assert!( + req.stop_sequences.is_none(), + "stop_sequences should be stripped" + ); + } + + #[test] + fn test_strip_unsupported_tool_params() { + use rig::client::CompletionClient; + use rig::providers::openai; + + let client: openai::Client = openai::Client::builder() + .api_key("test-key") + .base_url("http://localhost:0") + .build() + .unwrap(); + let client = client.completions_api(); + let model = client.completion_model("test-model"); + let adapter = RigAdapter::new(model, "test-model") + .with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]); + + let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]); + req.temperature = Some(0.5); + req.max_tokens = Some(200); + + adapter.strip_unsupported_tool_params(&mut req); + + assert!(req.temperature.is_none(), "temperature should be stripped"); + assert!(req.max_tokens.is_none(), "max_tokens should be stripped"); + } + + #[test] + fn test_unsupported_params_empty_by_default() { + use rig::client::CompletionClient; + use rig::providers::openai; + + let client: openai::Client = openai::Client::builder() + .api_key("test-key") + .base_url("http://localhost:0") + .build() + .unwrap(); + let client = client.completions_api(); + let model = client.completion_model("test-model"); + let adapter = RigAdapter::new(model, "test-model"); + + assert!(adapter.unsupported_params.is_empty()); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index ce090b7e..6c7d03cb 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -3996,6 +3996,7 @@ mod tests { description: "Custom provider with no setup wizard".to_string(), extra_headers_env: None, setup: None, + unsupported_params: vec![], }); let registry = crate::llm::ProviderRegistry::new(providers);