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