mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: openrouter provider (#538)
This commit is contained in:
@@ -51,6 +51,21 @@ pub enum ProviderSettings {
|
||||
#[serde(default)]
|
||||
estimate_factor: Option<f32>,
|
||||
},
|
||||
OpenRouter {
|
||||
#[serde(default = "default_openrouter_host")]
|
||||
host: String,
|
||||
api_key: String,
|
||||
#[serde(default = "default_model")]
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<i32>,
|
||||
#[serde(default)]
|
||||
context_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
estimate_factor: Option<f32>,
|
||||
},
|
||||
Databricks {
|
||||
#[serde(default = "default_databricks_host")]
|
||||
host: String,
|
||||
@@ -139,6 +154,7 @@ impl ProviderSettings {
|
||||
ProviderSettings::Google { .. } => ProviderType::Google,
|
||||
ProviderSettings::Groq { .. } => ProviderType::Groq,
|
||||
ProviderSettings::Anthropic { .. } => ProviderType::Anthropic,
|
||||
ProviderSettings::OpenRouter { .. } => ProviderType::OpenRouter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +178,23 @@ impl ProviderSettings {
|
||||
.with_context_limit(context_limit)
|
||||
.with_estimate_factor(estimate_factor),
|
||||
}),
|
||||
ProviderSettings::OpenRouter {
|
||||
host,
|
||||
api_key,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
context_limit,
|
||||
estimate_factor,
|
||||
} => ProviderConfig::OpenRouter(OpenAiProviderConfig {
|
||||
host,
|
||||
api_key,
|
||||
model: ModelConfig::new(model)
|
||||
.with_temperature(temperature)
|
||||
.with_max_tokens(max_tokens)
|
||||
.with_context_limit(context_limit)
|
||||
.with_estimate_factor(estimate_factor),
|
||||
}),
|
||||
ProviderSettings::Databricks {
|
||||
host,
|
||||
model,
|
||||
@@ -317,6 +350,10 @@ fn default_port() -> u16 {
|
||||
3000
|
||||
}
|
||||
|
||||
pub fn default_openrouter_host() -> String {
|
||||
"https://openrouter.ai".to_string()
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
OPEN_AI_DEFAULT_MODEL.to_string()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod utils;
|
||||
|
||||
pub mod google;
|
||||
pub mod groq;
|
||||
pub mod openrouter;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod mock;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub enum ProviderConfig {
|
||||
Anthropic(AnthropicProviderConfig),
|
||||
Google(GoogleProviderConfig),
|
||||
Groq(GroqProviderConfig),
|
||||
OpenRouter(OpenAiProviderConfig),
|
||||
}
|
||||
|
||||
/// Configuration for model-specific settings and limits
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
anthropic::AnthropicProvider, base::Provider, configs::ProviderConfig,
|
||||
databricks::DatabricksProvider, google::GoogleProvider, groq::GroqProvider,
|
||||
ollama::OllamaProvider, openai::OpenAiProvider,
|
||||
ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use strum_macros::EnumIter;
|
||||
@@ -14,6 +14,7 @@ pub enum ProviderType {
|
||||
Anthropic,
|
||||
Google,
|
||||
Groq,
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
pub fn get_provider(config: ProviderConfig) -> Result<Box<dyn Provider + Send + Sync>> {
|
||||
@@ -28,5 +29,8 @@ pub fn get_provider(config: ProviderConfig) -> Result<Box<dyn Provider + Send +
|
||||
}
|
||||
ProviderConfig::Google(google_config) => Ok(Box::new(GoogleProvider::new(google_config)?)),
|
||||
ProviderConfig::Groq(groq_config) => Ok(Box::new(GroqProvider::new(groq_config)?)),
|
||||
ProviderConfig::OpenRouter(openrouter_config) => {
|
||||
Ok(Box::new(OpenRouterProvider::new(openrouter_config)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,24 @@ lazy_static::lazy_static! {
|
||||
input_token_price: dec!(15.00),
|
||||
output_token_price: dec!(75.00),
|
||||
});
|
||||
// OpenRouter Models
|
||||
m.insert("anthropic/claude-3-sonnet".to_string(), Pricing {
|
||||
input_token_price: dec!(3.00),
|
||||
output_token_price: dec!(15.00),
|
||||
});
|
||||
m.insert("claude-3-sonnet".to_string(), Pricing {
|
||||
input_token_price: dec!(3.00),
|
||||
output_token_price: dec!(15.00),
|
||||
});
|
||||
// OpenAI
|
||||
m.insert("gpt-4o".to_string(), Pricing {
|
||||
input_token_price: dec!(2.50),
|
||||
output_token_price: dec!(10.00),
|
||||
});
|
||||
m.insert("gpt-4".to_string(), Pricing {
|
||||
input_token_price: dec!(2.50),
|
||||
output_token_price: dec!(10.00),
|
||||
});
|
||||
m.insert("gpt-4o-2024-11-20".to_string(), Pricing {
|
||||
input_token_price: dec!(2.50),
|
||||
output_token_price: dec!(10.00),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::base::ProviderUsage;
|
||||
use super::base::{Provider, Usage};
|
||||
use super::configs::OpenAiProviderConfig;
|
||||
use super::configs::{ModelConfig, ProviderModelConfig};
|
||||
use super::model_pricing::cost;
|
||||
use super::model_pricing::model_pricing_for;
|
||||
use super::utils::{get_model, handle_response};
|
||||
use crate::message::Message;
|
||||
use crate::providers::openai_utils::{
|
||||
check_openai_context_length_error, create_openai_request_payload_with_concat_response_content,
|
||||
get_openai_usage, openai_response_to_message,
|
||||
};
|
||||
use mcp_core::tool::Tool;
|
||||
|
||||
pub const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-3.5-sonnet";
|
||||
|
||||
pub struct OpenRouterProvider {
|
||||
client: Client,
|
||||
config: OpenAiProviderConfig,
|
||||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
pub fn new(config: OpenAiProviderConfig) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(600)) // 10 minutes timeout
|
||||
.build()?;
|
||||
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
|
||||
async fn post(&self, payload: Value) -> Result<Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/chat/completions",
|
||||
self.config.host.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {}", self.config.api_key))
|
||||
.header("HTTP-Referer", "https://github.com/block/goose")
|
||||
.header("X-Title", "Goose")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
handle_response(payload, response).await?
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OpenRouterProvider {
|
||||
fn get_model_config(&self) -> &ModelConfig {
|
||||
self.config.model_config()
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
system: &str,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
) -> Result<(Message, ProviderUsage)> {
|
||||
// Create the base payload
|
||||
let payload = create_openai_request_payload_with_concat_response_content(
|
||||
&self.config.model,
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
)?;
|
||||
|
||||
// Make request
|
||||
let response = self.post(payload).await?;
|
||||
|
||||
// Raise specific error if context length is exceeded
|
||||
if let Some(error) = response.get("error") {
|
||||
if let Some(err) = check_openai_context_length_error(error) {
|
||||
return Err(err.into());
|
||||
}
|
||||
return Err(anyhow!("OpenRouter API error: {}", error));
|
||||
}
|
||||
|
||||
// Parse response
|
||||
let message = openai_response_to_message(response.clone())?;
|
||||
let usage = self.get_usage(&response)?;
|
||||
let model = get_model(&response);
|
||||
let cost = cost(&usage, &model_pricing_for(&model));
|
||||
|
||||
Ok((message, ProviderUsage::new(model, usage, cost)))
|
||||
}
|
||||
|
||||
fn get_usage(&self, data: &Value) -> Result<Usage> {
|
||||
get_openai_usage(data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::MessageContent;
|
||||
use crate::providers::configs::ModelConfig;
|
||||
use crate::providers::mock_server::{
|
||||
create_mock_open_ai_response, create_mock_open_ai_response_with_tools, create_test_tool,
|
||||
get_expected_function_call_arguments, setup_mock_server, TEST_INPUT_TOKENS,
|
||||
TEST_OUTPUT_TOKENS, TEST_TOOL_FUNCTION_NAME, TEST_TOTAL_TOKENS,
|
||||
};
|
||||
use rust_decimal_macros::dec;
|
||||
use wiremock::MockServer;
|
||||
|
||||
async fn _setup_mock_response(response_body: Value) -> (MockServer, OpenRouterProvider) {
|
||||
let mock_server = setup_mock_server("/api/v1/chat/completions", response_body).await;
|
||||
|
||||
// Create the OpenRouterProvider with the mock server's URL as the host
|
||||
let config = OpenAiProviderConfig {
|
||||
host: mock_server.uri(),
|
||||
api_key: "test_api_key".to_string(),
|
||||
model: ModelConfig::new("gpt-3.5-turbo".to_string()).with_temperature(Some(0.7)),
|
||||
};
|
||||
|
||||
let provider = OpenRouterProvider::new(config).unwrap();
|
||||
(mock_server, provider)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_complete_basic() -> Result<()> {
|
||||
let model_name = "gpt-4";
|
||||
// Mock response for normal completion
|
||||
let response_body =
|
||||
create_mock_open_ai_response(model_name, "Hello! How can I assist you today?");
|
||||
|
||||
let (_, provider) = _setup_mock_response(response_body).await;
|
||||
|
||||
// Prepare input messages
|
||||
let messages = vec![Message::user().with_text("Hello?")];
|
||||
|
||||
// Call the complete method
|
||||
let (message, usage) = provider
|
||||
.complete("You are a helpful assistant.", &messages, &[])
|
||||
.await?;
|
||||
|
||||
// Assert the response
|
||||
if let MessageContent::Text(text) = &message.content[0] {
|
||||
assert_eq!(text.text, "Hello! How can I assist you today?");
|
||||
} else {
|
||||
panic!("Expected Text content");
|
||||
}
|
||||
assert_eq!(usage.usage.input_tokens, Some(TEST_INPUT_TOKENS));
|
||||
assert_eq!(usage.usage.output_tokens, Some(TEST_OUTPUT_TOKENS));
|
||||
assert_eq!(usage.usage.total_tokens, Some(TEST_TOTAL_TOKENS));
|
||||
assert_eq!(usage.model, model_name);
|
||||
assert_eq!(usage.cost, Some(dec!(0.00018)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_complete_tool_request() -> Result<()> {
|
||||
// Mock response for tool calling
|
||||
let response_body = create_mock_open_ai_response_with_tools("gpt-4");
|
||||
|
||||
let (_, provider) = _setup_mock_response(response_body).await;
|
||||
|
||||
// Input messages
|
||||
let messages = vec![Message::user().with_text("What's the weather in San Francisco?")];
|
||||
|
||||
// Call the complete method
|
||||
let (message, usage) = provider
|
||||
.complete(
|
||||
"You are a helpful assistant.",
|
||||
&messages,
|
||||
&[create_test_tool()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Assert the response
|
||||
if let MessageContent::ToolRequest(tool_request) = &message.content[0] {
|
||||
let tool_call = tool_request.tool_call.as_ref().unwrap();
|
||||
assert_eq!(tool_call.name, TEST_TOOL_FUNCTION_NAME);
|
||||
assert_eq!(tool_call.arguments, get_expected_function_call_arguments());
|
||||
} else {
|
||||
panic!("Expected ToolCall content");
|
||||
}
|
||||
|
||||
assert_eq!(usage.usage.input_tokens, Some(TEST_INPUT_TOKENS));
|
||||
assert_eq!(usage.usage.output_tokens, Some(TEST_OUTPUT_TOKENS));
|
||||
assert_eq!(usage.usage.total_tokens, Some(TEST_TOTAL_TOKENS));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Bird } from './ui/icons';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ApiKeyWarningProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Collapsible({ title, children, defaultOpen = false }: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg mb-2">
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left flex justify-between items-center hover:bg-gray-50"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="font-medium">{title}</span>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform ${
|
||||
isOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 border-t">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OPENAI_CONFIG = `export GOOSE_PROVIDER__TYPE=openai
|
||||
export GOOSE_PROVIDER__HOST=https://api.openai.com
|
||||
export GOOSE_PROVIDER__MODEL=gpt-4
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
const ANTHROPIC_CONFIG = `export GOOSE_PROVIDER__TYPE=anthropic
|
||||
export GOOSE_PROVIDER__HOST=https://api.anthropic.com
|
||||
export GOOSE_PROVIDER__MODEL=claude-3-sonnet
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
const DATABRICKS_CONFIG = `export GOOSE_PROVIDER__TYPE=databricks
|
||||
export GOOSE_PROVIDER__HOST=your_databricks_host
|
||||
export GOOSE_PROVIDER__MODEL=claude-3-sonnet-2`;
|
||||
|
||||
const OPENROUTER_CONFIG = `export GOOSE_PROVIDER__TYPE=openrouter
|
||||
export GOOSE_PROVIDER__HOST=https://openrouter.ai
|
||||
export GOOSE_PROVIDER__MODEL=anthropic/claude-3-sonnet
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
export function ApiKeyWarning({ className }: ApiKeyWarningProps) {
|
||||
return (
|
||||
<Card className={`flex flex-col items-center justify-center p-8 space-y-6 bg-card-gradient w-full h-full ${className}`}>
|
||||
<Card className={`flex flex-col items-center p-8 space-y-6 bg-card-gradient w-full h-full ${className}`}>
|
||||
<div className="w-16 h-16">
|
||||
<Bird />
|
||||
</div>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-center space-y-4 max-w-2xl w-full">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">API Key Required</h2>
|
||||
<div className="whitespace-pre-wrap">
|
||||
To use Goose, you need to set some combination of the following env variables
|
||||
<br />
|
||||
<br />
|
||||
# OpenAI
|
||||
<br />
|
||||
<br />
|
||||
export GOOSE_PROVIDER__TYPE=openai<br />
|
||||
GOOSE_PROVIDER__HOST=https://api.openai.com<br />
|
||||
GOOSE_PROVIDER__MODEL=gpt-4o<br />
|
||||
GOOSE_PROVIDER__API_KEY=...<br />
|
||||
<br />
|
||||
<br />
|
||||
# Databricks + Claude
|
||||
<br />
|
||||
<br />
|
||||
export GOOSE_PROVIDER__TYPE=databricks<br />
|
||||
export GOOSE_PROVIDER__HOST=...<br />
|
||||
export GOOSE_PROVIDER__MODEL="claude-3-5-sonnet-2"<br />
|
||||
<br />
|
||||
<br />
|
||||
Please export these and restart the application.
|
||||
<p className="text-gray-600 mb-4">
|
||||
To use Goose, you need to set environment variables for one of the following providers:
|
||||
</p>
|
||||
|
||||
<div className="text-left">
|
||||
<Collapsible title="OpenAI Configuration" defaultOpen={true}>
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">
|
||||
{OPENAI_CONFIG}
|
||||
</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Anthropic (Claude) Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">
|
||||
{ANTHROPIC_CONFIG}
|
||||
</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Databricks Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">
|
||||
{DATABRICKS_CONFIG}
|
||||
</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="OpenRouter Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">
|
||||
{OPENROUTER_CONFIG}
|
||||
</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
After setting these variables, restart Goose for the changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const checkApiCredentials = () => {
|
||||
|
||||
//{env-macro-start}//
|
||||
const apiKeyProvidersValid =
|
||||
['openai', 'anthropic', 'google', 'groq'].includes(process.env.GOOSE_PROVIDER__TYPE) &&
|
||||
['openai', 'anthropic', 'google', 'groq', 'openrouter'].includes(process.env.GOOSE_PROVIDER__TYPE) &&
|
||||
process.env.GOOSE_PROVIDER__HOST &&
|
||||
process.env.GOOSE_PROVIDER__MODEL &&
|
||||
process.env.GOOSE_PROVIDER__API_KEY;
|
||||
|
||||
Reference in New Issue
Block a user