diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs index 642a8909e..287a3cf0b 100644 --- a/crates/goose-providers/src/lib.rs +++ b/crates/goose-providers/src/lib.rs @@ -16,6 +16,8 @@ pub mod local_inference; pub mod ollama; pub mod openai; pub mod openai_compatible; +pub mod openrouter; +pub mod openrouter_format; pub use declarative::declarative_providers::*; diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose-providers/src/openrouter.rs similarity index 89% rename from crates/goose/src/providers/openrouter.rs rename to crates/goose-providers/src/openrouter.rs index 5cee26271..61c26aa17 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose-providers/src/openrouter.rs @@ -1,22 +1,23 @@ -use anyhow::{bail, Result}; +use crate::images::ImageFormat; +use anyhow::Result; use async_trait::async_trait; -use futures::future::BoxFuture; -use goose_providers::images::ImageFormat; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::ops::Range; -use super::api_client::{ApiClient, AuthMethod}; -use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; -use super::openai_compatible::{handle_status, stream_openai_compat}; -use super::retry::ProviderRetry; +use crate::api_client::ApiClient; + +pub type OpenRouterSessionIdProvider = Box Option + Send + Sync>; +use crate::base::{ConfigKey, MessageStream, Provider, ProviderMetadata}; +use crate::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics}; use crate::conversation::message::Message; -use crate::providers::formats::openrouter as openrouter_format; -use goose_providers::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics}; -use goose_providers::errors::ProviderError; -use goose_providers::formats::openai::create_request; -use goose_providers::model::ModelConfig; -use goose_providers::request_log::{start_log, LoggerHandleExt}; +use crate::errors::ProviderError; +use crate::formats::openai::create_request; +use crate::model::ModelConfig; +use crate::openai_compatible::{handle_status, stream_openai_compat}; +use crate::openrouter_format; +use crate::request_log::{start_log, LoggerHandleExt}; +use crate::retry::ProviderRetry; use rmcp::model::Tool; pub const OPENROUTER_PROVIDER_NAME: &str = "openrouter"; @@ -51,33 +52,23 @@ pub struct OpenRouterProvider { name: String, #[serde(skip)] configured_parameters: Option>, + #[serde(skip)] + session_id_provider: Option, } impl OpenRouterProvider { - pub async fn from_env( - tls_config: Option, - ) -> Result { - 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()); - - 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()) - .with_header("HTTP-Referer", "https://goose-docs.ai")? - .with_header("X-Title", "goose")? - .with_header("X-OpenRouter-Categories", "cli-agent,productivity")?; - - Ok(Self { + pub fn new( + api_client: ApiClient, + configured_parameters: Option>, + session_id_provider: Option, + ) -> Self { + Self { api_client, supports_streaming: true, name: OPENROUTER_PROVIDER_NAME.to_string(), configured_parameters, - }) + session_id_provider, + } } async fn post_chat_completions( @@ -368,26 +359,6 @@ fn escape_gemini_schema_ref_keys_in_tool_responses(payload: &mut Value) -> usize escaped } -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, @@ -401,7 +372,7 @@ fn merge_openrouter_parameters(model: &mut ModelConfig, params: HashMap ProviderMetadata { ProviderMetadata::new( OPENROUTER_PROVIDER_NAME, @@ -422,32 +393,10 @@ impl goose_providers::base::ProviderDescriptor for OpenRouterProvider { ConfigKey::new(OPENROUTER_PARAMETERS_CONFIG_KEY, false, false, None, false), ], ) - .with_setup( - crate::providers::catalog::ProviderSetupMetadata::api_key( - crate::providers::catalog::ProviderSetupGroup::Default, - ) - .with_docs_url("https://openrouter.ai/keys"), - ) - .with_setup_steps(vec![ - "Go to https://openrouter.ai/settings/keys", - "Click 'Create' or use an existing API key", - "Copy the key and paste it above", - ]) .with_fast_model(OPENROUTER_DEFAULT_FAST_MODEL) } } -impl ProviderDef for OpenRouterProvider { - type Provider = Self; - - fn from_env( - _extensions: Vec, - tls_config: Option, - ) -> BoxFuture<'static, Result> { - Box::pin(Self::from_env(tls_config)) - } -} - #[async_trait] impl Provider for OpenRouterProvider { fn get_name(&self) -> &str { @@ -522,7 +471,11 @@ impl Provider for OpenRouterProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let session_id = crate::session_context::current_session_id().unwrap_or_default(); + let session_id = self + .session_id_provider + .as_ref() + .and_then(|provider| provider()) + .unwrap_or_default(); let mut merged_model; let model_config = if let Some(params) = &self.configured_parameters { @@ -592,7 +545,7 @@ impl Provider for OpenRouterProvider { #[cfg(test)] mod tests { use super::*; - use goose_providers::base::ProviderDescriptor; + use crate::base::ProviderDescriptor; fn model_config(model_name: &str) -> ModelConfig { ModelConfig { @@ -619,48 +572,14 @@ mod tests { .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(); + let params = HashMap::from([ + ("plugins".to_string(), json!([{ "id": "web" }])), + ("verbosity".to_string(), json!("xhigh")), + ]); merge_openrouter_parameters(&mut model, params); @@ -700,17 +619,16 @@ mod tests { .mount(&server) .await; - let provider = OpenRouterProvider { - api_client: ApiClient::new_with_tls( + let provider = OpenRouterProvider::new( + ApiClient::new_with_tls( server.uri(), - AuthMethod::BearerToken("test-key".to_string()), + crate::api_client::AuthMethod::BearerToken("test-key".to_string()), None, ) .unwrap(), - supports_streaming: true, - name: OPENROUTER_PROVIDER_NAME.to_string(), - configured_parameters: None, - }; + None, + None, + ); let mut config = model_config("google/gemini-3.5-flash"); config.reasoning = Some(true); diff --git a/crates/goose/src/providers/formats/openrouter.rs b/crates/goose-providers/src/openrouter_format.rs similarity index 99% rename from crates/goose/src/providers/formats/openrouter.rs rename to crates/goose-providers/src/openrouter_format.rs index 615f96661..e82129a7d 100644 --- a/crates/goose/src/providers/formats/openrouter.rs +++ b/crates/goose-providers/src/openrouter_format.rs @@ -1,7 +1,7 @@ use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; -use goose_providers::formats::openai; -use goose_providers::model::ModelConfig; -use goose_providers::thinking::ThinkingEffort; +use crate::formats::openai; +use crate::model::ModelConfig; +use crate::thinking::ThinkingEffort; use rmcp::model::Role; use serde_json::{json, Value}; diff --git a/crates/goose/src/config/signup_openrouter/mod.rs b/crates/goose/src/config/signup_openrouter/mod.rs index 3717cf67c..7d30f3409 100644 --- a/crates/goose/src/config/signup_openrouter/mod.rs +++ b/crates/goose/src/config/signup_openrouter/mod.rs @@ -167,7 +167,7 @@ pub fn configure_openrouter(config: &Config, api_key: String) -> Result<()> { config.set_secret("OPENROUTER_API_KEY", &api_key)?; crate::config::set_active_provider( config, - crate::providers::openrouter::OPENROUTER_PROVIDER_NAME, + goose_providers::openrouter::OPENROUTER_PROVIDER_NAME, OPENROUTER_DEFAULT_MODEL, )?; Ok(()) diff --git a/crates/goose/src/providers/formats/mod.rs b/crates/goose/src/providers/formats/mod.rs index 8f86a9373..3f213e83a 100644 --- a/crates/goose/src/providers/formats/mod.rs +++ b/crates/goose/src/providers/formats/mod.rs @@ -29,7 +29,9 @@ pub mod google { create_request_with_thinking_budget(model_config, system, messages, tools, thinking_budget) } } -pub mod openrouter; +pub mod openrouter { + pub use goose_providers::openrouter_format::*; +} pub mod snowflake { pub use goose_providers::formats::snowflake::*; } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 5d9286a50..7c9c3e830 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -28,7 +28,6 @@ use super::{ kimicode::KimiCodeProvider, litellm::LiteLLMProvider, nanogpt::NanoGptProvider, - openrouter::OpenRouterProvider, pi_acp::PiAcpProvider, provider_registry::ProviderRegistry, snowflake_def::SnowflakeProviderDef, @@ -45,6 +44,7 @@ use crate::providers::databricks_v2_def::{self, DatabricksV2ProviderDef}; use crate::providers::google_def::GoogleProviderDef; use crate::providers::ollama_def::OllamaProviderDef; use crate::providers::openai_def::OpenAiProviderDef; +use crate::providers::openrouter_def::OpenRouterProviderDef; use crate::{ config::declarative_providers::register_declarative_providers, providers::provider_registry::ProviderEntry, @@ -157,7 +157,7 @@ async fn init_registry() -> RwLock { true, Some(registrations::openai_inventory()), ); - registry.register_with_inventory::( + registry.register_with_inventory::( true, Some(registrations::refresh_only().with_configured(|| { let config = crate::config::Config::global(); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 9987b8da2..ce333e3d2 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -70,7 +70,10 @@ pub mod openai { pub mod openai_compatible { pub use goose_providers::openai_compatible::*; } -pub mod openrouter; +pub mod openrouter { + pub use goose_providers::openrouter::*; +} +pub mod openrouter_def; pub mod pi_acp; pub(crate) mod private_file; pub mod provider_registry; diff --git a/crates/goose/src/providers/openrouter_def.rs b/crates/goose/src/providers/openrouter_def.rs new file mode 100644 index 000000000..8deed5061 --- /dev/null +++ b/crates/goose/src/providers/openrouter_def.rs @@ -0,0 +1,120 @@ +use anyhow::{bail, Result}; +use futures::future::BoxFuture; +use goose_providers::{ + api_client::{ApiClient, AuthMethod, TlsConfig}, + base::{ProviderDescriptor, ProviderMetadata}, + openrouter::OpenRouterProvider, +}; +use serde_json::Value; +use std::collections::HashMap; + +use crate::{ + config::{Config, ConfigError, ExtensionConfig}, + providers::base::ProviderDef, +}; + +const OPENROUTER_PARAMETERS_CONFIG_KEY: &str = "OPENROUTER_PARAMETERS"; + +pub struct OpenRouterProviderDef; + +impl ProviderDescriptor for OpenRouterProviderDef { + fn metadata() -> ProviderMetadata { + OpenRouterProvider::metadata() + .with_setup( + crate::providers::catalog::ProviderSetupMetadata::api_key( + crate::providers::catalog::ProviderSetupGroup::Default, + ) + .with_docs_url("https://openrouter.ai/keys"), + ) + .with_setup_steps(vec![ + "Go to https://openrouter.ai/settings/keys", + "Click 'Create' or use an existing API key", + "Copy the key and paste it above", + ]) + } +} + +impl ProviderDef for OpenRouterProviderDef { + type Provider = OpenRouterProvider; + + fn from_env( + _extensions: Vec, + tls_config: Option, + ) -> BoxFuture<'static, Result> { + Box::pin(from_env(tls_config)) + } +} + +async fn from_env(tls_config: Option) -> Result { + let 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()); + let configured_parameters = configured_openrouter_parameters(config)?; + + let api_client = ApiClient::new_with_tls(host, AuthMethod::BearerToken(api_key), tls_config)? + .with_request_builder(crate::session_context::session_id_request_builder()) + .with_header("HTTP-Referer", "https://goose-docs.ai")? + .with_header("X-Title", "goose")? + .with_header("X-OpenRouter-Categories", "cli-agent,productivity")?; + + Ok(OpenRouterProvider::new( + api_client, + configured_parameters, + Some(Box::new(crate::session_context::current_session_id)), + )) +} + +fn configured_openrouter_parameters(config: &Config) -> Result>> { + match config.get_param::(OPENROUTER_PARAMETERS_CONFIG_KEY) { + Ok(raw) => parse_openrouter_parameters(raw).map(Some), + Err(ConfigError::NotFound(_)) => Ok(None), + Err(err) => Err(err.into()), + } +} + +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"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn metadata_includes_parameters_config_key() { + assert!(OpenRouterProviderDef::metadata() + .config_keys + .iter() + .any(|key| key.name == OPENROUTER_PARAMETERS_CONFIG_KEY)); + } + + #[test] + fn parses_object_and_json_string_parameters() { + assert_eq!( + parse_openrouter_parameters(json!({ "verbosity": "high" })).unwrap()["verbosity"], + json!("high") + ); + assert_eq!( + parse_openrouter_parameters(json!(r#"{"plugins":[{"id":"web"}]}"#)).unwrap()["plugins"], + json!([{ "id": "web" }]) + ); + } + + #[test] + fn rejects_non_object_parameters() { + assert!(parse_openrouter_parameters(json!(r#"["web"]"#)) + .unwrap_err() + .to_string() + .contains("OPENROUTER_PARAMETERS must be a JSON object")); + } +}