vtcode_core/llm/providers/
openrouter.rs

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