converge_provider/
openrouter.rs1use crate::common::{
9 ChatCompletionRequest, ChatCompletionResponse, HttpProviderConfig, OpenAiCompatibleProvider,
10 chat_response_to_llm_response,
11};
12use converge_traits::llm::{LlmError, LlmProvider, LlmRequest, LlmResponse};
13use serde::Deserialize;
14
15pub struct OpenRouterProvider {
36 config: HttpProviderConfig,
37}
38
39impl OpenRouterProvider {
40 #[must_use]
42 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
43 Self {
44 config: HttpProviderConfig::new(api_key, model, "https://openrouter.ai/api/v1"),
45 }
46 }
47
48 pub fn from_env(model: impl Into<String>) -> Result<Self, LlmError> {
54 let api_key = std::env::var("OPENROUTER_API_KEY")
55 .map_err(|_| LlmError::auth("OPENROUTER_API_KEY environment variable not set"))?;
56 Ok(Self::new(api_key, model))
57 }
58
59 #[must_use]
61 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
62 self.config.base_url = url.into();
63 self
64 }
65}
66
67impl OpenAiCompatibleProvider for OpenRouterProvider {
68 fn config(&self) -> &HttpProviderConfig {
69 &self.config
70 }
71
72 fn endpoint(&self) -> &'static str {
73 "/chat/completions"
74 }
75}
76
77impl LlmProvider for OpenRouterProvider {
78 fn name(&self) -> &'static str {
79 "openrouter"
80 }
81
82 fn model(&self) -> &str {
83 &self.config.model
84 }
85
86 fn complete(&self, request: &LlmRequest) -> Result<LlmResponse, LlmError> {
87 let chat_request =
89 ChatCompletionRequest::from_llm_request(self.config.model.clone(), request);
90 let url = format!("{}{}", self.config.base_url, self.endpoint());
91
92 let http_response = self
93 .config
94 .client
95 .post(&url)
96 .header("Authorization", format!("Bearer {}", self.config.api_key))
97 .header("Content-Type", "application/json")
98 .header("HTTP-Referer", "https://github.com/converge-hey-sh") .header("X-Title", "Converge") .json(&chat_request)
101 .send()
102 .map_err(|e| LlmError::network(format!("Request failed: {e}")))?;
103
104 let status = http_response.status();
105
106 if !status.is_success() {
107 #[derive(Deserialize)]
108 struct OpenRouterError {
109 error: OpenRouterErrorDetail,
110 }
111 #[derive(Deserialize)]
112 struct OpenRouterErrorDetail {
113 message: String,
114 #[serde(rename = "type")]
115 error_type: Option<String>,
116 }
117
118 let error_body: OpenRouterError = http_response
119 .json()
120 .map_err(|e| LlmError::parse(format!("Failed to parse error: {e}")))?;
121
122 let error_type = error_body.error.error_type.as_deref().unwrap_or("unknown");
123 return match error_type {
124 "invalid_request_error" | "authentication_error" => {
125 Err(LlmError::auth(error_body.error.message))
126 }
127 "rate_limit_error" => Err(LlmError::rate_limit(error_body.error.message)),
128 _ => Err(LlmError::provider(error_body.error.message)),
129 };
130 }
131
132 let api_response: ChatCompletionResponse = http_response
133 .json()
134 .map_err(|e| LlmError::parse(format!("Failed to parse response: {e}")))?;
135
136 chat_response_to_llm_response(api_response)
137 }
138
139 fn provenance(&self, request_id: &str) -> String {
140 format!("openrouter:{}:{}", self.config.model, request_id)
141 }
142}