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