use crate::ToolSpec;
use crate::traits::{ChatMessage, ChatRequest, ChatResponse, ModelProvider, TokenUsage, ToolCall};
use async_trait::async_trait;
use reqwest::Client;
use reqwest::header::ACCEPT_ENCODING;
use serde::{Deserialize, Serialize};
const OPENROUTER_MAX_TRANSPORT_ATTEMPTS: u32 = 3;
pub struct OpenRouterProvider {
api_key: Option<String>,
client: Client,
last_good_provider: std::sync::Mutex<Option<String>>,
}
#[derive(Debug, Serialize)]
struct NativeChatRequest {
model: String,
messages: Vec<NativeMessage>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<NativeToolSpec>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<NativeProviderRouting>,
}
#[derive(Debug, Serialize)]
struct NativeProviderRouting {
order: Vec<String>,
allow_fallbacks: bool,
}
#[derive(Debug, Serialize)]
struct NativeMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<NativeToolCall>>,
}
#[derive(Debug, Serialize)]
struct NativeToolSpec {
#[serde(rename = "type")]
kind: String,
function: NativeToolFunctionSpec,
}
#[derive(Debug, Serialize)]
struct NativeToolFunctionSpec {
name: String,
description: String,
parameters: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct NativeToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
kind: Option<String>,
function: NativeFunctionCall,
}
#[derive(Debug, Serialize, Deserialize)]
struct NativeFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct NativeUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
}
#[derive(Debug, Deserialize)]
struct NativeChatResponse {
choices: Vec<NativeChoice>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
openrouter_metadata: Option<NativeOpenRouterMetadata>,
#[serde(default)]
usage: Option<NativeUsage>,
}
#[derive(Debug, Deserialize)]
struct NativeOpenRouterMetadata {
#[serde(default)]
endpoints: Option<NativeEndpointsMetadata>,
}
#[derive(Debug, Deserialize)]
struct NativeEndpointsMetadata {
#[serde(default)]
available: Vec<NativeEndpointInfo>,
}
#[derive(Debug, Deserialize)]
struct NativeEndpointInfo {
provider: String,
#[serde(default)]
selected: bool,
}
#[derive(Debug, Deserialize)]
struct NativeChoice {
message: NativeResponseMessage,
}
#[derive(Debug, Deserialize)]
struct NativeResponseMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<NativeToolCall>>,
}
impl OpenRouterProvider {
pub fn new(api_key: Option<&str>) -> Self {
Self {
api_key: api_key.map(ToString::to_string),
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
last_good_provider: std::sync::Mutex::new(None),
}
}
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
let items = tools?;
if items.is_empty() {
return None;
}
Some(
items
.iter()
.map(|tool| NativeToolSpec {
kind: "function".to_string(),
function: NativeToolFunctionSpec {
name: crate::sanitize_tool_name(&tool.name),
description: tool.description.clone(),
parameters: tool.parameters.clone(),
},
})
.collect(),
)
}
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
messages
.iter()
.map(|m| {
if m.role == "assistant"
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
&& let Some(tool_calls_value) = value.get("tool_calls")
&& let Ok(parsed_calls) =
serde_json::from_value::<Vec<ToolCall>>(tool_calls_value.clone())
{
let tool_calls = parsed_calls
.into_iter()
.map(|tc| NativeToolCall {
id: Some(tc.id),
kind: Some("function".to_string()),
function: NativeFunctionCall {
name: tc.name,
arguments: tc.arguments,
},
})
.collect::<Vec<_>>();
let content = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
return NativeMessage {
role: "assistant".to_string(),
content,
tool_call_id: None,
tool_calls: Some(tool_calls),
};
}
if m.role == "tool"
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
{
let tool_call_id = value
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
let content = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
return NativeMessage {
role: "tool".to_string(),
content,
tool_call_id,
tool_calls: None,
};
}
NativeMessage {
role: m.role.clone(),
content: Some(m.content.clone()),
tool_call_id: None,
tool_calls: None,
}
})
.collect()
}
fn parse_native_response(message: NativeResponseMessage) -> ChatResponse {
let tool_calls = message
.tool_calls
.unwrap_or_default()
.into_iter()
.map(|tc| ToolCall {
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
name: tc.function.name,
arguments: tc.function.arguments,
})
.collect::<Vec<_>>();
ChatResponse {
text: message.content,
tool_calls,
usage: TokenUsage::default(),
}
}
fn selected_provider_name(response: &NativeChatResponse) -> Option<String> {
response.provider.clone().or_else(|| {
response
.openrouter_metadata
.as_ref()
.and_then(|metadata| metadata.endpoints.as_ref())
.and_then(|endpoints| {
endpoints
.available
.iter()
.find(|endpoint| endpoint.selected)
})
.map(|endpoint| endpoint.provider.clone())
})
}
}
#[async_trait]
impl ModelProvider for OpenRouterProvider {
async fn warmup(&self) -> anyhow::Result<()> {
if let Some(api_key) = self.api_key.as_ref() {
self.client
.get("https://openrouter.ai/api/v1/auth/key")
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await?
.error_for_status()?;
}
Ok(())
}
async fn chat(
&self,
request: ChatRequest<'_>,
model: &str,
temperature: f64,
) -> anyhow::Result<ChatResponse> {
let api_key = self.api_key.as_ref().ok_or_else(|| {
anyhow::anyhow!("OpenRouter API key not set. Set OPENROUTER_API_KEY env var.")
})?;
let tools = Self::convert_tools(request.tools);
let provider_routing = self
.last_good_provider
.lock()
.ok()
.and_then(|guard| guard.clone())
.map(|p| NativeProviderRouting {
order: vec![p],
allow_fallbacks: true,
});
let messages = Self::convert_messages(request.messages);
let estimated_chars: usize = messages
.iter()
.map(|m| m.content.as_deref().unwrap_or("").len())
.sum();
let estimated_tokens = estimated_chars / 4;
tracing::info!(
model = model,
messages = messages.len(),
estimated_tokens = estimated_tokens,
"OpenRouter request"
);
let native_request = NativeChatRequest {
model: model.to_string(),
messages,
temperature,
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
tools,
provider: provider_routing,
};
let body_text = {
let mut last_error = None;
let mut body = None;
for attempt in 1..=OPENROUTER_MAX_TRANSPORT_ATTEMPTS {
let response = match self
.client
.post("https://openrouter.ai/api/v1/chat/completions")
.header("Authorization", format!("Bearer {api_key}"))
.header("HTTP-Referer", "https://github.com/nenjo-ai/nenjo")
.header("X-Title", "Nenjo")
.header(ACCEPT_ENCODING, "identity")
.json(&native_request)
.send()
.await
{
Ok(response) => response,
Err(error) => {
last_error = Some(anyhow::anyhow!(
"OpenRouter: request failed (~{estimated_tokens} input tokens, \
{messages_count} messages, attempt {attempt}/{OPENROUTER_MAX_TRANSPORT_ATTEMPTS}): {error}",
messages_count = native_request.messages.len(),
));
if attempt < OPENROUTER_MAX_TRANSPORT_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
250 * u64::from(attempt),
))
.await;
continue;
}
break;
}
};
let status = response.status();
if !status.is_success() {
return Err(crate::api_error("OpenRouter", response).await);
}
match response.text().await {
Ok(text) => {
body = Some(text);
break;
}
Err(error) => {
last_error = Some(anyhow::anyhow!(
"OpenRouter: failed to read response body (status {status}, \
~{estimated_tokens} input tokens, {messages_count} messages, \
attempt {attempt}/{OPENROUTER_MAX_TRANSPORT_ATTEMPTS}): {error}",
messages_count = native_request.messages.len(),
));
if attempt < OPENROUTER_MAX_TRANSPORT_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
250 * u64::from(attempt),
))
.await;
}
}
}
}
body.ok_or_else(|| {
last_error.unwrap_or_else(|| anyhow::anyhow!("OpenRouter: empty response body"))
})?
};
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
&& let Some(err) = value.get("error")
{
let msg = err
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
return Err(anyhow::anyhow!(
"OpenRouter returned an error in a 200 response: {msg}"
));
}
let native_response: NativeChatResponse =
serde_json::from_str(&body_text).map_err(|e| {
anyhow::anyhow!(
"OpenRouter response decode error: {e}\nBody: {}",
&body_text[..body_text.len().min(500)]
)
})?;
if let Some(provider_name) = Self::selected_provider_name(&native_response)
&& let Ok(mut guard) = self.last_good_provider.lock()
{
*guard = Some(provider_name);
}
let usage = native_response
.usage
.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
})
.unwrap_or_default();
let message = native_response
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
let mut result = Self::parse_native_response(message);
result.usage = usage;
Ok(result)
}
fn context_window(&self, model: &str) -> Option<usize> {
let m = model.to_lowercase();
if m.contains("claude-opus-4")
|| m.contains("claude-sonnet-4.6")
|| m.contains("claude-sonnet-4-6")
{
Some(1_000_000)
} else if m.contains("claude-sonnet-4")
|| m.contains("claude-haiku-4")
|| m.contains("claude-3.5")
|| m.contains("claude-3-")
|| m.contains("claude-3.7")
{
Some(200_000)
} else if m.contains("gpt-5") {
Some(1_000_000)
} else if m.contains("gpt-4o") {
Some(128_000)
} else if m.contains("o1") || m.contains("o3") || m.contains("o4") {
Some(200_000)
} else if m.contains("gemini") {
Some(1_000_000)
} else if m.contains("deepseek") {
Some(128_000)
} else if m.contains("llama-4") || m.contains("llama4") {
Some(1_000_000)
} else if m.contains("llama-3") || m.contains("llama3") {
Some(128_000)
} else if m.contains("mistral-large") || m.contains("qwen") {
Some(256_000)
} else if m.contains("grok-4") && m.contains("fast") {
Some(2_000_000)
} else if m.contains("grok-4") {
Some(256_000)
} else if m.contains("grok-3") {
Some(1_000_000)
} else if m.contains("kimi") {
Some(256_000)
} else if m.contains("minimax") {
Some(200_000)
} else {
None
}
}
fn supports_native_tools(&self) -> bool {
true
}
fn supports_developer_role(&self, model: &str) -> bool {
let m = model.to_lowercase();
(m.contains("openai/") || m.contains("azure/"))
&& (m.contains("/o1")
|| m.contains("/o3")
|| m.contains("/o4")
|| m.contains("/gpt-5")
|| m.contains("/gpt-4.5")
|| m.contains("/gpt-4.1"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::{ChatMessage, ChatRequest, ModelProvider};
#[test]
fn creates_with_key() {
let provider = OpenRouterProvider::new(Some("sk-or-123"));
assert_eq!(provider.api_key.as_deref(), Some("sk-or-123"));
}
#[test]
fn creates_without_key() {
let provider = OpenRouterProvider::new(None);
assert!(provider.api_key.is_none());
}
#[tokio::test]
async fn warmup_without_key_is_noop() {
let provider = OpenRouterProvider::new(None);
let result = provider.warmup().await;
assert!(result.is_ok());
}
#[test]
fn developer_role_only_for_openai_newer_models() {
let provider = OpenRouterProvider::new(None);
assert!(provider.supports_developer_role("openai/gpt-5.1"));
assert!(provider.supports_developer_role("openai/gpt-4.1"));
assert!(provider.supports_developer_role("openai/o3"));
assert!(!provider.supports_developer_role("openai/gpt-4o"));
assert!(!provider.supports_developer_role("anthropic/claude-sonnet-4"));
assert!(!provider.supports_developer_role("minimax/minimax-m2.5"));
}
#[test]
fn selected_provider_uses_openrouter_metadata() {
let response: NativeChatResponse = serde_json::from_value(serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "ok"
}
}],
"openrouter_metadata": {
"endpoints": {
"available": [
{
"model": "minimax/minimax-m2.5",
"provider": "Clarifai",
"selected": false
},
{
"model": "minimax/minimax-m2.5",
"provider": "Minimax",
"selected": true
}
],
"total": 2
}
}
}))
.unwrap();
assert_eq!(
OpenRouterProvider::selected_provider_name(&response).as_deref(),
Some("Minimax")
);
}
#[test]
fn selected_provider_preserves_legacy_top_level_provider() {
let response: NativeChatResponse = serde_json::from_value(serde_json::json!({
"provider": "SambaNova",
"choices": [{
"message": {
"role": "assistant",
"content": "ok"
}
}],
"openrouter_metadata": {
"endpoints": {
"available": [{
"model": "meta-llama/llama-3",
"provider": "Together",
"selected": true
}],
"total": 1
}
}
}))
.unwrap();
assert_eq!(
OpenRouterProvider::selected_provider_name(&response).as_deref(),
Some("SambaNova")
);
}
#[tokio::test]
async fn chat_fails_without_key() {
let provider = OpenRouterProvider::new(None);
let messages = vec![ChatMessage::system("system"), ChatMessage::user("hello")];
let request = ChatRequest {
messages: &messages,
tools: None,
};
let result = provider.chat(request, "openai/gpt-4o", 0.2).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("API key not set"));
}
#[tokio::test]
async fn chat_with_history_fails_without_key() {
let provider = OpenRouterProvider::new(None);
let messages = vec![
ChatMessage::system("be concise"),
ChatMessage::user("hello"),
];
let request = ChatRequest {
messages: &messages,
tools: None,
};
let result = provider
.chat(request, "anthropic/claude-sonnet-4", 0.7)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("API key not set"));
}
}