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, Response, StatusCode};
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    const TOOL_UNSUPPORTED_ERROR: &'static str = "No endpoints found that support tool use";
822
823    pub fn new(api_key: String) -> Self {
824        Self::with_model_internal(api_key, models::openrouter::DEFAULT_MODEL.to_string(), None)
825    }
826
827    pub fn with_model(api_key: String, model: String) -> Self {
828        Self::with_model_internal(api_key, model, None)
829    }
830
831    pub fn from_config(
832        api_key: Option<String>,
833        model: Option<String>,
834        base_url: Option<String>,
835        prompt_cache: Option<PromptCachingConfig>,
836    ) -> Self {
837        let api_key_value = api_key.unwrap_or_default();
838        let mut provider = if let Some(model_value) = model {
839            Self::with_model_internal(api_key_value, model_value, prompt_cache)
840        } else {
841            Self::with_model_internal(
842                api_key_value,
843                models::openrouter::DEFAULT_MODEL.to_string(),
844                prompt_cache,
845            )
846        };
847        if let Some(base) = base_url {
848            provider.base_url = base;
849        }
850        provider
851    }
852
853    fn with_model_internal(
854        api_key: String,
855        model: String,
856        prompt_cache: Option<PromptCachingConfig>,
857    ) -> Self {
858        let (prompt_cache_enabled, prompt_cache_settings) =
859            Self::extract_prompt_cache_settings(prompt_cache);
860
861        Self {
862            api_key,
863            http_client: HttpClient::new(),
864            base_url: urls::OPENROUTER_API_BASE.to_string(),
865            model,
866            prompt_cache_enabled,
867            prompt_cache_settings,
868        }
869    }
870
871    fn extract_prompt_cache_settings(
872        prompt_cache: Option<PromptCachingConfig>,
873    ) -> (bool, OpenRouterPromptCacheSettings) {
874        if let Some(cfg) = prompt_cache {
875            let provider_settings = cfg.providers.openrouter;
876            let enabled = cfg.enabled && provider_settings.enabled;
877            (enabled, provider_settings)
878        } else {
879            (false, OpenRouterPromptCacheSettings::default())
880        }
881    }
882
883    fn default_request(&self, prompt: &str) -> LLMRequest {
884        LLMRequest {
885            messages: vec![Message::user(prompt.to_string())],
886            system_prompt: None,
887            tools: None,
888            model: self.model.clone(),
889            max_tokens: None,
890            temperature: None,
891            stream: false,
892            tool_choice: None,
893            parallel_tool_calls: None,
894            parallel_tool_config: None,
895            reasoning_effort: None,
896        }
897    }
898
899    fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
900        let trimmed = prompt.trim_start();
901        if trimmed.starts_with('{') {
902            if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
903                if let Some(request) = self.parse_chat_request(&value) {
904                    return request;
905                }
906            }
907        }
908
909        self.default_request(prompt)
910    }
911
912    fn is_gpt5_codex_model(model: &str) -> bool {
913        model == models::openrouter::OPENAI_GPT_5_CODEX
914    }
915
916    fn resolve_model<'a>(&'a self, request: &'a LLMRequest) -> &'a str {
917        if request.model.trim().is_empty() {
918            self.model.as_str()
919        } else {
920            request.model.as_str()
921        }
922    }
923
924    fn uses_responses_api_for(&self, request: &LLMRequest) -> bool {
925        Self::is_gpt5_codex_model(self.resolve_model(request))
926    }
927
928    fn request_includes_tools(request: &LLMRequest) -> bool {
929        request
930            .tools
931            .as_ref()
932            .map(|tools| !tools.is_empty())
933            .unwrap_or(false)
934    }
935
936    fn tool_free_request(original: &LLMRequest) -> LLMRequest {
937        let mut sanitized = original.clone();
938        sanitized.tools = None;
939        sanitized.tool_choice = Some(ToolChoice::None);
940        sanitized.parallel_tool_calls = None;
941        sanitized
942    }
943
944    fn build_provider_payload(&self, request: &LLMRequest) -> Result<(Value, String), LLMError> {
945        if self.uses_responses_api_for(request) {
946            Ok((
947                self.convert_to_openrouter_responses_format(request)?,
948                format!("{}/responses", self.base_url),
949            ))
950        } else {
951            Ok((
952                self.convert_to_openrouter_format(request)?,
953                format!("{}/chat/completions", self.base_url),
954            ))
955        }
956    }
957
958    async fn dispatch_request(&self, url: &str, payload: &Value) -> Result<Response, LLMError> {
959        self.http_client
960            .post(url)
961            .bearer_auth(&self.api_key)
962            .json(payload)
963            .send()
964            .await
965            .map_err(|e| {
966                let formatted_error =
967                    error_display::format_llm_error("OpenRouter", &format!("Network error: {}", e));
968                LLMError::Network(formatted_error)
969            })
970    }
971
972    fn is_tool_unsupported_error(status: StatusCode, body: &str) -> bool {
973        status == StatusCode::NOT_FOUND && body.contains(Self::TOOL_UNSUPPORTED_ERROR)
974    }
975
976    async fn send_with_tool_fallback(
977        &self,
978        request: &LLMRequest,
979        stream_override: Option<bool>,
980    ) -> Result<Response, LLMError> {
981        let (mut payload, url) = self.build_provider_payload(request)?;
982        if let Some(stream_flag) = stream_override {
983            payload["stream"] = Value::Bool(stream_flag);
984        }
985
986        let response = self.dispatch_request(&url, &payload).await?;
987        if response.status().is_success() {
988            return Ok(response);
989        }
990
991        let status = response.status();
992        let error_text = response.text().await.unwrap_or_default();
993
994        if status.as_u16() == 429 || error_text.contains("quota") {
995            return Err(LLMError::RateLimit);
996        }
997
998        if Self::request_includes_tools(request)
999            && Self::is_tool_unsupported_error(status, &error_text)
1000        {
1001            let fallback_request = Self::tool_free_request(request);
1002            let (mut fallback_payload, fallback_url) =
1003                self.build_provider_payload(&fallback_request)?;
1004            if let Some(stream_flag) = stream_override {
1005                fallback_payload["stream"] = Value::Bool(stream_flag);
1006            }
1007
1008            let fallback_response = self
1009                .dispatch_request(&fallback_url, &fallback_payload)
1010                .await?;
1011            if fallback_response.status().is_success() {
1012                return Ok(fallback_response);
1013            }
1014
1015            let fallback_status = fallback_response.status();
1016            let fallback_text = fallback_response.text().await.unwrap_or_default();
1017
1018            if fallback_status.as_u16() == 429 || fallback_text.contains("quota") {
1019                return Err(LLMError::RateLimit);
1020            }
1021
1022            let combined_error = format!(
1023                "HTTP {}: {} | Tool fallback failed with HTTP {}: {}",
1024                status, error_text, fallback_status, fallback_text
1025            );
1026            let formatted_error = error_display::format_llm_error("OpenRouter", &combined_error);
1027            return Err(LLMError::Provider(formatted_error));
1028        }
1029
1030        let formatted_error = error_display::format_llm_error(
1031            "OpenRouter",
1032            &format!("HTTP {}: {}", status, error_text),
1033        );
1034        Err(LLMError::Provider(formatted_error))
1035    }
1036
1037    fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
1038        let messages_value = value.get("messages")?.as_array()?;
1039        let mut system_prompt = None;
1040        let mut messages = Vec::new();
1041
1042        for entry in messages_value {
1043            let role = entry
1044                .get("role")
1045                .and_then(|r| r.as_str())
1046                .unwrap_or(crate::config::constants::message_roles::USER);
1047            let content = entry.get("content");
1048            let text_content = content.map(Self::extract_content_text).unwrap_or_default();
1049
1050            match role {
1051                "system" => {
1052                    if system_prompt.is_none() && !text_content.is_empty() {
1053                        system_prompt = Some(text_content);
1054                    }
1055                }
1056                "assistant" => {
1057                    let tool_calls = entry
1058                        .get("tool_calls")
1059                        .and_then(|tc| tc.as_array())
1060                        .map(|calls| {
1061                            calls
1062                                .iter()
1063                                .filter_map(|call| {
1064                                    let id = call.get("id").and_then(|v| v.as_str())?;
1065                                    let function = call.get("function")?;
1066                                    let name = function.get("name").and_then(|v| v.as_str())?;
1067                                    let arguments = function.get("arguments");
1068                                    let serialized = arguments.map_or("{}".to_string(), |value| {
1069                                        if value.is_string() {
1070                                            value.as_str().unwrap_or("").to_string()
1071                                        } else {
1072                                            value.to_string()
1073                                        }
1074                                    });
1075                                    Some(ToolCall::function(
1076                                        id.to_string(),
1077                                        name.to_string(),
1078                                        serialized,
1079                                    ))
1080                                })
1081                                .collect::<Vec<_>>()
1082                        })
1083                        .filter(|calls| !calls.is_empty());
1084
1085                    let message = if let Some(calls) = tool_calls {
1086                        Message {
1087                            role: MessageRole::Assistant,
1088                            content: text_content,
1089                            tool_calls: Some(calls),
1090                            tool_call_id: None,
1091                        }
1092                    } else {
1093                        Message::assistant(text_content)
1094                    };
1095                    messages.push(message);
1096                }
1097                "tool" => {
1098                    let tool_call_id = entry
1099                        .get("tool_call_id")
1100                        .and_then(|id| id.as_str())
1101                        .map(|s| s.to_string());
1102                    let content_value = entry
1103                        .get("content")
1104                        .map(|value| {
1105                            if text_content.is_empty() {
1106                                value.to_string()
1107                            } else {
1108                                text_content.clone()
1109                            }
1110                        })
1111                        .unwrap_or_else(|| text_content.clone());
1112                    messages.push(Message {
1113                        role: MessageRole::Tool,
1114                        content: content_value,
1115                        tool_calls: None,
1116                        tool_call_id,
1117                    });
1118                }
1119                _ => {
1120                    messages.push(Message::user(text_content));
1121                }
1122            }
1123        }
1124
1125        if messages.is_empty() {
1126            return None;
1127        }
1128
1129        let tools = value.get("tools").and_then(|tools_value| {
1130            let tools_array = tools_value.as_array()?;
1131            let converted: Vec<_> = tools_array
1132                .iter()
1133                .filter_map(|tool| {
1134                    let function = tool.get("function")?;
1135                    let name = function.get("name").and_then(|n| n.as_str())?;
1136                    let description = function
1137                        .get("description")
1138                        .and_then(|d| d.as_str())
1139                        .unwrap_or("")
1140                        .to_string();
1141                    let parameters = function
1142                        .get("parameters")
1143                        .cloned()
1144                        .unwrap_or_else(|| json!({}));
1145                    Some(ToolDefinition::function(
1146                        name.to_string(),
1147                        description,
1148                        parameters,
1149                    ))
1150                })
1151                .collect();
1152
1153            if converted.is_empty() {
1154                None
1155            } else {
1156                Some(converted)
1157            }
1158        });
1159
1160        let max_tokens = value
1161            .get("max_tokens")
1162            .and_then(|v| v.as_u64())
1163            .map(|v| v as u32);
1164        let temperature = value
1165            .get("temperature")
1166            .and_then(|v| v.as_f64())
1167            .map(|v| v as f32);
1168        let stream = value
1169            .get("stream")
1170            .and_then(|v| v.as_bool())
1171            .unwrap_or(false);
1172        let tool_choice = value.get("tool_choice").and_then(Self::parse_tool_choice);
1173        let parallel_tool_calls = value.get("parallel_tool_calls").and_then(|v| v.as_bool());
1174        let reasoning_effort = value
1175            .get("reasoning_effort")
1176            .and_then(|v| v.as_str())
1177            .map(|s| s.to_string())
1178            .or_else(|| {
1179                value
1180                    .get("reasoning")
1181                    .and_then(|r| r.get("effort"))
1182                    .and_then(|effort| effort.as_str())
1183                    .map(|s| s.to_string())
1184            });
1185
1186        let model = value
1187            .get("model")
1188            .and_then(|m| m.as_str())
1189            .unwrap_or(&self.model)
1190            .to_string();
1191
1192        Some(LLMRequest {
1193            messages,
1194            system_prompt,
1195            tools,
1196            model,
1197            max_tokens,
1198            temperature,
1199            stream,
1200            tool_choice,
1201            parallel_tool_calls,
1202            parallel_tool_config: None,
1203            reasoning_effort,
1204        })
1205    }
1206
1207    fn extract_content_text(content: &Value) -> String {
1208        match content {
1209            Value::String(text) => text.to_string(),
1210            Value::Array(parts) => parts
1211                .iter()
1212                .filter_map(|part| {
1213                    if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
1214                        Some(text.to_string())
1215                    } else if let Some(Value::String(text)) = part.get("content") {
1216                        Some(text.clone())
1217                    } else {
1218                        None
1219                    }
1220                })
1221                .collect::<Vec<_>>()
1222                .join(""),
1223            _ => String::new(),
1224        }
1225    }
1226
1227    fn parse_tool_choice(choice: &Value) -> Option<ToolChoice> {
1228        match choice {
1229            Value::String(value) => match value.as_str() {
1230                "auto" => Some(ToolChoice::auto()),
1231                "none" => Some(ToolChoice::none()),
1232                "required" => Some(ToolChoice::any()),
1233                _ => None,
1234            },
1235            Value::Object(map) => {
1236                let choice_type = map.get("type").and_then(|t| t.as_str())?;
1237                match choice_type {
1238                    "function" => map
1239                        .get("function")
1240                        .and_then(|f| f.get("name"))
1241                        .and_then(|n| n.as_str())
1242                        .map(|name| ToolChoice::function(name.to_string())),
1243                    "auto" => Some(ToolChoice::auto()),
1244                    "none" => Some(ToolChoice::none()),
1245                    "any" | "required" => Some(ToolChoice::any()),
1246                    _ => None,
1247                }
1248            }
1249            _ => None,
1250        }
1251    }
1252
1253    fn build_standard_responses_input(&self, request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
1254        let mut input = Vec::new();
1255
1256        if let Some(system_prompt) = &request.system_prompt {
1257            if !system_prompt.trim().is_empty() {
1258                input.push(json!({
1259                    "role": "developer",
1260                    "content": [{
1261                        "type": "input_text",
1262                        "text": system_prompt.clone()
1263                    }]
1264                }));
1265            }
1266        }
1267
1268        for msg in &request.messages {
1269            match msg.role {
1270                MessageRole::System => {
1271                    if !msg.content.trim().is_empty() {
1272                        input.push(json!({
1273                            "role": "developer",
1274                            "content": [{
1275                                "type": "input_text",
1276                                "text": msg.content.clone()
1277                            }]
1278                        }));
1279                    }
1280                }
1281                MessageRole::User => {
1282                    input.push(json!({
1283                        "role": "user",
1284                        "content": [{
1285                            "type": "input_text",
1286                            "text": msg.content.clone()
1287                        }]
1288                    }));
1289                }
1290                MessageRole::Assistant => {
1291                    let mut content_parts = Vec::new();
1292                    if !msg.content.is_empty() {
1293                        content_parts.push(json!({
1294                            "type": "output_text",
1295                            "text": msg.content.clone()
1296                        }));
1297                    }
1298
1299                    if let Some(tool_calls) = &msg.tool_calls {
1300                        for call in tool_calls {
1301                            content_parts.push(json!({
1302                                "type": "tool_call",
1303                                "id": call.id.clone(),
1304                                "name": call.function.name.clone(),
1305                                "arguments": call.function.arguments.clone()
1306                            }));
1307                        }
1308                    }
1309
1310                    if !content_parts.is_empty() {
1311                        input.push(json!({
1312                            "role": "assistant",
1313                            "content": content_parts
1314                        }));
1315                    }
1316                }
1317                MessageRole::Tool => {
1318                    let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
1319                        let formatted_error = error_display::format_llm_error(
1320                            "OpenRouter",
1321                            "Tool messages must include tool_call_id for Responses API",
1322                        );
1323                        LLMError::InvalidRequest(formatted_error)
1324                    })?;
1325
1326                    let mut tool_content = Vec::new();
1327                    if !msg.content.trim().is_empty() {
1328                        tool_content.push(json!({
1329                            "type": "output_text",
1330                            "text": msg.content.clone()
1331                        }));
1332                    }
1333
1334                    let mut tool_result = json!({
1335                        "type": "tool_result",
1336                        "tool_call_id": tool_call_id
1337                    });
1338
1339                    if !tool_content.is_empty() {
1340                        if let Value::Object(ref mut map) = tool_result {
1341                            map.insert("content".to_string(), json!(tool_content));
1342                        }
1343                    }
1344
1345                    input.push(json!({
1346                        "role": "tool",
1347                        "content": [tool_result]
1348                    }));
1349                }
1350            }
1351        }
1352
1353        Ok(input)
1354    }
1355
1356    fn build_codex_responses_input(&self, request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
1357        let mut additional_guidance = Vec::new();
1358
1359        if let Some(system_prompt) = &request.system_prompt {
1360            let trimmed = system_prompt.trim();
1361            if !trimmed.is_empty() {
1362                additional_guidance.push(trimmed.to_string());
1363            }
1364        }
1365
1366        let mut input = Vec::new();
1367
1368        for msg in &request.messages {
1369            match msg.role {
1370                MessageRole::System => {
1371                    let trimmed = msg.content.trim();
1372                    if !trimmed.is_empty() {
1373                        additional_guidance.push(trimmed.to_string());
1374                    }
1375                }
1376                MessageRole::User => {
1377                    input.push(json!({
1378                        "role": "user",
1379                        "content": [{
1380                            "type": "input_text",
1381                            "text": msg.content.clone()
1382                        }]
1383                    }));
1384                }
1385                MessageRole::Assistant => {
1386                    let mut content_parts = Vec::new();
1387                    if !msg.content.is_empty() {
1388                        content_parts.push(json!({
1389                            "type": "output_text",
1390                            "text": msg.content.clone()
1391                        }));
1392                    }
1393
1394                    if let Some(tool_calls) = &msg.tool_calls {
1395                        for call in tool_calls {
1396                            content_parts.push(json!({
1397                                "type": "tool_call",
1398                                "id": call.id.clone(),
1399                                "name": call.function.name.clone(),
1400                                "arguments": call.function.arguments.clone()
1401                            }));
1402                        }
1403                    }
1404
1405                    if !content_parts.is_empty() {
1406                        input.push(json!({
1407                            "role": "assistant",
1408                            "content": content_parts
1409                        }));
1410                    }
1411                }
1412                MessageRole::Tool => {
1413                    let tool_call_id = msg.tool_call_id.clone().ok_or_else(|| {
1414                        let formatted_error = error_display::format_llm_error(
1415                            "OpenRouter",
1416                            "Tool messages must include tool_call_id for Responses API",
1417                        );
1418                        LLMError::InvalidRequest(formatted_error)
1419                    })?;
1420
1421                    let mut tool_content = Vec::new();
1422                    if !msg.content.trim().is_empty() {
1423                        tool_content.push(json!({
1424                            "type": "output_text",
1425                            "text": msg.content.clone()
1426                        }));
1427                    }
1428
1429                    let mut tool_result = json!({
1430                        "type": "tool_result",
1431                        "tool_call_id": tool_call_id
1432                    });
1433
1434                    if !tool_content.is_empty() {
1435                        if let Value::Object(ref mut map) = tool_result {
1436                            map.insert("content".to_string(), json!(tool_content));
1437                        }
1438                    }
1439
1440                    input.push(json!({
1441                        "role": "tool",
1442                        "content": [tool_result]
1443                    }));
1444                }
1445            }
1446        }
1447
1448        let developer_prompt = gpt5_codex_developer_prompt(&additional_guidance);
1449        input.insert(
1450            0,
1451            json!({
1452                "role": "developer",
1453                "content": [{
1454                    "type": "input_text",
1455                    "text": developer_prompt
1456                }]
1457            }),
1458        );
1459
1460        Ok(input)
1461    }
1462
1463    fn convert_to_openrouter_responses_format(
1464        &self,
1465        request: &LLMRequest,
1466    ) -> Result<Value, LLMError> {
1467        let resolved_model = self.resolve_model(request);
1468        let input = if Self::is_gpt5_codex_model(resolved_model) {
1469            self.build_codex_responses_input(request)?
1470        } else {
1471            self.build_standard_responses_input(request)?
1472        };
1473
1474        if input.is_empty() {
1475            let formatted_error = error_display::format_llm_error(
1476                "OpenRouter",
1477                "No messages provided for Responses API",
1478            );
1479            return Err(LLMError::InvalidRequest(formatted_error));
1480        }
1481
1482        let mut provider_request = json!({
1483            "model": resolved_model,
1484            "input": input,
1485            "stream": request.stream
1486        });
1487
1488        if let Some(max_tokens) = request.max_tokens {
1489            provider_request["max_output_tokens"] = json!(max_tokens);
1490        }
1491
1492        if let Some(temperature) = request.temperature {
1493            provider_request["temperature"] = json!(temperature);
1494        }
1495
1496        if let Some(tools) = &request.tools {
1497            if !tools.is_empty() {
1498                let tools_json: Vec<Value> = tools
1499                    .iter()
1500                    .map(|tool| {
1501                        json!({
1502                            "type": "function",
1503                            "function": {
1504                                "name": tool.function.name,
1505                                "description": tool.function.description,
1506                                "parameters": tool.function.parameters
1507                            }
1508                        })
1509                    })
1510                    .collect();
1511                provider_request["tools"] = Value::Array(tools_json);
1512            }
1513        }
1514
1515        if let Some(tool_choice) = &request.tool_choice {
1516            provider_request["tool_choice"] = tool_choice.to_provider_format("openai");
1517        }
1518
1519        if let Some(parallel) = request.parallel_tool_calls {
1520            provider_request["parallel_tool_calls"] = Value::Bool(parallel);
1521        }
1522
1523        if let Some(effort) = request.reasoning_effort.as_deref() {
1524            if self.supports_reasoning_effort(resolved_model) {
1525                provider_request["reasoning"] = json!({ "effort": effort });
1526            }
1527        }
1528
1529        if Self::is_gpt5_codex_model(resolved_model) {
1530            provider_request["reasoning"] = json!({ "effort": "medium" });
1531        }
1532
1533        Ok(provider_request)
1534    }
1535
1536    fn convert_to_openrouter_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
1537        let resolved_model = self.resolve_model(request);
1538        let mut messages = Vec::new();
1539
1540        if let Some(system_prompt) = &request.system_prompt {
1541            messages.push(json!({
1542                "role": crate::config::constants::message_roles::SYSTEM,
1543                "content": system_prompt
1544            }));
1545        }
1546
1547        for msg in &request.messages {
1548            let role = msg.role.as_openai_str();
1549            let mut message = json!({
1550                "role": role,
1551                "content": msg.content
1552            });
1553
1554            if msg.role == MessageRole::Assistant {
1555                if let Some(tool_calls) = &msg.tool_calls {
1556                    if !tool_calls.is_empty() {
1557                        let tool_calls_json: Vec<Value> = tool_calls
1558                            .iter()
1559                            .map(|tc| {
1560                                json!({
1561                                    "id": tc.id,
1562                                    "type": "function",
1563                                    "function": {
1564                                        "name": tc.function.name,
1565                                        "arguments": tc.function.arguments
1566                                    }
1567                                })
1568                            })
1569                            .collect();
1570                        message["tool_calls"] = Value::Array(tool_calls_json);
1571                    }
1572                }
1573            }
1574
1575            if msg.role == MessageRole::Tool {
1576                if let Some(tool_call_id) = &msg.tool_call_id {
1577                    message["tool_call_id"] = Value::String(tool_call_id.clone());
1578                }
1579            }
1580
1581            messages.push(message);
1582        }
1583
1584        if messages.is_empty() {
1585            let formatted_error =
1586                error_display::format_llm_error("OpenRouter", "No messages provided");
1587            return Err(LLMError::InvalidRequest(formatted_error));
1588        }
1589
1590        let mut provider_request = json!({
1591            "model": resolved_model,
1592            "messages": messages,
1593            "stream": request.stream
1594        });
1595
1596        if let Some(max_tokens) = request.max_tokens {
1597            provider_request["max_tokens"] = json!(max_tokens);
1598        }
1599
1600        if let Some(temperature) = request.temperature {
1601            provider_request["temperature"] = json!(temperature);
1602        }
1603
1604        if let Some(tools) = &request.tools {
1605            if !tools.is_empty() {
1606                let tools_json: Vec<Value> = tools
1607                    .iter()
1608                    .map(|tool| {
1609                        json!({
1610                            "type": "function",
1611                            "function": {
1612                                "name": tool.function.name,
1613                                "description": tool.function.description,
1614                                "parameters": tool.function.parameters
1615                            }
1616                        })
1617                    })
1618                    .collect();
1619                provider_request["tools"] = Value::Array(tools_json);
1620            }
1621        }
1622
1623        if let Some(tool_choice) = &request.tool_choice {
1624            provider_request["tool_choice"] = tool_choice.to_provider_format("openai");
1625        }
1626
1627        if let Some(parallel) = request.parallel_tool_calls {
1628            provider_request["parallel_tool_calls"] = Value::Bool(parallel);
1629        }
1630
1631        if let Some(effort) = request.reasoning_effort.as_deref() {
1632            if self.supports_reasoning_effort(resolved_model) {
1633                provider_request["reasoning"] = json!({ "effort": effort });
1634            }
1635        }
1636
1637        Ok(provider_request)
1638    }
1639
1640    fn parse_openrouter_response(&self, response_json: Value) -> Result<LLMResponse, LLMError> {
1641        if let Some(choices) = response_json
1642            .get("choices")
1643            .and_then(|value| value.as_array())
1644        {
1645            if choices.is_empty() {
1646                let formatted_error =
1647                    error_display::format_llm_error("OpenRouter", "No choices in response");
1648                return Err(LLMError::Provider(formatted_error));
1649            }
1650
1651            let choice = &choices[0];
1652            let message = choice.get("message").ok_or_else(|| {
1653                let formatted_error = error_display::format_llm_error(
1654                    "OpenRouter",
1655                    "Invalid response format: missing message",
1656                );
1657                LLMError::Provider(formatted_error)
1658            })?;
1659
1660            let content = match message.get("content") {
1661                Some(Value::String(text)) => Some(text.to_string()),
1662                Some(Value::Array(parts)) => {
1663                    let text = parts
1664                        .iter()
1665                        .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
1666                        .collect::<Vec<_>>()
1667                        .join("");
1668                    if text.is_empty() { None } else { Some(text) }
1669                }
1670                _ => None,
1671            };
1672
1673            let tool_calls = message
1674                .get("tool_calls")
1675                .and_then(|tc| tc.as_array())
1676                .map(|calls| {
1677                    calls
1678                        .iter()
1679                        .filter_map(|call| {
1680                            let id = call.get("id").and_then(|v| v.as_str())?;
1681                            let function = call.get("function")?;
1682                            let name = function.get("name").and_then(|v| v.as_str())?;
1683                            let arguments = function.get("arguments");
1684                            let serialized = arguments.map_or("{}".to_string(), |value| {
1685                                if value.is_string() {
1686                                    value.as_str().unwrap_or("").to_string()
1687                                } else {
1688                                    value.to_string()
1689                                }
1690                            });
1691                            Some(ToolCall::function(
1692                                id.to_string(),
1693                                name.to_string(),
1694                                serialized,
1695                            ))
1696                        })
1697                        .collect::<Vec<_>>()
1698                })
1699                .filter(|calls| !calls.is_empty());
1700
1701            let mut reasoning = message
1702                .get("reasoning")
1703                .and_then(extract_reasoning_trace)
1704                .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace));
1705
1706            if reasoning.is_none() {
1707                reasoning = extract_reasoning_from_message_content(message);
1708            }
1709
1710            let finish_reason = choice
1711                .get("finish_reason")
1712                .and_then(|fr| fr.as_str())
1713                .map(map_finish_reason)
1714                .unwrap_or(FinishReason::Stop);
1715
1716            let usage = response_json.get("usage").map(parse_usage_value);
1717
1718            return Ok(LLMResponse {
1719                content,
1720                tool_calls,
1721                usage,
1722                finish_reason,
1723                reasoning,
1724            });
1725        }
1726
1727        self.parse_responses_api_response(&response_json)
1728    }
1729
1730    fn parse_responses_api_response(&self, payload: &Value) -> Result<LLMResponse, LLMError> {
1731        let response_container = payload.get("response").unwrap_or(payload);
1732
1733        let outputs = response_container
1734            .get("output")
1735            .or_else(|| response_container.get("outputs"))
1736            .and_then(|value| value.as_array())
1737            .ok_or_else(|| {
1738                let formatted_error = error_display::format_llm_error(
1739                    "OpenRouter",
1740                    "Invalid response format: missing output",
1741                );
1742                LLMError::Provider(formatted_error)
1743            })?;
1744
1745        if outputs.is_empty() {
1746            let formatted_error =
1747                error_display::format_llm_error("OpenRouter", "No output in response");
1748            return Err(LLMError::Provider(formatted_error));
1749        }
1750
1751        let message = outputs
1752            .iter()
1753            .find(|value| {
1754                value
1755                    .get("role")
1756                    .and_then(|role| role.as_str())
1757                    .map(|role| role == "assistant")
1758                    .unwrap_or(true)
1759            })
1760            .unwrap_or(&outputs[0]);
1761
1762        let mut aggregated_content = String::new();
1763        let mut reasoning_buffer = ReasoningBuffer::default();
1764        let mut tool_call_builders: Vec<ToolCallBuilder> = Vec::new();
1765        let mut deltas = StreamDelta::default();
1766
1767        if let Some(content_value) = message.get("content") {
1768            process_content_value(
1769                content_value,
1770                &mut aggregated_content,
1771                &mut reasoning_buffer,
1772                &mut tool_call_builders,
1773                &mut deltas,
1774            );
1775        } else {
1776            process_content_value(
1777                message,
1778                &mut aggregated_content,
1779                &mut reasoning_buffer,
1780                &mut tool_call_builders,
1781                &mut deltas,
1782            );
1783        }
1784
1785        let mut tool_calls = finalize_tool_calls(tool_call_builders);
1786        if tool_calls.is_none() {
1787            tool_calls = extract_tool_calls_from_content(message);
1788        }
1789
1790        let mut reasoning = reasoning_buffer.finalize();
1791        if reasoning.is_none() {
1792            reasoning = extract_reasoning_from_message_content(message)
1793                .or_else(|| message.get("reasoning").and_then(extract_reasoning_trace))
1794                .or_else(|| payload.get("reasoning").and_then(extract_reasoning_trace));
1795        }
1796
1797        let content = if aggregated_content.is_empty() {
1798            message
1799                .get("output_text")
1800                .and_then(|value| value.as_str())
1801                .map(|value| value.to_string())
1802        } else {
1803            Some(aggregated_content)
1804        };
1805
1806        let mut usage = payload.get("usage").map(parse_usage_value);
1807        if usage.is_none() {
1808            usage = response_container.get("usage").map(parse_usage_value);
1809        }
1810
1811        let finish_reason = payload
1812            .get("stop_reason")
1813            .or_else(|| payload.get("finish_reason"))
1814            .or_else(|| payload.get("status"))
1815            .or_else(|| response_container.get("stop_reason"))
1816            .or_else(|| response_container.get("finish_reason"))
1817            .or_else(|| message.get("stop_reason"))
1818            .or_else(|| message.get("finish_reason"))
1819            .and_then(|value| value.as_str())
1820            .map(map_finish_reason)
1821            .unwrap_or(FinishReason::Stop);
1822
1823        Ok(LLMResponse {
1824            content,
1825            tool_calls,
1826            usage,
1827            finish_reason,
1828            reasoning,
1829        })
1830    }
1831}
1832
1833#[async_trait]
1834impl LLMProvider for OpenRouterProvider {
1835    fn name(&self) -> &str {
1836        "openrouter"
1837    }
1838
1839    fn supports_streaming(&self) -> bool {
1840        true
1841    }
1842
1843    fn supports_reasoning(&self, model: &str) -> bool {
1844        let requested = if model.trim().is_empty() {
1845            self.model.as_str()
1846        } else {
1847            model
1848        };
1849
1850        models::openrouter::REASONING_MODELS
1851            .iter()
1852            .any(|candidate| *candidate == requested)
1853    }
1854
1855    fn supports_reasoning_effort(&self, model: &str) -> bool {
1856        let requested = if model.trim().is_empty() {
1857            self.model.as_str()
1858        } else {
1859            model
1860        };
1861        models::openrouter::REASONING_MODELS
1862            .iter()
1863            .any(|candidate| *candidate == requested)
1864    }
1865
1866    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
1867        let response = self.send_with_tool_fallback(&request, Some(true)).await?;
1868
1869        fn find_sse_boundary(buffer: &str) -> Option<(usize, usize)> {
1870            let newline_boundary = buffer.find("\n\n").map(|idx| (idx, 2));
1871            let carriage_boundary = buffer.find("\r\n\r\n").map(|idx| (idx, 4));
1872
1873            match (newline_boundary, carriage_boundary) {
1874                (Some((n_idx, n_len)), Some((c_idx, c_len))) => {
1875                    if n_idx <= c_idx {
1876                        Some((n_idx, n_len))
1877                    } else {
1878                        Some((c_idx, c_len))
1879                    }
1880                }
1881                (Some(boundary), None) => Some(boundary),
1882                (None, Some(boundary)) => Some(boundary),
1883                (None, None) => None,
1884            }
1885        }
1886
1887        let stream = try_stream! {
1888            let mut body_stream = response.bytes_stream();
1889            let mut buffer = String::new();
1890            let mut aggregated_content = String::new();
1891            let mut tool_call_builders: Vec<ToolCallBuilder> = Vec::new();
1892            let mut reasoning = ReasoningBuffer::default();
1893            let mut usage: Option<Usage> = None;
1894            let mut finish_reason = FinishReason::Stop;
1895            let mut done = false;
1896
1897            while let Some(chunk_result) = body_stream.next().await {
1898                let chunk = chunk_result.map_err(|err| {
1899                    let formatted_error = error_display::format_llm_error(
1900                        "OpenRouter",
1901                        &format!("Streaming error: {}", err),
1902                    );
1903                    LLMError::Network(formatted_error)
1904                })?;
1905
1906                buffer.push_str(&String::from_utf8_lossy(&chunk));
1907
1908                while let Some((split_idx, delimiter_len)) = find_sse_boundary(&buffer) {
1909                    let event = buffer[..split_idx].to_string();
1910                    buffer.drain(..split_idx + delimiter_len);
1911
1912                    if let Some(data_payload) = extract_data_payload(&event) {
1913                        let trimmed_payload = data_payload.trim();
1914                        if trimmed_payload == "[DONE]" {
1915                            done = true;
1916                            break;
1917                        }
1918
1919                        if !trimmed_payload.is_empty() {
1920                            let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
1921                                let formatted_error = error_display::format_llm_error(
1922                                    "OpenRouter",
1923                                    &format!("Failed to parse stream payload: {}", err),
1924                                );
1925                                LLMError::Provider(formatted_error)
1926                            })?;
1927
1928                            if let Some(delta) = parse_stream_payload(
1929                                &payload,
1930                                &mut aggregated_content,
1931                                &mut tool_call_builders,
1932                                &mut reasoning,
1933                                &mut usage,
1934                                &mut finish_reason,
1935                            ) {
1936                                for fragment in delta.into_fragments() {
1937                                    match fragment {
1938                                        StreamFragment::Content(text) if !text.is_empty() => {
1939                                            yield LLMStreamEvent::Token { delta: text };
1940                                        }
1941                                        StreamFragment::Reasoning(text) if !text.is_empty() => {
1942                                            yield LLMStreamEvent::Reasoning { delta: text };
1943                                        }
1944                                        _ => {}
1945                                    }
1946                                }
1947                            }
1948                        }
1949                    }
1950                }
1951
1952                if done {
1953                    break;
1954                }
1955            }
1956
1957            if !done && !buffer.trim().is_empty() {
1958                if let Some(data_payload) = extract_data_payload(&buffer) {
1959                    let trimmed_payload = data_payload.trim();
1960                    if trimmed_payload != "[DONE]" && !trimmed_payload.is_empty() {
1961                        let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
1962                            let formatted_error = error_display::format_llm_error(
1963                                "OpenRouter",
1964                                &format!("Failed to parse stream payload: {}", err),
1965                            );
1966                            LLMError::Provider(formatted_error)
1967                        })?;
1968
1969                        if let Some(delta) = parse_stream_payload(
1970                            &payload,
1971                            &mut aggregated_content,
1972                            &mut tool_call_builders,
1973                            &mut reasoning,
1974                            &mut usage,
1975                            &mut finish_reason,
1976                        ) {
1977                            for fragment in delta.into_fragments() {
1978                                match fragment {
1979                                    StreamFragment::Content(text) if !text.is_empty() => {
1980                                        yield LLMStreamEvent::Token { delta: text };
1981                                    }
1982                                    StreamFragment::Reasoning(text) if !text.is_empty() => {
1983                                        yield LLMStreamEvent::Reasoning { delta: text };
1984                                    }
1985                                    _ => {}
1986                                }
1987                            }
1988                        }
1989                    }
1990                }
1991            }
1992
1993            let response = finalize_stream_response(
1994                aggregated_content,
1995                tool_call_builders,
1996                usage,
1997                finish_reason,
1998                reasoning,
1999            );
2000
2001            yield LLMStreamEvent::Completed { response };
2002        };
2003
2004        Ok(Box::pin(stream))
2005    }
2006
2007    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
2008        if self.prompt_cache_enabled && self.prompt_cache_settings.propagate_provider_capabilities {
2009            // When enabled, vtcode forwards provider-specific cache_control markers directly
2010            // through the OpenRouter payload without further transformation.
2011        }
2012
2013        if self.prompt_cache_enabled && self.prompt_cache_settings.report_savings {
2014            // Cache savings are surfaced via usage metrics parsed later in the response cycle.
2015        }
2016
2017        let response = self.send_with_tool_fallback(&request, None).await?;
2018
2019        let openrouter_response: Value = response.json().await.map_err(|e| {
2020            let formatted_error = error_display::format_llm_error(
2021                "OpenRouter",
2022                &format!("Failed to parse response: {}", e),
2023            );
2024            LLMError::Provider(formatted_error)
2025        })?;
2026
2027        self.parse_openrouter_response(openrouter_response)
2028    }
2029
2030    fn supported_models(&self) -> Vec<String> {
2031        models::openrouter::SUPPORTED_MODELS
2032            .iter()
2033            .map(|s| s.to_string())
2034            .collect()
2035    }
2036
2037    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
2038        if request.messages.is_empty() {
2039            let formatted_error =
2040                error_display::format_llm_error("OpenRouter", "Messages cannot be empty");
2041            return Err(LLMError::InvalidRequest(formatted_error));
2042        }
2043
2044        for message in &request.messages {
2045            if let Err(err) = message.validate_for_provider("openai") {
2046                let formatted = error_display::format_llm_error("OpenRouter", &err);
2047                return Err(LLMError::InvalidRequest(formatted));
2048            }
2049        }
2050
2051        if request.model.trim().is_empty() {
2052            let formatted_error =
2053                error_display::format_llm_error("OpenRouter", "Model must be provided");
2054            return Err(LLMError::InvalidRequest(formatted_error));
2055        }
2056
2057        Ok(())
2058    }
2059}
2060
2061#[async_trait]
2062impl LLMClient for OpenRouterProvider {
2063    async fn generate(&mut self, prompt: &str) -> Result<llm_types::LLMResponse, LLMError> {
2064        let request = self.parse_client_prompt(prompt);
2065        let request_model = request.model.clone();
2066        let response = LLMProvider::generate(self, request).await?;
2067
2068        Ok(llm_types::LLMResponse {
2069            content: response.content.unwrap_or_default(),
2070            model: request_model,
2071            usage: response.usage.map(|u| llm_types::Usage {
2072                prompt_tokens: u.prompt_tokens as usize,
2073                completion_tokens: u.completion_tokens as usize,
2074                total_tokens: u.total_tokens as usize,
2075                cached_prompt_tokens: u.cached_prompt_tokens.map(|v| v as usize),
2076                cache_creation_tokens: u.cache_creation_tokens.map(|v| v as usize),
2077                cache_read_tokens: u.cache_read_tokens.map(|v| v as usize),
2078            }),
2079            reasoning: response.reasoning,
2080        })
2081    }
2082
2083    fn backend_kind(&self) -> llm_types::BackendKind {
2084        llm_types::BackendKind::OpenRouter
2085    }
2086
2087    fn model_id(&self) -> &str {
2088        &self.model
2089    }
2090}
2091
2092#[cfg(test)]
2093mod tests {
2094    use super::*;
2095    use serde_json::json;
2096
2097    #[test]
2098    fn test_parse_stream_payload_chat_chunk() {
2099        let payload = json!({
2100            "choices": [{
2101                "delta": {
2102                    "content": [
2103                        {"type": "output_text", "text": "Hello"}
2104                    ]
2105                }
2106            }]
2107        });
2108
2109        let mut aggregated = String::new();
2110        let mut builders = Vec::new();
2111        let mut reasoning = ReasoningBuffer::default();
2112        let mut usage = None;
2113        let mut finish_reason = FinishReason::Stop;
2114
2115        let delta = parse_stream_payload(
2116            &payload,
2117            &mut aggregated,
2118            &mut builders,
2119            &mut reasoning,
2120            &mut usage,
2121            &mut finish_reason,
2122        );
2123
2124        let fragments = delta.expect("delta should exist").into_fragments();
2125        assert_eq!(
2126            fragments,
2127            vec![StreamFragment::Content("Hello".to_string())]
2128        );
2129        assert_eq!(aggregated, "Hello");
2130        assert!(builders.is_empty());
2131        assert!(usage.is_none());
2132        assert!(reasoning.finalize().is_none());
2133    }
2134
2135    #[test]
2136    fn test_parse_stream_payload_response_delta() {
2137        let payload = json!({
2138            "type": "response.delta",
2139            "delta": {
2140                "type": "output_text_delta",
2141                "text": "Stream"
2142            }
2143        });
2144
2145        let mut aggregated = String::new();
2146        let mut builders = Vec::new();
2147        let mut reasoning = ReasoningBuffer::default();
2148        let mut usage = None;
2149        let mut finish_reason = FinishReason::Stop;
2150
2151        let delta = parse_stream_payload(
2152            &payload,
2153            &mut aggregated,
2154            &mut builders,
2155            &mut reasoning,
2156            &mut usage,
2157            &mut finish_reason,
2158        );
2159
2160        let fragments = delta.expect("delta should exist").into_fragments();
2161        assert_eq!(
2162            fragments,
2163            vec![StreamFragment::Content("Stream".to_string())]
2164        );
2165        assert_eq!(aggregated, "Stream");
2166    }
2167
2168    #[test]
2169    fn test_extract_data_payload_joins_multiline_events() {
2170        let event = ": keep-alive\n".to_string() + "data: {\"a\":1}\n" + "data: {\"b\":2}\n";
2171        let payload = extract_data_payload(&event);
2172        assert_eq!(payload.as_deref(), Some("{\"a\":1}\n{\"b\":2}"));
2173    }
2174
2175    #[test]
2176    fn parse_usage_value_includes_cache_metrics() {
2177        let value = json!({
2178            "prompt_tokens": 120,
2179            "completion_tokens": 80,
2180            "total_tokens": 200,
2181            "prompt_cache_read_tokens": 90,
2182            "prompt_cache_write_tokens": 15
2183        });
2184
2185        let usage = parse_usage_value(&value);
2186        assert_eq!(usage.prompt_tokens, 120);
2187        assert_eq!(usage.completion_tokens, 80);
2188        assert_eq!(usage.total_tokens, 200);
2189        assert_eq!(usage.cached_prompt_tokens, Some(90));
2190        assert_eq!(usage.cache_read_tokens, Some(90));
2191        assert_eq!(usage.cache_creation_tokens, Some(15));
2192    }
2193}