1use crate::{
13 agent::AgentBuilder,
14 completion::{self, CompletionError, CompletionRequest},
15 extractor::ExtractorBuilder,
16 json_utils,
17 providers::openai::Message,
18 OneOrMany,
19};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23
24use super::openai::AssistantContent;
25
26const OPENROUTER_API_BASE_URL: &str = "https://openrouter.ai/api/v1";
30
31#[derive(Clone)]
32pub struct Client {
33 base_url: String,
34 http_client: reqwest::Client,
35}
36
37impl Client {
38 pub fn new(api_key: &str) -> Self {
40 Self::from_url(api_key, OPENROUTER_API_BASE_URL)
41 }
42
43 pub fn from_url(api_key: &str, base_url: &str) -> Self {
45 Self {
46 base_url: base_url.to_string(),
47 http_client: reqwest::Client::builder()
48 .default_headers({
49 let mut headers = reqwest::header::HeaderMap::new();
50 headers.insert(
51 "Authorization",
52 format!("Bearer {}", api_key)
53 .parse()
54 .expect("Bearer token should parse"),
55 );
56 headers
57 })
58 .build()
59 .expect("OpenRouter reqwest client should build"),
60 }
61 }
62
63 pub fn from_env() -> Self {
66 let api_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY not set");
67 Self::new(&api_key)
68 }
69
70 fn post(&self, path: &str) -> reqwest::RequestBuilder {
71 let url = format!("{}/{}", self.base_url, path).replace("//", "/");
72 self.http_client.post(url)
73 }
74
75 pub fn completion_model(&self, model: &str) -> CompletionModel {
87 CompletionModel::new(self.clone(), model)
88 }
89
90 pub fn agent(&self, model: &str) -> AgentBuilder<CompletionModel> {
105 AgentBuilder::new(self.completion_model(model))
106 }
107
108 pub fn extractor<T: JsonSchema + for<'a> Deserialize<'a> + Serialize + Send + Sync>(
110 &self,
111 model: &str,
112 ) -> ExtractorBuilder<T, CompletionModel> {
113 ExtractorBuilder::new(self.completion_model(model))
114 }
115}
116
117#[derive(Debug, Deserialize)]
118struct ApiErrorResponse {
119 message: String,
120}
121
122#[derive(Debug, Deserialize)]
123#[serde(untagged)]
124enum ApiResponse<T> {
125 Ok(T),
126 Err(ApiErrorResponse),
127}
128
129#[derive(Clone, Debug, Deserialize)]
130pub struct Usage {
131 pub prompt_tokens: usize,
132 pub completion_tokens: usize,
133 pub total_tokens: usize,
134}
135
136impl std::fmt::Display for Usage {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 write!(
139 f,
140 "Prompt tokens: {} Total tokens: {}",
141 self.prompt_tokens, self.total_tokens
142 )
143 }
144}
145
146pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
151pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
153pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
155pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
157
158#[derive(Debug, Deserialize)]
162pub struct CompletionResponse {
163 pub id: String,
164 pub object: String,
165 pub created: u64,
166 pub model: String,
167 pub choices: Vec<Choice>,
168 pub system_fingerprint: Option<String>,
169 pub usage: Option<Usage>,
170}
171
172impl From<ApiErrorResponse> for CompletionError {
173 fn from(err: ApiErrorResponse) -> Self {
174 CompletionError::ProviderError(err.message)
175 }
176}
177
178impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionResponse> {
179 type Error = CompletionError;
180
181 fn try_from(response: CompletionResponse) -> Result<Self, Self::Error> {
182 let choice = response.choices.first().ok_or_else(|| {
183 CompletionError::ResponseError("Response contained no choices".to_owned())
184 })?;
185
186 let content = match &choice.message {
187 Message::Assistant {
188 content,
189 tool_calls,
190 ..
191 } => {
192 let mut content = content
193 .iter()
194 .map(|c| match c {
195 AssistantContent::Text { text } => completion::AssistantContent::text(text),
196 AssistantContent::Refusal { refusal } => {
197 completion::AssistantContent::text(refusal)
198 }
199 })
200 .collect::<Vec<_>>();
201
202 content.extend(
203 tool_calls
204 .iter()
205 .map(|call| {
206 completion::AssistantContent::tool_call(
207 &call.function.name,
208 &call.function.name,
209 call.function.arguments.clone(),
210 )
211 })
212 .collect::<Vec<_>>(),
213 );
214 Ok(content)
215 }
216 _ => Err(CompletionError::ResponseError(
217 "Response did not contain a valid message or tool call".into(),
218 )),
219 }?;
220
221 let choice = OneOrMany::many(content).map_err(|_| {
222 CompletionError::ResponseError(
223 "Response contained no message or tool call (empty)".to_owned(),
224 )
225 })?;
226
227 Ok(completion::CompletionResponse {
228 choice,
229 raw_response: response,
230 })
231 }
232}
233
234#[derive(Debug, Deserialize)]
235pub struct Choice {
236 pub index: usize,
237 pub native_finish_reason: Option<String>,
238 pub message: Message,
239 pub finish_reason: Option<String>,
240}
241
242#[derive(Clone)]
243pub struct CompletionModel {
244 client: Client,
245 pub model: String,
247}
248
249impl CompletionModel {
250 pub fn new(client: Client, model: &str) -> Self {
251 Self {
252 client,
253 model: model.to_string(),
254 }
255 }
256}
257
258impl completion::CompletionModel for CompletionModel {
259 type Response = CompletionResponse;
260
261 #[cfg_attr(feature = "worker", worker::send)]
262 async fn completion(
263 &self,
264 completion_request: CompletionRequest,
265 ) -> Result<completion::CompletionResponse<CompletionResponse>, CompletionError> {
266 let mut full_history: Vec<Message> = match &completion_request.preamble {
268 Some(preamble) => vec![Message::system(preamble)],
269 None => vec![],
270 };
271
272 let prompt: Vec<Message> = completion_request.prompt_with_context().try_into()?;
274
275 let chat_history: Vec<Message> = completion_request
277 .chat_history
278 .into_iter()
279 .map(|message| message.try_into())
280 .collect::<Result<Vec<Vec<Message>>, _>>()?
281 .into_iter()
282 .flatten()
283 .collect();
284
285 full_history.extend(chat_history);
287 full_history.extend(prompt);
288
289 let request = json!({
290 "model": self.model,
291 "messages": full_history,
292 "temperature": completion_request.temperature,
293 });
294
295 let response = self
296 .client
297 .post("/chat/completions")
298 .json(
299 &if let Some(params) = completion_request.additional_params {
300 json_utils::merge(request, params)
301 } else {
302 request
303 },
304 )
305 .send()
306 .await?;
307
308 if response.status().is_success() {
309 match response.json::<ApiResponse<CompletionResponse>>().await? {
310 ApiResponse::Ok(response) => {
311 tracing::info!(target: "rig",
312 "OpenRouter completion token usage: {:?}",
313 response.usage.clone().map(|usage| format!("{usage}")).unwrap_or("N/A".to_string())
314 );
315
316 response.try_into()
317 }
318 ApiResponse::Err(err) => Err(CompletionError::ProviderError(err.message)),
319 }
320 } else {
321 Err(CompletionError::ProviderError(response.text().await?))
322 }
323 }
324}