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 }
2012
2013 if self.prompt_cache_enabled && self.prompt_cache_settings.report_savings {
2014 }
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}