From 12c3e234f5ac948fa5835d9fd7420723a03fe621 Mon Sep 17 00:00:00 2001 From: Sunil Kumar Date: Wed, 1 Jul 2026 04:18:03 +0530 Subject: [PATCH] feat(providers): support OpenRouter request parameters (#9276) Signed-off-by: sunilkumarvalmiki Signed-off-by: Douwe Osinga Co-authored-by: Douwe M Osinga --- .../goose/src/providers/formats/openrouter.rs | 24 ++++ crates/goose/src/providers/openrouter.rs | 132 +++++++++++++++++- .../docs/getting-started/providers.md | 25 +++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose/src/providers/formats/openrouter.rs index ff7ac35b3a..f2405e50c1 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose/src/providers/formats/openrouter.rs @@ -105,6 +105,11 @@ pub fn apply_reasoning_config(payload: &mut Value, model_config: &ModelConfig) { }; if let Some(obj) = payload.as_object_mut() { + if obj.contains_key("reasoning") { + obj.remove("reasoning_effort"); + return; + } + let clamped_effort = obj .remove("reasoning_effort") .and_then(|value| value.as_str().map(str::to_owned)); @@ -201,6 +206,25 @@ mod tests { assert!(payload.get("reasoning_effort").is_none()); } + #[test] + fn test_apply_reasoning_config_preserves_user_reasoning() { + let mut payload = json!({ + "model": "openai/gpt-5", + "messages": [], + "reasoning": { "max_tokens": 2000 }, + "reasoning_effort": "high" + }); + let mut model_config = ModelConfig::new("openai/gpt-5"); + let mut params = HashMap::new(); + params.insert("thinking_effort".to_string(), json!("high")); + model_config.request_params = Some(params); + + apply_reasoning_config(&mut payload, &model_config); + + assert_eq!(payload["reasoning"], json!({ "max_tokens": 2000 })); + assert!(payload.get("reasoning_effort").is_none()); + } + #[test] fn test_apply_reasoning_config_disables_reasoning_capable_model() { let mut payload = json!({ diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 77b6e5413a..85c3b0d88a 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -1,8 +1,9 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use async_trait::async_trait; use futures::future::BoxFuture; use goose_providers::images::ImageFormat; use serde_json::{json, Value}; +use std::collections::HashMap; use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; @@ -17,6 +18,7 @@ use goose_providers::request_log::{start_log, LoggerHandleExt}; use rmcp::model::Tool; pub const OPENROUTER_PROVIDER_NAME: &str = "openrouter"; +const OPENROUTER_PARAMETERS_CONFIG_KEY: &str = "OPENROUTER_PARAMETERS"; pub const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4"; pub const OPENROUTER_DEFAULT_FAST_MODEL: &str = "google/gemini-2.5-flash"; pub const OPENROUTER_MODEL_PREFIX_ANTHROPIC: &str = "anthropic"; @@ -43,6 +45,8 @@ pub struct OpenRouterProvider { supports_streaming: bool, #[serde(skip)] name: String, + #[serde(skip)] + configured_parameters: Option>, } impl OpenRouterProvider { @@ -55,6 +59,8 @@ impl OpenRouterProvider { .get_param("OPENROUTER_HOST") .unwrap_or_else(|_| "https://openrouter.ai".to_string()); + let configured_parameters = configured_openrouter_parameters()?; + let auth = AuthMethod::BearerToken(api_key); let api_client = ApiClient::new_with_tls(host, auth, tls_config)? .with_request_builder(crate::session_context::session_id_request_builder()) @@ -65,6 +71,7 @@ impl OpenRouterProvider { api_client, supports_streaming: true, name: OPENROUTER_PROVIDER_NAME.to_string(), + configured_parameters, }) } } @@ -146,6 +153,39 @@ fn is_gemini_model(model_name: &str) -> bool { model_name.starts_with("google/") } +fn parse_openrouter_parameters(raw: Value) -> Result> { + match raw { + Value::Object(params) => Ok(params.into_iter().collect()), + Value::String(raw_json) => match serde_json::from_str::(&raw_json)? { + Value::Object(params) => Ok(params.into_iter().collect()), + _ => bail!("{OPENROUTER_PARAMETERS_CONFIG_KEY} must be a JSON object"), + }, + _ => bail!("{OPENROUTER_PARAMETERS_CONFIG_KEY} must be a JSON object"), + } +} + +fn configured_openrouter_parameters() -> Result>> { + let config = crate::config::Config::global(); + match config.get_param::(OPENROUTER_PARAMETERS_CONFIG_KEY) { + Ok(raw) => parse_openrouter_parameters(raw).map(Some), + Err(crate::config::ConfigError::NotFound(_)) => Ok(None), + Err(err) => Err(err.into()), + } +} + +fn merge_request_params( + request_params: &mut Option>, + params: HashMap, +) { + request_params + .get_or_insert_with(HashMap::new) + .extend(params); +} + +fn merge_openrouter_parameters(model: &mut ModelConfig, params: HashMap) { + merge_request_params(&mut model.request_params, params); +} + impl goose_providers::base::ProviderDescriptor for OpenRouterProvider { fn metadata() -> ProviderMetadata { ProviderMetadata::new( @@ -164,6 +204,7 @@ impl goose_providers::base::ProviderDescriptor for OpenRouterProvider { Some("https://openrouter.ai"), false, ), + ConfigKey::new(OPENROUTER_PARAMETERS_CONFIG_KEY, false, false, None, false), ], ) .with_setup_steps(vec![ @@ -248,6 +289,16 @@ impl Provider for OpenRouterProvider { tools: &[Tool], ) -> Result { let session_id = crate::session_context::current_session_id().unwrap_or_default(); + + let mut merged_model; + let model_config = if let Some(params) = &self.configured_parameters { + merged_model = model_config.clone(); + merge_openrouter_parameters(&mut merged_model, params.clone()); + &merged_model + } else { + model_config + }; + let mut payload = create_request( model_config, system, @@ -301,3 +352,82 @@ fn supports_cache_control(model: &ModelConfig) -> bool { .model_name .starts_with(OPENROUTER_MODEL_PREFIX_ANTHROPIC) } + +#[cfg(test)] +mod tests { + use super::*; + use goose_providers::base::ProviderDescriptor; + + fn model_config(model_name: &str) -> ModelConfig { + ModelConfig { + model_name: model_name.to_string(), + context_limit: None, + temperature: None, + max_tokens: None, + toolshim: false, + toolshim_model: None, + request_params: None, + reasoning: None, + } + } + + #[test] + fn metadata_includes_openrouter_parameters_config_key() { + let metadata = OpenRouterProvider::metadata(); + + assert!(metadata + .config_keys + .iter() + .any(|key| key.name == OPENROUTER_PARAMETERS_CONFIG_KEY)); + } + + #[test] + fn parse_openrouter_parameters_accepts_object_value() { + let params = parse_openrouter_parameters(json!({ + "verbosity": "xhigh", + "reasoning": { "effort": "high" } + })) + .unwrap(); + + assert_eq!(params["verbosity"], json!("xhigh")); + assert_eq!(params["reasoning"], json!({ "effort": "high" })); + } + + #[test] + fn parse_openrouter_parameters_accepts_json_string_value() { + let params = parse_openrouter_parameters(json!( + r#"{"plugins":[{"id":"web"}],"reasoning":{"max_tokens":2000}}"# + )) + .unwrap(); + + assert_eq!(params["plugins"], json!([{ "id": "web" }])); + assert_eq!(params["reasoning"], json!({ "max_tokens": 2000 })); + } + + #[test] + fn parse_openrouter_parameters_rejects_non_object_json_string() { + let err = parse_openrouter_parameters(json!(r#"["web"]"#)).unwrap_err(); + + assert!(err + .to_string() + .contains("OPENROUTER_PARAMETERS must be a JSON object")); + } + + #[test] + fn merge_openrouter_parameters_updates_model_request_params() { + let mut model = model_config("anthropic/claude-sonnet-4"); + model.request_params = Some(HashMap::from([("verbosity".to_string(), json!("low"))])); + + let params = parse_openrouter_parameters(json!({ + "plugins": [{ "id": "web" }], + "verbosity": "xhigh" + })) + .unwrap(); + + merge_openrouter_parameters(&mut model, params); + + let request_params = model.request_params.as_ref().unwrap(); + assert_eq!(request_params["plugins"], json!([{ "id": "web" }])); + assert_eq!(request_params["verbosity"], json!("xhigh")); + } +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index e577e9ed00..60dbdd152d 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -47,7 +47,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [Ollama](https://ollama.com/) | Local model runner supporting Qwen, Llama, DeepSeek, and other open-source models. **Because this provider runs locally, you must first [download and run a model](#local-llms).** | `OLLAMA_HOST` | | [Ollama Cloud](https://ollama.com/) | Access hosted models on ollama.com via OpenAI-compatible API. Requires an Ollama account and API key. | `OLLAMA_CLOUD_API_KEY` | | [OpenAI](https://platform.openai.com/api-keys) | Provides gpt-4o, o1, and other advanced language models. Also supports OpenAI-compatible endpoints (e.g., self-hosted LLaMA, vLLM, KServe). **o1-mini and o1-preview are not supported because goose uses tool calling.** | `OPENAI_API_KEY`, `OPENAI_HOST` (optional), `OPENAI_ORGANIZATION` (optional), `OPENAI_PROJECT` (optional), `OPENAI_CUSTOM_HEADERS` (optional) | -| [OpenRouter](https://openrouter.ai/) | API gateway for unified access to various models with features like rate-limiting management. | `OPENROUTER_API_KEY` | +| [OpenRouter](https://openrouter.ai/) | API gateway for unified access to various models with features like rate-limiting management. | `OPENROUTER_API_KEY`, `OPENROUTER_HOST` (optional), `OPENROUTER_PARAMETERS` (optional) | | [Perplexity](https://www.perplexity.ai/) | Chat models with built-in real-time web search grounding. OpenAI-compatible chat completions API at `https://api.perplexity.ai`. | `PERPLEXITY_API_KEY` | | [OVHcloud AI](https://www.ovhcloud.com/en/public-cloud/ai-endpoints/) | Provides access to open-source models including Qwen, Llama, Mistral, and DeepSeek through AI Endpoints service. | `OVHCLOUD_API_KEY` | | [Ramalama](https://ramalama.ai/) | Local model using native [OCI](https://opencontainers.org/) container runtimes, [CNCF](https://www.cncf.io/) tools, and supporting models as OCI artifacts. Ramalama API is a compatible alternative to Ollama and can be used with the goose Ollama provider. Supports Qwen, Llama, DeepSeek, and other open-source models. **Because this provider runs locally, you must first [download and run a model](#local-llms).** | `OLLAMA_HOST` | @@ -1398,6 +1398,29 @@ Here are some local providers we support: +## OpenRouter Advanced Parameters + +OpenRouter accepts provider-specific request parameters such as `verbosity`, `reasoning`, `plugins`, `require_parameters`, and other supported fields. Set `OPENROUTER_PARAMETERS` in your `config.yaml` to add these fields to every OpenRouter chat completion request. + +You can use a YAML object: + +```yaml +OPENROUTER_PARAMETERS: + verbosity: xhigh + reasoning: + effort: high + plugins: + - id: web +``` + +Or a JSON string: + +```yaml +OPENROUTER_PARAMETERS: '{"verbosity":"xhigh","plugins":[{"id":"web"}]}' +``` + +goose ignores reserved request fields it already manages, such as `model`, `messages`, `stream`, and `stream_options`. Other OpenRouter-specific top-level fields are passed through the shared OpenAI-compatible request parameter handling. + ## GitHub Copilot Authentication GitHub Copilot uses a device flow for authentication, so no API keys are required: