1use super::util;
7use super::{
8 CompletionRequest, CompletionResponse, ContentPart, FinishReason, Message, ModelInfo, Provider,
9 Role, StreamChunk, ToolDefinition, Usage,
10};
11use anyhow::{Context, Result};
12use async_trait::async_trait;
13use reqwest::Client;
14use serde::Deserialize;
15use serde_json::{Value, json};
16
17pub struct OpenRouterProvider {
18 client: Client,
19 api_key: String,
20 base_url: String,
21}
22
23impl OpenRouterProvider {
24 pub fn new(api_key: String) -> Result<Self> {
25 let client = Client::builder()
26 .connect_timeout(std::time::Duration::from_secs(15))
27 .timeout(std::time::Duration::from_secs(300))
28 .build()
29 .context("Failed to build reqwest client")?;
30 Ok(Self {
31 client,
32 api_key,
33 base_url: "https://openrouter.ai/api/v1".to_string(),
34 })
35 }
36
37 fn convert_messages(messages: &[Message]) -> Vec<Value> {
38 messages
39 .iter()
40 .map(|msg| {
41 let role = match msg.role {
42 Role::System => "system",
43 Role::User => "user",
44 Role::Assistant => "assistant",
45 Role::Tool => "tool",
46 };
47
48 match msg.role {
49 Role::Tool => {
50 if let Some(ContentPart::ToolResult {
52 tool_call_id,
53 content,
54 }) = msg.content.first()
55 {
56 json!({
57 "role": "tool",
58 "tool_call_id": tool_call_id,
59 "content": content
60 })
61 } else {
62 json!({"role": role, "content": ""})
63 }
64 }
65 Role::Assistant => {
66 let text: String = msg
68 .content
69 .iter()
70 .filter_map(|p| match p {
71 ContentPart::Text { text } => Some(text.clone()),
72 _ => None,
73 })
74 .collect::<Vec<_>>()
75 .join("");
76
77 let tool_calls: Vec<Value> = msg
78 .content
79 .iter()
80 .filter_map(|p| match p {
81 ContentPart::ToolCall {
82 id,
83 name,
84 arguments,
85 ..
86 } => Some(json!({
87 "id": id,
88 "type": "function",
89 "function": {
90 "name": name,
91 "arguments": arguments
92 }
93 })),
94 _ => None,
95 })
96 .collect();
97
98 if tool_calls.is_empty() {
99 json!({"role": "assistant", "content": text})
100 } else {
101 json!({
103 "role": "assistant",
104 "content": if text.is_empty() { "".to_string() } else { text },
105 "tool_calls": tool_calls
106 })
107 }
108 }
109 _ => {
110 let text: String = msg
112 .content
113 .iter()
114 .filter_map(|p| match p {
115 ContentPart::Text { text } => Some(text.clone()),
116 _ => None,
117 })
118 .collect::<Vec<_>>()
119 .join("\n");
120
121 json!({"role": role, "content": text})
122 }
123 }
124 })
125 .collect()
126 }
127
128 fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
129 tools
130 .iter()
131 .map(|t| {
132 json!({
133 "type": "function",
134 "function": {
135 "name": t.name,
136 "description": t.description,
137 "parameters": t.parameters
138 }
139 })
140 })
141 .collect()
142 }
143
144 fn parse_error_body(text: &str) -> Option<String> {
145 let err = serde_json::from_str::<OpenRouterError>(text).ok()?;
146 let mut message = format!("OpenRouter API error: {}", err.error.message);
147 if let Some(code) = err.error.code {
148 message.push_str(&format!(" (code: {code})"));
149 }
150 Some(message)
151 }
152}
153
154#[derive(Debug, Deserialize)]
155struct OpenRouterResponse {
156 #[serde(default)]
157 id: String,
158 #[serde(default)]
160 provider: Option<String>,
161 #[serde(default)]
162 model: Option<String>,
163 choices: Vec<OpenRouterChoice>,
164 #[serde(default)]
165 usage: Option<OpenRouterUsage>,
166}
167
168#[derive(Debug, Deserialize)]
169struct OpenRouterChoice {
170 message: OpenRouterMessage,
171 #[serde(default)]
172 finish_reason: Option<String>,
173 #[serde(default)]
175 native_finish_reason: Option<String>,
176}
177
178#[derive(Debug, Deserialize)]
179struct OpenRouterMessage {
180 role: String,
181 #[serde(default)]
182 content: Option<String>,
183 #[serde(default)]
184 tool_calls: Option<Vec<OpenRouterToolCall>>,
185 #[serde(default)]
187 reasoning: Option<String>,
188 #[serde(default)]
189 reasoning_details: Option<Vec<Value>>,
190 #[serde(default)]
191 refusal: Option<String>,
192}
193
194#[derive(Debug, Deserialize)]
195struct OpenRouterToolCall {
196 id: String,
197 #[serde(rename = "type")]
198 #[allow(dead_code)]
199 call_type: String,
200 function: OpenRouterFunction,
201 #[serde(default)]
202 #[allow(dead_code)]
203 index: Option<usize>,
204}
205
206#[derive(Debug, Deserialize)]
207struct OpenRouterFunction {
208 name: String,
209 arguments: String,
210}
211
212#[derive(Debug, Deserialize)]
213struct OpenRouterUsage {
214 #[serde(default)]
215 prompt_tokens: usize,
216 #[serde(default)]
217 completion_tokens: usize,
218 #[serde(default)]
219 total_tokens: usize,
220}
221
222#[derive(Debug, Deserialize)]
223struct OpenRouterError {
224 error: OpenRouterErrorDetail,
225}
226
227#[derive(Debug, Deserialize)]
228struct OpenRouterErrorDetail {
229 message: String,
230 #[serde(default)]
231 code: Option<Value>,
232}
233
234#[async_trait]
235impl Provider for OpenRouterProvider {
236 fn name(&self) -> &str {
237 "openrouter"
238 }
239
240 async fn list_models(&self) -> Result<Vec<ModelInfo>> {
241 let response = self
243 .client
244 .get(format!("{}/models", self.base_url))
245 .header("Authorization", format!("Bearer {}", self.api_key))
246 .send()
247 .await
248 .context("Failed to fetch models")?;
249
250 if !response.status().is_success() {
251 return Ok(vec![]); }
253
254 #[derive(Deserialize)]
255 struct ModelsResponse {
256 data: Vec<ModelData>,
257 }
258
259 #[derive(Deserialize)]
260 struct ModelData {
261 id: String,
262 #[serde(default)]
263 name: Option<String>,
264 #[serde(default)]
265 context_length: Option<usize>,
266 }
267
268 let models: ModelsResponse = match crate::provider::body_cap::json_capped(
273 response,
274 crate::provider::body_cap::PROVIDER_METADATA_BODY_CAP,
275 )
276 .await
277 {
278 Ok(v) => v,
279 Err(err) => {
280 tracing::warn!(
281 error = %err,
282 "openrouter /models response exceeded body cap or failed to decode; returning empty list",
283 );
284 return Ok(vec![]);
285 }
286 };
287
288 Ok(models
289 .data
290 .into_iter()
291 .map(|m| ModelInfo {
292 id: m.id.clone(),
293 name: m.name.unwrap_or_else(|| m.id.clone()),
294 provider: "openrouter".to_string(),
295 context_window: m.context_length.unwrap_or(128_000),
296 max_output_tokens: Some(16_384),
297 supports_vision: false,
298 supports_tools: true,
299 supports_streaming: true,
300 input_cost_per_million: None,
301 output_cost_per_million: None,
302 })
303 .collect())
304 }
305
306 async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
307 let messages = Self::convert_messages(&request.messages);
308 let tools = Self::convert_tools(&request.tools);
309
310 let mut body = json!({
312 "model": request.model,
313 "messages": messages,
314 });
315
316 if !tools.is_empty() {
317 body["tools"] = json!(tools);
318 }
319 if let Some(temp) = request.temperature {
320 body["temperature"] = json!(temp);
321 }
322 if let Some(max) = request.max_tokens {
323 body["max_tokens"] = json!(max);
324 }
325
326 tracing::debug!(
327 "OpenRouter request: {}",
328 serde_json::to_string_pretty(&body).unwrap_or_default()
329 );
330
331 let response = self
332 .client
333 .post(format!("{}/chat/completions", self.base_url))
334 .header("Authorization", format!("Bearer {}", self.api_key))
335 .header("Content-Type", "application/json")
336 .header("HTTP-Referer", "https://codetether.run")
337 .header("X-Title", "CodeTether Agent")
338 .json(&body)
339 .send()
340 .await
341 .context("Failed to send request")?;
342
343 let status = response.status();
344 let text = response.text().await.context("Failed to read response")?;
345
346 if let Some(error_message) = Self::parse_error_body(&text) {
347 anyhow::bail!(error_message);
348 }
349
350 if !status.is_success() {
351 anyhow::bail!("OpenRouter API error: {} {}", status, text);
352 }
353
354 tracing::debug!(
355 "OpenRouter response: {}",
356 util::truncate_bytes_safe(&text, 500)
357 );
358
359 let response: OpenRouterResponse = serde_json::from_str(&text).context(format!(
360 "Failed to parse response: {}",
361 util::truncate_bytes_safe(&text, 200)
362 ))?;
363
364 tracing::debug!(
366 response_id = %response.id,
367 provider = ?response.provider,
368 model = ?response.model,
369 "Received OpenRouter response"
370 );
371
372 let choice = response
373 .choices
374 .first()
375 .ok_or_else(|| anyhow::anyhow!("No choices"))?;
376
377 if let Some(ref native_reason) = choice.native_finish_reason {
379 tracing::debug!(native_finish_reason = %native_reason, "OpenRouter native finish reason");
380 }
381
382 if let Some(ref reasoning) = choice.message.reasoning
384 && !reasoning.is_empty()
385 {
386 tracing::info!(
387 reasoning_len = reasoning.len(),
388 "Model reasoning content received"
389 );
390 tracing::debug!(
391 reasoning = %reasoning,
392 "Full model reasoning"
393 );
394 }
395 if let Some(ref details) = choice.message.reasoning_details
396 && !details.is_empty()
397 {
398 tracing::debug!(
399 reasoning_details = ?details,
400 "Model reasoning details"
401 );
402 }
403
404 let mut content = Vec::new();
405 let mut has_tool_calls = false;
406
407 if let Some(text) = &choice.message.content
409 && !text.is_empty()
410 {
411 content.push(ContentPart::Text { text: text.clone() });
412 }
413
414 tracing::debug!(message_role = %choice.message.role, "OpenRouter message role");
416
417 if let Some(ref refusal) = choice.message.refusal {
419 tracing::warn!(refusal = %refusal, "Model refused to respond");
420 }
421
422 if let Some(tool_calls) = &choice.message.tool_calls {
424 has_tool_calls = !tool_calls.is_empty();
425 for tc in tool_calls {
426 tracing::debug!(
428 tool_call_id = %tc.id,
429 call_type = %tc.call_type,
430 index = ?tc.index,
431 function_name = %tc.function.name,
432 "Processing OpenRouter tool call"
433 );
434 content.push(ContentPart::ToolCall {
435 id: tc.id.clone(),
436 name: tc.function.name.clone(),
437 arguments: tc.function.arguments.clone(),
438 thought_signature: None,
439 });
440 }
441 }
442
443 let finish_reason = if has_tool_calls {
445 FinishReason::ToolCalls
446 } else {
447 match choice.finish_reason.as_deref() {
448 Some("stop") => FinishReason::Stop,
449 Some("length") => FinishReason::Length,
450 Some("tool_calls") => FinishReason::ToolCalls,
451 Some("content_filter") => FinishReason::ContentFilter,
452 _ => FinishReason::Stop,
453 }
454 };
455
456 Ok(CompletionResponse {
457 message: Message {
458 role: Role::Assistant,
459 content,
460 },
461 usage: Usage {
462 prompt_tokens: response
463 .usage
464 .as_ref()
465 .map(|u| u.prompt_tokens)
466 .unwrap_or(0),
467 completion_tokens: response
468 .usage
469 .as_ref()
470 .map(|u| u.completion_tokens)
471 .unwrap_or(0),
472 total_tokens: response.usage.as_ref().map(|u| u.total_tokens).unwrap_or(0),
473 ..Default::default()
474 },
475 finish_reason,
476 })
477 }
478
479 async fn complete_stream(
480 &self,
481 request: CompletionRequest,
482 ) -> Result<futures::stream::BoxStream<'static, StreamChunk>> {
483 use futures::StreamExt;
484
485 let messages = Self::convert_messages(&request.messages);
486 let tools = Self::convert_tools(&request.tools);
487
488 let mut body = json!({
489 "model": request.model,
490 "messages": messages,
491 "stream": true,
492 });
493 if !tools.is_empty() {
494 body["tools"] = json!(tools);
495 }
496 if let Some(temp) = request.temperature {
497 body["temperature"] = json!(temp);
498 }
499 if let Some(max) = request.max_tokens {
500 body["max_tokens"] = json!(max);
501 }
502
503 tracing::debug!(
504 provider = "openrouter",
505 model = %request.model,
506 message_count = request.messages.len(),
507 "Starting streaming completion request"
508 );
509
510 let response = self
511 .client
512 .post(format!("{}/chat/completions", self.base_url))
513 .header("Authorization", format!("Bearer {}", self.api_key))
514 .header("Content-Type", "application/json")
515 .header("HTTP-Referer", "https://codetether.run")
516 .header("X-Title", "CodeTether Agent")
517 .json(&body)
518 .send()
519 .await
520 .context("Failed to send streaming request to OpenRouter")?;
521
522 if !response.status().is_success() {
523 let status = response.status();
524 let text = response.text().await.unwrap_or_default();
525 if let Some(error_message) = Self::parse_error_body(&text) {
526 anyhow::bail!(error_message);
527 }
528 anyhow::bail!("OpenRouter streaming error: {} {}", status, text);
529 }
530
531 let stream = response.bytes_stream();
532 let mut buffer = String::new();
533
534 Ok(stream
535 .flat_map(move |chunk_result| {
536 let mut chunks: Vec<StreamChunk> = Vec::new();
537 match chunk_result {
538 Ok(bytes) => {
539 let text = String::from_utf8_lossy(&bytes);
540 buffer.push_str(&text);
541
542 while let Some(line_end) = buffer.find('\n') {
543 let line = buffer[..line_end].trim().to_string();
544 buffer = buffer[line_end + 1..].to_string();
545
546 if line.is_empty() {
547 continue;
548 }
549
550 if line == "data: [DONE]" {
551 chunks.push(StreamChunk::Done { usage: None });
552 continue;
553 }
554
555 if let Some(data) = line.strip_prefix("data: ") {
556 if let Ok(parsed) =
557 serde_json::from_str::<OpenRouterStreamResponse>(data)
558 {
559 if let Some(choice) = parsed.choices.first() {
560 if let Some(ref content) = choice.delta.content {
561 if !content.is_empty() {
562 chunks.push(StreamChunk::Text(content.clone()));
563 }
564 }
565 if let Some(ref tool_calls) = choice.delta.tool_calls {
566 for tc in tool_calls {
567 if let Some(ref func) = tc.function {
568 if let Some(ref name) = func.name {
569 let id = tc.id.clone().unwrap_or_default();
570 chunks.push(StreamChunk::ToolCallStart {
571 id: id.clone(),
572 name: name.clone(),
573 });
574 }
575 if let Some(ref args) = func.arguments {
576 let id = tc.id.clone().unwrap_or_default();
577 if !args.is_empty() {
578 chunks.push(
579 StreamChunk::ToolCallDelta {
580 id,
581 arguments_delta: args.clone(),
582 },
583 );
584 }
585 }
586 }
587 }
588 }
589 if choice.finish_reason.as_deref() == Some("stop")
590 || choice.finish_reason.as_deref() == Some("tool_calls")
591 {
592 let usage = parsed.usage.map(|u| Usage {
593 prompt_tokens: u.prompt_tokens,
594 completion_tokens: u.completion_tokens,
595 total_tokens: u.total_tokens,
596 ..Default::default()
597 });
598 chunks.push(StreamChunk::Done { usage });
599 }
600 }
601 }
602 }
603 }
604 }
605 Err(e) => {
606 chunks.push(StreamChunk::Error(e.to_string()));
607 }
608 }
609 futures::stream::iter(chunks)
610 })
611 .boxed())
612 }
613}
614
615#[derive(Debug, Deserialize)]
617struct OpenRouterStreamResponse {
618 #[serde(default)]
619 choices: Vec<OpenRouterStreamChoice>,
620 #[serde(default)]
621 usage: Option<OpenRouterUsage>,
622}
623
624#[derive(Debug, Deserialize)]
625struct OpenRouterStreamChoice {
626 #[serde(default)]
627 delta: OpenRouterStreamDelta,
628 #[serde(default)]
629 finish_reason: Option<String>,
630}
631
632#[derive(Debug, Default, Deserialize)]
633struct OpenRouterStreamDelta {
634 #[serde(default)]
635 content: Option<String>,
636 #[serde(default)]
637 tool_calls: Option<Vec<OpenRouterStreamToolCall>>,
638}
639
640#[derive(Debug, Deserialize)]
641struct OpenRouterStreamToolCall {
642 #[serde(default)]
643 id: Option<String>,
644 #[serde(default)]
645 function: Option<OpenRouterStreamFunction>,
646}
647
648#[derive(Debug, Deserialize)]
649struct OpenRouterStreamFunction {
650 #[serde(default)]
651 name: Option<String>,
652 #[serde(default)]
653 arguments: Option<String>,
654}
655
656#[cfg(test)]
657mod tests {
658 use super::OpenRouterProvider;
659
660 #[test]
661 fn parses_embedded_error_body() {
662 let body = r#"{"error":{"message":"Internal Server Error","code":500}}"#;
663 let message = OpenRouterProvider::parse_error_body(body);
664
665 assert_eq!(
666 message.as_deref(),
667 Some("OpenRouter API error: Internal Server Error (code: 500)")
668 );
669 }
670
671 #[test]
672 fn ignores_success_body_without_error_envelope() {
673 let body = r#"{
674 "id":"chatcmpl-123",
675 "choices":[{
676 "message":{"role":"assistant","content":"ok"},
677 "finish_reason":"stop"
678 }]
679 }"#;
680
681 assert_eq!(OpenRouterProvider::parse_error_body(body), None);
682 }
683}