Skip to main content

codetether_agent/provider/
openrouter.rs

1//! OpenRouter provider implementation using raw HTTP
2//!
3//! This provider uses reqwest directly instead of async_openai to handle
4//! OpenRouter's extended response formats (like Kimi's reasoning fields).
5
6use 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                        // Tool result message
51                        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                        // Assistant message - may have tool calls
67                        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                            // For assistant with tool calls, content should be empty string or the text
102                            json!({
103                                "role": "assistant",
104                                "content": if text.is_empty() { "".to_string() } else { text },
105                                "tool_calls": tool_calls
106                            })
107                        }
108                    }
109                    _ => {
110                        // System or User message
111                        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    // provider and model fields from OpenRouter
159    #[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    // OpenRouter adds native_finish_reason
174    #[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    // Extended fields from thinking models like Kimi K2.5
186    #[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        // Fetch models from OpenRouter API
242        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![]); // Return empty on error
252        }
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        // Cap body size: OpenRouter's /models payload is normally sub-MiB
269        // but has grown over time. A hard cap prevents a runaway response
270        // (or a misconfigured proxy) from OOM'ing the process during TUI
271        // startup / worker registration.
272        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        // Build request body
311        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        // Log response metadata for debugging
365        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        // Log native finish reason if present
378        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        // Log reasoning content if present (e.g., Kimi K2 models)
383        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        // Add text content if present
408        if let Some(text) = &choice.message.content
409            && !text.is_empty()
410        {
411            content.push(ContentPart::Text { text: text.clone() });
412        }
413
414        // Log message role for debugging
415        tracing::debug!(message_role = %choice.message.role, "OpenRouter message role");
416
417        // Log refusal if present (model declined to respond)
418        if let Some(ref refusal) = choice.message.refusal {
419            tracing::warn!(refusal = %refusal, "Model refused to respond");
420        }
421
422        // Add tool calls if present
423        if let Some(tool_calls) = &choice.message.tool_calls {
424            has_tool_calls = !tool_calls.is_empty();
425            for tc in tool_calls {
426                // Log tool call details (uses call_type and index fields)
427                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        // Determine finish reason
444        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/// Streaming SSE delta types for OpenRouter (OpenAI-compatible)
616#[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}