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