use std::env;
use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::{RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{debug, info, instrument, warn};
use super::client::{self, with_retries, AttemptError, HttpRetryConfig, DEFAULT_TIMEOUT_SECS};
use super::sse::create_sse_stream;
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError,
StreamChunk, TokenUsage, ToolCallRequest, ToolDefinition,
};
const PROVIDER_NAME: &str = "openrouter";
const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
const OPENROUTER_DEFAULT_MODEL_ENV: &str = "OPENROUTER_DEFAULT_MODEL";
const OPENROUTER_SITE_URL_ENV: &str = "OPENROUTER_SITE_URL";
const OPENROUTER_APP_TITLE_ENV: &str = "OPENROUTER_APP_TITLE";
const OPENROUTER_ENV_PREFIX: &str = "OPENROUTER";
const DEFAULT_MODEL: &str = "meta-llama/llama-3.3-70b-instruct";
const AVAILABLE_MODELS: &[&str] = &[
"meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-8b-instruct",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-haiku",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.0-flash-001",
"google/gemini-pro-1.5",
"mistralai/mistral-large",
"mistralai/mistral-nemo",
"qwen/qwen-2.5-72b-instruct",
];
const API_BASE_URL: &str = "https://openrouter.ai/api/v1";
#[derive(Debug, Serialize)]
struct OpenRouterRequest {
model: String,
messages: Vec<OpenRouterMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<OpenRouterTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct OpenRouterTool {
#[serde(rename = "type")]
tool_type: String,
function: OpenRouterFunction,
}
#[derive(Debug, Clone, Serialize)]
struct OpenRouterFunction {
name: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenRouterMessage {
role: String,
content: String,
}
impl From<&ChatMessage> for OpenRouterMessage {
fn from(msg: &ChatMessage) -> Self {
Self {
role: msg.role.as_str().to_owned(),
content: msg.content.clone(),
}
}
}
#[derive(Debug, Deserialize)]
struct OpenRouterResponse {
choices: Vec<OpenRouterChoice>,
#[serde(default)]
usage: Option<OpenRouterUsage>,
model: String,
}
#[derive(Debug, Deserialize)]
struct OpenRouterChoice {
message: OpenRouterResponseMessage,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterResponseMessage {
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<OpenRouterToolCall>>,
}
#[derive(Debug, Clone, Deserialize)]
struct OpenRouterToolCall {
id: String,
#[serde(rename = "type")]
call_type: String,
function: OpenRouterFunctionCall,
}
#[derive(Debug, Clone, Deserialize)]
struct OpenRouterFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct OpenRouterUsage {
#[serde(rename = "prompt_tokens")]
prompt: u32,
#[serde(rename = "completion_tokens")]
completion: u32,
#[serde(rename = "total_tokens")]
total: u32,
#[serde(default)]
prompt_tokens_details: Option<OpenRouterUsageDetails>,
#[serde(default)]
completion_tokens_details: Option<OpenRouterCompletionDetails>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterCompletionDetails {
#[serde(default)]
reasoning_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterUsageDetails {
#[serde(default)]
cached_tokens: Option<u32>,
}
impl OpenRouterUsage {
fn into_token_usage(self) -> TokenUsage {
TokenUsage::new(self.prompt, self.completion, self.total)
.with_cache(
self.prompt_tokens_details.and_then(|d| d.cached_tokens),
None,
)
.with_reasoning(
self.completion_tokens_details
.and_then(|d| d.reasoning_tokens),
)
}
}
#[derive(Debug, Deserialize)]
struct OpenRouterStreamChunk {
choices: Vec<OpenRouterStreamChoice>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterStreamChoice {
delta: OpenRouterDelta,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterDelta {
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenRouterErrorResponse {
error: OpenRouterErrorDetail,
}
#[derive(Debug, Deserialize)]
struct OpenRouterErrorDetail {
message: String,
#[serde(rename = "type")]
error_type: Option<String>,
}
#[derive(Clone)]
pub struct OpenRouterConfig {
pub api_key: String,
pub model: String,
pub site_url: Option<String>,
pub app_title: Option<String>,
pub timeout: Duration,
pub retry: HttpRetryConfig,
}
impl OpenRouterConfig {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: DEFAULT_MODEL.to_owned(),
site_url: None,
app_title: None,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
retry: HttpRetryConfig::default(),
}
}
pub fn from_env() -> Result<Self, RunnerError> {
let api_key = env::var(OPENROUTER_API_KEY_ENV).map_err(|_| {
RunnerError::config(format!(
"Missing {OPENROUTER_API_KEY_ENV} environment variable. Get your API key from https://openrouter.ai/keys"
))
})?;
let model =
env::var(OPENROUTER_DEFAULT_MODEL_ENV).unwrap_or_else(|_| DEFAULT_MODEL.to_owned());
let non_empty = |name: &str| env::var(name).ok().filter(|s| !s.is_empty());
Ok(Self {
model,
site_url: non_empty(OPENROUTER_SITE_URL_ENV),
app_title: non_empty(OPENROUTER_APP_TITLE_ENV),
retry: HttpRetryConfig::from_env(OPENROUTER_ENV_PREFIX),
..Self::new(api_key)
})
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_retry(mut self, retry: HttpRetryConfig) -> Self {
self.retry = retry;
self
}
}
impl Debug for OpenRouterConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OpenRouterConfig")
.field("api_key", &"[REDACTED]")
.field("model", &self.model)
.field("site_url", &self.site_url)
.field("app_title", &self.app_title)
.field("timeout", &self.timeout)
.field("retry", &self.retry)
.finish()
}
}
pub struct OpenRouterProvider {
config: OpenRouterConfig,
client: reqwest::Client,
available_models: Vec<String>,
}
impl OpenRouterProvider {
pub fn new(config: OpenRouterConfig) -> Result<Self, RunnerError> {
let client = client::build_client(config.timeout)?;
Ok(Self::with_client(config, client))
}
#[must_use]
pub fn with_client(config: OpenRouterConfig, client: reqwest::Client) -> Self {
info!(
default_model = %config.model,
site_url_set = config.site_url.is_some(),
app_title_set = config.app_title.is_some(),
max_retries = config.retry.max_retries,
initial_delay_ms = config.retry.initial_delay_ms,
"OpenRouter provider initialized"
);
Self {
config,
client,
available_models: AVAILABLE_MODELS.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[must_use]
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
fn api_url(endpoint: &str) -> String {
format!("{API_BASE_URL}/{endpoint}")
}
fn convert_messages(messages: &[ChatMessage]) -> Vec<OpenRouterMessage> {
messages.iter().map(OpenRouterMessage::from).collect()
}
fn parse_error_response(status: StatusCode, body: &str) -> RunnerError {
let message = serde_json::from_str::<OpenRouterErrorResponse>(body).map_or_else(
|_| {
debug!(
status = status.as_u16(),
body_preview = %body.chars().take(200).collect::<String>(),
"OpenRouter API returned non-JSON error response"
);
format!("HTTP {status}")
},
|e| {
let error_type = e.error.error_type.unwrap_or_else(|| "unknown".to_owned());
if status.as_u16() == 402 {
format!("credit balance exhausted: {}", e.error.message)
} else {
format!("{error_type} - {}", e.error.message)
}
},
);
client::map_http_error(PROVIDER_NAME, status, &message)
}
fn with_ranking_headers(&self, mut builder: RequestBuilder) -> RequestBuilder {
if let Some(ref site) = self.config.site_url {
builder = builder.header("HTTP-Referer", site);
}
if let Some(ref title) = self.config.app_title {
builder = builder.header("X-Title", title);
}
builder
}
fn build_request(&self, body: &OpenRouterRequest) -> RequestBuilder {
let builder = self
.client
.post(Self::api_url("chat/completions"))
.bearer_auth(&self.config.api_key)
.header("Content-Type", "application/json");
self.with_ranking_headers(builder).json(body)
}
fn build_body(&self, request: &ChatRequest, stream: bool) -> OpenRouterRequest {
let model = request.model.as_deref().unwrap_or(&self.config.model);
let tools = request
.tools
.as_deref()
.filter(|t| !t.is_empty())
.map(Self::convert_tools);
OpenRouterRequest {
model: model.to_owned(),
messages: Self::convert_messages(&request.messages),
temperature: request.temperature,
max_tokens: request.max_tokens,
stream: Some(stream),
tool_choice: tools.as_ref().map(|_| "auto".to_owned()),
tools,
}
}
fn parse_stream_data(json_str: &str) -> Option<Result<StreamChunk, RunnerError>> {
match serde_json::from_str::<OpenRouterStreamChunk>(json_str) {
Ok(chunk) => {
let choice = chunk.choices.into_iter().next()?;
let delta = choice.delta.content.unwrap_or_default();
let is_final = choice.finish_reason.is_some();
Some(Ok(StreamChunk {
delta,
is_final,
finish_reason: choice.finish_reason,
}))
}
Err(e) => {
warn!("Failed to parse OpenRouter stream chunk: {e}");
None
}
}
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<OpenRouterTool> {
tools
.iter()
.map(|func| OpenRouterTool {
tool_type: "function".to_owned(),
function: OpenRouterFunction {
name: func.name.clone(),
description: func.description.clone(),
parameters: func.parameters.clone(),
},
})
.collect()
}
fn convert_tool_calls(tool_calls: &[OpenRouterToolCall]) -> Vec<ToolCallRequest> {
tool_calls
.iter()
.map(|call| {
debug!(
tool_call_id = %call.id,
tool_call_type = %call.call_type,
function_name = %call.function.name,
"Converting OpenRouter tool call"
);
ToolCallRequest {
id: call.id.clone(),
function_name: call.function.name.clone(),
arguments: serde_json::from_str(&call.function.arguments).unwrap_or_default(),
}
})
.collect()
}
async fn attempt_complete(
&self,
body: &OpenRouterRequest,
) -> Result<ChatResponse, AttemptError> {
let response = self
.build_request(body)
.send()
.await
.map_err(|e| AttemptError::from_send(PROVIDER_NAME, e))?;
let status = response.status();
let text = response.text().await.map_err(|e| {
AttemptError::transient(RunnerError::external_service(
PROVIDER_NAME,
format!("Failed to read response: {e}"),
))
})?;
if !status.is_success() {
return Err(AttemptError::from_status(
status,
Self::parse_error_response(status, &text),
));
}
let or_response: OpenRouterResponse = serde_json::from_str(&text).map_err(|e| {
AttemptError::permanent(RunnerError::external_service(
PROVIDER_NAME,
format!("Failed to parse response: {e}"),
))
})?;
let choice = or_response.choices.into_iter().next().ok_or_else(|| {
AttemptError::permanent(RunnerError::external_service(
PROVIDER_NAME,
"API returned no choices",
))
})?;
let content = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(|calls| {
info!("OpenRouter returned {} tool calls", calls.len());
Self::convert_tool_calls(&calls)
});
debug!(
content_len = content.len(),
tool_calls = tool_calls.as_ref().map(Vec::len),
finish_reason = ?choice.finish_reason,
"Received response from OpenRouter"
);
Ok(ChatResponse {
content,
model: or_response.model,
usage: or_response.usage.map(OpenRouterUsage::into_token_usage),
finish_reason: choice.finish_reason,
warnings: None,
tool_calls,
})
}
async fn attempt_stream(&self, body: &OpenRouterRequest) -> Result<ChatStream, AttemptError> {
let response = self
.build_request(body)
.send()
.await
.map_err(|e| AttemptError::from_send(PROVIDER_NAME, e))?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(AttemptError::from_status(
status,
Self::parse_error_response(status, &text),
));
}
Ok(create_sse_stream(
response.bytes_stream(),
Self::parse_stream_data,
PROVIDER_NAME,
))
}
}
#[async_trait]
impl LlmProvider for OpenRouterProvider {
fn name(&self) -> &'static str {
PROVIDER_NAME
}
fn display_name(&self) -> &str {
"OpenRouter"
}
fn capabilities(&self) -> LlmCapabilities {
LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES
| LlmCapabilities::JSON_MODE
}
fn default_model(&self) -> &str {
&self.config.model
}
fn available_models(&self) -> &[String] {
&self.available_models
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let body = self.build_body(request, false);
let body = &body;
with_retries(
&self.config.retry,
PROVIDER_NAME,
"OpenRouter request",
|attempt| async move {
debug!(attempt, "Sending chat completion request to OpenRouter");
self.attempt_complete(body).await
},
)
.await
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let body = self.build_body(request, true);
let body = &body;
with_retries(
&self.config.retry,
PROVIDER_NAME,
"OpenRouter streaming request",
|attempt| async move {
debug!(
attempt,
"Sending streaming chat completion request to OpenRouter"
);
self.attempt_stream(body).await
},
)
.await
}
#[instrument(skip(self))]
async fn health_check(&self) -> Result<bool, RunnerError> {
debug!("Performing OpenRouter API health check");
let builder = self
.client
.get(Self::api_url("models"))
.bearer_auth(&self.config.api_key);
let response = self
.with_ranking_headers(builder)
.send()
.await
.map_err(|e| client::map_send_error(PROVIDER_NAME, e))?;
let healthy = response.status().is_success();
if healthy {
debug!("OpenRouter API health check passed");
} else {
warn!(
status = response.status().as_u16(),
"OpenRouter API health check failed"
);
}
Ok(healthy)
}
}
impl Debug for OpenRouterProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OpenRouterProvider")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ErrorKind;
#[test]
fn usage_reads_cached_and_reasoning_details() {
let body = r#"{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,
"prompt_tokens_details":{"cached_tokens":60},
"completion_tokens_details":{"reasoning_tokens":15}}"#;
let usage: OpenRouterUsage = serde_json::from_str(body).expect("parses"); let usage = usage.into_token_usage();
assert_eq!(usage.cached_read_tokens, Some(60));
assert_eq!(usage.cached_write_tokens, None);
assert_eq!(usage.reasoning_tokens, Some(15));
}
#[test]
fn a_402_is_an_external_service_error_a_429_a_rate_limit() {
let body = r#"{"error":{"message":"Insufficient credits","type":"payment"}}"#;
let err = OpenRouterProvider::parse_error_response(StatusCode::PAYMENT_REQUIRED, body);
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(err.message.contains("credit balance exhausted"));
let body = r#"{"error":{"message":"Rate limit exceeded: free-models-per-minute"}}"#;
let err = OpenRouterProvider::parse_error_response(StatusCode::TOO_MANY_REQUESTS, body);
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(err.message.contains("free-models-per-minute"));
}
#[test]
fn ranking_headers_are_sent_when_configured() {
let config = OpenRouterConfig {
site_url: Some("https://example.test".to_owned()),
app_title: Some("Example".to_owned()),
..OpenRouterConfig::new("k")
};
let provider = OpenRouterProvider::with_client(config, reqwest::Client::new());
let request = provider
.build_request(&provider.build_body(&ChatRequest::new(vec![]), false))
.build()
.expect("builds"); assert_eq!(
request
.headers()
.get("HTTP-Referer")
.map(|v| v.to_str().ok()),
Some(Some("https://example.test"))
);
assert_eq!(
request.headers().get("X-Title").map(|v| v.to_str().ok()),
Some(Some("Example"))
);
}
#[test]
fn debug_redacts_the_api_key() {
let output = format!("{:?}", OpenRouterConfig::new("super-secret"));
assert!(!output.contains("super-secret"));
assert!(output.contains("[REDACTED]"));
}
}