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