use std::sync::Arc;
use std::time::Duration;
use terraphim_service::llm::{ChatOptions, LlmClient, SummarizeOptions};
pub struct OpenRouterClient {
client: reqwest::Client,
api_key: String,
model: String,
base_url: String,
}
impl OpenRouterClient {
pub fn new(api_key: &str, model: &str) -> anyhow::Result<Self> {
anyhow::ensure!(!api_key.is_empty(), "OpenRouter API key cannot be empty");
anyhow::ensure!(!model.is_empty(), "OpenRouter model cannot be empty");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.user_agent(concat!(
"terraphim-grep/",
env!("CARGO_PKG_VERSION"),
" (https://terraphim.ai)"
))
.build()?;
Ok(Self {
client,
api_key: api_key.to_string(),
model: model.to_string(),
base_url: std::env::var("OPENROUTER_BASE_URL")
.unwrap_or_else(|_| "https://openrouter.ai/api/v1".to_string()),
})
}
}
#[async_trait::async_trait]
impl LlmClient for OpenRouterClient {
fn name(&self) -> &'static str {
"openrouter"
}
async fn summarize(
&self,
_content: &str,
_opts: SummarizeOptions,
) -> terraphim_service::Result<String> {
Err(terraphim_service::ServiceError::Config(
"summarize not supported by terraphim-grep openrouter client".to_string(),
))
}
async fn chat_completion(
&self,
messages: Vec<serde_json::Value>,
opts: ChatOptions,
) -> terraphim_service::Result<String> {
let request_body = serde_json::json!({
"model": self.model,
"messages": messages,
"max_tokens": opts.max_tokens.unwrap_or(512),
"temperature": opts.temperature.unwrap_or(0.2),
"stream": false
});
let response = self
.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://terraphim.ai")
.header("X-Title", "Terraphim AI")
.json(&request_body)
.send()
.await
.map_err(|e| {
terraphim_service::ServiceError::Common(
terraphim_service::error::CommonError::Network {
message: format!("OpenRouter request failed: {e}"),
source: Some(Box::new(e)),
},
)
})?;
if response.status() == 429 {
return Err(terraphim_service::ServiceError::Common(
terraphim_service::error::CommonError::Network {
message: "OpenRouter rate limit exceeded".to_string(),
source: None,
},
));
}
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(terraphim_service::ServiceError::Common(
terraphim_service::error::CommonError::Network {
message: format!("OpenRouter API error: {error_text}"),
source: None,
},
));
}
let response_json: serde_json::Value = response.json().await.map_err(|e| {
terraphim_service::ServiceError::Common(
terraphim_service::error::CommonError::Network {
message: format!("OpenRouter response JSON decode failed: {e}"),
source: Some(Box::new(e)),
},
)
})?;
let content = response_json
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
Ok(content)
}
}
pub fn into_llm_client(client: OpenRouterClient) -> Arc<dyn LlmClient> {
Arc::new(client) as Arc<dyn LlmClient>
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_openrouter_client_new_validates_inputs() {
assert!(OpenRouterClient::new("", "model").is_err());
assert!(OpenRouterClient::new("key", "").is_err());
assert!(OpenRouterClient::new("key", "model").is_ok());
}
#[test]
fn test_openrouter_client_base_url_resolution() {
let saved = std::env::var("OPENROUTER_BASE_URL").ok();
unsafe {
std::env::remove_var("OPENROUTER_BASE_URL");
}
let client = OpenRouterClient::new("key", "model").expect("build client");
assert_eq!(client.base_url, "https://openrouter.ai/api/v1");
unsafe {
std::env::set_var("OPENROUTER_BASE_URL", "https://example.com/v1");
}
let client = OpenRouterClient::new("key", "model").expect("build client");
assert_eq!(client.base_url, "https://example.com/v1");
if let Some(v) = saved {
unsafe {
std::env::set_var("OPENROUTER_BASE_URL", v);
}
} else {
unsafe {
std::env::remove_var("OPENROUTER_BASE_URL");
}
}
}
#[test]
fn test_openrouter_client_timeout_is_120_seconds() {
let client = OpenRouterClient::new("key", "model").expect("build client");
assert_eq!(client.model, "model");
assert_eq!(client.api_key, "key");
}
#[test]
fn test_into_llm_client_returns_trait_object() {
let client = OpenRouterClient::new("key", "model").expect("build client");
let llm: Arc<dyn LlmClient> = into_llm_client(client);
assert_eq!(llm.name(), "openrouter");
}
}