vtcode_core/llm/providers/
openrouter.rs

1use crate::config::constants::{models, urls};
2use crate::config::core::{OpenRouterPromptCacheSettings, PromptCachingConfig};
3use crate::config::models::Provider;
4use crate::config::types::ReasoningEffortLevel;
5use crate::llm::client::LLMClient;
6use crate::llm::error_display;
7use crate::llm::provider::{
8    FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent,
9    Message, MessageRole, ToolCall, ToolChoice, ToolDefinition, Usage,
10};
11use crate::llm::rig_adapter::reasoning_parameters_for;
12use crate::llm::types as llm_types;
13use async_stream::try_stream;
14use async_trait::async_trait;
15use futures::StreamExt;
16use reqwest::{Client as HttpClient, Response, StatusCode};
17use serde_json::{Map, Value, json};
18use std::borrow::Cow;
19
20use super::{extract_reasoning_trace, gpt5_codex_developer_prompt};
21
22#[derive(Default, Clone)]
23struct ToolCallBuilder {
24    id: Option<String>,
25    name: Option<String>,
26    arguments: String,
27}
28
29impl ToolCallBuilder {
30    fn finalize(self, fallback_index: usize) -> Option<ToolCall> {
31        let name = self.name?;
32        let id = self
33            .id
34            .unwrap_or_else(|| format!("tool_call_{}", fallback_index));
35        let arguments = if self.arguments.is_empty() {
36            "{}".to_string()
37        } else {
38            self.arguments
39        };
40        Some(ToolCall::function(id, name, arguments))
41    }
42}
43
44fn update_tool_calls(builders: &mut Vec<ToolCallBuilder>, deltas: &[Value]) {
45    for (index, delta) in deltas.iter().enumerate() {
46        if builders.len() <= index {
47            builders.push(ToolCallBuilder::default());
48        }
49        let builder = builders
50            .get_mut(index)
51            .expect("tool call builder must exist after push");
52
53        if let Some(id) = delta.get("id").and_then(|v| v.as_str()) {
54            builder.id = Some(id.to_string());
55        }
56
57        if let Some(function) = delta.get("function") {
58            if let Some(name) = function.get("name").and_then(|v| v.as_str()) {
59                builder.name = Some(name.to_string());
60            }
61
62            if let Some(arguments_value) = function.get("arguments") {
63                if let Some(arguments) = arguments_value.as_str() {
64                    builder.arguments.push_str(arguments);
65                } else if arguments_value.is_object() || arguments_value.is_array() {
66                    builder.arguments.push_str(&arguments_value.to_string());
67                }
68            }
69        }
70    }
71}
72
73fn finalize_tool_calls(builders: Vec<ToolCallBuilder>) -> Option<Vec<ToolCall>> {
74    let calls: Vec<ToolCall> = builders
75        .into_iter()
76        .enumerate()
77        .filter_map(|(index, builder)| builder.finalize(index))
78        .collect();
79
80    if calls.is_empty() { None } else { Some(calls) }
81}
82
83#[derive(Debug, PartialEq, Eq)]
84enum StreamFragment {
85    Content(String),
86    Reasoning(String),
87}
88
89#[derive(Default, Debug)]
90struct StreamDelta {
91    fragments: Vec<StreamFragment>,
92}
93
94impl StreamDelta {
95    fn push_content(&mut self, text: &str) {
96        if text.is_empty() {
97            return;
98        }
99
100        match self.fragments.last_mut() {
101            Some(StreamFragment::Content(existing)) => existing.push_str(text),
102            _ => self
103                .fragments
104                .push(StreamFragment::Content(text.to_string())),
105        }
106    }
107
108    fn push_reasoning(&mut self, text: &str) {
109        if text.is_empty() {
110            return;
111        }
112
113        match self.fragments.last_mut() {
114            Some(StreamFragment::Reasoning(existing)) => existing.push_str(text),
115            _ => self
116                .fragments
117                .push(StreamFragment::Reasoning(text.to_string())),
118        }
119    }
120
121    fn is_empty(&self) -> bool {
122        self.fragments.is_empty()
123    }
124
125    fn into_fragments(self) -> Vec<StreamFragment> {
126        self.fragments
127    }
128
129    fn extend(&mut self, other: StreamDelta) {
130        self.fragments.extend(other.fragments);
131    }
132}
133
134#[derive(Default, Clone)]
135struct ReasoningBuffer {
136    text: String,
137    last_chunk: Option<String>,
138}
139
140impl ReasoningBuffer {
141    fn push(&mut self, chunk: &str) -> Option<String> {
142        if chunk.trim().is_empty() {
143            return None;
144        }
145
146        let normalized = Self::normalize_chunk(chunk);
147
148        if normalized.is_empty() {
149            return None;
150        }
151
152        if self.last_chunk.as_deref() == Some(&normalized) {
153            return None;
154        }
155
156        let last_has_spacing = self.text.ends_with(' ') || self.text.ends_with('\n');
157        let chunk_starts_with_space = chunk
158            .chars()
159            .next()
160            .map(|value| value.is_whitespace())
161            .unwrap_or(false);
162        let leading_punctuation = Self::is_leading_punctuation(chunk);
163        let trailing_connector = Self::ends_with_connector(&self.text);
164
165        let mut delta = String::new();
166
167        if !self.text.is_empty()
168            && !last_has_spacing
169            && !chunk_starts_with_space
170            && !leading_punctuation
171            && !trailing_connector
172        {
173            delta.push(' ');
174        }
175
176        delta.push_str(&normalized);
177        self.text.push_str(&delta);
178        self.last_chunk = Some(normalized);
179
180        Some(delta)
181    }
182
183    fn finalize(self) -> Option<String> {
184        let trimmed = self.text.trim();
185        if trimmed.is_empty() {
186            None
187        } else {
188            Some(trimmed.to_string())
189        }
190    }
191
192    fn normalize_chunk(chunk: &str) -> String {
193        let mut normalized = String::new();
194        for part in chunk.split_whitespace() {
195            if !normalized.is_empty() {
196                normalized.push(' ');
197            }
198            normalized.push_str(part);
199        }
200        normalized
201    }
202
203    fn is_leading_punctuation(chunk: &str) -> bool {
204        chunk
205            .chars()
206            .find(|ch| !ch.is_whitespace())
207            .map(|ch| matches!(ch, ',' | '.' | '!' | '?' | ':' | ';' | ')' | ']' | '}'))
208            .unwrap_or(false)
209    }
210
211    fn ends_with_connector(text: &str) -> bool {
212        text.chars()
213            .rev()
214            .find(|ch| !ch.is_whitespace())
215            .map(|ch| matches!(ch, '(' | '[' | '{' | '/' | '-'))
216            .unwrap_or(false)
217    }
218}
219
220fn apply_tool_call_delta_from_content(
221    builders: &mut Vec<ToolCallBuilder>,
222    container: &Map<String, Value>,
223) {
224    if let Some(nested) = container.get("delta").and_then(|value| value.as_object()) {
225        apply_tool_call_delta_from_content(builders, nested);
226    }
227
228    let (index, delta_source) = if let Some(tool_call_value) = container.get("tool_call") {
229        match tool_call_value.as_object() {
230            Some(tool_call) => {
231                let idx = tool_call
232                    .get("index")
233                    .and_then(|value| value.as_u64())
234                    .unwrap_or(0) as usize;
235                (idx, tool_call)
236            }
237            None => (0usize, container),
238        }
239    } else {
240        let idx = container
241            .get("index")
242            .and_then(|value| value.as_u64())
243            .unwrap_or(0) as usize;
244        (idx, container)
245    };
246
247    let mut delta_map = Map::new();
248
249    if let Some(id_value) = delta_source.get("id") {
250        delta_map.insert("id".to_string(), id_value.clone());
251    }
252
253    if let Some(function_value) = delta_source.get("function") {
254        delta_map.insert("function".to_string(), function_value.clone());
255    }
256
257    if delta_map.is_empty() {
258        return;
259    }
260
261    if builders.len() <= index {
262        builders.resize_with(index + 1, ToolCallBuilder::default);
263    }
264
265    let mut deltas = vec![Value::Null; index + 1];
266    deltas[index] = Value::Object(delta_map);
267    update_tool_calls(builders, &deltas);
268}
269
270fn process_content_object(
271    map: &Map<String, Value>,
272    aggregated_content: &mut String,
273    reasoning: &mut ReasoningBuffer,
274    tool_call_builders: &mut Vec<ToolCallBuilder>,
275    deltas: &mut StreamDelta,
276) {
277    if let Some(content_type) = map.get("type").and_then(|value| value.as_str()) {
278        match content_type {
279            "reasoning" | "thinking" | "analysis" => {
280                if let Some(text_value) = map.get("text").and_then(|value| value.as_str()) {
281                    if let Some(delta) = reasoning.push(text_value) {
282                        deltas.push_reasoning(&delta);
283                    }
284                } else if let Some(text_value) =
285                    map.get("output_text").and_then(|value| value.as_str())
286                {
287                    if let Some(delta) = reasoning.push(text_value) {
288                        deltas.push_reasoning(&delta);
289                    }
290                }
291                return;
292            }
293            "tool_call_delta" | "tool_call" => {
294                apply_tool_call_delta_from_content(tool_call_builders, map);
295                return;
296            }
297            _ => {}
298        }
299    }
300
301    if let Some(tool_call_value) = map.get("tool_call").and_then(|value| value.as_object()) {
302        apply_tool_call_delta_from_content(tool_call_builders, tool_call_value);
303        return;
304    }
305
306    if let Some(text_value) = map.get("text").and_then(|value| value.as_str()) {
307        if !text_value.is_empty() {
308            aggregated_content.push_str(text_value);
309            deltas.push_content(text_value);
310        }
311        return;
312    }
313
314    if let Some(text_value) = map.get("output_text").and_then(|value| value.as_str()) {
315        if !text_value.is_empty() {
316            aggregated_content.push_str(text_value);
317            deltas.push_content(text_value);
318        }
319        return;
320    }
321
322    if let Some(text_value) = map
323        .get("output_text_delta")
324        .and_then(|value| value.as_str())
325    {
326        if !text_value.is_empty() {
327            aggregated_content.push_str(text_value);
328            deltas.push_content(text_value);
329        }
330        return;
331    }
332
333    for key in ["content", "items", "output", "outputs", "delta"] {
334        if let Some(inner) = map.get(key) {
335            process_content_value(
336                inner,
337                aggregated_content,
338                reasoning,
339                tool_call_builders,
340                deltas,
341            );
342        }
343    }
344}
345
346fn process_content_part(
347    part: &Value,
348    aggregated_content: &mut String,
349    reasoning: &mut ReasoningBuffer,
350    tool_call_builders: &mut Vec<ToolCallBuilder>,
351    deltas: &mut StreamDelta,
352) {
353    if let Some(text) = part.as_str() {
354        if !text.is_empty() {
355            aggregated_content.push_str(text);
356            deltas.push_content(text);
357        }
358        return;
359    }
360
361    if let Some(map) = part.as_object() {
362        process_content_object(
363            map,
364            aggregated_content,
365            reasoning,
366            tool_call_builders,
367            deltas,
368        );
369        return;
370    }
371
372    if part.is_array() {
373        process_content_value(
374            part,
375            aggregated_content,
376            reasoning,
377            tool_call_builders,
378            deltas,
379        );
380    }
381}
382
383fn process_content_value(
384    value: &Value,
385    aggregated_content: &mut String,
386    reasoning: &mut ReasoningBuffer,
387    tool_call_builders: &mut Vec<ToolCallBuilder>,
388    deltas: &mut StreamDelta,
389) {
390    match value {
391        Value::String(text) => {
392            if !text.is_empty() {
393                aggregated_content.push_str(text);
394                deltas.push_content(text);
395            }
396        }
397        Value::Array(parts) => {
398            for part in parts {
399                process_content_part(
400                    part,
401                    aggregated_content,
402                    reasoning,
403                    tool_call_builders,
404                    deltas,
405                );
406            }
407        }
408        Value::Object(map) => {
409            process_content_object(
410                map,
411                aggregated_content,
412                reasoning,
413                tool_call_builders,
414                deltas,
415            );
416        }
417        _ => {}
418    }
419}
420
421fn extract_tool_calls_from_content(message: &Value) -> Option<Vec<ToolCall>> {
422    let parts = message.get("content").and_then(|value| value.as_array())?;
423    let mut calls: Vec<ToolCall> = Vec::new();
424
425    for (index, part) in parts.iter().enumerate() {
426        let map = match part.as_object() {
427            Some(value) => value,
428            None => continue,
429        };
430
431        let content_type = map.get("type").and_then(|value| value.as_str());
432        let is_tool_call = matches!(content_type, Some("tool_call") | Some("function_call"))
433            || (content_type.is_none()
434                && map.contains_key("name")
435                && map.contains_key("arguments"));
436
437        if !is_tool_call {
438            continue;
439        }
440
441        let id = map
442            .get("id")
443            .and_then(|value| value.as_str())
444            .map(|value| value.to_string())
445            .unwrap_or_else(|| format!("tool_call_{}", index));
446
447        let (name, arguments_value) =
448            if let Some(function) = map.get("function").and_then(|value| value.as_object()) {
449                (
450                    function
451                        .get("name")
452                        .and_then(|value| value.as_str())
453                        .map(|value| value.to_string()),
454                    function.get("arguments"),
455                )
456            } else {
457                (
458                    map.get("name")
459                        .and_then(|value| value.as_str())
460                        .map(|value| value.to_string()),
461                    map.get("arguments"),
462                )
463            };
464
465        let Some(name) = name else {
466            continue;
467        };
468
469        let arguments = arguments_value
470            .map(|value| {
471                if let Some(text) = value.as_str() {
472                    text.to_string()
473                } else if value.is_null() {
474                    "{}".to_string()
475                } else {
476                    value.to_string()
477                }
478            })
479            .unwrap_or_else(|| "{}".to_string());
480
481        calls.push(ToolCall::function(id, name, arguments));
482    }
483
484    if calls.is_empty() { None } else { Some(calls) }
485}
486
487fn extract_reasoning_from_message_content(message: &Value) -> Option<String> {
488    let parts = message.get("content")?.as_array()?;
489    let mut segments: Vec<String> = Vec::new();
490
491    for part in parts {
492        match part {
493            Value::Object(map) => {
494                let part_type = map
495                    .get("type")
496                    .and_then(|value| value.as_str())
497                    .unwrap_or("");
498
499                if matches!(part_type, "reasoning" | "thinking" | "analysis") {
500                    if let Some(extracted) = extract_reasoning_trace(part) {
501                        if !extracted.trim().is_empty() {
502                            segments.push(extracted);
503                            continue;
504                        }
505                    }
506
507                    if let Some(text) = map.get("text").and_then(|value| value.as_str()) {
508                        let trimmed = text.trim();
509                        if !trimmed.is_empty() {
510                            segments.push(trimmed.to_string());
511                        }
512                    }
513                }
514            }
515            Value::String(text) => {
516                let trimmed = text.trim();
517                if !trimmed.is_empty() {
518                    segments.push(trimmed.to_string());
519                }
520            }
521            _ => {}
522        }
523    }
524
525    if segments.is_empty() {
526        None
527    } else {
528        let mut combined = String::new();
529        for (idx, segment) in segments.iter().enumerate() {
530            if idx > 0 {
531                combined.push('\n');
532            }
533            combined.push_str(segment);
534        }
535        Some(combined)
536    }
537}
538
539fn parse_usage_value(value: &Value) -> Usage {
540    let cache_read_tokens = value
541        .get("prompt_cache_read_tokens")
542        .or_else(|| value.get("cache_read_input_tokens"))
543        .and_then(|v| v.as_u64())
544        .map(|v| v as u32);
545
546    let cache_creation_tokens = value
547        .get("prompt_cache_write_tokens")
548        .or_else(|| value.get("cache_creation_input_tokens"))
549        .and_then(|v| v.as_u64())
550        .map(|v| v as u32);
551
552    Usage {
553        prompt_tokens: value
554            .get("prompt_tokens")
555            .and_then(|pt| pt.as_u64())
556            .unwrap_or(0) as u32,
557        completion_tokens: value
558            .get("completion_tokens")
559            .and_then(|ct| ct.as_u64())
560            .unwrap_or(0) as u32,
561        total_tokens: value
562            .get("total_tokens")
563            .and_then(|tt| tt.as_u64())
564            .unwrap_or(0) as u32,
565        cached_prompt_tokens: cache_read_tokens,
566        cache_creation_tokens,
567        cache_read_tokens,
568    }
569}
570
571fn map_finish_reason(reason: &str) -> FinishReason {
572    match reason {
573        "stop" | "completed" | "done" | "finished" => FinishReason::Stop,
574        "length" => FinishReason::Length,
575        "tool_calls" => FinishReason::ToolCalls,
576        "content_filter" => FinishReason::ContentFilter,
577        other => FinishReason::Error(other.to_string()),
578    }
579}
580
581fn push_reasoning_value(reasoning: &mut ReasoningBuffer, value: &Value, deltas: &mut StreamDelta) {
582    if let Some(reasoning_text) = extract_reasoning_trace(value) {
583        if let Some(delta) = reasoning.push(&reasoning_text) {
584            deltas.push_reasoning(&delta);
585        }
586    } else if let Some(text_value) = value.get("text").and_then(|v| v.as_str()) {
587        if let Some(delta) = reasoning.push(text_value) {
588            deltas.push_reasoning(&delta);
589        }
590    }
591}
592
593fn parse_chat_completion_chunk(
594    payload: &Value,
595    aggregated_content: &mut String,
596    tool_call_builders: &mut Vec<ToolCallBuilder>,
597    reasoning: &mut ReasoningBuffer,
598    finish_reason: &mut FinishReason,
599) -> StreamDelta {
600    let mut deltas = StreamDelta::default();
601
602    if let Some(choices) = payload.get("choices").and_then(|c| c.as_array()) {
603        if let Some(choice) = choices.first() {
604            if let Some(delta) = choice.get("delta") {
605                if let Some(content_value) = delta.get("content") {
606                    process_content_value(
607                        content_value,
608                        aggregated_content,
609                        reasoning,
610                        tool_call_builders,
611                        &mut deltas,
612                    );
613                }
614
615                if let Some(reasoning_value) = delta.get("reasoning") {
616                    push_reasoning_value(reasoning, reasoning_value, &mut deltas);
617                }
618
619                if let Some(tool_calls_value) = delta.get("tool_calls").and_then(|v| v.as_array()) {
620                    update_tool_calls(tool_call_builders, tool_calls_value);
621                }
622            }
623
624            if let Some(reasoning_value) = choice.get("reasoning") {
625                push_reasoning_value(reasoning, reasoning_value, &mut deltas);
626            }
627
628            if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
629                *finish_reason = map_finish_reason(reason);
630            }
631        }
632    }
633
634    deltas
635}
636
637fn parse_response_chunk(
638    payload: &Value,
639    aggregated_content: &mut String,
640    tool_call_builders: &mut Vec<ToolCallBuilder>,
641    reasoning: &mut ReasoningBuffer,
642    finish_reason: &mut FinishReason,
643) -> StreamDelta {
644    let mut deltas = StreamDelta::default();
645
646    if let Some(delta_value) = payload.get("delta") {
647        process_content_value(
648            delta_value,
649            aggregated_content,
650            reasoning,
651            tool_call_builders,
652            &mut deltas,
653        );
654    }
655
656    if let Some(event_type) = payload.get("type").and_then(|v| v.as_str()) {
657        match event_type {
658            "response.reasoning.delta" => {
659                if let Some(delta_value) = payload.get("delta") {
660                    push_reasoning_value(reasoning, delta_value, &mut deltas);
661                }
662            }
663            "response.tool_call.delta" => {
664                if let Some(delta_object) = payload.get("delta").and_then(|v| v.as_object()) {
665                    apply_tool_call_delta_from_content(tool_call_builders, delta_object);
666                }
667            }
668            "response.completed" | "response.done" | "response.finished" => {
669                if let Some(response_obj) = payload.get("response") {
670                    if aggregated_content.is_empty() {
671                        process_content_value(
672                            response_obj,
673                            aggregated_content,
674                            reasoning,
675                            tool_call_builders,
676                            &mut deltas,
677                        );
678                    }
679
680                    if let Some(reason) = response_obj
681                        .get("stop_reason")
682                        .and_then(|value| value.as_str())
683                        .or_else(|| response_obj.get("status").and_then(|value| value.as_str()))
684                    {
685                        *finish_reason = map_finish_reason(reason);
686                    }
687                }
688            }
689            _ => {}
690        }
691    }
692
693    if let Some(response_obj) = payload.get("response") {
694        if aggregated_content.is_empty() {
695            if let Some(content_value) = response_obj
696                .get("output_text")
697                .or_else(|| response_obj.get("output"))
698                .or_else(|| response_obj.get("content"))
699            {
700                process_content_value(
701                    content_value,
702                    aggregated_content,
703                    reasoning,
704                    tool_call_builders,
705                    &mut deltas,
706                );
707            }
708        }
709    }
710
711    if let Some(reasoning_value) = payload.get("reasoning") {
712        push_reasoning_value(reasoning, reasoning_value, &mut deltas);
713    }
714
715    deltas
716}
717
718fn update_usage_from_value(source: &Value, usage: &mut Option<Usage>) {
719    if let Some(usage_value) = source.get("usage") {
720        *usage = Some(parse_usage_value(usage_value));
721    }
722}
723
724fn extract_data_payload(event: &str) -> Option<String> {
725    let mut data_lines: Vec<String> = Vec::new();
726
727    for raw_line in event.lines() {
728        let line = raw_line.trim_end_matches('\r');
729        if line.is_empty() || line.starts_with(':') {
730            continue;
731        }
732
733        if let Some(value) = line.strip_prefix("data:") {
734            data_lines.push(value.trim_start().to_string());
735        }
736    }
737
738    if data_lines.is_empty() {
739        None
740    } else {
741        Some(data_lines.join("\n"))
742    }
743}
744
745fn parse_stream_payload(
746    payload: &Value,
747    aggregated_content: &mut String,
748    tool_call_builders: &mut Vec<ToolCallBuilder>,
749    reasoning: &mut ReasoningBuffer,
750    usage: &mut Option<Usage>,
751    finish_reason: &mut FinishReason,
752) -> Option<StreamDelta> {
753    let mut emitted_delta = StreamDelta::default();
754
755    let chat_delta = parse_chat_completion_chunk(
756        payload,
757        aggregated_content,
758        tool_call_builders,
759        reasoning,
760        finish_reason,
761    );
762    emitted_delta.extend(chat_delta);
763
764    let response_delta = parse_response_chunk(
765        payload,
766        aggregated_content,
767        tool_call_builders,
768        reasoning,
769        finish_reason,
770    );
771    emitted_delta.extend(response_delta);
772
773    update_usage_from_value(payload, usage);
774    if let Some(response_obj) = payload.get("response") {
775        update_usage_from_value(response_obj, usage);
776        if let Some(reason) = response_obj
777            .get("finish_reason")
778            .and_then(|value| value.as_str())
779        {
780            *finish_reason = map_finish_reason(reason);
781        }
782    }
783
784    if emitted_delta.is_empty() {
785        None
786    } else {
787        Some(emitted_delta)
788    }
789}
790
791fn finalize_stream_response(
792    aggregated_content: String,
793    tool_call_builders: Vec<ToolCallBuilder>,
794    usage: Option<Usage>,
795    finish_reason: FinishReason,
796    reasoning: ReasoningBuffer,
797) -> LLMResponse {
798    let content = if aggregated_content.is_empty() {
799        None
800    } else {
801        Some(aggregated_content)
802    };
803
804    let reasoning = reasoning.finalize();
805
806    LLMResponse {
807        content,
808        tool_calls: finalize_tool_calls(tool_call_builders),
809        usage,
810        finish_reason,
811        reasoning,
812    }
813}
814
815pub struct OpenRouterProvider {
816    api_key: String,
817    http_client: HttpClient,
818    base_url: String,
819    model: String,
820    prompt_cache_enabled: bool,
821    prompt_cache_settings: OpenRouterPromptCacheSettings,
822}
823
824impl OpenRouterProvider {
825    const TOOL_UNSUPPORTED_ERROR: &'static str = "No endpoints found that support tool use";
826
827    pub fn new(api_key: String) -> Self {
828        Self::with_model_internal(api_key, models::openrouter::DEFAULT_MODEL.to_string(), None)
829    }
830
831    pub fn with_model(api_key: String, model: String) -> Self {
832        Self::with_model_internal(api_key, model, None)
833    }
834
835    pub fn from_config(
836        api_key: Option<String>,
837        model: Option<String>,
838        base_url: Option<String>,
839        prompt_cache: Option<PromptCachingConfig>,
840    ) -> Self {
841        let api_key_value = api_key.unwrap_or_default();
842        let mut provider = if let Some(model_value) = model {
843            Self::with_model_internal(api_key_value, model_value, prompt_cache)
844        } else {
845            Self::with_model_internal(
846                api_key_value,
847                models::openrouter::DEFAULT_MODEL.to_string(),
848                prompt_cache,
849            )
850        };
851        if let Some(base) = base_url {
852            provider.base_url = base;
853        }
854        provider
855    }
856
857    fn with_model_internal(
858        api_key: String,
859        model: String,
860        prompt_cache: Option<PromptCachingConfig>,
861    ) -> Self {
862        let (prompt_cache_enabled, prompt_cache_settings) =
863            Self::extract_prompt_cache_settings(prompt_cache);
864
865        Self {
866            api_key,
867            http_client: HttpClient::new(),
868            base_url: urls::OPENROUTER_API_BASE.to_string(),
869            model,
870            prompt_cache_enabled,
871            prompt_cache_settings,
872        }
873    }
874
875    fn extract_prompt_cache_settings(
876        prompt_cache: Option<PromptCachingConfig>,
877    ) -> (bool, OpenRouterPromptCacheSettings) {
878        if let Some(cfg) = prompt_cache {
879            let provider_settings = cfg.providers.openrouter;
880            let enabled = cfg.enabled && provider_settings.enabled;
881            (enabled, provider_settings)
882        } else {
883            (false, OpenRouterPromptCacheSettings::default())
884        }
885    }
886
887    fn default_request(&self, prompt: &str) -> LLMRequest {
888        LLMRequest {
889            messages: vec![Message::user(prompt.to_string())],
890            system_prompt: None,
891            tools: None,
892            model: self.model.clone(),
893            max_tokens: None,
894            temperature: None,
895            stream: false,
896            tool_choice: None,
897            parallel_tool_calls: None,
898            parallel_tool_config: None,
899            reasoning_effort: None,
900        }
901    }
902
903    fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
904        let trimmed = prompt.trim_start();
905        if trimmed.starts_with('{') {
906            if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
907                if let Some(request) = self.parse_chat_request(&value) {
908                    return request;
909                }
910            }
911        }
912
913        self.default_request(prompt)
914    }
915
916    fn is_gpt5_codex_model(model: &str) -> bool {
917        model == models::openrouter::OPENAI_GPT_5_CODEX
918    }
919
920    fn resolve_model<'a>(&'a self, request: &'a LLMRequest) -> &'a str {
921        if request.model.trim().is_empty() {
922            self.model.as_str()
923        } else {
924            request.model.as_str()
925        }
926    }
927
928    fn uses_responses_api_for(&self, request: &LLMRequest) -> bool {
929        Self::is_gpt5_codex_model(self.resolve_model(request))
930    }
931
932    fn request_includes_tools(request: &LLMRequest) -> bool {
933        request
934            .tools
935            .as_ref()
936            .map(|tools| !tools.is_empty())
937            .unwrap_or(false)
938    }
939
940    fn enforce_tool_capabilities<'a>(&'a self, request: &'a LLMRequest) -> Cow<'a, LLMRequest> {
941        let resolved_model = self.resolve_model(request);
942        let tools_requested = Self::request_includes_tools(request);
943        let tool_restricted = models::openrouter::TOOL_UNAVAILABLE_MODELS
944            .iter()
945            .any(|candidate| *candidate == resolved_model);
946
947        if tools_requested && tool_restricted {
948            Cow::Owned(Self::tool_free_request(request))
949        } else {
950            Cow::Borrowed(request)
951        }
952    }
953
954    fn tool_free_request(original: &LLMRequest) -> LLMRequest {
955        let mut sanitized = original.clone();
956        sanitized.tools = None;
957        sanitized.tool_choice = Some(ToolChoice::None);
958        sanitized.parallel_tool_calls = None;
959        sanitized
960    }
961
962    fn build_provider_payload(&self, request: &LLMRequest) -> Result<(Value, String), LLMError> {
963        if self.uses_responses_api_for(request) {
964            Ok((
965                self.convert_to_openrouter_responses_format(request)?,
966                format!("{}/responses", self.base_url),
967            ))
968        } else {
969            Ok((
970                self.convert_to_openrouter_format(request)?,
971                format!("{}/chat/completions", self.base_url),
972            ))
973        }
974    }
975
976    async fn dispatch_request(&self, url: &str, payload: &Value) -> Result<Response, LLMError> {
977        self.http_client
978            .post(url)
979            .bearer_auth(&self.api_key)
980            .json(payload)
981            .send()
982            .await
983            .map_err(|e| {
984                let formatted_error =
985                    error_display::format_llm_error("OpenRouter", &format!("Network error: {}", e));
986                LLMError::Network(formatted_error)
987            })
988    }
989
990    fn is_tool_unsupported_error(status: StatusCode, body: &str) -> bool {
991        status == StatusCode::NOT_FOUND && body.contains(Self::TOOL_UNSUPPORTED_ERROR)
992    }
993
994    async fn send_with_tool_fallback(
995        &self,
996        request: &LLMRequest,
997        stream_override: Option<bool>,
998    ) -> Result<Response, LLMError> {
999        let adjusted_request = self.enforce_tool_capabilities(request);
1000        let request_ref = adjusted_request.as_ref();
1001        let request_with_tools = Self::request_includes_tools(request_ref);
1002
1003        let (mut payload, url) = self.build_provider_payload(request_ref)?;
1004        if let Some(stream_flag) = stream_override {
1005            payload["stream"] = Value::Bool(stream_flag);
1006        }
1007
1008        let response = self.dispatch_request(&url, &payload).await?;
1009        if response.status().is_success() {
1010            return Ok(response);
1011        }
1012
1013        let status = response.status();
1014        let error_text = response.text().await.unwrap_or_default();
1015
1016        if status.as_u16() == 429 || error_text.contains("quota") {
1017            return Err(LLMError::RateLimit);
1018        }
1019
1020        if request_with_tools && Self::is_tool_unsupported_error(status, &error_text) {
1021            let fallback_request = Self::tool_free_request(request_ref);
1022            let (mut fallback_payload, fallback_url) =
1023                self.build_provider_payload(&fallback_request)?;
1024            if let Some(stream_flag) = stream_override {
1025                fallback_payload["stream"] = Value::Bool(stream_flag);
1026            }
1027
1028            let fallback_response = self
1029                .dispatch_request(&fallback_url, &fallback_payload)
1030                .await?;
1031            if fallback_response.status().is_success() {
1032                return Ok(fallback_response);
1033            }
1034
1035            let fallback_status = fallback_response.status();
1036            let fallback_text = fallback_response.text().await.unwrap_or_default();
1037
1038            if fallback_status.as_u16() == 429 || fallback_text.contains("quota") {
1039                return Err(LLMError::RateLimit);
1040            }
1041
1042            let combined_error = format!(
1043                "HTTP {}: {} | Tool fallback failed with HTTP {}: {}",
1044                status, error_text, fallback_status, fallback_text
1045            );
1046            let formatted_error = error_display::format_llm_error("OpenRouter", &combined_error);
1047            return Err(LLMError::Provider(formatted_error));
1048        }
1049
1050        let formatted_error = error_display::format_llm_error(
1051            "OpenRouter",
1052            &format!("HTTP {}: {}", status, error_text),
1053        );
1054        Err(LLMError::Provider(formatted_error))
1055    }
1056
1057    fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
1058        let messages_value = value.get("messages")?.as_array()?;
1059        let mut system_prompt = None;
1060        let mut messages = Vec::new();
1061
1062        for entry in messages_value {
1063            let role = entry
1064                .get("role")
1065                .and_then(|r| r.as_str())
1066                .unwrap_or(crate::config::constants::message_roles::USER);
1067            let content = entry.get("content");
1068            let text_content = content.map(Self::extract_content_text).unwrap_or_default();
1069
1070            match role {
1071                "system" => {
1072                    if system_prompt.is_none() && !text_content.is_empty() {
1073                        system_prompt = Some(text_content);
1074                    }
1075                }
1076                "assistant" => {
1077                    let tool_calls = entry
1078                        .get("tool_calls")
1079                        .and_then(|tc| tc.as_array())
1080                        .map(|calls| {
1081                            calls
1082                                .iter()
1083                                .filter_map(|call| {
1084                                    let id = call.get("id").and_then(|v| v.as_str())?;
1085                                    let function = call.get("function")?;
1086                                    let name = function.get("name").and_then(|v| v.as_str())?;
1087                                    let arguments = function.get("arguments");
1088                                    let serialized = arguments.map_or("{}".to_string(), |value| {
1089                                        if value.is_string() {
1090                                            value.as_str().unwrap_or("").to_string()
1091                                        } else {
1092                                            value.to_string()
1093                                        }
1094                                    });
1095                                    Some(ToolCall::function(
1096                                        id.to_string(),
1097                                        name.to_string(),
1098                                        serialized,
1099                                    ))
1100                                })
1101                                .collect::<Vec<_>>()
1102                        })
1103                        .filter(|calls| !calls.is_empty());
1104
1105                    let message = if let Some(calls) = tool_calls {
1106                        Message {
1107                            role: MessageRole::Assistant,
1108                            content: text_content,
1109                            tool_calls: Some(calls),
1110                            tool_call_id: None,
1111                        }
1112                    } else {
1113                        Message::assistant(text_content)
1114                    };
1115                    messages.push(message);
1116                }
1117                "tool" => {
1118                    let tool_call_id = entry
1119                        .get("tool_call_id")
1120                        .and_then(|id| id.as_str())
1121                        .map(|s| s.to_string());
1122                    let content_value = entry
1123                        .get("content")
1124                        .map(|value| {
1125                            if text_content.is_empty() {
1126                                value.to_string()
1127                            } else {
1128                                text_content.clone()
1129                            }
1130                        })
1131                        .unwrap_or_else(|| text_content.clone());
1132                    messages.push(Message {
1133                        role: MessageRole::Tool,
1134                        content: content_value,
1135                        tool_calls: None,
1136                        tool_call_id,
1137                    });
1138                }
1139                _ => {
1140                    messages.push(Message::user(text_content));
1141                }
1142            }
1143        }
1144
1145        if messages.is_empty() {
1146            return None;
1147        }
1148
1149        let tools = value.get("tools").and_then(|tools_value| {
1150            let tools_array = tools_value.as_array()?;
1151            let converted: Vec<_> = tools_array
1152                .iter()
1153                .filter_map(|tool| {
1154                    let function = tool.get("function")?;
1155                    let name = function.get("name").and_then(|n| n.as_str())?;
1156                    let description = function
1157                        .get("description")
1158                        .and_then(|d| d.as_str())
1159                        .unwrap_or("")
1160                        .to_string();
1161                    let parameters = function
1162                        .get("parameters")
1163                        .cloned()
1164                        .unwrap_or_else(|| json!({}));
1165                    Some(ToolDefinition::function(
1166                        name.to_string(),
1167                        description,
1168                        parameters,
1169                    ))
1170                })
1171                .collect();
1172
1173            if converted.is_empty() {
1174                None
1175            } else {
1176                Some(converted)
1177            }
1178        });
1179
1180        let max_tokens = value
1181            .get("max_tokens")
1182            .and_then(|v| v.as_u64())
1183            .map(|v| v as u32);
1184        let temperature = value
1185            .get("temperature")
1186            .and_then(|v| v.as_f64())
1187            .map(|v| v as f32);
1188        let stream = value
1189            .get("stream")
1190            .and_then(|v| v.as_bool())
1191            .unwrap_or(false);
1192        let tool_choice = value.get("tool_choice").and_then(Self::parse_tool_choice);
1193        let parallel_tool_calls = value.get("parallel_tool_calls").and_then(|v| v.as_bool());
1194        let reasoning_effort = value
1195            .get("reasoning_effort")
1196            .and_then(|v| v.as_str())
1197            .and_then(ReasoningEffortLevel::from_str)
1198            .or_else(|| {
1199                value
1200                    .get("reasoning")
1201                    .and_then(|r| r.get("effort"))
1202                    .and_then(|effort| effort.as_str())
1203                    .and_then(ReasoningEffortLevel::from_str)
1204            });
1205
1206        let model = value
1207            .get("model")
1208            .and_then(|m| m.as_str())
1209            .unwrap_or(&self.model)
1210            .to_string();
1211
1212        Some(LLMRequest {
1213            messages,
1214            system_prompt,
1215            tools,
1216            model,
1217            max_tokens,
1218            temperature,
1219            stream,
1220            tool_choice,
1221            parallel_tool_calls,
1222            parallel_tool_config: None,
1223            reasoning_effort,
1224        })
1225    }
1226
1227    fn extract_content_text(content: &Value) -> String {
1228        match content {
1229            Value::String(text) => text.to_string(),
1230            Value::Array(parts) => parts
1231                .iter()
1232                .filter_map(|part| {
1233                    if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
1234                        Some(text.to_string())
1235                    } else if let Some(Value::String(text)) = part.get("content") {
1236                        Some(text.clone())
1237                    } else {
1238                        None
1239                    }
1240                })
1241                .collect::<Vec<_>>()
1242                .join(""),
1243            _ => String::new(),
1244        }
1245    }
1246
1247    fn parse_tool_choice(choice: &Value) -> Option<ToolChoice> {
1248        match choice {
1249            Value::String(value) => match value.as_str() {
1250                "auto" => Some(ToolChoice::auto()),
1251                "none" => Some(ToolChoice::none()),
1252                "required" => Some(ToolChoice::any()),
1253                _ => None,
1254            },
1255            Value::Object(map) => {
1256                let choice_type = map.get("type").and_then(|t| t.as_str())?;
1257                match choice_type {
1258                    "function" => map
1259                        .get("function")
1260                        .and_then(|f| f.get("name"))
1261                        .and_then(|n| n.as_str())
1262                        .map(|name| ToolChoice::function(name.to_string())),
1263                    "auto" => Some(ToolChoice::auto()),
1264                    "none" => Some(ToolChoice::none()),
1265                    "any" | "required" => Some(ToolChoice::any()),
1266                    _ => None,
1267                }
1268            }
1269            _ => None,
1270        }
1271    }
1272
1273    fn build_standard_responses_input(&self, request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
1274        let mut input = Vec::new();
1275
1276        if let Some(system_prompt) = &request.system_prompt {
1277            if !system_prompt.trim().is_empty() {
1278                input.push(json!({
1279                    "role": "developer",
1280                    "content": [{
1281                        "type": "input_text",
1282                        "text": system_prompt.clone()
1283                    }]
1284                }));
1285            }
1286        }
1287
1288        for msg in &request.messages {
1289            match msg.role {
1290                MessageRole::System => {
1291                    if !msg.content.trim().is_empty() {
1292                        input.push(json!({
1293                            "role": "developer",
1294                            "content": [{
1295                                "type": "input_text",
1296                                "text": msg.content.clone()
1297                            }]
1298                        }));
1299                    }
1300                }
1301                MessageRole::User => {
1302                    input.push(json!({
1303                        "role": "user",
1304                        "content": [{
1305                            "type": "input_text",
1306                            "text": msg.content.clone()
1307                        }]
1308                    }));
1309                }
1310                MessageRole::Assistant => {
1311                    let mut content_parts = Vec::new();
1312                    if !msg.content.is_empty() {
1313                        content_parts.push(json!({
1314                            "type": "output_text",
1315                            "text": msg.content.clone()
1316                        }));
1317                    }
1318
1319                    if let Some(tool_calls) = &msg.tool_calls {
1320                        for call in tool_calls {
1321                            content_parts.push(json!({
1322                                "type": "tool_call",
1323                                "id": call.id.clone(),
1324                                "name": call.function.name.clone(),
1325                                "arguments": call.function.arguments.clone()
1326                            }));
1327                        }
1328                    }
1329
1330                    if !content_parts.is_empty() {
1331                        input.push(json!({
1332                            "role": "assistant",
1333                            "content": content_parts
1334                        }));
1335                    }
1336                }
1337                MessageRole::Tool => {
1338                    let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
1339                        let formatted_error = error_display::format_llm_error(
1340                            "OpenRouter",
1341                            "Tool messages must include tool_call_id for Responses API",
1342                        );
1343                        LLMError::InvalidRequest(formatted_error)
1344                    })?;
1345
1346                    let mut tool_content = Vec::new();
1347                    if !msg.content.trim().is_empty() {
1348                        tool_content.push(json!({
1349                            "type": "output_text",
1350                            "text": msg.content.clone()
1351                        }));
1352                    }
1353
1354                    let mut tool_result = json!({
1355                        "type": "tool_result",
1356                        "tool_call_id": tool_call_id
1357                    });
1358
1359                    if !tool_content.is_empty() {
1360                        if let Value::Object(ref mut map) = tool_result {
1361                            map.insert("content".to_string(), json!(tool_content));
1362                        }
1363                    }
1364
1365                    input.push(json!({
1366                        "role": "tool",
1367                        "content": [tool_result]
1368                    }));
1369                }
1370            }
1371        }
1372
1373        Ok(input)
1374    }
1375
1376    fn build_codex_responses_input(&self, request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
1377        let mut additional_guidance = Vec::new();
1378
1379        if let Some(system_prompt) = &request.system_prompt {
1380            let trimmed = system_prompt.trim();
1381            if !trimmed.is_empty() {
1382                additional_guidance.push(trimmed.to_string());
1383            }
1384        }
1385
1386        let mut input = Vec::new();
1387
1388        for msg in &request.messages {
1389            match msg.role {
1390                MessageRole::System => {
1391                    let trimmed = msg.content.trim();
1392                    if !trimmed.is_empty() {
1393                        additional_guidance.push(trimmed.to_string());
1394                    }
1395                }
1396                MessageRole::User => {
1397                    input.push(json!({
1398                        "role": "user",
1399                        "content": [{
1400                            "type": "input_text",
1401                            "text": msg.content.clone()
1402                        }]
1403                    }));
1404                }
1405                MessageRole::Assistant => {
1406                    let mut content_parts = Vec::new();
1407                    if !msg.content.is_empty() {
1408                        content_parts.push(json!({
1409                            "type": "output_text",
1410                            "text": msg.content.clone()
1411                        }));
1412                    }
1413
1414                    if let Some(tool_calls) = &msg.tool_calls {
1415                        for call in tool_calls {
1416                            content_parts.push(json!({
1417                                "type": "tool_call",
1418                                "id": call.id.clone(),
1419                                "name": call.function.name.clone(),
1420                                "arguments": call.function.arguments.clone()
1421                            }));
1422                        }
1423                    }
1424
1425                    if !content_parts.is_empty() {
1426                        input.push(json!({
1427                            "role": "assistant",
1428                            "content": content_parts
1429                        }));
1430                    }
1431                }
1432                MessageRole::Tool => {
1433                    let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
1434                        let formatted_error = error_display::format_llm_error(
1435                            "OpenRouter",
1436                            "Tool messages must include tool_call_id for Responses API",
1437                        );
1438                        LLMError::InvalidRequest(formatted_error)
1439                    })?;
1440
1441                    let mut tool_content = Vec::new();
1442                    if !msg.content.trim().is_empty() {
1443                        tool_content.push(json!({
1444                            "type": "output_text",
1445                            "text": msg.content.clone()
1446                        }));
1447                    }
1448
1449                    let mut tool_result = json!({
1450                        "type": "tool_result",
1451                        "tool_call_id": tool_call_id
1452                    });
1453
1454                    if !tool_content.is_empty() {
1455                        if let Value::Object(ref mut map) = tool_result {
1456                            map.insert("content".to_string(), json!(tool_content));
1457                        }
1458                    }
1459
1460                    input.push(json!({
1461                        "role": "tool",
1462                        "content": [tool_result]
1463                    }));
1464                }
1465            }
1466        }
1467
1468        let developer_prompt = gpt5_codex_developer_prompt(&additional_guidance);
1469        input.insert(
1470            0,
1471            json!({
1472                "role": "developer",
1473                "content": [{
1474                    "type": "input_text",
1475                    "text": developer_prompt
1476                }]
1477            }),
1478        );
1479
1480        Ok(input)
1481    }
1482
1483    fn convert_to_openrouter_responses_format(
1484        &self,
1485        request: &LLMRequest,
1486    ) -> Result<Value, LLMError> {
1487        let resolved_model = self.resolve_model(request);
1488        let input = if Self::is_gpt5_codex_model(resolved_model) {
1489            self.build_codex_responses_input(request)?
1490        } else {
1491            self.build_standard_responses_input(request)?
1492        };
1493
1494        if input.is_empty() {
1495            let formatted_error = error_display::format_llm_error(
1496                "OpenRouter",
1497                "No messages provided for Responses API",
1498            );
1499            return Err(LLMError::InvalidRequest(formatted_error));
1500        }
1501
1502        let mut provider_request = json!({
1503            "model": resolved_model,
1504            "input": input,
1505            "stream": request.stream
1506        });
1507
1508        if let Some(max_tokens) = request.max_tokens {
1509            provider_request["max_output_tokens"] = json!(max_tokens);
1510        }
1511
1512        if let Some(temperature) = request.temperature {
1513            provider_request["temperature"] = json!(temperature);
1514        }
1515
1516        if let Some(tools) = &request.tools {
1517            if !tools.is_empty() {
1518                let tools_json: Vec<Value> = tools
1519                    .iter()
1520                    .map(|tool| {
1521                        json!({
1522                            "type": "function",
1523                            "function": {
1524                                "name": tool.function.name,
1525                                "description": tool.function.description,
1526                                "parameters": tool.function.parameters
1527                            }
1528                        })
1529                    })
1530                    .collect();
1531                provider_request["tools"] = Value::Array(tools_json);
1532            }
1533        }
1534
1535        if let Some(tool_choice) = &request.tool_choice {
1536            provider_request["tool_choice"] = tool_choice.to_provider_format("openai");
1537        }
1538
1539        if let Some(parallel) = request.parallel_tool_calls {
1540            provider_request["parallel_tool_calls"] = Value::Bool(parallel);
1541        }
1542
1543        if let Some(effort) = request.reasoning_effort {
1544            if self.supports_reasoning_effort(resolved_model) {
1545                if let Some(payload) = reasoning_parameters_for(Provider::OpenRouter, effort) {
1546                    provider_request["reasoning"] = payload;
1547                } else {
1548                    provider_request["reasoning"] = json!({ "effort": effort.as_str() });
1549                }
1550            }
1551        }
1552
1553        if Self::is_gpt5_codex_model(resolved_model) {
1554            provider_request["reasoning"] = json!({ "effort": "medium" });
1555        }
1556
1557        Ok(provider_request)
1558    }
1559
1560    fn convert_to_openrouter_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
1561        let resolved_model = self.resolve_model(request);
1562        let mut messages = Vec::new();
1563
1564        if let Some(system_prompt) = &request.system_prompt {
1565            messages.push(json!({
1566                "role": crate::config::constants::message_roles::SYSTEM,
1567                "content": system_prompt
1568            }));
1569        }
1570
1571        for msg in &request.messages {
1572            let role = msg.role.as_openai_str();
1573            let mut message = json!({
1574                "role": role,
1575                "content": msg.content
1576            });
1577
1578            if msg.role == MessageRole::Assistant {
1579                if let Some(tool_calls) = &msg.tool_calls {
1580                    if !tool_calls.is_empty() {
1581                        let tool_calls_json: Vec<Value> = tool_calls
1582                            .iter()
1583                            .map(|tc| {
1584                                json!({
1585                                    "id": tc.id,
1586                                    "type": "function",
1587                                    "function": {
1588                                        "name": tc.function.name,
1589                                        "arguments": tc.function.arguments
1590                                    }
1591                                })
1592                            })
1593                            .collect();
1594                        message["tool_calls"] = Value::Array(tool_calls_json);
1595                    }
1596                }
1597            }
1598
1599            if msg.role == MessageRole::Tool {
1600                if let Some(tool_call_id) = &msg.tool_call_id {
1601                    message["tool_call_id"] = Value::String(tool_call_id.clone());
1602                }
1603            }
1604
1605            messages.push(message);
1606        }
1607
1608        if messages.is_empty() {
1609            let formatted_error =
1610                error_display::format_llm_error("OpenRouter", "No messages provided");
1611            return Err(LLMError::InvalidRequest(formatted_error));
1612        }
1613
1614        let mut provider_request = json!({
1615            "model": resolved_model,
1616            "messages": messages,
1617            "stream": request.stream
1618        });
1619
1620        if let Some(max_tokens) = request.max_tokens {
1621            provider_request["max_tokens"] = json!(max_tokens);
1622        }
1623
1624        if let Some(temperature) = request.temperature {
1625            provider_request["temperature"] = json!(temperature);
1626        }
1627
1628        if let Some(tools) = &request.tools {
1629            if !tools.is_empty() {
1630                let tools_json: Vec<Value> = tools
1631                    .iter()
1632                    .map(|tool| {
1633                        json!({
1634                            "type": "function",
1635                            "function": {
1636                                "name": tool.function.name,
1637                                "description": tool.function.description,
1638                                "parameters": tool.function.parameters
1639                            }
1640                        })
1641                    })
1642                    .collect();
1643                provider_request["tools"] = Value::Array(tools_json);
1644            }
1645        }
1646
1647        if let Some(tool_choice) = &request.tool_choice {
1648            provider_request["tool_choice"] = tool_choice.to_provider_format("openai");
1649        }
1650
1651        if let Some(parallel) = request.parallel_tool_calls {
1652            provider_request["parallel_tool_calls"] = Value::Bool(parallel);
1653        }
1654
1655        if let Some(effort) = request.reasoning_effort {
1656            if self.supports_reasoning_effort(resolved_model) {
1657                if let Some(payload) = reasoning_parameters_for(Provider::OpenRouter, effort) {
1658                    provider_request["reasoning"] = payload;
1659                } else {
1660                    provider_request["reasoning"] = json!({ "effort": effort.as_str() });
1661                }
1662            }
1663        }
1664
1665        Ok(provider_request)
1666    }
1667
1668    fn parse_openrouter_response(&self, response_json: Value) -> Result<LLMResponse, LLMError> {
1669        if let Some(choices) = response_json
1670            .get("choices")
1671            .and_then(|value| value.as_array())
1672        {
1673            if choices.is_empty() {
1674                let formatted_error =
1675                    error_display::format_llm_error("OpenRouter", "No choices in response");
1676                return Err(LLMError::Provider(formatted_error));
1677            }
1678
1679            let choice = &choices[0];
1680            let message = choice.get("message").ok_or_else(|| {
1681                let formatted_error = error_display::format_llm_error(
1682                    "OpenRouter",
1683                    "Invalid response format: missing message",
1684                );
1685                LLMError::Provider(formatted_error)
1686            })?;
1687
1688            let content = match message.get("content") {
1689                Some(Value::String(text)) => Some(text.to_string()),
1690                Some(Value::Array(parts)) => {
1691                    let text = parts
1692                        .iter()
1693                        .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
1694                        .collect::<Vec<_>>()
1695                        .join("");
1696                    if text.is_empty() { None } else { Some(text) }
1697                }
1698                _ => None,
1699            };
1700
1701            let tool_calls = message
1702                .get("tool_calls")
1703                .and_then(|tc| tc.as_array())
1704                .map(|calls| {
1705                    calls
1706                        .iter()
1707                        .filter_map(|call| {
1708                            let id = call.get("id").and_then(|v| v.as_str())?;
1709                            let function = call.get("function")?;
1710                            let name = function.get("name").and_then(|v| v.as_str())?;
1711                            let arguments = function.get("arguments");
1712                            let serialized = arguments.map_or("{}".to_string(), |value| {
1713                                if value.is_string() {
1714                                    value.as_str().unwrap_or("").to_string()
1715                                } else {
1716                                    value.to_string()
1717                                }
1718                            });
1719                            Some(ToolCall::function(
1720                                id.to_string(),
1721                                name.to_string(),
1722                                serialized,
1723                            ))
1724                        })
1725                        .collect::<Vec<_>>()
1726                })
1727                .filter(|calls| !calls.is_empty());
1728
1729            let mut reasoning = message
1730                .get("reasoning")
1731                .and_then(extract_reasoning_trace)
1732                .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace));
1733
1734            if reasoning.is_none() {
1735                reasoning = extract_reasoning_from_message_content(message);
1736            }
1737
1738            let finish_reason = choice
1739                .get("finish_reason")
1740                .and_then(|fr| fr.as_str())
1741                .map(map_finish_reason)
1742                .unwrap_or(FinishReason::Stop);
1743
1744            let usage = response_json.get("usage").map(parse_usage_value);
1745
1746            return Ok(LLMResponse {
1747                content,
1748                tool_calls,
1749                usage,
1750                finish_reason,
1751                reasoning,
1752            });
1753        }
1754
1755        self.parse_responses_api_response(&response_json)
1756    }
1757
1758    fn parse_responses_api_response(&self, payload: &Value) -> Result<LLMResponse, LLMError> {
1759        let response_container = payload.get("response").unwrap_or(payload);
1760
1761        let outputs = response_container
1762            .get("output")
1763            .or_else(|| response_container.get("outputs"))
1764            .and_then(|value| value.as_array())
1765            .ok_or_else(|| {
1766                let formatted_error = error_display::format_llm_error(
1767                    "OpenRouter",
1768                    "Invalid response format: missing output",
1769                );
1770                LLMError::Provider(formatted_error)
1771            })?;
1772
1773        if outputs.is_empty() {
1774            let formatted_error =
1775                error_display::format_llm_error("OpenRouter", "No output in response");
1776            return Err(LLMError::Provider(formatted_error));
1777        }
1778
1779        let message = outputs
1780            .iter()
1781            .find(|value| {
1782                value
1783                    .get("role")
1784                    .and_then(|role| role.as_str())
1785                    .map(|role| role == "assistant")
1786                    .unwrap_or(true)
1787            })
1788            .unwrap_or(&outputs[0]);
1789
1790        let mut aggregated_content = String::new();
1791        let mut reasoning_buffer = ReasoningBuffer::default();
1792        let mut tool_call_builders: Vec<ToolCallBuilder> = Vec::new();
1793        let mut deltas = StreamDelta::default();
1794
1795        if let Some(content_value) = message.get("content") {
1796            process_content_value(
1797                content_value,
1798                &mut aggregated_content,
1799                &mut reasoning_buffer,
1800                &mut tool_call_builders,
1801                &mut deltas,
1802            );
1803        } else {
1804            process_content_value(
1805                message,
1806                &mut aggregated_content,
1807                &mut reasoning_buffer,
1808                &mut tool_call_builders,
1809                &mut deltas,
1810            );
1811        }
1812
1813        let mut tool_calls = finalize_tool_calls(tool_call_builders);
1814        if tool_calls.is_none() {
1815            tool_calls = extract_tool_calls_from_content(message);
1816        }
1817
1818        let mut reasoning = reasoning_buffer.finalize();
1819        if reasoning.is_none() {
1820            reasoning = extract_reasoning_from_message_content(message)
1821                .or_else(|| message.get("reasoning").and_then(extract_reasoning_trace))
1822                .or_else(|| payload.get("reasoning").and_then(extract_reasoning_trace));
1823        }
1824
1825        let content = if aggregated_content.is_empty() {
1826            message
1827                .get("output_text")
1828                .and_then(|value| value.as_str())
1829                .map(|value| value.to_string())
1830        } else {
1831            Some(aggregated_content)
1832        };
1833
1834        let mut usage = payload.get("usage").map(parse_usage_value);
1835        if usage.is_none() {
1836            usage = response_container.get("usage").map(parse_usage_value);
1837        }
1838
1839        let finish_reason = payload
1840            .get("stop_reason")
1841            .or_else(|| payload.get("finish_reason"))
1842            .or_else(|| payload.get("status"))
1843            .or_else(|| response_container.get("stop_reason"))
1844            .or_else(|| response_container.get("finish_reason"))
1845            .or_else(|| message.get("stop_reason"))
1846            .or_else(|| message.get("finish_reason"))
1847            .and_then(|value| value.as_str())
1848            .map(map_finish_reason)
1849            .unwrap_or(FinishReason::Stop);
1850
1851        Ok(LLMResponse {
1852            content,
1853            tool_calls,
1854            usage,
1855            finish_reason,
1856            reasoning,
1857        })
1858    }
1859}
1860
1861#[async_trait]
1862impl LLMProvider for OpenRouterProvider {
1863    fn name(&self) -> &str {
1864        "openrouter"
1865    }
1866
1867    fn supports_streaming(&self) -> bool {
1868        true
1869    }
1870
1871    fn supports_reasoning(&self, model: &str) -> bool {
1872        let requested = if model.trim().is_empty() {
1873            self.model.as_str()
1874        } else {
1875            model
1876        };
1877
1878        models::openrouter::REASONING_MODELS
1879            .iter()
1880            .any(|candidate| *candidate == requested)
1881    }
1882
1883    fn supports_reasoning_effort(&self, model: &str) -> bool {
1884        let requested = if model.trim().is_empty() {
1885            self.model.as_str()
1886        } else {
1887            model
1888        };
1889        models::openrouter::REASONING_MODELS
1890            .iter()
1891            .any(|candidate| *candidate == requested)
1892    }
1893
1894    fn supports_tools(&self, model: &str) -> bool {
1895        let requested = if model.trim().is_empty() {
1896            self.model.as_str()
1897        } else {
1898            model
1899        };
1900
1901        !models::openrouter::TOOL_UNAVAILABLE_MODELS
1902            .iter()
1903            .any(|candidate| *candidate == requested)
1904    }
1905
1906    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
1907        let response = self.send_with_tool_fallback(&request, Some(true)).await?;
1908
1909        fn find_sse_boundary(buffer: &str) -> Option<(usize, usize)> {
1910            let newline_boundary = buffer.find("\n\n").map(|idx| (idx, 2));
1911            let carriage_boundary = buffer.find("\r\n\r\n").map(|idx| (idx, 4));
1912
1913            match (newline_boundary, carriage_boundary) {
1914                (Some((n_idx, n_len)), Some((c_idx, c_len))) => {
1915                    if n_idx <= c_idx {
1916                        Some((n_idx, n_len))
1917                    } else {
1918                        Some((c_idx, c_len))
1919                    }
1920                }
1921                (Some(boundary), None) => Some(boundary),
1922                (None, Some(boundary)) => Some(boundary),
1923                (None, None) => None,
1924            }
1925        }
1926
1927        let stream = try_stream! {
1928            let mut body_stream = response.bytes_stream();
1929            let mut buffer = String::new();
1930            let mut aggregated_content = String::new();
1931            let mut tool_call_builders: Vec<ToolCallBuilder> = Vec::new();
1932            let mut reasoning = ReasoningBuffer::default();
1933            let mut usage: Option<Usage> = None;
1934            let mut finish_reason = FinishReason::Stop;
1935            let mut done = false;
1936
1937            while let Some(chunk_result) = body_stream.next().await {
1938                let chunk = chunk_result.map_err(|err| {
1939                    let formatted_error = error_display::format_llm_error(
1940                        "OpenRouter",
1941                        &format!("Streaming error: {}", err),
1942                    );
1943                    LLMError::Network(formatted_error)
1944                })?;
1945
1946                buffer.push_str(&String::from_utf8_lossy(&chunk));
1947
1948                while let Some((split_idx, delimiter_len)) = find_sse_boundary(&buffer) {
1949                    let event = buffer[..split_idx].to_string();
1950                    buffer.drain(..split_idx + delimiter_len);
1951
1952                    if let Some(data_payload) = extract_data_payload(&event) {
1953                        let trimmed_payload = data_payload.trim();
1954                        if trimmed_payload == "[DONE]" {
1955                            done = true;
1956                            break;
1957                        }
1958
1959                        if !trimmed_payload.is_empty() {
1960                            let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
1961                                let formatted_error = error_display::format_llm_error(
1962                                    "OpenRouter",
1963                                    &format!("Failed to parse stream payload: {}", err),
1964                                );
1965                                LLMError::Provider(formatted_error)
1966                            })?;
1967
1968                            if let Some(delta) = parse_stream_payload(
1969                                &payload,
1970                                &mut aggregated_content,
1971                                &mut tool_call_builders,
1972                                &mut reasoning,
1973                                &mut usage,
1974                                &mut finish_reason,
1975                            ) {
1976                                for fragment in delta.into_fragments() {
1977                                    match fragment {
1978                                        StreamFragment::Content(text) if !text.is_empty() => {
1979                                            yield LLMStreamEvent::Token { delta: text };
1980                                        }
1981                                        StreamFragment::Reasoning(text) if !text.is_empty() => {
1982                                            yield LLMStreamEvent::Reasoning { delta: text };
1983                                        }
1984                                        _ => {}
1985                                    }
1986                                }
1987                            }
1988                        }
1989                    }
1990                }
1991
1992                if done {
1993                    break;
1994                }
1995            }
1996
1997            if !done && !buffer.trim().is_empty() {
1998                if let Some(data_payload) = extract_data_payload(&buffer) {
1999                    let trimmed_payload = data_payload.trim();
2000                    if trimmed_payload != "[DONE]" && !trimmed_payload.is_empty() {
2001                        let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
2002                            let formatted_error = error_display::format_llm_error(
2003                                "OpenRouter",
2004                                &format!("Failed to parse stream payload: {}", err),
2005                            );
2006                            LLMError::Provider(formatted_error)
2007                        })?;
2008
2009                        if let Some(delta) = parse_stream_payload(
2010                            &payload,
2011                            &mut aggregated_content,
2012                            &mut tool_call_builders,
2013                            &mut reasoning,
2014                            &mut usage,
2015                            &mut finish_reason,
2016                        ) {
2017                            for fragment in delta.into_fragments() {
2018                                match fragment {
2019                                    StreamFragment::Content(text) if !text.is_empty() => {
2020                                        yield LLMStreamEvent::Token { delta: text };
2021                                    }
2022                                    StreamFragment::Reasoning(text) if !text.is_empty() => {
2023                                        yield LLMStreamEvent::Reasoning { delta: text };
2024                                    }
2025                                    _ => {}
2026                                }
2027                            }
2028                        }
2029                    }
2030                }
2031            }
2032
2033            let response = finalize_stream_response(
2034                aggregated_content,
2035                tool_call_builders,
2036                usage,
2037                finish_reason,
2038                reasoning,
2039            );
2040
2041            yield LLMStreamEvent::Completed { response };
2042        };
2043
2044        Ok(Box::pin(stream))
2045    }
2046
2047    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
2048        if self.prompt_cache_enabled && self.prompt_cache_settings.propagate_provider_capabilities {
2049            // When enabled, vtcode forwards provider-specific cache_control markers directly
2050            // through the OpenRouter payload without further transformation.
2051        }
2052
2053        if self.prompt_cache_enabled && self.prompt_cache_settings.report_savings {
2054            // Cache savings are surfaced via usage metrics parsed later in the response cycle.
2055        }
2056
2057        let response = self.send_with_tool_fallback(&request, None).await?;
2058
2059        let openrouter_response: Value = response.json().await.map_err(|e| {
2060            let formatted_error = error_display::format_llm_error(
2061                "OpenRouter",
2062                &format!("Failed to parse response: {}", e),
2063            );
2064            LLMError::Provider(formatted_error)
2065        })?;
2066
2067        self.parse_openrouter_response(openrouter_response)
2068    }
2069
2070    fn supported_models(&self) -> Vec<String> {
2071        models::openrouter::SUPPORTED_MODELS
2072            .iter()
2073            .map(|s| s.to_string())
2074            .collect()
2075    }
2076
2077    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
2078        if request.messages.is_empty() {
2079            let formatted_error =
2080                error_display::format_llm_error("OpenRouter", "Messages cannot be empty");
2081            return Err(LLMError::InvalidRequest(formatted_error));
2082        }
2083
2084        for message in &request.messages {
2085            if let Err(err) = message.validate_for_provider("openai") {
2086                let formatted = error_display::format_llm_error("OpenRouter", &err);
2087                return Err(LLMError::InvalidRequest(formatted));
2088            }
2089        }
2090
2091        if request.model.trim().is_empty() {
2092            let formatted_error =
2093                error_display::format_llm_error("OpenRouter", "Model must be provided");
2094            return Err(LLMError::InvalidRequest(formatted_error));
2095        }
2096
2097        Ok(())
2098    }
2099}
2100
2101#[async_trait]
2102impl LLMClient for OpenRouterProvider {
2103    async fn generate(&mut self, prompt: &str) -> Result<llm_types::LLMResponse, LLMError> {
2104        let request = self.parse_client_prompt(prompt);
2105        let request_model = request.model.clone();
2106        let response = LLMProvider::generate(self, request).await?;
2107
2108        Ok(llm_types::LLMResponse {
2109            content: response.content.unwrap_or_default(),
2110            model: request_model,
2111            usage: response.usage.map(|u| llm_types::Usage {
2112                prompt_tokens: u.prompt_tokens as usize,
2113                completion_tokens: u.completion_tokens as usize,
2114                total_tokens: u.total_tokens as usize,
2115                cached_prompt_tokens: u.cached_prompt_tokens.map(|v| v as usize),
2116                cache_creation_tokens: u.cache_creation_tokens.map(|v| v as usize),
2117                cache_read_tokens: u.cache_read_tokens.map(|v| v as usize),
2118            }),
2119            reasoning: response.reasoning,
2120        })
2121    }
2122
2123    fn backend_kind(&self) -> llm_types::BackendKind {
2124        llm_types::BackendKind::OpenRouter
2125    }
2126
2127    fn model_id(&self) -> &str {
2128        &self.model
2129    }
2130}
2131
2132#[cfg(test)]
2133mod tests {
2134    use super::*;
2135    use serde_json::json;
2136
2137    fn sample_tool() -> ToolDefinition {
2138        ToolDefinition::function(
2139            "fetch_data".to_string(),
2140            "Fetch data".to_string(),
2141            json!({
2142                "type": "object",
2143                "properties": {}
2144            }),
2145        )
2146    }
2147
2148    fn request_with_tools(model: &str) -> LLMRequest {
2149        LLMRequest {
2150            messages: vec![Message::user("hi".to_string())],
2151            system_prompt: None,
2152            tools: Some(vec![sample_tool()]),
2153            model: model.to_string(),
2154            max_tokens: None,
2155            temperature: None,
2156            stream: false,
2157            tool_choice: Some(ToolChoice::Any),
2158            parallel_tool_calls: Some(true),
2159            parallel_tool_config: None,
2160            reasoning_effort: None,
2161        }
2162    }
2163
2164    #[test]
2165    fn enforce_tool_capabilities_disables_tools_for_restricted_models() {
2166        let provider = OpenRouterProvider::with_model(
2167            "test-key".to_string(),
2168            models::openrouter::Z_AI_GLM_4_5_AIR_FREE.to_string(),
2169        );
2170        let request = request_with_tools(models::openrouter::Z_AI_GLM_4_5_AIR_FREE);
2171
2172        match provider.enforce_tool_capabilities(&request) {
2173            Cow::Borrowed(_) => panic!("expected sanitized request"),
2174            Cow::Owned(sanitized) => {
2175                assert!(sanitized.tools.is_none());
2176                assert!(matches!(sanitized.tool_choice, Some(ToolChoice::None)));
2177                assert!(sanitized.parallel_tool_calls.is_none());
2178                assert_eq!(sanitized.model, models::openrouter::Z_AI_GLM_4_5_AIR_FREE);
2179                assert_eq!(sanitized.messages, request.messages);
2180            }
2181        }
2182    }
2183
2184    #[test]
2185    fn enforce_tool_capabilities_keeps_tools_for_supported_models() {
2186        let provider = OpenRouterProvider::with_model(
2187            "test-key".to_string(),
2188            models::openrouter::OPENAI_GPT_5.to_string(),
2189        );
2190        let request = request_with_tools(models::openrouter::OPENAI_GPT_5);
2191
2192        match provider.enforce_tool_capabilities(&request) {
2193            Cow::Borrowed(borrowed) => {
2194                assert!(std::ptr::eq(borrowed, &request));
2195                assert!(borrowed.tools.as_ref().is_some());
2196            }
2197            Cow::Owned(_) => panic!("should not sanitize supported models"),
2198        }
2199    }
2200
2201    #[test]
2202    fn test_parse_stream_payload_chat_chunk() {
2203        let payload = json!({
2204            "choices": [{
2205                "delta": {
2206                    "content": [
2207                        {"type": "output_text", "text": "Hello"}
2208                    ]
2209                }
2210            }]
2211        });
2212
2213        let mut aggregated = String::new();
2214        let mut builders = Vec::new();
2215        let mut reasoning = ReasoningBuffer::default();
2216        let mut usage = None;
2217        let mut finish_reason = FinishReason::Stop;
2218
2219        let delta = parse_stream_payload(
2220            &payload,
2221            &mut aggregated,
2222            &mut builders,
2223            &mut reasoning,
2224            &mut usage,
2225            &mut finish_reason,
2226        );
2227
2228        let fragments = delta.expect("delta should exist").into_fragments();
2229        assert_eq!(
2230            fragments,
2231            vec![StreamFragment::Content("Hello".to_string())]
2232        );
2233        assert_eq!(aggregated, "Hello");
2234        assert!(builders.is_empty());
2235        assert!(usage.is_none());
2236        assert!(reasoning.finalize().is_none());
2237    }
2238
2239    #[test]
2240    fn test_parse_stream_payload_response_delta() {
2241        let payload = json!({
2242            "type": "response.delta",
2243            "delta": {
2244                "type": "output_text_delta",
2245                "text": "Stream"
2246            }
2247        });
2248
2249        let mut aggregated = String::new();
2250        let mut builders = Vec::new();
2251        let mut reasoning = ReasoningBuffer::default();
2252        let mut usage = None;
2253        let mut finish_reason = FinishReason::Stop;
2254
2255        let delta = parse_stream_payload(
2256            &payload,
2257            &mut aggregated,
2258            &mut builders,
2259            &mut reasoning,
2260            &mut usage,
2261            &mut finish_reason,
2262        );
2263
2264        let fragments = delta.expect("delta should exist").into_fragments();
2265        assert_eq!(
2266            fragments,
2267            vec![StreamFragment::Content("Stream".to_string())]
2268        );
2269        assert_eq!(aggregated, "Stream");
2270    }
2271
2272    #[test]
2273    fn test_extract_data_payload_joins_multiline_events() {
2274        let event = ": keep-alive\n".to_string() + "data: {\"a\":1}\n" + "data: {\"b\":2}\n";
2275        let payload = extract_data_payload(&event);
2276        assert_eq!(payload.as_deref(), Some("{\"a\":1}\n{\"b\":2}"));
2277    }
2278
2279    #[test]
2280    fn parse_usage_value_includes_cache_metrics() {
2281        let value = json!({
2282            "prompt_tokens": 120,
2283            "completion_tokens": 80,
2284            "total_tokens": 200,
2285            "prompt_cache_read_tokens": 90,
2286            "prompt_cache_write_tokens": 15
2287        });
2288
2289        let usage = parse_usage_value(&value);
2290        assert_eq!(usage.prompt_tokens, 120);
2291        assert_eq!(usage.completion_tokens, 80);
2292        assert_eq!(usage.total_tokens, 200);
2293        assert_eq!(usage.cached_prompt_tokens, Some(90));
2294        assert_eq!(usage.cache_read_tokens, Some(90));
2295        assert_eq!(usage.cache_creation_tokens, Some(15));
2296    }
2297}