Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions crates/goose/src/providers/openrouter.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use anyhow::Result;
use anyhow::{bail, Result};
use async_trait::async_trait;
use futures::future::BoxFuture;
use serde_json::{json, Value};
use std::collections::HashMap;

use super::api_client::{ApiClient, AuthMethod};
use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
Expand All @@ -16,6 +17,7 @@ use crate::providers::formats::openrouter as openrouter_format;
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";
Expand Down Expand Up @@ -47,14 +49,18 @@ pub struct OpenRouterProvider {

impl OpenRouterProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let model = model.with_fast(OPENROUTER_DEFAULT_FAST_MODEL, OPENROUTER_PROVIDER_NAME)?;
let mut model = model.with_fast(OPENROUTER_DEFAULT_FAST_MODEL, OPENROUTER_PROVIDER_NAME)?;

let config = crate::config::Config::global();
let api_key: String = config.get_secret("OPENROUTER_API_KEY")?;
let host: String = config
.get_param("OPENROUTER_HOST")
.unwrap_or_else(|_| "https://openrouter.ai".to_string());

if let Some(params) = configured_openrouter_parameters()? {
merge_openrouter_parameters(&mut model, params);
}

let auth = AuthMethod::BearerToken(api_key);
let api_client = ApiClient::new(host, auth)?
.with_header("HTTP-Referer", "https://goose-docs.ai")?
Expand Down Expand Up @@ -146,6 +152,42 @@ 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.clone());
if let Some(fast_model_config) = &mut model.fast_model_config {
merge_request_params(&mut fast_model_config.request_params, params);
}
}

impl ProviderDef for OpenRouterProvider {
type Provider = Self;

Expand All @@ -166,6 +208,7 @@ impl ProviderDef for OpenRouterProvider {
Some("https://openrouter.ai"),
false,
),
ConfigKey::new(OPENROUTER_PARAMETERS_CONFIG_KEY, false, false, None, false),
],
)
.with_setup_steps(vec![
Expand Down Expand Up @@ -302,3 +345,94 @@ impl Provider for OpenRouterProvider {
stream_openai_compat(response, log)
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;

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,
fast_model_config: 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_and_fast_model_request_params() {
let mut model = model_config("anthropic/claude-sonnet-4");
model.request_params = Some(HashMap::from([("verbosity".to_string(), json!("low"))]));
model.fast_model_config = Some(Box::new(model_config(OPENROUTER_DEFAULT_FAST_MODEL)));

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"));

let fast_request_params = model
.fast_model_config
.as_ref()
.unwrap()
.request_params
.as_ref()
.unwrap();
assert_eq!(fast_request_params["plugins"], json!([{ "id": "web" }]));
assert_eq!(fast_request_params["verbosity"], json!("xhigh"));
}
}
25 changes: 24 additions & 1 deletion documentation/docs/getting-started/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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) |
| [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` |
| [Routstr](https://routstr.com/) | OpenAI-compatible aggregator that fronts dozens of upstream providers (Anthropic, OpenAI, Google, DeepSeek, Llama, …) behind a single API. Authenticate with an `sk-...` bearer issued by your Routstr instance — payment is handled outside goose. | `ROUTSTR_API_KEY`, `ROUTSTR_HOST` (optional, default `https://api.routstr.com`) |
Expand Down Expand Up @@ -1350,6 +1350,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:
Expand Down