feat(providers): support OpenRouter request parameters (#9276)

Signed-off-by: sunilkumarvalmiki <g.sunilkumarvalmiki@gmail.com>
Signed-off-by: Douwe Osinga <douwe@sidewalklabs.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Sunil Kumar 2026-07-01 04:18:03 +05:30 committed by GitHub
parent d1cc1439ec
commit 12c3e234f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 179 additions and 2 deletions

View file

@ -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!({

View file

@ -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<HashMap<String, Value>>,
}
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<HashMap<String, Value>> {
match raw {
Value::Object(params) => Ok(params.into_iter().collect()),
Value::String(raw_json) => match serde_json::from_str::<Value>(&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<Option<HashMap<String, Value>>> {
let config = crate::config::Config::global();
match config.get_param::<Value>(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<HashMap<String, Value>>,
params: HashMap<String, Value>,
) {
request_params
.get_or_insert_with(HashMap::new)
.extend(params);
}
fn merge_openrouter_parameters(model: &mut ModelConfig, params: HashMap<String, Value>) {
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<MessageStream, ProviderError> {
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"));
}
}

View file

@ -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: