1use crate::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, LlmErrorKind, Result};
19use crate::openresponses_protocol::{CompactOutputItem, CompactRequest, CompactResponse};
20use crate::tool_types::{ToolCall, ToolDefinition};
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use futures::Stream;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::pin::Pin;
27use std::sync::Arc;
28
29pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
35
36#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum ProviderOpaqueContext {
44 OpenResponsesCompact { output: Vec<CompactOutputItem> },
46}
47
48impl std::fmt::Debug for ProviderOpaqueContext {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::OpenResponsesCompact { output } => f
52 .debug_struct("OpenResponsesCompact")
53 .field("item_count", &output.len())
54 .finish_non_exhaustive(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct LlmStreamError {
66 pub code: Option<String>,
68 pub status: Option<u16>,
70 pub message: String,
72}
73
74impl LlmStreamError {
75 pub fn new(message: impl Into<String>) -> Self {
76 Self {
77 code: None,
78 status: None,
79 message: message.into(),
80 }
81 }
82
83 pub fn provider(
85 code: Option<impl Into<String>>,
86 status: Option<u16>,
87 message: impl Into<String>,
88 ) -> Self {
89 Self {
90 code: code.map(Into::into),
91 status,
92 message: message.into(),
93 }
94 }
95
96 pub fn kind(&self) -> LlmErrorKind {
98 if let Some(code) = self.code.as_deref()
99 && let Some(kind) = LlmErrorKind::from_provider_code(code)
100 {
101 return kind;
102 }
103 if let Some(status) = self.status {
104 return LlmErrorKind::from_provider_status(status, &self.message);
105 }
106 LlmErrorKind::from_error_text(&self.message)
107 }
108}
109
110impl std::error::Error for LlmStreamError {}
111
112impl std::fmt::Display for LlmStreamError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match (&self.code, self.status) {
115 (Some(code), Some(status)) => write!(f, "{code} ({status}): {}", self.message),
116 (Some(code), None) => write!(f, "{code}: {}", self.message),
117 (None, Some(status)) => write!(f, "({status}): {}", self.message),
118 (None, None) => f.write_str(&self.message),
119 }
120 }
121}
122
123impl From<String> for LlmStreamError {
124 fn from(message: String) -> Self {
125 Self::new(message)
126 }
127}
128
129impl From<&str> for LlmStreamError {
130 fn from(message: &str) -> Self {
131 Self::new(message)
132 }
133}
134
135#[derive(Debug, Clone)]
137pub enum LlmStreamEvent {
138 TextDelta(String),
140 ThinkingDelta(String),
142 ThinkingSignature(String),
145 ReasonItem {
151 provider: String,
153 model: Option<String>,
155 item_id: String,
157 encrypted_content: Option<String>,
159 summary: Vec<String>,
161 token_count: Option<u32>,
163 },
164 ToolCalls(Vec<ToolCall>),
166 MessagePhase(crate::execution_phase::ExecutionPhase),
176 Done(Box<LlmCompletionMetadata>),
178 Error(LlmStreamError),
180}
181
182#[derive(Debug, Clone)]
193pub struct DiscoveredModel {
194 pub model_id: String,
196 pub display_name: Option<String>,
198 pub created_at: Option<DateTime<Utc>>,
200 pub owned_by: Option<String>,
202 pub discovered_profile: Option<crate::model::ModelProfile>,
205}
206
207#[derive(Debug, Clone, Default)]
220pub struct LlmCompletionMetadata {
221 pub total_tokens: Option<u32>,
223 pub prompt_tokens: Option<u32>,
225 pub completion_tokens: Option<u32>,
227 pub cache_read_tokens: Option<u32>,
229 pub cache_creation_tokens: Option<u32>,
231 pub provider_cost_usd: Option<f64>,
235 pub model: Option<String>,
237 pub finish_reason: Option<String>,
239 pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
241 pub response_id: Option<String>,
244 pub phase: Option<String>,
248}
249
250pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
260 reported_input.saturating_sub(cache_read.unwrap_or(0))
261}
262
263#[async_trait]
285pub trait ChatDriver: Send + Sync {
286 async fn chat_completion_stream(
288 &self,
289 messages: Vec<LlmMessage>,
290 config: &LlmCallConfig,
291 ) -> Result<LlmResponseStream>;
292
293 async fn chat_completion(
295 &self,
296 messages: Vec<LlmMessage>,
297 config: &LlmCallConfig,
298 ) -> Result<LlmResponse> {
299 use futures::StreamExt;
300
301 let mut stream = self.chat_completion_stream(messages, config).await?;
302 let mut text = String::new();
303 let mut thinking = String::new();
304 let mut thinking_signature: Option<String> = None;
305 let mut tool_calls = Vec::new();
306 let mut metadata = LlmCompletionMetadata::default();
307
308 while let Some(event) = stream.next().await {
309 match event? {
310 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
311 LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
312 LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
313 LlmStreamEvent::ReasonItem {
314 encrypted_content, ..
315 } => {
316 if let Some(sig) = encrypted_content {
317 thinking_signature = Some(sig);
318 }
319 }
320 LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
321 LlmStreamEvent::MessagePhase(_) => {}
324 LlmStreamEvent::Done(meta) => metadata = *meta,
325 LlmStreamEvent::Error(err) => {
326 return Err(crate::error::AgentLoopError::llm_kind(
327 err.kind(),
328 err.to_string(),
329 ));
330 }
331 }
332 }
333
334 Ok(LlmResponse {
335 text,
336 thinking: if thinking.is_empty() {
337 None
338 } else {
339 Some(thinking)
340 },
341 thinking_signature,
342 tool_calls: if tool_calls.is_empty() {
343 None
344 } else {
345 Some(tool_calls)
346 },
347 metadata,
348 })
349 }
350
351 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
359 Ok(None)
361 }
362
363 fn supports_compact(&self) -> bool {
372 false
374 }
375
376 fn supports_stateful_responses(&self) -> bool {
382 false
383 }
384
385 fn effective_context_window(&self, _model: &str) -> Option<usize> {
391 None
392 }
393
394 fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
405 false
406 }
407
408 async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
428 Ok(None)
430 }
431}
432
433#[async_trait]
435impl ChatDriver for Box<dyn ChatDriver> {
436 async fn chat_completion_stream(
437 &self,
438 messages: Vec<LlmMessage>,
439 config: &LlmCallConfig,
440 ) -> Result<LlmResponseStream> {
441 (**self).chat_completion_stream(messages, config).await
442 }
443
444 async fn chat_completion(
445 &self,
446 messages: Vec<LlmMessage>,
447 config: &LlmCallConfig,
448 ) -> Result<LlmResponse> {
449 (**self).chat_completion(messages, config).await
450 }
451
452 async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
453 (**self).list_models().await
454 }
455
456 fn supports_compact(&self) -> bool {
457 (**self).supports_compact()
458 }
459
460 fn supports_stateful_responses(&self) -> bool {
461 (**self).supports_stateful_responses()
462 }
463
464 fn effective_context_window(&self, model: &str) -> Option<usize> {
465 (**self).effective_context_window(model)
466 }
467
468 fn supports_parallel_tool_calls(&self, model: &str) -> bool {
469 (**self).supports_parallel_tool_calls(model)
470 }
471
472 async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
473 (**self).compact(request).await
474 }
475}
476
477#[derive(Debug, Clone)]
483pub struct LlmMessage {
484 pub role: LlmMessageRole,
485 pub content: LlmMessageContent,
486 pub tool_calls: Option<Vec<ToolCall>>,
487 pub tool_call_id: Option<String>,
488 pub phase: Option<crate::execution_phase::ExecutionPhase>,
493 pub thinking: Option<String>,
496 pub thinking_signature: Option<String>,
499}
500
501impl LlmMessage {
502 pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
504 Self {
505 role,
506 content: LlmMessageContent::Text(content.into()),
507 tool_calls: None,
508 tool_call_id: None,
509 phase: None,
510 thinking: None,
511 thinking_signature: None,
512 }
513 }
514
515 pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
517 Self {
518 role,
519 content: LlmMessageContent::Parts(parts),
520 tool_calls: None,
521 tool_call_id: None,
522 phase: None,
523 thinking: None,
524 thinking_signature: None,
525 }
526 }
527
528 pub fn content_as_text(&self) -> String {
530 self.content.to_text()
531 }
532
533 pub fn prepend_text_prefix(&mut self, prefix: &str) {
538 match &mut self.content {
539 LlmMessageContent::Text(text) => {
540 *text = format!("{}{}", prefix, text);
541 }
542 LlmMessageContent::Parts(parts) => {
543 for part in parts.iter_mut() {
544 if let LlmContentPart::Text { text } = part {
545 *text = format!("{}{}", prefix, text);
546 return;
547 }
548 }
549 parts.insert(
551 0,
552 LlmContentPart::Text {
553 text: prefix.to_string(),
554 },
555 );
556 }
557 }
558 }
559}
560
561pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
572 let mut system: Option<String> = None;
573 for msg in messages {
574 if msg.role == LlmMessageRole::System {
575 let text = msg.content.to_text();
576 system = Some(match system.take() {
577 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
578 _ => text,
579 });
580 }
581 }
582 system
583}
584
585#[derive(Debug, Clone)]
587pub enum LlmMessageContent {
588 Text(String),
590 Parts(Vec<LlmContentPart>),
592}
593
594impl LlmMessageContent {
595 pub fn to_text(&self) -> String {
597 match self {
598 LlmMessageContent::Text(s) => s.clone(),
599 LlmMessageContent::Parts(parts) => parts
600 .iter()
601 .filter_map(|p| match p {
602 LlmContentPart::Text { text } => Some(text.clone()),
603 _ => None,
604 })
605 .collect::<Vec<_>>()
606 .join(""),
607 }
608 }
609
610 pub fn is_text(&self) -> bool {
612 matches!(self, LlmMessageContent::Text(_))
613 }
614
615 pub fn is_parts(&self) -> bool {
617 matches!(self, LlmMessageContent::Parts(_))
618 }
619}
620
621impl From<String> for LlmMessageContent {
622 fn from(s: String) -> Self {
623 LlmMessageContent::Text(s)
624 }
625}
626
627impl From<&str> for LlmMessageContent {
628 fn from(s: &str) -> Self {
629 LlmMessageContent::Text(s.to_string())
630 }
631}
632
633#[derive(Debug, Clone)]
635pub enum LlmContentPart {
636 Text { text: String },
638 Image { url: String },
640 Audio { url: String },
642}
643
644impl LlmContentPart {
645 pub fn text(text: impl Into<String>) -> Self {
647 LlmContentPart::Text { text: text.into() }
648 }
649
650 pub fn image(url: impl Into<String>) -> Self {
652 LlmContentPart::Image { url: url.into() }
653 }
654
655 pub fn audio(url: impl Into<String>) -> Self {
657 LlmContentPart::Audio { url: url.into() }
658 }
659}
660
661#[derive(Debug, Clone, PartialEq, Eq)]
663pub enum LlmMessageRole {
664 System,
665 User,
666 Assistant,
667 Tool,
668}
669
670#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
680pub struct ToolSearchConfig {
681 pub enabled: bool,
683 pub threshold: usize,
686}
687
688#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
690#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
691#[serde(rename_all = "snake_case")]
692pub enum PromptCacheStrategy {
693 #[default]
695 Auto,
696}
697
698#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
703#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
704pub struct PromptCacheConfig {
705 pub enabled: bool,
707 #[serde(default)]
709 pub strategy: PromptCacheStrategy,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub gemini_cached_content: Option<String>,
718}
719
720#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
730#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
731#[serde(tag = "kind", rename_all = "snake_case")]
732pub enum OpenRouterRoutingPreset {
733 CheapestWithTools,
735 LowestLatencyReview,
737 ZdrOnly,
739 ByokFirst,
741 NoDataCollection,
743 StrictJson,
745 ReasoningRequired,
747 MaxPrice {
750 #[serde(default, skip_serializing_if = "Option::is_none")]
752 prompt_usd_per_million: Option<f64>,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
755 completion_usd_per_million: Option<f64>,
756 },
757}
758
759#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
767#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
768#[serde(rename_all = "snake_case")]
769pub enum OpenRouterCapacityStrategy {
770 #[default]
772 SharedCapacity,
773 ByokFirst,
777 ByokOnly,
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
792#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
793#[serde(rename_all = "snake_case")]
794pub enum OpenRouterServerToolKind {
795 WebSearch,
796 WebFetch,
797 Datetime,
798 ImageGeneration,
799 ApplyPatch,
800 Fusion,
801 Advisor,
802 Subagent,
803}
804
805impl OpenRouterServerToolKind {
806 pub const ALL: [OpenRouterServerToolKind; 8] = [
808 Self::WebSearch,
809 Self::WebFetch,
810 Self::Datetime,
811 Self::ImageGeneration,
812 Self::ApplyPatch,
813 Self::Fusion,
814 Self::Advisor,
815 Self::Subagent,
816 ];
817
818 pub fn name(&self) -> &'static str {
820 match self {
821 Self::WebSearch => "web_search",
822 Self::WebFetch => "web_fetch",
823 Self::Datetime => "datetime",
824 Self::ImageGeneration => "image_generation",
825 Self::ApplyPatch => "apply_patch",
826 Self::Fusion => "fusion",
827 Self::Advisor => "advisor",
828 Self::Subagent => "subagent",
829 }
830 }
831
832 pub fn display_name(&self) -> &'static str {
834 match self {
835 Self::WebSearch => "Web Search",
836 Self::WebFetch => "Web Fetch",
837 Self::Datetime => "Date & Time",
838 Self::ImageGeneration => "Image Generation",
839 Self::ApplyPatch => "Apply Patch",
840 Self::Fusion => "Fusion",
841 Self::Advisor => "Advisor",
842 Self::Subagent => "Subagent",
843 }
844 }
845
846 pub fn wire_type(&self) -> String {
849 format!("openrouter:{}", self.name())
850 }
851
852 pub fn from_name(name: &str) -> Option<Self> {
854 Self::ALL.into_iter().find(|kind| kind.name() == name)
855 }
856}
857
858#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
862#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
863pub struct OpenRouterServerTool {
864 pub kind: OpenRouterServerToolKind,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
866 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
867 pub parameters: Option<serde_json::Value>,
868}
869
870impl OpenRouterServerTool {
871 pub fn new(kind: OpenRouterServerToolKind) -> Self {
873 Self {
874 kind,
875 parameters: None,
876 }
877 }
878
879 pub fn with_parameters(kind: OpenRouterServerToolKind, parameters: serde_json::Value) -> Self {
881 Self {
882 kind,
883 parameters: Some(parameters),
884 }
885 }
886}
887
888#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
891#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
892pub struct OpenRouterRoutingConfig {
893 #[serde(default, skip_serializing_if = "Vec::is_empty")]
895 pub models: Vec<String>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub route: Option<OpenRouterRoute>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub provider: Option<OpenRouterProviderRouting>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub plugins: Option<OpenRouterPluginConfig>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
911 #[serde(default, skip_serializing_if = "Vec::is_empty")]
915 pub presets: Vec<OpenRouterRoutingPreset>,
916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
919 pub server_tools: Vec<OpenRouterServerTool>,
920}
921
922impl OpenRouterRoutingConfig {
923 pub fn is_empty(&self) -> bool {
924 self.models.is_empty()
925 && self.route.is_none()
926 && self.provider.is_none()
927 && self.plugins.as_ref().is_none_or(|p| p.is_empty())
928 && matches!(
929 self.capacity_strategy,
930 None | Some(OpenRouterCapacityStrategy::SharedCapacity)
931 )
932 && self.presets.is_empty()
933 && self.server_tools.is_empty()
934 }
935
936 pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
938 let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
939 let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
940 Self {
941 models,
942 route,
943 provider: None,
944 plugins: None,
945 capacity_strategy: None,
946 presets: vec![],
947 server_tools: vec![],
948 }
949 }
950
951 pub fn validate_for_primary_model(
952 &self,
953 primary_model: &str,
954 ) -> std::result::Result<(), String> {
955 if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
956 return Err(
957 "OpenRouter fallback routing requires at least one model in `models`".to_string(),
958 );
959 }
960
961 if let Some(first_model) = self.models.first()
962 && first_model != primary_model
963 {
964 return Err(format!(
965 "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
966 ));
967 }
968
969 Ok(())
970 }
971
972 pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
982 match self.capacity_strategy {
983 None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
984 Some(OpenRouterCapacityStrategy::ByokFirst) => {
985 let mut result = self.clone();
986 let provider = result.provider.get_or_insert_with(Default::default);
987 if provider.allow_fallbacks.is_none() {
988 provider.allow_fallbacks = Some(true);
989 }
990 Ok(result)
991 }
992 Some(OpenRouterCapacityStrategy::ByokOnly) => {
993 let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
994 if only_is_empty {
995 return Err(
996 "OpenRouter BYOK-only strategy requires provider.only to list at least \
997 one upstream provider slug. Configure the provider list to match the \
998 BYOK providers registered in your OpenRouter workspace."
999 .to_string(),
1000 );
1001 }
1002 let mut result = self.clone();
1003 let provider = result.provider.get_or_insert_with(Default::default);
1004 provider.allow_fallbacks = Some(false);
1005 Ok(result)
1006 }
1007 }
1008 }
1009
1010 pub fn apply_presets(&self) -> std::result::Result<Self, String> {
1020 if self.presets.is_empty() {
1021 return Ok(self.clone());
1022 }
1023
1024 let mut derived = OpenRouterProviderRouting::default();
1025
1026 for preset in &self.presets {
1027 match preset {
1028 OpenRouterRoutingPreset::CheapestWithTools => {
1029 derived.require_parameters = Some(true);
1030 derived.sort = Some(OpenRouterProviderSort::Simple(
1031 OpenRouterProviderSortBy::Price,
1032 ));
1033 }
1034 OpenRouterRoutingPreset::LowestLatencyReview => {
1035 derived.sort = Some(OpenRouterProviderSort::Simple(
1036 OpenRouterProviderSortBy::Throughput,
1037 ));
1038 }
1039 OpenRouterRoutingPreset::ZdrOnly => {
1040 derived.zdr = Some(true);
1041 }
1042 OpenRouterRoutingPreset::ByokFirst => {
1043 if derived.allow_fallbacks.is_none() {
1044 derived.allow_fallbacks = Some(true);
1045 }
1046 }
1047 OpenRouterRoutingPreset::NoDataCollection => {
1048 derived.data_collection = Some(OpenRouterDataCollection::Deny);
1049 }
1050 OpenRouterRoutingPreset::StrictJson
1051 | OpenRouterRoutingPreset::ReasoningRequired => {
1052 derived.require_parameters = Some(true);
1053 }
1054 OpenRouterRoutingPreset::MaxPrice {
1055 prompt_usd_per_million,
1056 completion_usd_per_million,
1057 } => {
1058 if prompt_usd_per_million.is_some_and(|v| v < 0.0)
1059 || completion_usd_per_million.is_some_and(|v| v < 0.0)
1060 {
1061 return Err(
1062 "MaxPrice preset values must be non-negative USD per million tokens"
1063 .to_string(),
1064 );
1065 }
1066 if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
1067 let mp = derived.max_price.get_or_insert_with(Default::default);
1068 if let Some(p) = prompt_usd_per_million {
1069 mp.prompt = Some(p / 1_000_000.0);
1070 }
1071 if let Some(c) = completion_usd_per_million {
1072 mp.completion = Some(c / 1_000_000.0);
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
1081
1082 let mut result = self.clone();
1083 result.presets = vec![];
1084 result.provider = if merged.is_empty() {
1085 None
1086 } else {
1087 Some(merged)
1088 };
1089 Ok(result)
1090 }
1091}
1092
1093fn merge_provider_routing(
1097 derived: OpenRouterProviderRouting,
1098 explicit: OpenRouterProviderRouting,
1099) -> OpenRouterProviderRouting {
1100 OpenRouterProviderRouting {
1101 order: if !explicit.order.is_empty() {
1102 explicit.order
1103 } else {
1104 derived.order
1105 },
1106 only: if !explicit.only.is_empty() {
1107 explicit.only
1108 } else {
1109 derived.only
1110 },
1111 ignore: if !explicit.ignore.is_empty() {
1112 explicit.ignore
1113 } else {
1114 derived.ignore
1115 },
1116 allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
1117 require_parameters: explicit.require_parameters.or(derived.require_parameters),
1118 data_collection: explicit.data_collection.or(derived.data_collection),
1119 zdr: explicit.zdr.or(derived.zdr),
1120 enforce_distillable_text: explicit
1121 .enforce_distillable_text
1122 .or(derived.enforce_distillable_text),
1123 quantizations: if !explicit.quantizations.is_empty() {
1124 explicit.quantizations
1125 } else {
1126 derived.quantizations
1127 },
1128 sort: explicit.sort.or(derived.sort),
1129 max_price: explicit.max_price.or(derived.max_price),
1130 }
1131}
1132
1133#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1136#[serde(rename_all = "snake_case")]
1137pub enum OpenRouterRoute {
1138 Fallback,
1139}
1140
1141#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1144pub struct OpenRouterProviderRouting {
1145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1147 pub order: Vec<String>,
1148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1150 pub only: Vec<String>,
1151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1153 pub ignore: Vec<String>,
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub allow_fallbacks: Option<bool>,
1157 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub require_parameters: Option<bool>,
1160 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub data_collection: Option<OpenRouterDataCollection>,
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1165 pub zdr: Option<bool>,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub enforce_distillable_text: Option<bool>,
1169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1171 pub quantizations: Vec<String>,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub sort: Option<OpenRouterProviderSort>,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub max_price: Option<OpenRouterMaxPrice>,
1178}
1179
1180impl OpenRouterProviderRouting {
1181 pub fn is_empty(&self) -> bool {
1182 self.order.is_empty()
1183 && self.only.is_empty()
1184 && self.ignore.is_empty()
1185 && self.allow_fallbacks.is_none()
1186 && self.require_parameters.is_none()
1187 && self.data_collection.is_none()
1188 && self.zdr.is_none()
1189 && self.enforce_distillable_text.is_none()
1190 && self.quantizations.is_empty()
1191 && self.sort.is_none()
1192 && self.max_price.is_none()
1193 }
1194}
1195
1196#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1199#[serde(rename_all = "snake_case")]
1200pub enum OpenRouterDataCollection {
1201 Allow,
1202 Deny,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1208#[serde(untagged)]
1209pub enum OpenRouterProviderSort {
1210 Simple(OpenRouterProviderSortBy),
1211 Advanced(OpenRouterProviderSortOptions),
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1216#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1217#[serde(rename_all = "snake_case")]
1218pub enum OpenRouterProviderSortBy {
1219 Price,
1220 Throughput,
1221 Latency,
1222}
1223
1224#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1226#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1227pub struct OpenRouterProviderSortOptions {
1228 pub by: OpenRouterProviderSortBy,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub partition: Option<OpenRouterSortPartition>,
1231}
1232
1233#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1235#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1236#[serde(rename_all = "snake_case")]
1237pub enum OpenRouterSortPartition {
1238 Model,
1239 None,
1240}
1241
1242#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1245#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1246pub struct OpenRouterMaxPrice {
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1248 pub prompt: Option<f64>,
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 pub completion: Option<f64>,
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub request: Option<f64>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub image: Option<f64>,
1255}
1256
1257#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1263#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1264pub struct OpenRouterWebSearchPlugin {
1265 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 pub max_results: Option<u32>,
1268 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub search_prompt: Option<String>,
1271}
1272
1273#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1278#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1279pub struct OpenRouterFilePlugin {}
1280
1281#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1286#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1287pub struct OpenRouterPluginConfig {
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub web: Option<OpenRouterWebSearchPlugin>,
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub file: Option<OpenRouterFilePlugin>,
1294}
1295
1296impl OpenRouterPluginConfig {
1297 pub fn is_empty(&self) -> bool {
1298 self.web.is_none() && self.file.is_none()
1299 }
1300}
1301
1302pub const OPENROUTER_HTTP_REFERER_METADATA_KEY: &str = "openrouter.http_referer";
1304pub const OPENROUTER_X_TITLE_METADATA_KEY: &str = "openrouter.x_title";
1306
1307#[derive(Debug, Clone)]
1309pub struct LlmCallConfig {
1310 pub model: String,
1311 pub temperature: Option<f32>,
1312 pub max_tokens: Option<u32>,
1313 pub tools: Vec<ToolDefinition>,
1314 pub reasoning_effort: Option<String>,
1316 pub speed: Option<String>,
1320 pub verbosity: Option<String>,
1324 pub metadata: HashMap<String, String>,
1328 pub previous_response_id: Option<String>,
1331 pub provider_opaque_context: Option<ProviderOpaqueContext>,
1337 pub tool_search: Option<ToolSearchConfig>,
1339 pub prompt_cache: Option<PromptCacheConfig>,
1341 pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1343 pub parallel_tool_calls: Option<bool>,
1350 pub volatile_suffix_len: usize,
1360}
1361
1362impl LlmCallConfig {
1363 pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1373 if supported {
1374 self.parallel_tool_calls
1375 } else {
1376 None
1377 }
1378 }
1379}
1380
1381#[derive(Debug, Clone)]
1386pub struct LlmResponse {
1387 pub text: String,
1388 pub thinking: Option<String>,
1390 pub thinking_signature: Option<String>,
1392 pub tool_calls: Option<Vec<ToolCall>>,
1393 pub metadata: LlmCompletionMetadata,
1394}
1395
1396pub struct LlmCallConfigBuilder {
1402 config: LlmCallConfig,
1403}
1404
1405impl LlmCallConfigBuilder {
1406 pub fn from_config(config: LlmCallConfig) -> Self {
1408 Self { config }
1409 }
1410
1411 pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1413 self.config.reasoning_effort = Some(effort.into());
1414 self
1415 }
1416
1417 pub fn speed(mut self, speed: impl Into<String>) -> Self {
1419 self.config.speed = Some(speed.into());
1420 self
1421 }
1422
1423 pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
1425 self.config.verbosity = Some(verbosity.into());
1426 self
1427 }
1428
1429 pub fn model(mut self, model: impl Into<String>) -> Self {
1431 self.config.model = model.into();
1432 self
1433 }
1434
1435 pub fn temperature(mut self, temp: f32) -> Self {
1437 self.config.temperature = Some(temp);
1438 self
1439 }
1440
1441 pub fn max_tokens(mut self, tokens: u32) -> Self {
1443 self.config.max_tokens = Some(tokens);
1444 self
1445 }
1446
1447 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1449 self.config.tools = tools;
1450 self
1451 }
1452
1453 pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1458 self.config.metadata = metadata;
1459 self
1460 }
1461
1462 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1464 self.config.metadata.insert(key.into(), value.into());
1465 self
1466 }
1467
1468 pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1470 self.config.previous_response_id = id;
1471 self
1472 }
1473
1474 pub fn provider_opaque_context(mut self, context: Option<ProviderOpaqueContext>) -> Self {
1476 self.config.provider_opaque_context = context;
1477 self
1478 }
1479
1480 pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1482 self.config.tool_search = Some(config);
1483 self
1484 }
1485
1486 pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1488 self.config.prompt_cache = Some(config);
1489 self
1490 }
1491
1492 pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1494 self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1495 self
1496 }
1497
1498 pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1500 self.config.parallel_tool_calls = parallel_tool_calls;
1501 self
1502 }
1503
1504 pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1508 self.config.volatile_suffix_len = len;
1509 self
1510 }
1511
1512 pub fn build(self) -> LlmCallConfig {
1514 self.config
1515 }
1516}
1517
1518pub use crate::provider::DriverId;
1527
1528#[derive(Debug, Clone, Default, PartialEq, Eq)]
1534pub struct ProviderMetadata {
1535 pub refresh_token: Option<String>,
1537 pub account_id: Option<String>,
1539 pub extra: Option<serde_json::Value>,
1541}
1542
1543#[derive(Debug, Clone)]
1545pub struct ProviderConfig {
1546 pub provider_type: DriverId,
1548 pub api_key: Option<String>,
1550 pub base_url: Option<String>,
1552 pub metadata: ProviderMetadata,
1554}
1555
1556impl ProviderConfig {
1557 pub fn new(provider_type: DriverId) -> Self {
1559 Self {
1560 provider_type,
1561 api_key: None,
1562 base_url: None,
1563 metadata: ProviderMetadata::default(),
1564 }
1565 }
1566
1567 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1569 self.api_key = Some(api_key.into());
1570 self
1571 }
1572
1573 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1575 self.base_url = Some(base_url.into());
1576 self
1577 }
1578
1579 pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1581 self.metadata = metadata;
1582 self
1583 }
1584}
1585
1586#[derive(Debug, Clone)]
1592pub struct DriverConfig {
1593 pub provider_type: DriverId,
1595 pub api_key: Option<String>,
1601 pub credentials: std::collections::BTreeMap<String, String>,
1607 pub base_url: Option<String>,
1609 pub metadata: ProviderMetadata,
1611}
1612
1613impl DriverConfig {
1614 pub fn from_provider_config(config: &ProviderConfig) -> Self {
1620 Self {
1621 provider_type: config.provider_type.clone(),
1622 credentials: crate::credential_schema::parse_credential_document(
1623 config.api_key.as_deref(),
1624 ),
1625 api_key: config.api_key.clone(),
1626 base_url: config.base_url.clone(),
1627 metadata: config.metadata.clone(),
1628 }
1629 }
1630
1631 pub fn credential(&self, name: &str) -> Option<&str> {
1633 self.credentials
1634 .get(name)
1635 .map(String::as_str)
1636 .filter(|s| !s.is_empty())
1637 }
1638}
1639
1640pub type BoxedChatDriver = Box<dyn ChatDriver>;
1645
1646#[derive(Debug, Clone)]
1652pub struct EmbedRequest {
1653 pub texts: Vec<String>,
1655 pub model: String,
1657}
1658
1659#[derive(Debug, Clone)]
1661pub struct EmbedResponse {
1662 pub embeddings: Vec<Vec<f32>>,
1664 pub usage_tokens: Option<u32>,
1667}
1668
1669#[derive(Debug, thiserror::Error)]
1671pub enum EmbeddingsDriverError {
1672 #[error("embeddings provider returned an error: {0}")]
1673 Provider(String),
1674 #[error("embeddings request failed: {0}")]
1675 Transport(String),
1676}
1677
1678#[async_trait]
1684pub trait EmbeddingsDriver: Send + Sync {
1685 async fn embed(
1687 &self,
1688 request: EmbedRequest,
1689 ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1690}
1691
1692pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1694
1695pub type EmbeddingsDriverFactory =
1697 Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1698
1699pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1708
1709#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1715#[serde(rename_all = "snake_case")]
1716pub enum ServiceKind {
1717 Chat,
1719 Embeddings,
1721 Realtime,
1723 Images,
1725 Rerank,
1727}
1728
1729impl std::fmt::Display for ServiceKind {
1730 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1731 let s = match self {
1732 ServiceKind::Chat => "chat",
1733 ServiceKind::Embeddings => "embeddings",
1734 ServiceKind::Realtime => "realtime",
1735 ServiceKind::Images => "images",
1736 ServiceKind::Rerank => "rerank",
1737 };
1738 f.write_str(s)
1739 }
1740}
1741
1742#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1755pub enum DriverOAuthFlow {
1756 OpenRouterPkce,
1764}
1765
1766#[derive(Debug, Clone)]
1771pub struct DriverOAuthConfig {
1772 pub authorize_url: String,
1774 pub token_url: String,
1776 pub flow: DriverOAuthFlow,
1778}
1779
1780impl DriverOAuthConfig {
1781 pub fn openrouter() -> Self {
1783 Self {
1784 authorize_url: "https://openrouter.ai/auth".to_string(),
1785 token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1786 flow: DriverOAuthFlow::OpenRouterPkce,
1787 }
1788 }
1789}
1790
1791#[derive(Clone)]
1798pub struct DriverDescriptor {
1799 pub id: DriverId,
1801 pub display_name: String,
1803 pub services: Vec<ServiceKind>,
1805 pub credential_schema: CredentialFormSchema,
1807 pub oauth: Option<DriverOAuthConfig>,
1810 pub chat: Option<DriverFactory>,
1812 pub embeddings: Option<EmbeddingsDriverFactory>,
1814}
1815
1816impl DriverDescriptor {
1817 pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1822 where
1823 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1824 {
1825 let id = id.into();
1826 Self {
1827 display_name: default_display_name(&id),
1828 credential_schema: default_credential_schema(&id),
1829 services: vec![ServiceKind::Chat],
1830 oauth: None,
1831 chat: Some(Arc::new(factory)),
1832 embeddings: None,
1833 id,
1834 }
1835 }
1836
1837 pub fn supports(&self, service: ServiceKind) -> bool {
1839 self.services.contains(&service)
1840 }
1841}
1842
1843impl std::fmt::Debug for DriverDescriptor {
1844 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1845 f.debug_struct("DriverDescriptor")
1846 .field("id", &self.id)
1847 .field("display_name", &self.display_name)
1848 .field("services", &self.services)
1849 .field("oauth", &self.oauth.is_some())
1850 .field("chat", &self.chat.is_some())
1851 .field("embeddings", &self.embeddings.is_some())
1852 .finish()
1853 }
1854}
1855
1856fn default_display_name(id: &DriverId) -> String {
1857 match id {
1858 DriverId::OpenAI => "OpenAI".to_string(),
1859 DriverId::OpenRouter => "OpenRouter".to_string(),
1860 DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1861 DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1862 DriverId::Anthropic => "Anthropic".to_string(),
1863 DriverId::Gemini => "Google Gemini".to_string(),
1864 DriverId::Bedrock => "AWS Bedrock".to_string(),
1865 DriverId::Mai => "Microsoft MAI".to_string(),
1866 DriverId::Fireworks => "Fireworks AI".to_string(),
1867 DriverId::LlmSim => "LLM Simulator".to_string(),
1868 DriverId::External(id) => id.to_string(),
1869 }
1870}
1871
1872fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1873 match id {
1874 DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1876 _ => CredentialFormSchema::api_key(String::new()),
1877 }
1878}
1879
1880#[derive(Clone, Default)]
1900pub struct DriverRegistry {
1901 descriptors: HashMap<DriverId, DriverDescriptor>,
1902}
1903
1904impl DriverRegistry {
1905 pub fn new() -> Self {
1907 Self {
1908 descriptors: HashMap::new(),
1909 }
1910 }
1911
1912 pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1918 if self.descriptors.contains_key(&descriptor.id) {
1919 panic!(
1920 "driver already registered for provider '{}'; \
1921 use register_descriptor_or_replace to overwrite intentionally",
1922 descriptor.id
1923 );
1924 }
1925 self.descriptors.insert(descriptor.id.clone(), descriptor);
1926 }
1927
1928 pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1930 self.descriptors.insert(descriptor.id.clone(), descriptor);
1931 }
1932
1933 pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1939 where
1940 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1941 {
1942 self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1943 }
1944
1945 pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1950 where
1951 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1952 {
1953 self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1954 }
1955
1956 pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1961 where
1962 F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1963 {
1964 self.register(DriverId::external(id), factory);
1965 }
1966
1967 pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1976 let requires_api_key = !matches!(
1980 config.provider_type,
1981 DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1982 );
1983 if requires_api_key && config.api_key.is_none() {
1984 return Err(AgentLoopError::llm(
1985 "API key is required. Configure the API key in provider settings.",
1986 ));
1987 }
1988
1989 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1991 AgentLoopError::driver_not_registered(config.provider_type.to_string())
1992 })?;
1993 let factory = descriptor.chat.as_ref().ok_or_else(|| {
1994 AgentLoopError::llm(format!(
1995 "Provider driver '{}' does not implement the chat service.",
1996 config.provider_type
1997 ))
1998 })?;
1999
2000 let driver_config = DriverConfig::from_provider_config(config);
2002 Ok(factory(&driver_config))
2003 }
2004
2005 pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2007 self.descriptors.contains_key(provider_type)
2008 }
2009
2010 pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2012 self.descriptors.get(provider_type)
2013 }
2014
2015 pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2017 self.descriptors
2018 .get(provider_type)
2019 .is_some_and(|d| d.supports(service))
2020 }
2021
2022 pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2024 self.descriptors
2025 .values()
2026 .filter(|d| d.supports(service))
2027 .map(|d| d.id.clone())
2028 .collect()
2029 }
2030
2031 pub fn registered_providers(&self) -> Vec<DriverId> {
2033 self.descriptors.keys().cloned().collect()
2034 }
2035
2036 pub fn create_embeddings_driver(
2044 &self,
2045 config: &ProviderConfig,
2046 ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2047 let requires_api_key = !matches!(
2048 config.provider_type,
2049 DriverId::LlmSim | DriverId::External(_)
2050 );
2051 if requires_api_key && config.api_key.is_none() {
2052 return Err(EmbeddingsDriverError::Provider(
2053 "API key is required. Configure the API key in provider settings.".to_string(),
2054 ));
2055 }
2056 let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2057 EmbeddingsDriverError::Provider(format!(
2058 "No driver registered for provider '{}'",
2059 config.provider_type
2060 ))
2061 })?;
2062 let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2063 EmbeddingsDriverError::Provider(format!(
2064 "Provider driver '{}' does not implement the embeddings service.",
2065 config.provider_type
2066 ))
2067 })?;
2068 let driver_config = DriverConfig::from_provider_config(config);
2069 Ok(factory(&driver_config))
2070 }
2071}
2072
2073const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2078
2079const TRUNCATION_SUFFIX: &str =
2080 "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2081
2082pub fn truncate_tool_result(text: String) -> String {
2083 if text.len() <= MAX_TOOL_RESULT_BYTES {
2084 return text;
2085 }
2086 let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2087 let mut end = content_budget;
2088 while end > 0 && !text.is_char_boundary(end) {
2089 end -= 1;
2090 }
2091 let mut truncated = text[..end].to_string();
2092 truncated.push_str(TRUNCATION_SUFFIX);
2093 truncated
2094}
2095
2096#[cfg(test)]
2101mod tests {
2102 use super::*;
2103
2104 #[test]
2105 fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2106 assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2109 assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2111 assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2112 assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2114 }
2115
2116 #[test]
2117 fn test_chat_driver_defaults_are_conservative_and_boxed_capabilities_forward() {
2118 struct DefaultDriver;
2120 #[async_trait]
2121 impl ChatDriver for DefaultDriver {
2122 async fn chat_completion_stream(
2123 &self,
2124 _messages: Vec<LlmMessage>,
2125 _config: &LlmCallConfig,
2126 ) -> Result<LlmResponseStream> {
2127 unreachable!()
2128 }
2129 }
2130 assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2131 assert!(!DefaultDriver.supports_stateful_responses());
2132
2133 struct StatefulDriver;
2134 #[async_trait]
2135 impl ChatDriver for StatefulDriver {
2136 async fn chat_completion_stream(
2137 &self,
2138 _messages: Vec<LlmMessage>,
2139 _config: &LlmCallConfig,
2140 ) -> Result<LlmResponseStream> {
2141 unreachable!()
2142 }
2143
2144 fn supports_stateful_responses(&self) -> bool {
2145 true
2146 }
2147 }
2148 let boxed: BoxedChatDriver = Box::new(StatefulDriver);
2149 assert!(boxed.supports_stateful_responses());
2150 }
2151
2152 #[test]
2153 fn test_fold_system_messages_none_when_absent() {
2154 let messages = vec![
2155 LlmMessage::text(LlmMessageRole::User, "hi"),
2156 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2157 ];
2158 assert_eq!(fold_system_messages(&messages), None);
2159 }
2160
2161 #[test]
2162 fn test_fold_system_messages_single() {
2163 let messages = vec![
2164 LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2165 LlmMessage::text(LlmMessageRole::User, "hi"),
2166 ];
2167 assert_eq!(
2168 fold_system_messages(&messages),
2169 Some("AGENT-PROMPT".to_string())
2170 );
2171 }
2172
2173 #[test]
2174 fn test_fold_system_messages_accumulates_in_order() {
2175 let messages = vec![
2179 LlmMessage::text(LlmMessageRole::System, "A"),
2180 LlmMessage::text(LlmMessageRole::User, "hi"),
2181 LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2182 LlmMessage::text(LlmMessageRole::System, "B"),
2183 ];
2184 assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2185 }
2186
2187 #[test]
2188 fn test_fold_system_messages_concatenates_parts() {
2189 let messages = vec![LlmMessage::parts(
2190 LlmMessageRole::System,
2191 vec![
2192 LlmContentPart::text("foo"),
2193 LlmContentPart::image("data:image/png;base64,xxx"),
2194 LlmContentPart::text("bar"),
2195 ],
2196 )];
2197 assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2198 }
2199
2200 #[test]
2201 fn test_openrouter_fallback_models_empty_is_empty() {
2202 let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2203
2204 assert!(routing.is_empty());
2205 assert_eq!(routing.route, None);
2206 }
2207
2208 #[test]
2209 fn test_openrouter_routing_validates_primary_model() {
2210 let routing = OpenRouterRoutingConfig::fallback_models([
2211 "openai/gpt-5-mini",
2212 "anthropic/claude-sonnet-4.5",
2213 ]);
2214
2215 assert!(
2216 routing
2217 .validate_for_primary_model("openai/gpt-5-mini")
2218 .is_ok()
2219 );
2220 let err = routing
2221 .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2222 .unwrap_err();
2223 assert!(err.contains("models[0]"));
2224 }
2225
2226 #[test]
2227 fn test_openrouter_routing_rejects_fallback_without_models() {
2228 let routing = OpenRouterRoutingConfig {
2229 route: Some(OpenRouterRoute::Fallback),
2230 ..Default::default()
2231 };
2232
2233 let err = routing
2234 .validate_for_primary_model("openai/gpt-5-mini")
2235 .unwrap_err();
2236 assert!(err.contains("requires at least one model"));
2237 }
2238
2239 #[test]
2240 fn test_openrouter_routing_serializes_request_fields() {
2241 let routing = OpenRouterRoutingConfig {
2242 models: vec![
2243 "openai/gpt-5-mini".to_string(),
2244 "anthropic/claude-sonnet-4.5".to_string(),
2245 ],
2246 route: Some(OpenRouterRoute::Fallback),
2247 provider: Some(OpenRouterProviderRouting {
2248 order: vec!["anthropic".to_string(), "openai".to_string()],
2249 allow_fallbacks: Some(false),
2250 require_parameters: Some(true),
2251 data_collection: Some(OpenRouterDataCollection::Deny),
2252 zdr: Some(true),
2253 sort: Some(OpenRouterProviderSort::Advanced(
2254 OpenRouterProviderSortOptions {
2255 by: OpenRouterProviderSortBy::Throughput,
2256 partition: Some(OpenRouterSortPartition::None),
2257 },
2258 )),
2259 max_price: Some(OpenRouterMaxPrice {
2260 prompt: Some(1.0),
2261 completion: Some(2.0),
2262 ..Default::default()
2263 }),
2264 ..Default::default()
2265 }),
2266 ..Default::default()
2267 };
2268
2269 let json = serde_json::to_value(routing).unwrap();
2270
2271 assert_eq!(
2272 json,
2273 serde_json::json!({
2274 "models": [
2275 "openai/gpt-5-mini",
2276 "anthropic/claude-sonnet-4.5"
2277 ],
2278 "route": "fallback",
2279 "provider": {
2280 "order": ["anthropic", "openai"],
2281 "allow_fallbacks": false,
2282 "require_parameters": true,
2283 "data_collection": "deny",
2284 "zdr": true,
2285 "sort": {
2286 "by": "throughput",
2287 "partition": "none"
2288 },
2289 "max_price": {
2290 "prompt": 1.0,
2291 "completion": 2.0
2292 }
2293 }
2294 })
2295 );
2296 }
2297
2298 #[test]
2299 fn test_provider_type_parsing() {
2300 assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2301 assert_eq!(
2302 "openrouter".parse::<DriverId>().unwrap(),
2303 DriverId::OpenRouter
2304 );
2305 assert_eq!(
2306 "openai_completions".parse::<DriverId>().unwrap(),
2307 DriverId::OpenAICompletions
2308 );
2309 assert_eq!(
2310 "azure_openai".parse::<DriverId>().unwrap(),
2311 DriverId::AzureOpenAI
2312 );
2313 assert_eq!(
2314 "anthropic".parse::<DriverId>().unwrap(),
2315 DriverId::Anthropic
2316 );
2317 assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2318 assert_eq!(
2320 "ollama".parse::<DriverId>().unwrap(),
2321 DriverId::external("ollama")
2322 );
2323 assert_eq!(
2324 "custom".parse::<DriverId>().unwrap(),
2325 DriverId::external("custom")
2326 );
2327 }
2328
2329 #[test]
2330 fn test_external_provider_id_is_case_insensitive() {
2331 assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2334 assert_eq!(
2335 "Ollama".parse::<DriverId>().unwrap(),
2336 "ollama".parse::<DriverId>().unwrap()
2337 );
2338 assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2339 assert_eq!(
2341 DriverId::external("MyProvider"),
2342 "myprovider".parse::<DriverId>().unwrap()
2343 );
2344 }
2345
2346 #[test]
2347 fn test_provider_type_display() {
2348 assert_eq!(DriverId::OpenAI.to_string(), "openai");
2349 assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2350 assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2351 assert_eq!(
2352 DriverId::OpenAICompletions.to_string(),
2353 "openai_completions"
2354 );
2355 assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2356 assert_eq!(DriverId::Gemini.to_string(), "gemini");
2357 }
2358
2359 #[test]
2360 fn test_provider_config_builder() {
2361 let config = ProviderConfig::new(DriverId::Anthropic)
2362 .with_api_key("test-key")
2363 .with_base_url("https://custom.api.com");
2364
2365 assert_eq!(config.provider_type, DriverId::Anthropic);
2366 assert_eq!(config.api_key, Some("test-key".to_string()));
2367 assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2368 }
2369
2370 #[test]
2371 fn test_driver_registry_requires_api_key() {
2372 let mut registry = DriverRegistry::new();
2374 registry.register(DriverId::OpenAI, |_config| {
2375 struct MockDriver;
2377 #[async_trait]
2378 impl ChatDriver for MockDriver {
2379 async fn chat_completion_stream(
2380 &self,
2381 _messages: Vec<LlmMessage>,
2382 _config: &LlmCallConfig,
2383 ) -> Result<LlmResponseStream> {
2384 unimplemented!()
2385 }
2386 }
2387 Box::new(MockDriver)
2388 });
2389
2390 let config = ProviderConfig::new(DriverId::OpenAI);
2392 let result = registry.create_chat_driver(&config);
2393 assert!(result.is_err());
2394
2395 let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2397 let result = registry.create_chat_driver(&config_with_key);
2398 assert!(result.is_ok());
2399 }
2400
2401 #[test]
2402 fn test_driver_registry_returns_error_for_unregistered_provider() {
2403 let registry = DriverRegistry::new();
2404 let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2405
2406 let result = registry.create_chat_driver(&config);
2407
2408 if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2410 assert_eq!(provider, "anthropic");
2411 } else {
2412 panic!("Expected DriverNotRegistered error");
2413 }
2414 }
2415
2416 #[test]
2417 fn test_driver_registry_registration() {
2418 let mut registry = DriverRegistry::new();
2419
2420 assert!(!registry.has_driver(&DriverId::OpenAI));
2421 assert!(!registry.has_driver(&DriverId::Anthropic));
2422
2423 registry.register(DriverId::OpenAI, |_config| {
2424 struct MockDriver;
2425 #[async_trait]
2426 impl ChatDriver for MockDriver {
2427 async fn chat_completion_stream(
2428 &self,
2429 _messages: Vec<LlmMessage>,
2430 _config: &LlmCallConfig,
2431 ) -> Result<LlmResponseStream> {
2432 unimplemented!()
2433 }
2434 }
2435 Box::new(MockDriver)
2436 });
2437
2438 assert!(registry.has_driver(&DriverId::OpenAI));
2439 assert!(!registry.has_driver(&DriverId::Anthropic));
2440 }
2441
2442 #[test]
2443 fn test_register_external_and_create_driver_without_api_key() {
2444 struct MockDriver;
2445 #[async_trait]
2446 impl ChatDriver for MockDriver {
2447 async fn chat_completion_stream(
2448 &self,
2449 _messages: Vec<LlmMessage>,
2450 _config: &LlmCallConfig,
2451 ) -> Result<LlmResponseStream> {
2452 unimplemented!()
2453 }
2454 }
2455
2456 let mut registry = DriverRegistry::new();
2457 registry.register_external("openai-codex", |config| {
2458 assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2460 Box::new(MockDriver)
2461 });
2462
2463 assert!(registry.has_driver(&DriverId::external("openai-codex")));
2464
2465 let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2467 ProviderMetadata {
2468 refresh_token: Some("rt".into()),
2469 ..Default::default()
2470 },
2471 );
2472 assert!(registry.create_chat_driver(&config).is_ok());
2473 }
2474
2475 #[test]
2476 fn test_register_defaults_to_chat_only_descriptor() {
2477 struct MockDriver;
2478 #[async_trait]
2479 impl ChatDriver for MockDriver {
2480 async fn chat_completion_stream(
2481 &self,
2482 _messages: Vec<LlmMessage>,
2483 _config: &LlmCallConfig,
2484 ) -> Result<LlmResponseStream> {
2485 unimplemented!()
2486 }
2487 }
2488
2489 let mut registry = DriverRegistry::new();
2490 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2491
2492 let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2493 assert_eq!(descriptor.display_name, "Anthropic");
2494 assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2495 assert!(descriptor.chat.is_some());
2496 assert_eq!(descriptor.credential_schema.fields.len(), 1);
2498 assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2499 assert!(descriptor.credential_schema.fields[0].required);
2500
2501 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2503 let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2504 assert!(sim.credential_schema.fields.is_empty());
2505 }
2506
2507 #[test]
2508 fn test_descriptor_services_and_lookup() {
2509 struct MockDriver;
2510 #[async_trait]
2511 impl ChatDriver for MockDriver {
2512 async fn chat_completion_stream(
2513 &self,
2514 _messages: Vec<LlmMessage>,
2515 _config: &LlmCallConfig,
2516 ) -> Result<LlmResponseStream> {
2517 unimplemented!()
2518 }
2519 }
2520
2521 let mut registry = DriverRegistry::new();
2522 registry.register_descriptor(DriverDescriptor {
2523 services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2524 ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2525 });
2526 registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2527
2528 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2529 assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2530 assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2531 assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2532
2533 let realtime = registry.providers_for(ServiceKind::Realtime);
2534 assert_eq!(realtime, vec![DriverId::OpenAI]);
2535 let mut chat = registry.providers_for(ServiceKind::Chat);
2536 chat.sort_by_key(|p| p.to_string());
2537 assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2538 }
2539
2540 #[test]
2541 fn test_create_chat_driver_fails_without_chat_factory() {
2542 let mut registry = DriverRegistry::new();
2543 registry.register_descriptor(DriverDescriptor {
2544 id: DriverId::external("embeddings-only"),
2545 display_name: "Embeddings Only".to_string(),
2546 services: vec![ServiceKind::Embeddings],
2547 credential_schema: CredentialFormSchema::empty(),
2548 oauth: None,
2549 chat: None,
2550 embeddings: None,
2551 });
2552
2553 let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2554 let err = match registry.create_chat_driver(&config) {
2555 Ok(_) => panic!("expected error for missing chat factory"),
2556 Err(err) => err,
2557 };
2558 assert!(
2559 err.to_string()
2560 .contains("does not implement the chat service"),
2561 "unexpected error: {err}"
2562 );
2563 }
2564
2565 #[test]
2566 #[should_panic(expected = "already registered")]
2567 fn test_register_duplicate_panics() {
2568 struct MockDriver;
2569 #[async_trait]
2570 impl ChatDriver for MockDriver {
2571 async fn chat_completion_stream(
2572 &self,
2573 _messages: Vec<LlmMessage>,
2574 _config: &LlmCallConfig,
2575 ) -> Result<LlmResponseStream> {
2576 unimplemented!()
2577 }
2578 }
2579
2580 let mut registry = DriverRegistry::new();
2581 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2582 registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2584 }
2585
2586 #[test]
2587 fn test_register_or_replace_overwrites() {
2588 struct MockDriver;
2589 #[async_trait]
2590 impl ChatDriver for MockDriver {
2591 async fn chat_completion_stream(
2592 &self,
2593 _messages: Vec<LlmMessage>,
2594 _config: &LlmCallConfig,
2595 ) -> Result<LlmResponseStream> {
2596 unimplemented!()
2597 }
2598 }
2599
2600 let mut registry = DriverRegistry::new();
2601 registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2602 registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2604 assert!(registry.has_driver(&DriverId::LlmSim));
2605 }
2606
2607 #[test]
2608 fn test_prepend_text_prefix_simple_text() {
2609 let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2610 msg.prepend_text_prefix("[Alice] ");
2611 assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2612 }
2613
2614 #[test]
2615 fn test_prepend_text_prefix_parts() {
2616 let mut msg = LlmMessage::parts(
2617 LlmMessageRole::User,
2618 vec![
2619 LlmContentPart::Text {
2620 text: "Hello".to_string(),
2621 },
2622 LlmContentPart::Image {
2623 url: "data:image/png;base64,abc".to_string(),
2624 },
2625 ],
2626 );
2627 msg.prepend_text_prefix("[Bob] ");
2628 match &msg.content {
2629 LlmMessageContent::Parts(parts) => {
2630 if let LlmContentPart::Text { text } = &parts[0] {
2631 assert_eq!(text, "[Bob] Hello");
2632 } else {
2633 panic!("Expected text part");
2634 }
2635 }
2636 _ => panic!("Expected parts content"),
2637 }
2638 }
2639
2640 #[test]
2641 fn test_prepend_text_prefix_parts_no_text() {
2642 let mut msg = LlmMessage::parts(
2643 LlmMessageRole::User,
2644 vec![LlmContentPart::Image {
2645 url: "data:image/png;base64,abc".to_string(),
2646 }],
2647 );
2648 msg.prepend_text_prefix("[Eve] ");
2649 match &msg.content {
2650 LlmMessageContent::Parts(parts) => {
2651 assert_eq!(parts.len(), 2);
2652 if let LlmContentPart::Text { text } = &parts[0] {
2653 assert_eq!(text, "[Eve] ");
2654 } else {
2655 panic!("Expected prepended text part");
2656 }
2657 }
2658 _ => panic!("Expected parts content"),
2659 }
2660 }
2661
2662 #[test]
2663 fn test_openrouter_plugin_config_is_empty() {
2664 assert!(OpenRouterPluginConfig::default().is_empty());
2665 assert!(
2666 !OpenRouterPluginConfig {
2667 web: Some(OpenRouterWebSearchPlugin::default()),
2668 file: None,
2669 }
2670 .is_empty()
2671 );
2672 assert!(
2673 !OpenRouterPluginConfig {
2674 web: None,
2675 file: Some(OpenRouterFilePlugin {}),
2676 }
2677 .is_empty()
2678 );
2679 }
2680
2681 #[test]
2682 fn test_openrouter_routing_is_empty_with_plugins() {
2683 let with_plugins = OpenRouterRoutingConfig {
2684 plugins: Some(OpenRouterPluginConfig {
2685 web: Some(OpenRouterWebSearchPlugin::default()),
2686 file: None,
2687 }),
2688 ..Default::default()
2689 };
2690 assert!(!with_plugins.is_empty());
2691
2692 let empty_plugins = OpenRouterRoutingConfig {
2693 plugins: Some(OpenRouterPluginConfig::default()),
2694 ..Default::default()
2695 };
2696 assert!(empty_plugins.is_empty());
2697 }
2698
2699 #[test]
2700 fn test_openrouter_web_search_plugin_serialization() {
2701 let plugin = OpenRouterWebSearchPlugin {
2702 max_results: Some(10),
2703 search_prompt: Some("search for Rust crates".to_string()),
2704 };
2705 let json = serde_json::to_value(&plugin).unwrap();
2706 assert_eq!(json["max_results"], 10);
2707 assert_eq!(json["search_prompt"], "search for Rust crates");
2708 }
2709
2710 #[test]
2711 fn test_openrouter_web_search_plugin_omits_none_fields() {
2712 let plugin = OpenRouterWebSearchPlugin::default();
2713 let json = serde_json::to_value(&plugin).unwrap();
2714 assert!(json.get("max_results").is_none());
2715 assert!(json.get("search_prompt").is_none());
2716 }
2717
2718 #[test]
2719 fn test_capacity_strategy_shared_capacity_is_noop() {
2720 let base = OpenRouterRoutingConfig {
2721 models: vec!["openai/gpt-5-mini".to_string()],
2722 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2723 ..Default::default()
2724 };
2725 let result = base.apply_capacity_strategy().unwrap();
2726 assert_eq!(
2727 result.capacity_strategy,
2728 Some(OpenRouterCapacityStrategy::SharedCapacity)
2729 );
2730 assert!(result.provider.is_none());
2731 }
2732
2733 #[test]
2734 fn test_capacity_strategy_none_is_noop() {
2735 let base = OpenRouterRoutingConfig {
2736 models: vec!["openai/gpt-5-mini".to_string()],
2737 capacity_strategy: None,
2738 ..Default::default()
2739 };
2740 let result = base.apply_capacity_strategy().unwrap();
2741 assert!(result.provider.is_none());
2742 }
2743
2744 #[test]
2745 fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2746 let base = OpenRouterRoutingConfig {
2747 models: vec!["openai/gpt-5-mini".to_string()],
2748 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2749 ..Default::default()
2750 };
2751 let result = base.apply_capacity_strategy().unwrap();
2752 let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2753 assert_eq!(provider.allow_fallbacks, Some(true));
2754 }
2755
2756 #[test]
2757 fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2758 let base = OpenRouterRoutingConfig {
2760 models: vec!["openai/gpt-5-mini".to_string()],
2761 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2762 provider: Some(OpenRouterProviderRouting {
2763 allow_fallbacks: Some(false),
2764 ..Default::default()
2765 }),
2766 ..Default::default()
2767 };
2768 let result = base.apply_capacity_strategy().unwrap();
2769 let provider = result.provider.as_ref().unwrap();
2770 assert_eq!(provider.allow_fallbacks, Some(false));
2771 }
2772
2773 #[test]
2774 fn test_capacity_strategy_byok_only_requires_provider_only() {
2775 let base = OpenRouterRoutingConfig {
2776 models: vec!["openai/gpt-5-mini".to_string()],
2777 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2778 ..Default::default()
2779 };
2780 let err = base.apply_capacity_strategy().unwrap_err();
2781 assert!(
2782 err.contains("provider.only"),
2783 "error should mention provider.only: {err}"
2784 );
2785 }
2786
2787 #[test]
2788 fn test_capacity_strategy_byok_only_disables_fallbacks() {
2789 let base = OpenRouterRoutingConfig {
2790 models: vec!["openai/gpt-5-mini".to_string()],
2791 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2792 provider: Some(OpenRouterProviderRouting {
2793 only: vec!["my-byok-provider".to_string()],
2794 ..Default::default()
2795 }),
2796 ..Default::default()
2797 };
2798 let result = base.apply_capacity_strategy().unwrap();
2799 let provider = result.provider.as_ref().unwrap();
2800 assert_eq!(provider.allow_fallbacks, Some(false));
2801 assert_eq!(provider.only, vec!["my-byok-provider"]);
2802 }
2803
2804 #[test]
2805 fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2806 let with_strategy = OpenRouterRoutingConfig {
2807 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2808 ..Default::default()
2809 };
2810 assert!(!with_strategy.is_empty());
2811
2812 let byok_first = OpenRouterRoutingConfig {
2813 capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2814 ..Default::default()
2815 };
2816 assert!(!byok_first.is_empty());
2817
2818 let shared = OpenRouterRoutingConfig {
2819 capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2820 ..Default::default()
2821 };
2822 assert!(shared.is_empty());
2823 }
2824
2825 #[test]
2832 fn test_preset_no_presets_is_noop() {
2833 let base = OpenRouterRoutingConfig {
2834 models: vec!["openai/gpt-5-mini".to_string()],
2835 ..Default::default()
2836 };
2837 let result = base.apply_presets().unwrap();
2838 assert_eq!(result, base);
2839 }
2840
2841 #[test]
2842 fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2843 let base = OpenRouterRoutingConfig {
2844 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2845 ..Default::default()
2846 };
2847 let result = base.apply_presets().unwrap();
2848 assert!(result.presets.is_empty(), "presets cleared after apply");
2849 let provider = result.provider.expect("provider set by preset");
2850 assert_eq!(provider.require_parameters, Some(true));
2851 assert_eq!(
2852 provider.sort,
2853 Some(OpenRouterProviderSort::Simple(
2854 OpenRouterProviderSortBy::Price
2855 ))
2856 );
2857 }
2858
2859 #[test]
2860 fn test_preset_lowest_latency_review_sets_sort_throughput() {
2861 let base = OpenRouterRoutingConfig {
2862 presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2863 ..Default::default()
2864 };
2865 let result = base.apply_presets().unwrap();
2866 let provider = result.provider.expect("provider set by preset");
2867 assert_eq!(
2868 provider.sort,
2869 Some(OpenRouterProviderSort::Simple(
2870 OpenRouterProviderSortBy::Throughput
2871 ))
2872 );
2873 }
2874
2875 #[test]
2876 fn test_preset_zdr_only_sets_zdr() {
2877 let base = OpenRouterRoutingConfig {
2878 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2879 ..Default::default()
2880 };
2881 let result = base.apply_presets().unwrap();
2882 let provider = result.provider.expect("provider set");
2883 assert_eq!(provider.zdr, Some(true));
2884 }
2885
2886 #[test]
2887 fn test_preset_byok_first_sets_allow_fallbacks() {
2888 let base = OpenRouterRoutingConfig {
2889 presets: vec![OpenRouterRoutingPreset::ByokFirst],
2890 ..Default::default()
2891 };
2892 let result = base.apply_presets().unwrap();
2893 let provider = result.provider.expect("provider set");
2894 assert_eq!(provider.allow_fallbacks, Some(true));
2895 }
2896
2897 #[test]
2898 fn test_preset_no_data_collection_sets_data_collection_deny() {
2899 let base = OpenRouterRoutingConfig {
2900 presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2901 ..Default::default()
2902 };
2903 let result = base.apply_presets().unwrap();
2904 let provider = result.provider.expect("provider set");
2905 assert_eq!(
2906 provider.data_collection,
2907 Some(OpenRouterDataCollection::Deny)
2908 );
2909 }
2910
2911 #[test]
2912 fn test_preset_strict_json_sets_require_parameters() {
2913 let base = OpenRouterRoutingConfig {
2914 presets: vec![OpenRouterRoutingPreset::StrictJson],
2915 ..Default::default()
2916 };
2917 let result = base.apply_presets().unwrap();
2918 let provider = result.provider.expect("provider set");
2919 assert_eq!(provider.require_parameters, Some(true));
2920 }
2921
2922 #[test]
2923 fn test_preset_reasoning_required_sets_require_parameters() {
2924 let base = OpenRouterRoutingConfig {
2925 presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2926 ..Default::default()
2927 };
2928 let result = base.apply_presets().unwrap();
2929 let provider = result.provider.expect("provider set");
2930 assert_eq!(provider.require_parameters, Some(true));
2931 }
2932
2933 #[test]
2934 fn test_preset_max_price_converts_usd_per_million() {
2935 let base = OpenRouterRoutingConfig {
2936 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2937 prompt_usd_per_million: Some(5.0),
2938 completion_usd_per_million: Some(15.0),
2939 }],
2940 ..Default::default()
2941 };
2942 let result = base.apply_presets().unwrap();
2943 let provider = result.provider.expect("provider set");
2944 let max_price = provider.max_price.expect("max_price set");
2945 let prompt = max_price.prompt.expect("prompt set");
2947 assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2948 let completion = max_price.completion.expect("completion set");
2949 assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2950 }
2951
2952 #[test]
2953 fn test_preset_max_price_rejects_negative_values() {
2954 let base = OpenRouterRoutingConfig {
2955 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2956 prompt_usd_per_million: Some(-1.0),
2957 completion_usd_per_million: None,
2958 }],
2959 ..Default::default()
2960 };
2961 let err = base.apply_presets().unwrap_err();
2962 assert!(
2963 err.contains("non-negative"),
2964 "error should mention non-negative: {err}"
2965 );
2966 }
2967
2968 #[test]
2969 fn test_preset_max_price_both_none_no_provider_field() {
2970 let base = OpenRouterRoutingConfig {
2971 presets: vec![OpenRouterRoutingPreset::MaxPrice {
2972 prompt_usd_per_million: None,
2973 completion_usd_per_million: None,
2974 }],
2975 ..Default::default()
2976 };
2977 let result = base.apply_presets().unwrap();
2978 assert!(
2979 result.provider.is_none(),
2980 "MaxPrice with no dimensions should not produce a provider field"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_preset_explicit_provider_overrides_preset() {
2986 let base = OpenRouterRoutingConfig {
2987 presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2988 provider: Some(OpenRouterProviderRouting {
2989 sort: Some(OpenRouterProviderSort::Simple(
2991 OpenRouterProviderSortBy::Throughput,
2992 )),
2993 ..Default::default()
2994 }),
2995 ..Default::default()
2996 };
2997 let result = base.apply_presets().unwrap();
2998 let provider = result.provider.expect("provider set");
2999 assert_eq!(
3001 provider.sort,
3002 Some(OpenRouterProviderSort::Simple(
3003 OpenRouterProviderSortBy::Throughput
3004 ))
3005 );
3006 assert_eq!(provider.require_parameters, Some(true));
3008 }
3009
3010 #[test]
3011 fn test_preset_multiple_presets_combined() {
3012 let base = OpenRouterRoutingConfig {
3013 presets: vec![
3014 OpenRouterRoutingPreset::ZdrOnly,
3015 OpenRouterRoutingPreset::NoDataCollection,
3016 OpenRouterRoutingPreset::LowestLatencyReview,
3017 ],
3018 ..Default::default()
3019 };
3020 let result = base.apply_presets().unwrap();
3021 let provider = result.provider.expect("provider set");
3022 assert_eq!(provider.zdr, Some(true));
3023 assert_eq!(
3024 provider.data_collection,
3025 Some(OpenRouterDataCollection::Deny)
3026 );
3027 assert_eq!(
3028 provider.sort,
3029 Some(OpenRouterProviderSort::Simple(
3030 OpenRouterProviderSortBy::Throughput
3031 ))
3032 );
3033 }
3034
3035 #[test]
3036 fn test_preset_later_preset_overrides_sort() {
3037 let base = OpenRouterRoutingConfig {
3038 presets: vec![
3039 OpenRouterRoutingPreset::CheapestWithTools, OpenRouterRoutingPreset::LowestLatencyReview, ],
3042 ..Default::default()
3043 };
3044 let result = base.apply_presets().unwrap();
3045 let provider = result.provider.expect("provider set");
3046 assert_eq!(
3048 provider.sort,
3049 Some(OpenRouterProviderSort::Simple(
3050 OpenRouterProviderSortBy::Throughput
3051 ))
3052 );
3053 assert_eq!(provider.require_parameters, Some(true));
3055 }
3056
3057 #[test]
3058 fn test_preset_non_empty_in_is_empty() {
3059 let with_preset = OpenRouterRoutingConfig {
3060 presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3061 ..Default::default()
3062 };
3063 assert!(!with_preset.is_empty());
3064
3065 let without = OpenRouterRoutingConfig::default();
3066 assert!(without.is_empty());
3067 }
3068}