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