migrate openrouter provider to goose-providers (#11757)
This commit is contained in:
@@ -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(())
|
||||
|
||||
@@ -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::*;
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
use crate::conversation::message::{Message, MessageContent, ProviderMetadata};
|
||||
use goose_providers::formats::openai;
|
||||
use goose_providers::model::ModelConfig;
|
||||
use goose_providers::thinking::ThinkingEffort;
|
||||
use rmcp::model::Role;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const REASONING_DETAILS_KEY: &str = "reasoning_details";
|
||||
|
||||
fn has_assistant_content(message: &Message) -> bool {
|
||||
message.content.iter().any(|c| match c {
|
||||
MessageContent::Text(t) => !t.text.is_empty(),
|
||||
MessageContent::Image(_) => true,
|
||||
MessageContent::ToolRequest(req) => req.tool_call.is_ok(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_reasoning_details(response: &Value) -> Option<Vec<Value>> {
|
||||
response
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|m| m.get("message"))
|
||||
.and_then(|msg| msg.get("reasoning_details"))
|
||||
.and_then(|d| d.as_array())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn get_reasoning_details(metadata: &Option<ProviderMetadata>) -> Option<Vec<Value>> {
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(REASONING_DETAILS_KEY))
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
let mut message = openai::response_to_message(response)?;
|
||||
|
||||
if let Some(details) = extract_reasoning_details(response) {
|
||||
for content in &mut message.content {
|
||||
if let MessageContent::ToolRequest(req) = content {
|
||||
let mut meta = req.metadata.clone().unwrap_or_default();
|
||||
meta.insert(REASONING_DETAILS_KEY.to_string(), json!(details));
|
||||
req.metadata = Some(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub fn add_reasoning_details_to_request(payload: &mut Value, messages: &[Message]) {
|
||||
let mut assistant_reasoning: Vec<Option<Vec<Value>>> = messages
|
||||
.iter()
|
||||
.filter(|m| m.is_agent_visible())
|
||||
.filter(|m| m.role == Role::Assistant)
|
||||
.filter(|m| has_assistant_content(m))
|
||||
.map(|message| {
|
||||
message.content.iter().find_map(|c| match c {
|
||||
MessageContent::ToolRequest(req) => get_reasoning_details(&req.metadata),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(payload_messages) = payload
|
||||
.as_object_mut()
|
||||
.and_then(|obj| obj.get_mut("messages"))
|
||||
.and_then(|m| m.as_array_mut())
|
||||
{
|
||||
let mut assistant_idx = 0;
|
||||
for payload_msg in payload_messages.iter_mut() {
|
||||
if payload_msg.get("role").and_then(|r| r.as_str()) == Some("assistant") {
|
||||
if assistant_idx < assistant_reasoning.len() {
|
||||
if let Some(details) = assistant_reasoning
|
||||
.get_mut(assistant_idx)
|
||||
.and_then(|d| d.take())
|
||||
{
|
||||
if let Some(obj) = payload_msg.as_object_mut() {
|
||||
obj.insert("reasoning_details".to_string(), json!(details));
|
||||
}
|
||||
}
|
||||
}
|
||||
assistant_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_effort_for_openrouter(effort: ThinkingEffort) -> Option<&'static str> {
|
||||
match effort {
|
||||
ThinkingEffort::Off => None,
|
||||
ThinkingEffort::Low => Some("low"),
|
||||
ThinkingEffort::Medium => Some("medium"),
|
||||
ThinkingEffort::High => Some("high"),
|
||||
ThinkingEffort::Max => Some("xhigh"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a reasoning disable request was inserted, which
|
||||
/// mandatory-reasoning endpoints reject; the provider downgrades those to
|
||||
/// the lowest effort on OpenRouter's mandatory-reasoning error.
|
||||
pub fn apply_reasoning_config(payload: &mut Value, model_config: &ModelConfig) -> bool {
|
||||
let Some(effort) = model_config.thinking_effort() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
if obj.contains_key("reasoning") {
|
||||
obj.remove("reasoning_effort");
|
||||
return false;
|
||||
}
|
||||
|
||||
let clamped_effort = obj
|
||||
.remove("reasoning_effort")
|
||||
.and_then(|value| value.as_str().map(str::to_owned));
|
||||
if effort == ThinkingEffort::Off {
|
||||
if !model_config.is_reasoning_model() {
|
||||
return false;
|
||||
}
|
||||
return match clamped_effort {
|
||||
Some(clamped) => {
|
||||
obj.insert("reasoning".to_string(), json!({ "effort": clamped }));
|
||||
false
|
||||
}
|
||||
None => {
|
||||
obj.insert("reasoning".to_string(), json!({ "enabled": false }));
|
||||
true
|
||||
}
|
||||
};
|
||||
}
|
||||
if clamped_effort.is_none() && !model_config.is_reasoning_model() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let effort = clamped_effort
|
||||
.as_deref()
|
||||
.or_else(|| reasoning_effort_for_openrouter(effort));
|
||||
if let Some(effort) = effort {
|
||||
obj.insert("reasoning".to_string(), json!({ "effort": effort }));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_extract_reasoning_details() {
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello",
|
||||
"reasoning_details": [
|
||||
{"type": "text", "text": "Let me think..."},
|
||||
{"type": "encrypted", "data": "abc123signature"}
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let details = extract_reasoning_details(&response).unwrap();
|
||||
assert_eq!(details.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_to_message_with_tool_calls() {
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"NYC\"}"
|
||||
}
|
||||
}],
|
||||
"reasoning_details": [
|
||||
{"type": "encrypted", "data": "sig456"}
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let message = response_to_message(&response).unwrap();
|
||||
assert!(!message.content.is_empty());
|
||||
|
||||
let tool_request = message
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
if let MessageContent::ToolRequest(req) = c {
|
||||
Some(req)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(tool_request.metadata.is_some());
|
||||
let details = get_reasoning_details(&tool_request.metadata).unwrap();
|
||||
assert_eq!(details.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_uses_openrouter_reasoning_object() {
|
||||
let mut payload = json!({
|
||||
"model": "openai/gpt-5",
|
||||
"messages": [],
|
||||
"reasoning_effort": "high"
|
||||
});
|
||||
let mut model_config = ModelConfig::new("openai/gpt-5");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("max"));
|
||||
model_config.request_params = Some(params);
|
||||
|
||||
apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert_eq!(payload["reasoning"], json!({ "effort": "high" }));
|
||||
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_uses_reasoning_metadata() {
|
||||
let mut payload = json!({
|
||||
"model": "x-ai/grok-4",
|
||||
"messages": []
|
||||
});
|
||||
let mut model_config = ModelConfig::new("x-ai/grok-4");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("high"));
|
||||
model_config.request_params = Some(params);
|
||||
model_config.reasoning = Some(true);
|
||||
|
||||
apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert_eq!(payload["reasoning"], json!({ "effort": "high" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_uses_model_detection() {
|
||||
let mut payload = json!({
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"messages": []
|
||||
});
|
||||
let mut model_config = ModelConfig::new("anthropic/claude-sonnet-4");
|
||||
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!({ "effort": "high" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_skips_non_reasoning_models() {
|
||||
let mut payload = json!({
|
||||
"model": "openai/gpt-4o",
|
||||
"messages": []
|
||||
});
|
||||
let mut model_config = ModelConfig::new("openai/gpt-4o");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("high"));
|
||||
model_config.request_params = Some(params);
|
||||
model_config.reasoning = Some(false);
|
||||
|
||||
apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert!(payload.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_off_keeps_clamped_effort() {
|
||||
let mut payload = json!({
|
||||
"model": "openai/gpt-5",
|
||||
"messages": [],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
let mut model_config = ModelConfig::new("openai/gpt-5");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("off"));
|
||||
model_config.request_params = Some(params);
|
||||
model_config.reasoning = Some(true);
|
||||
|
||||
let sent_disable = apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert!(!sent_disable);
|
||||
assert_eq!(payload["reasoning"], json!({ "effort": "low" }));
|
||||
assert!(payload.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_kimi_k3_off_disables_reasoning() {
|
||||
let mut payload = json!({
|
||||
"model": "moonshotai/kimi-k3",
|
||||
"messages": []
|
||||
});
|
||||
let mut model_config =
|
||||
ModelConfig::new("moonshotai/kimi-k3").with_canonical_limits("openrouter");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("off"));
|
||||
model_config.request_params = Some(params);
|
||||
|
||||
let sent_disable = apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert!(sent_disable);
|
||||
assert_eq!(payload["reasoning"], json!({ "enabled": false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_off_skips_non_reasoning_model() {
|
||||
// OpenAI-shaped but canonically non-reasoning; off must drop the clamp.
|
||||
let mut payload = json!({
|
||||
"model": "openai/gpt-5.1-chat",
|
||||
"messages": [],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
let mut model_config = ModelConfig::new("openai/gpt-5.1-chat");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("off"));
|
||||
model_config.request_params = Some(params);
|
||||
model_config.reasoning = Some(false);
|
||||
|
||||
apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert!(payload.get("reasoning").is_none());
|
||||
assert!(payload.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_reasoning_config_off_preserves_user_reasoning() {
|
||||
let mut payload = json!({
|
||||
"model": "x-ai/grok-4",
|
||||
"messages": [],
|
||||
"reasoning": { "max_tokens": 2000 }
|
||||
});
|
||||
let mut model_config = ModelConfig::new("x-ai/grok-4");
|
||||
let mut params = HashMap::new();
|
||||
params.insert("thinking_effort".to_string(), json!("off"));
|
||||
model_config.request_params = Some(params);
|
||||
model_config.reasoning = Some(true);
|
||||
|
||||
let sent_disable = apply_reasoning_config(&mut payload, &model_config);
|
||||
|
||||
assert!(!sent_disable);
|
||||
assert_eq!(payload["reasoning"], json!({ "max_tokens": 2000 }));
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderRegistry> {
|
||||
true,
|
||||
Some(registrations::openai_inventory()),
|
||||
);
|
||||
registry.register_with_inventory::<OpenRouterProvider>(
|
||||
registry.register_with_inventory::<OpenRouterProviderDef>(
|
||||
true,
|
||||
Some(registrations::refresh_only().with_configured(|| {
|
||||
let config = crate::config::Config::global();
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<ExtensionConfig>,
|
||||
tls_config: Option<TlsConfig>,
|
||||
) -> BoxFuture<'static, Result<Self::Provider>> {
|
||||
Box::pin(from_env(tls_config))
|
||||
}
|
||||
}
|
||||
|
||||
async fn from_env(tls_config: Option<TlsConfig>) -> Result<OpenRouterProvider> {
|
||||
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<Option<HashMap<String, Value>>> {
|
||||
match config.get_param::<Value>(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<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"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user