feat: openrouter provider (#538)

This commit is contained in:
Michael Neale
2025-01-07 08:59:00 +11:00
committed by GitHub
parent 44671d51ab
commit 667187e791
8 changed files with 339 additions and 26 deletions
+37
View File
@@ -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()
}
+1
View File
@@ -12,6 +12,7 @@ pub mod utils;
pub mod google;
pub mod groq;
pub mod openrouter;
#[cfg(test)]
pub mod mock;
+1
View File
@@ -15,6 +15,7 @@ pub enum ProviderConfig {
Anthropic(AnthropicProviderConfig),
Google(GoogleProviderConfig),
Groq(GroqProviderConfig),
OpenRouter(OpenAiProviderConfig),
}
/// Configuration for model-specific settings and limits
+5 -1
View File
@@ -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),
+196
View File
@@ -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(())
}
}
+85 -24
View File
@@ -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>
);
+1 -1
View File
@@ -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;